commit 7accb430485d85f9f2eec442c2c2128799a73f40 Author: Bluemangoo Date: Wed Apr 8 19:40:36 2026 +0800 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b41913 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/.idea +/data \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4dd1509 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3629 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "assets-updater" +version = "0.1.0" +dependencies = [ + "aes", + "cbc", + "chrono", + "cipher", + "common", + "cridecoder", + "hex", + "image", + "log", + "regex", + "reqwest", + "rmp-serde", + "serde", + "tempfile", + "thiserror 2.0.18", + "tokio", + "yaml_serde", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "client" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "common", + "communicator", + "h2", + "lazy_static", + "log", + "serde", + "serde_json", + "simplelog", + "structopt", + "tokio", + "tokio-util", + "yaml_serde", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "common" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "h2", + "serde", + "tokio", +] + +[[package]] +name = "communicator" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "futures", + "futures-util", + "h2", + "http", + "log", + "rustls-pemfile", + "serde", + "serde_json", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cridecoder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c8ef84d98e3be08aff923e2b4be312c2b8aef9d3ea6cf77cd88a0b5249450a" +dependencies = [ + "byteorder", + "encoding_rs", + "hex", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[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 = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "server" +version = "0.1.0" +dependencies = [ + "anyhow", + "assets-updater", + "bytes", + "common", + "communicator", + "h2", + "http", + "lazy_static", + "log", + "moka", + "serde", + "serde_json", + "simplelog", + "tokio", + "uuid", + "yaml_serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "structopt" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" +dependencies = [ + "clap", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" +dependencies = [ + "heck 0.3.3", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yaml_serde" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +dependencies = [ + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..248067f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] +resolver = "3" +members = ["client", "common", "server", "communicator", "assets-updater"] + +[workspace.dependencies] +bytes = "1.11.1" +h2 = "0.4.13" +http = "1.4.0" +log = "0.4.29" +serde = "1.0.228" +serde_json = "1.0.149" +simplelog = "0.12.2" +tokio = "1.51.0" +tokio-rustls = "0.26.4" +rustls-pemfile = "2.2.0" +webpki-roots = "1.0.6" +aes = "0.8.4" +rmp-serde = "1.3.1" +hex = "0.4.3" +cbc = "0.1.2" +tempfile = "3.27.0" +chrono = "0.4.44" +reqwest = "0.13.2" +regex = "1.12.3" +yaml_serde = "0.10.4" +anyhow = "1.0.102" +image = "0.25.10" +cridecoder = "0.1.1" +thiserror = "2.0.18" +cipher = "0.4.4" +moka = "0.12.15" +uuid = "1.23.0" +lazy_static = "1.5.0" +structopt = "0.3.26" +tokio-util = "0.7.18" +futures-util = "0.3.32" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7b1b1d --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +## server + +run `server` + +## client + +run `client -p ` (multiple profiles are supported, e.g. `client -p profile1 -p profile2`) diff --git a/assets-updater/Cargo.toml b/assets-updater/Cargo.toml new file mode 100644 index 0000000..df9f997 --- /dev/null +++ b/assets-updater/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "assets-updater" +version = "0.1.0" +edition = "2024" + +[dependencies] +common = { path = "../common" } + +aes = { workspace = true } +tokio = { workspace = true, features = ["process"] } +rmp-serde = { workspace = true } +hex = { workspace = true } +cbc = { workspace = true } +tempfile = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +reqwest = { workspace = true } +serde = { workspace = true, features = ["derive"] } +regex = { workspace = true } +yaml_serde = { workspace = true } +image = { workspace = true } +cridecoder = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +cipher = { workspace = true, features = ["block-padding"] } diff --git a/assets-updater/src/core/asset_execution.rs b/assets-updater/src/core/asset_execution.rs new file mode 100644 index 0000000..00b5a28 --- /dev/null +++ b/assets-updater/src/core/asset_execution.rs @@ -0,0 +1,576 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use crate::core::config::{AppConfig, RegionConfig, RegionProviderConfig}; +use crate::core::errors::AssetExecutionError; +use crate::core::export_pipeline::extract_unity_asset_bundle; +use crate::core::regions::{compile_patterns, matches_any}; +use crate::core::retry::retry_async; +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, KeyIvInit}; +use chrono::FixedOffset; +use common::updater::{AssetCategory, DownloadTask, SyncContext}; +use reqwest::header::{ + ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CONNECTION, COOKIE, HeaderMap, HeaderValue, + SET_COOKIE, USER_AGENT, +}; +use serde::{Deserialize, Serialize}; + +type Aes128CbcDec = cbc::Decryptor; +type Aes192CbcDec = cbc::Decryptor; +type Aes256CbcDec = cbc::Decryptor; + +/// Deserializes a msgpack/JSON null or missing value as an empty String. +/// Go silently coerces nil → zero value for non-pointer types; this helper +/// mirrors that behavior for String fields. +fn de_null_as_empty_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetBundleDetail { + #[serde(rename = "bundleName", deserialize_with = "de_null_as_empty_string")] + pub bundle_name: String, + #[serde(rename = "cacheFileName", deserialize_with = "de_null_as_empty_string")] + pub cache_file_name: String, + #[serde( + rename = "cacheDirectoryName", + deserialize_with = "de_null_as_empty_string" + )] + pub cache_directory_name: String, + // nuverse regions use `crc` instead of `hash`; the server may send nil here. + #[serde(rename = "hash", deserialize_with = "de_null_as_empty_string")] + pub hash: String, + #[serde(rename = "category")] + pub category: AssetCategory, + #[serde(rename = "crc")] + pub crc: i64, + #[serde(rename = "fileSize")] + pub file_size: i64, + #[serde(rename = "dependencies")] + pub dependencies: Vec, + #[serde(rename = "paths", default)] + pub paths: Vec, + #[serde(rename = "isBuiltin")] + pub is_builtin: bool, + #[serde(rename = "isRelocate")] + pub is_relocate: Option, + #[serde(rename = "md5Hash")] + pub md5_hash: Option, + #[serde(rename = "downloadPath")] + pub download_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetBundleInfo { + #[serde(rename = "version")] + pub version: Option, + #[serde(rename = "os")] + pub os: Option, + #[serde(rename = "bundles")] + pub bundles: HashMap, +} + +#[derive(Debug, Clone)] +pub struct AssetExecutionContext { + client: reqwest::Client, + region_name: String, + region: RegionConfig, + retry: crate::core::config::RetryConfig, + runtime_cookie: Option, + resolved_asset_version: Option, + pub sync_context: SyncContext, +} + +impl AssetExecutionContext { + pub fn new( + app_config: &AppConfig, + sync_context: &SyncContext, + region: &RegionConfig, + ) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("*/*")); + headers.insert( + USER_AGENT, + HeaderValue::from_static("ProductName/134 CFNetwork/1408.0.4 Darwin/22.5.0"), + ); + headers.insert(CONNECTION, HeaderValue::from_static("keep-alive")); + headers.insert( + ACCEPT_ENCODING, + HeaderValue::from_static("gzip, deflate, br"), + ); + headers.insert( + ACCEPT_LANGUAGE, + HeaderValue::from_static("zh-CN,zh-Hans;q=0.9"), + ); + headers.insert( + "X-Unity-Version", + HeaderValue::from_str(®ion.runtime.unity_version) + .map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?, + ); + + let mut builder = reqwest::Client::builder() + .default_headers(headers) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(180)) + .pool_max_idle_per_host(100) + .tcp_keepalive(Duration::from_secs(30)); + + if let Some(proxy) = &app_config.execution.proxy + && !proxy.is_empty() + { + builder = builder.proxy( + reqwest::Proxy::all(proxy) + .map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?, + ); + } + + Ok(Self { + client: builder + .build() + .map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?, + sync_context: sync_context.clone(), + region_name: sync_context.region.clone(), + region: region.clone(), + retry: app_config.execution.retry.clone(), + runtime_cookie: None, + resolved_asset_version: sync_context.asset_version.clone(), + }) + } + + pub async fn fetch_tasks(&mut self) -> Result, AssetExecutionError> { + if self.requires_cookies() { + self.fetch_runtime_cookies().await?; + } + + let info = self.fetch_asset_bundle_info().await?; + + let tasks = self.build_download_tasks(&info); + Ok(tasks) + } + + pub async fn download( + &self, + task: &DownloadTask, + app_config: &AppConfig, + ) -> Result { + let ctx = self.clone(); + ctx.download_and_export_bundle(app_config, task).await + } + + fn requires_cookies(&self) -> bool { + match &self.region.provider { + RegionProviderConfig::ColorfulPalette { + required_cookies, .. + } => *required_cookies, + RegionProviderConfig::Nuverse { + required_cookies, .. + } => *required_cookies, + } + } + + async fn fetch_runtime_cookies(&mut self) -> Result<(), AssetExecutionError> { + let url = match &self.region.provider { + RegionProviderConfig::ColorfulPalette { + cookie_bootstrap_url, + .. + } + | RegionProviderConfig::Nuverse { + cookie_bootstrap_url, + .. + } => cookie_bootstrap_url.clone().unwrap_or_else(|| { + "https://issue.sekai.colorfulpalette.org/api/signature".to_string() + }), + }; + self.runtime_cookie = retry_async( + &self.retry, + "cookie bootstrap", + |_| async { + let response = self.client.post(&url).send().await?; + if response.status().is_success() { + Ok(response + .headers() + .get(SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string)) + } else { + Err(AssetExecutionError::HttpStatus { + url: url.clone(), + status: response.status().as_u16(), + }) + } + }, + is_retryable_http_error, + ) + .await?; + Ok(()) + } + + async fn fetch_asset_bundle_info(&mut self) -> Result { + let url = self.render_asset_info_url().await?; + let body = self.get_with_retry(&url).await?; + decrypt_asset_bundle_info( + self.region.crypto.aes_key_hex.as_deref().ok_or_else(|| { + AssetExecutionError::MissingCryptoConfig { + region: self.region_name.clone(), + } + })?, + self.region.crypto.aes_iv_hex.as_deref().ok_or_else(|| { + AssetExecutionError::MissingCryptoConfig { + region: self.region_name.clone(), + } + })?, + &body, + ) + } + + async fn render_asset_info_url(&mut self) -> Result { + match &self.region.provider { + RegionProviderConfig::ColorfulPalette { + asset_info_url_template, + profile, + profile_hashes, + .. + } => { + let asset_version = + self.sync_context.asset_version.as_deref().ok_or_else(|| { + AssetExecutionError::MissingAssetVersionOrHash { + region: self.region_name.clone(), + } + })?; + let asset_hash = self.sync_context.asset_hash.as_deref().ok_or_else(|| { + AssetExecutionError::MissingAssetVersionOrHash { + region: self.region_name.clone(), + } + })?; + let profile_hash = profile_hashes.get(profile).ok_or_else(|| { + AssetExecutionError::MissingProfileHash { + region: self.region_name.clone(), + profile: profile.clone(), + } + })?; + Ok(asset_info_url_template + .replace("{env}", profile) + .replace("{hash}", profile_hash) + .replace("{asset_version}", asset_version) + .replace("{asset_hash}", asset_hash) + + &time_arg_jst()) + } + RegionProviderConfig::Nuverse { + asset_version_url, + app_version, + asset_info_url_template, + .. + } => { + // For nuverse, always fetch the version from asset_version_url. + // The incoming request.asset_version is intentionally ignored here + // to match Go reference behavior. + let version_url = asset_version_url.replace("{app_version}", app_version); + let resolved_version = + String::from_utf8_lossy(&self.get_with_retry(&version_url).await?) + .trim() + .to_string(); + self.resolved_asset_version = Some(resolved_version.clone()); + Ok(asset_info_url_template + .replace("{app_version}", app_version) + .replace("{asset_version}", &resolved_version) + + &time_arg_jst()) + } + } + } + + fn render_bundle_url(&self, task: &DownloadTask) -> Result { + match &self.region.provider { + RegionProviderConfig::ColorfulPalette { + asset_bundle_url_template, + profile, + profile_hashes, + .. + } => { + let asset_version = + self.sync_context.asset_version.as_deref().ok_or_else(|| { + AssetExecutionError::MissingAssetVersionOrHash { + region: self.region_name.clone(), + } + })?; + let asset_hash = self.sync_context.asset_hash.as_deref().ok_or_else(|| { + AssetExecutionError::MissingAssetVersionOrHash { + region: self.region_name.clone(), + } + })?; + let profile_hash = profile_hashes.get(profile).ok_or_else(|| { + AssetExecutionError::MissingProfileHash { + region: self.region_name.clone(), + profile: profile.clone(), + } + })?; + + Ok(asset_bundle_url_template + .replace("{bundle_path}", &task.download_path) + .replace("{asset_version}", asset_version) + .replace("{asset_hash}", asset_hash) + .replace("{env}", profile) + .replace("{hash}", profile_hash) + + &time_arg_jst()) + } + RegionProviderConfig::Nuverse { + asset_bundle_url_template, + app_version, + .. + } => { + let asset_version = self + .resolved_asset_version + .as_deref() + .unwrap_or(""); + Ok(asset_bundle_url_template + .replace("{bundle_path}", &task.download_path) + .replace("{app_version}", app_version) + .replace("{asset_version}", asset_version) + + &time_arg_jst()) + } + } + } + + async fn get_with_retry(&self, url: &str) -> Result, AssetExecutionError> { + retry_async( + &self.retry, + "http get", + |_| async { + let mut request = self.client.get(url); + if let Some(cookie) = &self.runtime_cookie { + request = request.header(COOKIE, cookie); + } + match request.send().await { + Ok(response) if response.status().is_success() => { + Ok(response.bytes().await?.to_vec()) + } + Ok(response) => Err(AssetExecutionError::HttpStatus { + url: url.to_string(), + status: response.status().as_u16(), + }), + Err(err) => Err(AssetExecutionError::Http(err)), + } + }, + is_retryable_http_error, + ) + .await + } + + fn build_download_tasks(&self, info: &AssetBundleInfo) -> Vec { + let skip_patterns = compile_patterns(&self.sync_context.filters.skip); + let start_app_patterns = compile_patterns(&self.sync_context.filters.start_app); + let on_demand_patterns = compile_patterns(&self.sync_context.filters.on_demand); + let mut tasks = Vec::new(); + + for (bundle_name, detail) in &info.bundles { + if matches_any(&skip_patterns, bundle_name) { + continue; + } + let category_patterns = match &detail.category { + AssetCategory::StartApp => &start_app_patterns, + AssetCategory::OnDemand => &on_demand_patterns, + AssetCategory::Other(_) => continue, + }; + if category_patterns.is_empty() || !matches_any(category_patterns, bundle_name) { + continue; + } + + let bundle_hash = match self.region.provider { + RegionProviderConfig::Nuverse { .. } => detail.crc.to_string(), + RegionProviderConfig::ColorfulPalette { .. } => detail.hash.clone(), + }; + + tasks.push(DownloadTask { + download_path: download_path_for_region(&self.region.provider, bundle_name, detail), + bundle_path: bundle_name.clone(), + bundle_hash, + category: detail.category.clone(), + }); + } + + tasks.sort_by(|a, b| a.bundle_path.cmp(&b.bundle_path)); + tasks + } + + async fn download_and_export_bundle( + &self, + app_config: &AppConfig, + task: &DownloadTask, + ) -> Result { + let bundle_url = self.render_bundle_url(task)?; + let body = self.get_with_retry(&bundle_url).await?; + let deobfuscated = deobfuscate(&body); + + let temp_file = std::env::temp_dir() + .join("sekai-updater") + .join("obf") + .join(&self.region_name) + .join(&task.bundle_path); + if let Some(parent) = temp_file.parent() { + std::fs::create_dir_all(parent).map_err(|source| { + AssetExecutionError::CreateTempDir { + path: parent.to_path_buf(), + source, + } + })?; + } + std::fs::write(&temp_file, deobfuscated).map_err(|source| { + AssetExecutionError::WriteTempFile { + path: temp_file.clone(), + source, + } + })?; + let category = match task.category { + AssetCategory::StartApp => "StartApp", + AssetCategory::OnDemand => "OnDemand", + AssetCategory::Other(_) => "OnDemand", + }; + let export_result = extract_unity_asset_bundle( + app_config, + &self.sync_context, + &self.region, + &temp_file, + &task.bundle_path, + category, + ) + .await; + let _ = std::fs::remove_file(&temp_file); + export_result.map_err(Into::into) + } +} + +pub async fn fetch_live_asset_bundle_info( + app_config: &AppConfig, + sync_context: &SyncContext, + region: &RegionConfig, +) -> Result { + let mut context = AssetExecutionContext::new(app_config, sync_context, region)?; + if context.requires_cookies() { + context.fetch_runtime_cookies().await?; + } + context.fetch_asset_bundle_info().await +} + +fn is_retryable_http_error(err: &AssetExecutionError) -> bool { + match err { + AssetExecutionError::Http(_) => true, + AssetExecutionError::HttpStatus { status, .. } => *status >= 500, + _ => false, + } +} + +pub fn decrypt_asset_bundle_info( + aes_key_hex: &str, + aes_iv_hex: &str, + content: &[u8], +) -> Result { + if content.is_empty() { + return Err(AssetExecutionError::EmptyEncryptedContent); + } + if !content.len().is_multiple_of(16) { + return Err(AssetExecutionError::InvalidEncryptedBlockSize); + } + + let key = hex::decode(aes_key_hex) + .map_err(|err| AssetExecutionError::InvalidAesKeyHex(err.to_string()))?; + let iv = hex::decode(aes_iv_hex) + .map_err(|err| AssetExecutionError::InvalidAesIvHex(err.to_string()))?; + if iv.len() != 16 { + return Err(AssetExecutionError::InvalidAesIvLength { got: iv.len() }); + } + + let mut buf = content.to_vec(); + let decrypted = match key.len() { + 16 => Aes128CbcDec::new_from_slices(&key, &iv) + .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))? + .decrypt_padded_mut::(&mut buf) + .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?, + 24 => Aes192CbcDec::new_from_slices(&key, &iv) + .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))? + .decrypt_padded_mut::(&mut buf) + .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?, + 32 => Aes256CbcDec::new_from_slices(&key, &iv) + .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))? + .decrypt_padded_mut::(&mut buf) + .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?, + _ => { + return Err(AssetExecutionError::AssetInfoDecode(format!( + "unsupported AES key length {}", + key.len() + ))); + } + }; + + rmp_serde::from_slice::(decrypted) + .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string())) +} + +pub fn deobfuscate(data: &[u8]) -> Vec { + const SIMPLE: [u8; 4] = [0x20, 0x00, 0x00, 0x00]; + const XOR_HEADER: [u8; 4] = [0x10, 0x00, 0x00, 0x00]; + + if data.starts_with(&SIMPLE) { + return data[4..].to_vec(); + } + + if data.starts_with(&XOR_HEADER) { + let body = &data[4..]; + if body.len() < 128 { + return body.to_vec(); + } + + let mut header = vec![0u8; 128]; + let pattern = [0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00]; + for idx in 0..128 { + header[idx] = body[idx] ^ pattern[idx % pattern.len()]; + } + let mut output = header; + output.extend_from_slice(&body[128..]); + return output; + } + + data.to_vec() +} + +pub fn should_download_bundle( + sync_context: &SyncContext, + bundle_name: &str, + category: &AssetCategory, +) -> bool { + let compiled = match category { + AssetCategory::StartApp => compile_patterns(&sync_context.filters.start_app), + AssetCategory::OnDemand => compile_patterns(&sync_context.filters.on_demand), + AssetCategory::Other(_) => return false, + }; + if compiled.is_empty() { + return false; + } + matches_any(&compiled, bundle_name) +} + +fn download_path_for_region( + provider: &RegionProviderConfig, + bundle_name: &str, + detail: &AssetBundleDetail, +) -> String { + match provider { + RegionProviderConfig::ColorfulPalette { .. } => bundle_name.to_string(), + RegionProviderConfig::Nuverse { .. } => detail + .download_path + .as_ref() + .map(|prefix| format!("{prefix}/{bundle_name}")) + .unwrap_or_else(|| bundle_name.to_string()), + } +} + +fn time_arg_jst() -> String { + let tz = FixedOffset::east_opt(9 * 3600).unwrap(); + format!( + "?t={}", + chrono::Utc::now().with_timezone(&tz).format("%Y%m%d%H%M%S") + ) +} diff --git a/assets-updater/src/core/codec.rs b/assets-updater/src/core/codec.rs new file mode 100644 index 0000000..6c4b1dd --- /dev/null +++ b/assets-updater/src/core/codec.rs @@ -0,0 +1,89 @@ +use std::fs::File; +use std::path::{Path, PathBuf}; + +use cridecoder::{extract_acb_from_file, extract_usm_file, HcaDecoder}; +use serde::Serialize; + +use crate::core::errors::CodecError; + +pub const CODEC_BACKEND: &str = "crates.io:cridecoder@0.1.1"; + +#[derive(Debug, Clone, Serialize)] +pub struct CodecSummary { + pub backend: &'static str, + pub supports_acb: bool, + pub supports_usm: bool, + pub supports_hca_to_wav: bool, + pub supports_usm_metadata: bool, +} + +pub fn codec_summary() -> CodecSummary { + CodecSummary { + backend: CODEC_BACKEND, + supports_acb: true, + supports_usm: true, + supports_hca_to_wav: true, + supports_usm_metadata: true, + } +} + +pub fn export_acb(input: &Path, output_dir: &Path) -> Result>, CodecError> { + extract_acb_from_file(input, output_dir).map_err(|err| CodecError::Acb(err.to_string())) +} + +pub fn export_usm(input: &Path, output_dir: &Path) -> Result, CodecError> { + let outputs = extract_usm_file(input, output_dir, None, false) + .map_err(|err| CodecError::Usm(err.to_string()))?; + normalize_usm_output_names(input, outputs) +} + +pub fn read_usm_metadata(input: &Path) -> Result { + cridecoder::usm::read_metadata_file(input).map_err(|err| CodecError::Metadata(err.to_string())) +} + +pub fn decode_hca_to_wav(input: &Path, output: &Path) -> Result<(), CodecError> { + let input_path = input + .to_str() + .ok_or_else(|| CodecError::NonUtf8Path(input.to_path_buf()))?; + + let mut decoder = + HcaDecoder::from_file(input_path).map_err(|err| CodecError::Hca(err.to_string()))?; + let mut file = File::create(output).map_err(|source| CodecError::Io { + path: output.to_path_buf(), + source, + })?; + decoder + .decode_to_wav(&mut file) + .map_err(|err| CodecError::Hca(err.to_string())) +} + +fn normalize_usm_output_names( + input: &Path, + outputs: Vec, +) -> Result, CodecError> { + let input_stem = input + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| CodecError::NonUtf8Path(input.to_path_buf()))?; + + let mut normalized = Vec::with_capacity(outputs.len()); + for output in outputs { + let ext = output + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| CodecError::NonUtf8Path(output.clone()))?; + let target = output.with_file_name(format!("{input_stem}.{ext}")); + + if output != target { + std::fs::rename(&output, &target).map_err(|source| CodecError::Io { + path: target.clone(), + source, + })?; + normalized.push(target); + } else { + normalized.push(output); + } + } + + Ok(normalized) +} diff --git a/assets-updater/src/core/config.rs b/assets-updater/src/core/config.rs new file mode 100644 index 0000000..1d32544 --- /dev/null +++ b/assets-updater/src/core/config.rs @@ -0,0 +1,207 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::core::errors::ConfigError; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AppConfig { + pub execution: ExecutionConfig, + pub tools: ToolsConfig, + pub concurrency: ConcurrencyConfig, + pub regions: BTreeMap, +} + +impl AppConfig { + pub fn validate(&self) -> Result<(), ConfigError> { + for region_name in self.regions.keys() { + if region_name.to_lowercase() != *region_name { + return Err(ConfigError::InvalidRegionName(region_name.clone())); + } + } + + Ok(()) + } + + pub fn enabled_regions(&self) -> Vec { + self.regions + .iter() + .filter_map(|(name, region)| region.enabled.then_some(name.clone())) + .collect() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AccessLogConfig { + pub enabled: bool, + pub format: String, + pub file: Option, +} + +impl Default for AccessLogConfig { + fn default() -> Self { + Self { + enabled: true, + format: "[${time}] ${status} - ${method} ${path} ${latency}\n".to_string(), + file: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ToolsConfig { + pub ffmpeg_path: String, + pub asset_studio_cli_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ExecutionConfig { + pub proxy: Option, + pub timeout_seconds: u64, + pub allow_cancel: bool, + /// How many successful downloads to accumulate before flushing the download + /// record to disk mid-run. Set to `0` to disable mid-run flushing (record + /// is only written once at the end). Mirrors Go's `batchSaveSize`. + pub batch_save_size: usize, + pub retry: RetryConfig, +} + +impl Default for ExecutionConfig { + fn default() -> Self { + Self { + proxy: None, + timeout_seconds: 300, + allow_cancel: true, + batch_save_size: 50, + retry: RetryConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RetryConfig { + pub attempts: usize, + pub initial_backoff_ms: u64, + pub max_backoff_ms: u64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + attempts: 4, + initial_backoff_ms: 1_000, + max_backoff_ms: 4_000, + } + } +} + +impl Default for ToolsConfig { + fn default() -> Self { + Self { + ffmpeg_path: "ffmpeg".to_string(), + asset_studio_cli_path: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ConcurrencyConfig { + pub download: usize, + pub upload: usize, + pub acb: usize, + pub usm: usize, + pub hca: usize, +} + +impl Default for ConcurrencyConfig { + fn default() -> Self { + Self { + download: 4, + upload: 4, + acb: 8, + usm: 4, + hca: 16, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RegionConfig { + pub enabled: bool, + pub provider: RegionProviderConfig, + pub crypto: CryptoConfig, + pub runtime: RegionRuntimeConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RegionProviderConfig { + ColorfulPalette { + asset_info_url_template: String, + asset_bundle_url_template: String, + profile: String, + profile_hashes: BTreeMap, + #[serde(default)] + required_cookies: bool, + #[serde(default)] + cookie_bootstrap_url: Option, + }, + Nuverse { + asset_version_url: String, + app_version: String, + asset_info_url_template: String, + asset_bundle_url_template: String, + #[serde(default)] + required_cookies: bool, + #[serde(default)] + cookie_bootstrap_url: Option, + }, +} + +impl Default for RegionProviderConfig { + fn default() -> Self { + Self::ColorfulPalette { + asset_info_url_template: String::new(), + asset_bundle_url_template: String::new(), + profile: String::new(), + profile_hashes: BTreeMap::new(), + required_cookies: false, + cookie_bootstrap_url: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct CryptoConfig { + pub aes_key_hex: Option, + pub aes_iv_hex: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RegionRuntimeConfig { + pub unity_version: String, +} + +impl Default for RegionRuntimeConfig { + fn default() -> Self { + Self { + unity_version: "2022.3.21f1".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RegionPathsConfig { + pub asset_save_dir: Option, + pub downloaded_asset_record_file: Option, +} diff --git a/assets-updater/src/core/errors.rs b/assets-updater/src/core/errors.rs new file mode 100644 index 0000000..d066526 --- /dev/null +++ b/assets-updater/src/core/errors.rs @@ -0,0 +1,142 @@ +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to read config file {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse config file {path}: {source}")] + Parse { + path: PathBuf, + #[source] + source: yaml_serde::Error, + }, + #[error("config_version must be 2, got {0}")] + UnsupportedVersion(u32), + #[error("no v2 config file found; tried: {0}")] + MissingConfigFile(String), + #[error("invalid region key `{0}`; region keys must be lowercase")] + InvalidRegionName(String), + #[error("missing required environment variable `{name}` referenced by `{field}`")] + MissingEnvironmentVariable { field: String, name: String }, +} + +#[derive(Debug, Error)] +pub enum RegionError { + #[error("region `{0}` not found")] + NotFound(String), + #[error("region `{0}` is disabled")] + Disabled(String), +} + + +#[derive(Debug, Error)] +pub enum CodecError { + #[error("path {0} is not valid UTF-8 for cridecoder file APIs")] + NonUtf8Path(PathBuf), + #[error("io error at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("ACB extraction failed: {0}")] + Acb(String), + #[error("USM extraction failed: {0}")] + Usm(String), + #[error("USM metadata read failed: {0}")] + Metadata(String), + #[error("HCA decode failed: {0}")] + Hca(String), +} + +#[derive(Debug, Error)] +pub enum ExportPipelineError { + #[error(transparent)] + Codec(#[from] CodecError), + #[error("io error at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("image codec error at {path}: {source}")] + Image { + path: PathBuf, + #[source] + source: image::ImageError, + }, + #[error("failed to spawn command `{program}`: {source}")] + Spawn { + program: String, + #[source] + source: std::io::Error, + }, + #[error("command `{program}` failed with status {status}: {stderr}")] + CommandFailed { + program: String, + status: String, + stderr: String, + }, + #[error("failed to spawn worker `{worker}`: {source}")] + WorkerSpawn { + worker: String, + #[source] + source: std::io::Error, + }, + #[error("worker `{worker}` panicked: {message}")] + WorkerPanic { worker: String, message: String }, +} + +#[derive(Debug, Error)] +pub enum AssetExecutionError { + #[error(transparent)] + Region(#[from] RegionError), + #[error(transparent)] + ExportPipeline(#[from] ExportPipelineError), + #[error("http request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("failed to initialize HTTP client: {0}")] + HttpClient(String), + #[error("HTTP request to {url} returned status {status}")] + HttpStatus { url: String, status: u16 }, + #[error("region `{region}` is missing asset_save_dir")] + MissingAssetSaveDir { region: String }, + #[error("colorful_palette region `{region}` requires asset_version and asset_hash")] + MissingAssetVersionOrHash { region: String }, + #[error("colorful_palette region `{region}` is missing profile hash for `{profile}`")] + MissingProfileHash { region: String, profile: String }, + #[error("region `{region}` is missing AES key or IV configuration")] + MissingCryptoConfig { region: String }, + #[error("invalid AES key hex: {0}")] + InvalidAesKeyHex(String), + #[error("invalid AES IV hex: {0}")] + InvalidAesIvHex(String), + #[error("invalid AES IV length: got {got}, want 16")] + InvalidAesIvLength { got: usize }, + #[error("encrypted content cannot be empty")] + EmptyEncryptedContent, + #[error("encrypted content length is not a multiple of AES block size")] + InvalidEncryptedBlockSize, + #[error("failed to decrypt or deserialize asset info: {0}")] + AssetInfoDecode(String), + #[error("failed to create temp directory for {path}: {source}")] + CreateTempDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to write temp file {path}: {source}")] + WriteTempFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("job execution cancelled")] + Cancelled, +} diff --git a/assets-updater/src/core/export_pipeline.rs b/assets-updater/src/core/export_pipeline.rs new file mode 100644 index 0000000..e3c1a0d --- /dev/null +++ b/assets-updater/src/core/export_pipeline.rs @@ -0,0 +1,790 @@ +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use crate::core::codec; +use crate::core::config::{AppConfig, RegionConfig}; +use crate::core::errors::ExportPipelineError; +use crate::core::media::{ + FrameRate, convert_m2v_to_mp4, convert_usm_to_mp4, convert_wav_to_flac, convert_wav_to_mp3, +}; +use crate::core::retry::retry_async; +use common::updater::SyncContext; +use image::codecs::webp::WebPEncoder; +use image::{ExtendedColorType, ImageReader}; +use log::debug; +use serde::Serialize; +use tokio::process::Command; + +#[derive(Debug, Clone, Copy)] +struct AssetStudioCliCapabilities { + filter_exclude_mode: bool, + filter_blacklist_mode: bool, + sekai_keep_single_container_filename: bool, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct PostProcessSummary { + pub export_root: PathBuf, + pub generated_files: Vec, +} + +pub fn get_export_group(export_path: &str) -> &'static str { + if export_path.is_empty() { + return "container"; + } + + let normalized = export_path + .replace('\\', "/") + .trim_start_matches('/') + .to_lowercase(); + + for prefix in [ + "event/center", + "event/thumbnail", + "gacha/icon", + "fix_prefab/mc_new", + "mysekai/character/", + ] { + if normalized.starts_with(prefix) { + return "containerFull"; + } + } + + "container" +} + +pub async fn extract_unity_asset_bundle( + app_config: &AppConfig, + sync_context: &SyncContext, + region: &RegionConfig, + asset_bundle_file: &Path, + export_path: &str, + category: &str, +) -> Result { + let output_dir = std::env::temp_dir() + .join("sekai-updater") + .join("extract") + .join(&sync_context.region); + let Some(asset_studio_cli_path) = app_config.tools.asset_studio_cli_path.as_deref() else { + return Ok(asset_bundle_file.parent().unwrap().to_path_buf()); + }; + + let exclude_path_prefix = if sync_context.export.by_category { + "assets/sekai/assetbundle/resources".to_string() + } else if export_path.starts_with("mysekai") { + "assets/sekai/assetbundle/resources/ondemand".to_string() + } else { + format!( + "assets/sekai/assetbundle/resources/{}", + category.to_lowercase() + ) + }; + + let actual_export_path = if sync_context.export.by_category { + output_dir.join(category.to_lowercase()).join(export_path) + } else { + output_dir.join(export_path) + }; + debug!("{}", actual_export_path.to_string_lossy()); + let capabilities = detect_assetstudio_cli_capabilities(asset_studio_cli_path); + let args = build_assetstudio_export_args( + asset_bundle_file, + output_dir.as_path(), + export_path, + &exclude_path_prefix, + region, + sync_context, + capabilities, + ); + + retry_async( + &app_config.execution.retry, + "assetstudio export", + |_| async { + let output = Command::new(asset_studio_cli_path) + .args(&args) + .output() + .await + .map_err(|source| ExportPipelineError::Spawn { + program: asset_studio_cli_path.to_string(), + source, + })?; + + if output.status.success() { + Ok(()) + } else { + Err(ExportPipelineError::CommandFailed { + program: asset_studio_cli_path.to_string(), + status: output.status.to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + } + }, + is_retryable_command_error, + ) + .await?; + + post_process_exported_files(app_config, sync_context, region, &actual_export_path).await?; + Ok(actual_export_path) +} + +pub async fn post_process_exported_files( + app_config: &AppConfig, + sync_context: &SyncContext, + region: &RegionConfig, + export_path: &Path, +) -> Result<(), ExportPipelineError> { + if !export_path.exists() { + return Ok(()); + } + + handle_usm_files( + export_path, + sync_context, + region, + &app_config.tools.ffmpeg_path, + &app_config.execution.retry, + ) + .await?; + handle_acb_files( + export_path, + sync_context, + region, + &app_config.tools.ffmpeg_path, + &app_config.execution.retry, + app_config.concurrency.acb, + app_config.concurrency.hca, + ) + .await?; + handle_png_conversion(export_path, sync_context, region).await?; + + Ok(()) +} + +async fn handle_usm_files( + export_path: &Path, + sync_context: &SyncContext, + region: &RegionConfig, + ffmpeg_path: &str, + retry: &crate::core::config::RetryConfig, +) -> Result, ExportPipelineError> { + let usm_files = find_files_by_extension(export_path, "usm")?; + if !sync_context.export.usm.export || !sync_context.export.usm.decode || usm_files.is_empty() { + return Ok(Vec::new()); + } + + let usm_input = if usm_files.len() == 1 { + usm_files[0].clone() + } else { + merge_usm_files(export_path, &usm_files)? + }; + + process_usm_file( + &usm_input, + export_path, + sync_context, + region, + ffmpeg_path, + retry, + ) + .await +} + +async fn process_usm_file( + usm_file: &Path, + export_path: &Path, + sync_context: &SyncContext, + _: &RegionConfig, + ffmpeg_path: &str, + retry: &crate::core::config::RetryConfig, +) -> Result, ExportPipelineError> { + let output_name = usm_file + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| ExportPipelineError::Io { + path: usm_file.to_path_buf(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid usm file name"), + })? + .to_string(); + + if sync_context.export.video.convert_to_mp4 + && sync_context.export.video.direct_usm_to_mp4_with_ffmpeg + { + let mp4 = export_path.join(format!("{output_name}.mp4")); + convert_usm_to_mp4(usm_file, &mp4, ffmpeg_path, retry).await?; + remove_file_if_exists(usm_file)?; + return Ok(vec![mp4]); + } + + let metadata = codec::read_usm_metadata(usm_file).ok(); + let frame_rate = metadata + .as_ref() + .and_then(|metadata| metadata.video_frame_rate()) + .filter(|(_, denominator)| *denominator > 0) + .map(FrameRate::from_tuple); + let extracted = codec::export_usm(usm_file, export_path)?; + let mut generated = extracted.clone(); + + if sync_context.export.video.convert_to_mp4 { + for extracted_file in extracted { + if extracted_file + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("m2v")) + .unwrap_or(false) + { + let mp4 = export_path.join(format!("{output_name}.mp4")); + convert_m2v_to_mp4( + &extracted_file, + &mp4, + sync_context.export.video.remove_m2v, + ffmpeg_path, + frame_rate, + retry, + ) + .await?; + generated.push(mp4); + if sync_context.export.video.remove_m2v { + generated.retain(|path| path != &extracted_file); + } + } + } + } + + remove_file_if_exists(usm_file)?; + Ok(generated) +} + +async fn handle_acb_files( + export_path: &Path, + sync_context: &SyncContext, + region: &RegionConfig, + ffmpeg_path: &str, + retry: &crate::core::config::RetryConfig, + acb_concurrency: usize, + hca_concurrency: usize, +) -> Result, ExportPipelineError> { + let acb_files = find_files_by_extension(export_path, "acb")?; + if !sync_context.export.acb.export || !sync_context.export.acb.decode || acb_files.is_empty() { + return Ok(Vec::new()); + } + + let export_path = export_path.to_path_buf(); + let region = region.clone(); + let sync_context = sync_context.clone(); + let ffmpeg_path = ffmpeg_path.to_string(); + let retry = retry.clone(); + run_path_tasks(acb_files, acb_concurrency, move |acb_file| { + process_acb_file( + &acb_file, + &export_path, + &sync_context, + ®ion, + &ffmpeg_path, + &retry, + hca_concurrency, + ) + }) +} + +fn process_acb_file( + acb_file: &Path, + output_dir: &Path, + sync_context: &SyncContext, + region: &RegionConfig, + ffmpeg_path: &str, + retry: &crate::core::config::RetryConfig, + hca_concurrency: usize, +) -> Result, ExportPipelineError> { + let parent_dir = acb_file.parent().ok_or_else(|| ExportPipelineError::Io { + path: acb_file.to_path_buf(), + source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing parent directory"), + })?; + let extract_dir = tempfile::Builder::new() + .prefix("acb-extract-") + .tempdir_in(parent_dir) + .map_err(|source| ExportPipelineError::Io { + path: parent_dir.to_path_buf(), + source, + })?; + + let _ = codec::export_acb(acb_file, extract_dir.path())?; + let mut hca_files = find_files_by_extension(extract_dir.path(), "hca")?; + + let acb_path_lower = acb_file.to_string_lossy().replace('\\', "/").to_lowercase(); + if acb_path_lower.contains("music/long") { + hca_files.retain(|path| { + let lower = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_lowercase(); + !(lower.ends_with("_vr.hca") || lower.ends_with("_screen.hca")) + }); + } + + if !sync_context.export.hca.decode { + remove_file_if_exists(acb_file)?; + return Ok(Vec::new()); + } + + let extract_output_dir = extract_dir.path().to_path_buf(); + let region = region.clone(); + let sync_context = sync_context.clone(); + let ffmpeg_path = ffmpeg_path.to_string(); + let retry = retry.clone(); + let generated = run_path_tasks(hca_files, hca_concurrency, move |hca_file| { + process_hca_file( + &hca_file, + &extract_output_dir, + &sync_context, + ®ion, + &ffmpeg_path, + &retry, + ) + })?; + let final_outputs = move_result_files(output_dir, &generated)?; + + remove_file_if_exists(acb_file)?; + Ok(final_outputs) +} + +fn process_hca_file( + hca_file: &Path, + output_dir: &Path, + sync_context: &SyncContext, + _: &RegionConfig, + ffmpeg_path: &str, + retry: &crate::core::config::RetryConfig, +) -> Result, ExportPipelineError> { + let base_name = hca_file + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| ExportPipelineError::Io { + path: hca_file.to_path_buf(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid hca file name"), + })?; + + let wav_file = hca_file.with_extension("wav"); + codec::decode_hca_to_wav(hca_file, &wav_file)?; + remove_file_if_exists(hca_file)?; + + let mut generated = vec![wav_file.clone()]; + if sync_context.export.audio.convert_to_mp3 { + let mp3 = output_dir.join(format!("{base_name}.mp3")); + convert_wav_to_mp3(&wav_file, &mp3, ffmpeg_path, retry)?; + if sync_context.export.audio.remove_wav { + remove_file_if_exists(&wav_file)?; + generated.retain(|path| path != &wav_file); + } + generated.push(mp3); + } else if sync_context.export.audio.convert_to_flac { + let flac = output_dir.join(format!("{base_name}.flac")); + convert_wav_to_flac(&wav_file, &flac, ffmpeg_path, retry)?; + if sync_context.export.audio.remove_wav { + remove_file_if_exists(&wav_file)?; + generated.retain(|path| path != &wav_file); + } + generated.push(flac); + } else if sync_context.export.audio.remove_wav { + remove_file_if_exists(&wav_file)?; + generated.clear(); + } + + let final_outputs = move_result_files(output_dir, &generated)?; + Ok(final_outputs) +} + +async fn handle_png_conversion( + export_path: &Path, + sync_context: &SyncContext, + _: &RegionConfig, +) -> Result, ExportPipelineError> { + if !sync_context.export.images.convert_to_webp { + return Ok(Vec::new()); + } + + let png_files = find_files_by_extension(export_path, "png")?; + let mut generated = Vec::new(); + for png_file in png_files { + let webp = png_file.with_extension("webp"); + convert_png_to_webp(&png_file, &webp)?; + generated.push(webp.clone()); + if sync_context.export.images.remove_png { + remove_file_if_exists(&png_file)?; + } + } + Ok(generated) +} + +fn convert_png_to_webp(png_file: &Path, webp_file: &Path) -> Result<(), ExportPipelineError> { + let image = ImageReader::open(png_file) + .map_err(|source| ExportPipelineError::Io { + path: png_file.to_path_buf(), + source, + })? + .decode() + .map_err(|source| ExportPipelineError::Image { + path: png_file.to_path_buf(), + source, + })?; + let rgba = image.to_rgba8(); + let (width, height) = rgba.dimensions(); + let writer = std::fs::File::create(webp_file).map_err(|source| ExportPipelineError::Io { + path: webp_file.to_path_buf(), + source, + })?; + let writer = std::io::BufWriter::new(writer); + + WebPEncoder::new_lossless(writer) + .encode(rgba.as_raw(), width, height, ExtendedColorType::Rgba8) + .map_err(|source| ExportPipelineError::Image { + path: webp_file.to_path_buf(), + source, + }) +} + +fn is_retryable_command_error(err: &ExportPipelineError) -> bool { + match err { + ExportPipelineError::Spawn { source, .. } => matches!( + source.kind(), + std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionRefused + ), + ExportPipelineError::CommandFailed { .. } => true, + _ => false, + } +} + +fn build_assetstudio_export_args( + asset_bundle_file: &Path, + output_dir: &Path, + export_path: &str, + exclude_path_prefix: &str, + region: &RegionConfig, + sync_context: &SyncContext, + capabilities: AssetStudioCliCapabilities, +) -> Vec { + let mut args = vec![ + asset_bundle_file.to_string_lossy().to_string(), + "-m".to_string(), + "export".to_string(), + "-t".to_string(), + "monoBehaviour,textAsset,tex2d,tex2dArray,audio".to_string(), + "-g".to_string(), + get_export_group(export_path).to_string(), + "-f".to_string(), + "assetName".to_string(), + "-o".to_string(), + output_dir.to_string_lossy().to_string(), + "--strip-path-prefix".to_string(), + exclude_path_prefix.to_string(), + "-r".to_string(), + ]; + + if capabilities.filter_exclude_mode { + args.push("--filter-exclude-mode".to_string()); + } else if capabilities.filter_blacklist_mode { + args.push("--filter-blacklist-mode".to_string()); + } + + args.push("--filter-with-regex".to_string()); + + if capabilities.sekai_keep_single_container_filename { + args.push("--sekai-keep-single-container-filename".to_string()); + } + + if !region.runtime.unity_version.is_empty() { + args.push("--unity-version".to_string()); + args.push(region.runtime.unity_version.clone()); + } + + let mut excluded_exts = Vec::new(); + if !sync_context.export.usm.export { + excluded_exts.push("usm"); + } + if !sync_context.export.acb.export { + excluded_exts.push("acb"); + } + if !excluded_exts.is_empty() { + args.push("--filter-by-name".to_string()); + args.push(format!(r".*\.({})$", excluded_exts.join("|"))); + } + + args +} + +fn detect_assetstudio_cli_capabilities(asset_studio_cli_path: &str) -> AssetStudioCliCapabilities { + static CACHE: std::sync::OnceLock< + Mutex>, + > = std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())); + + if let Some(cached) = cache.lock().unwrap().get(asset_studio_cli_path).copied() { + return cached; + } + + let fallback = AssetStudioCliCapabilities { + filter_exclude_mode: true, + filter_blacklist_mode: false, + sekai_keep_single_container_filename: true, + }; + + let detected = match std::process::Command::new(asset_studio_cli_path) + .arg("--help") + .output() + { + Ok(output) => { + let help = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + AssetStudioCliCapabilities { + filter_exclude_mode: help.contains("--filter-exclude-mode"), + filter_blacklist_mode: help.contains("--filter-blacklist-mode"), + sekai_keep_single_container_filename: help + .contains("--sekai-keep-single-container-filename"), + } + } + Err(_) => fallback, + }; + + cache + .lock() + .unwrap() + .insert(asset_studio_cli_path.to_string(), detected); + detected +} + +fn move_result_files( + output_dir: &Path, + generated: &[PathBuf], +) -> Result, ExportPipelineError> { + let mut final_outputs = Vec::new(); + for path in generated { + let file_name = match path.file_name() { + Some(name) => name, + None => continue, + }; + let destination = output_dir.join(file_name); + if path != &destination { + std::fs::rename(path, &destination).map_err(|source| ExportPipelineError::Io { + path: destination.clone(), + source, + })?; + final_outputs.push(destination); + } else if destination.exists() { + final_outputs.push(destination); + } + } + Ok(final_outputs) +} + +fn run_path_tasks( + paths: Vec, + concurrency: usize, + task: F, +) -> Result, ExportPipelineError> +where + F: Fn(PathBuf) -> Result, ExportPipelineError> + Send + Sync + 'static, +{ + if paths.is_empty() { + return Ok(Vec::new()); + } + + let worker_count = concurrency.max(1).min(paths.len()); + let queue = Arc::new(Mutex::new(VecDeque::from(paths))); + let results = Arc::new(Mutex::new(Vec::::new())); + let first_error = Arc::new(Mutex::new(None::)); + let task = Arc::new(task); + let mut handles = Vec::with_capacity(worker_count); + const WORKER_STACK_SIZE: usize = 32 * 1024 * 1024; + + for _ in 0..worker_count { + let queue = queue.clone(); + let results = results.clone(); + let first_error = first_error.clone(); + let task = task.clone(); + let worker_name = "export-task".to_string(); + let handle = std::thread::Builder::new() + .name(worker_name.clone()) + .stack_size(WORKER_STACK_SIZE) + .spawn(move || { + loop { + if first_error.lock().unwrap().is_some() { + break; + } + + let next_path = queue.lock().unwrap().pop_front(); + let Some(path) = next_path else { + break; + }; + + match task(path) { + Ok(mut generated) => results.lock().unwrap().append(&mut generated), + Err(err) => { + let mut first = first_error.lock().unwrap(); + if first.is_none() { + *first = Some(err); + } + break; + } + } + } + }) + .map_err(|source| ExportPipelineError::WorkerSpawn { + worker: worker_name, + source, + })?; + handles.push(handle); + } + + for handle in handles { + handle + .join() + .map_err(|panic| ExportPipelineError::WorkerPanic { + worker: "export task".to_string(), + message: panic_message(panic), + })?; + } + + if let Some(err) = first_error.lock().unwrap().take() { + return Err(err); + } + + let mut results = results.lock().unwrap(); + Ok(std::mem::take(&mut *results)) +} + +fn panic_message(panic: Box) -> String { + if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = panic.downcast_ref::() { + message.clone() + } else { + "unknown worker panic".to_string() + } +} + +fn merge_usm_files(dir: &Path, usm_files: &[PathBuf]) -> Result { + let dir_name = dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("merged"); + let merged_file = dir.join(format!("{dir_name}.usm")); + let mut target = + std::fs::File::create(&merged_file).map_err(|source| ExportPipelineError::Io { + path: merged_file.clone(), + source, + })?; + + for source_path in usm_files { + if *source_path == merged_file { + continue; + } + let mut source = + std::fs::File::open(source_path).map_err(|source| ExportPipelineError::Io { + path: source_path.clone(), + source, + })?; + std::io::copy(&mut source, &mut target).map_err(|source| ExportPipelineError::Io { + path: source_path.clone(), + source, + })?; + drop(source); + remove_file_if_exists(source_path)?; + } + + Ok(merged_file) +} + +pub fn find_files(dir: &Path) -> Result, ExportPipelineError> { + let mut files = Vec::new(); + walk(dir, &mut |path| { + files.push(path.to_path_buf()); + })?; + Ok(files) +} + +pub fn find_files_by_extension(dir: &Path, ext: &str) -> Result, ExportPipelineError> { + let target_ext = ext.to_lowercase(); + let mut files = Vec::new(); + walk(dir, &mut |path| { + if path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case(&target_ext)) + .unwrap_or(false) + { + files.push(path.to_path_buf()); + } + })?; + Ok(files) +} + +pub fn find_files_by_extensions

( + dir: &Path, + ext: &[P], +) -> Result, ExportPipelineError> +where + P: AsRef, +{ + let target_ext = ext + .iter() + .map(|x| x.as_ref().to_lowercase()) + .collect::>(); + let mut files = Vec::new(); + walk(dir, &mut |path| { + if path + .extension() + .and_then(|value| value.to_str()) + .map(|value| target_ext.contains(&value.to_lowercase())) + .unwrap_or(false) + { + files.push(path.to_path_buf()); + } + })?; + Ok(files) +} + +fn walk(dir: &Path, f: &mut dyn FnMut(&Path)) -> Result<(), ExportPipelineError> { + for entry in std::fs::read_dir(dir).map_err(|source| ExportPipelineError::Io { + path: dir.to_path_buf(), + source, + })? { + let entry = entry.map_err(|source| ExportPipelineError::Io { + path: dir.to_path_buf(), + source, + })?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|source| ExportPipelineError::Io { + path: path.clone(), + source, + })?; + if file_type.is_dir() { + walk(&path, f)?; + } else { + f(&path); + } + } + Ok(()) +} + +fn remove_file_if_exists(path: &Path) -> Result<(), ExportPipelineError> { + if path.exists() { + std::fs::remove_file(path).map_err(|source| ExportPipelineError::Io { + path: path.to_path_buf(), + source, + })?; + } + Ok(()) +} diff --git a/assets-updater/src/core/media.rs b/assets-updater/src/core/media.rs new file mode 100644 index 0000000..b79997a --- /dev/null +++ b/assets-updater/src/core/media.rs @@ -0,0 +1,229 @@ +use std::fmt::{Display, Formatter}; +use std::path::Path; + +use tokio::process::Command; + +use crate::core::config::RetryConfig; +use crate::core::errors::ExportPipelineError; +use crate::core::retry::{retry_async, retry_sync}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameRate { + pub numerator: i32, + pub denominator: i32, +} + +impl FrameRate { + pub fn from_tuple((numerator, denominator): (i32, i32)) -> Self { + Self { + numerator, + denominator, + } + } +} + +impl Display for FrameRate { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if self.denominator <= 1 { + write!(f, "{}", self.numerator) + } else { + write!(f, "{}/{}", self.numerator, self.denominator) + } + } +} + +pub async fn convert_usm_to_mp4( + usm_file: &Path, + mp4_file: &Path, + ffmpeg_path: &str, + retry: &RetryConfig, +) -> Result<(), ExportPipelineError> { + retry_async( + retry, + "ffmpeg usm->mp4", + |_| async { + run_ffmpeg( + ffmpeg_path, + &[ + "-i", + &usm_file.to_string_lossy(), + "-c:v", + "libx264", + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + "-y", + &mp4_file.to_string_lossy(), + ], + ) + .run_async() + .await + }, + is_retryable_command_error, + ) + .await +} + +pub async fn convert_m2v_to_mp4( + m2v_file: &Path, + mp4_file: &Path, + delete_original: bool, + ffmpeg_path: &str, + frame_rate: Option, + retry: &RetryConfig, +) -> Result<(), ExportPipelineError> { + let mut args = Vec::new(); + if let Some(rate) = frame_rate { + args.push("-r".to_string()); + args.push(rate.to_string()); + } + args.push("-i".to_string()); + args.push(m2v_file.to_string_lossy().to_string()); + args.push("-c:v".to_string()); + args.push("libx264".to_string()); + if let Some(rate) = frame_rate { + args.push("-r".to_string()); + args.push(rate.to_string()); + } + args.push("-y".to_string()); + args.push(mp4_file.to_string_lossy().to_string()); + + retry_async( + retry, + "ffmpeg m2v->mp4", + |_| async { + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + run_ffmpeg(ffmpeg_path, &refs).run_async().await + }, + is_retryable_command_error, + ) + .await?; + if delete_original && m2v_file.exists() { + std::fs::remove_file(m2v_file).map_err(|source| ExportPipelineError::Io { + path: m2v_file.to_path_buf(), + source, + })?; + } + Ok(()) +} + +pub fn convert_wav_to_mp3( + wav_file: &Path, + mp3_file: &Path, + ffmpeg_path: &str, + retry: &RetryConfig, +) -> Result<(), ExportPipelineError> { + retry_sync( + retry, + "ffmpeg wav->mp3", + |_| { + run_ffmpeg_sync( + ffmpeg_path, + &[ + "-i", + &wav_file.to_string_lossy(), + "-b:a", + "320k", + "-y", + &mp3_file.to_string_lossy(), + ], + ) + }, + is_retryable_command_error, + ) +} + +pub fn convert_wav_to_flac( + wav_file: &Path, + flac_file: &Path, + ffmpeg_path: &str, + retry: &RetryConfig, +) -> Result<(), ExportPipelineError> { + retry_sync( + retry, + "ffmpeg wav->flac", + |_| { + run_ffmpeg_sync( + ffmpeg_path, + &[ + "-i", + &wav_file.to_string_lossy(), + "-compression_level", + "12", + "-y", + &flac_file.to_string_lossy(), + ], + ) + }, + is_retryable_command_error, + ) +} + +fn run_ffmpeg<'a>(ffmpeg_path: &'a str, args: &'a [&'a str]) -> FfmpegCommand<'a> { + FfmpegCommand { ffmpeg_path, args } +} + +fn run_ffmpeg_sync(ffmpeg_path: &str, args: &[&str]) -> Result<(), ExportPipelineError> { + let output = std::process::Command::new(ffmpeg_path) + .args(args) + .output() + .map_err(|source| ExportPipelineError::Spawn { + program: ffmpeg_path.to_string(), + source, + })?; + map_command_output(ffmpeg_path, output) +} + +struct FfmpegCommand<'a> { + ffmpeg_path: &'a str, + args: &'a [&'a str], +} + +impl<'a> FfmpegCommand<'a> { + async fn run_async(self) -> Result<(), ExportPipelineError> { + let output = Command::new(self.ffmpeg_path) + .args(self.args) + .output() + .await + .map_err(|source| ExportPipelineError::Spawn { + program: self.ffmpeg_path.to_string(), + source, + })?; + map_command_output(self.ffmpeg_path, output) + } +} + +fn is_retryable_command_error(err: &ExportPipelineError) -> bool { + match err { + ExportPipelineError::Spawn { source, .. } => matches!( + source.kind(), + std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionRefused + ), + ExportPipelineError::CommandFailed { .. } => true, + _ => false, + } +} + +fn map_command_output( + program: &str, + output: std::process::Output, +) -> Result<(), ExportPipelineError> { + if output.status.success() { + Ok(()) + } else { + Err(ExportPipelineError::CommandFailed { + program: program.to_string(), + status: output.status.to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + } +} diff --git a/assets-updater/src/core/mod.rs b/assets-updater/src/core/mod.rs new file mode 100644 index 0000000..b6efc75 --- /dev/null +++ b/assets-updater/src/core/mod.rs @@ -0,0 +1,8 @@ +pub mod asset_execution; +pub mod config; +pub mod export_pipeline; +pub mod regions; +pub mod retry; +pub mod media; +pub mod codec; +pub mod errors; \ No newline at end of file diff --git a/assets-updater/src/core/regions.rs b/assets-updater/src/core/regions.rs new file mode 100644 index 0000000..da3c4d8 --- /dev/null +++ b/assets-updater/src/core/regions.rs @@ -0,0 +1,30 @@ +use regex::Regex; + +use crate::core::config::{AppConfig, RegionConfig}; +use crate::core::errors::RegionError; + +pub fn select_region<'a>( + config: &'a AppConfig, + name: &str, +) -> Result<&'a RegionConfig, RegionError> { + let region = config + .regions + .get(name) + .ok_or_else(|| RegionError::NotFound(name.to_string()))?; + if !region.enabled { + return Err(RegionError::Disabled(name.to_string())); + } + Ok(region) +} + +pub(crate) fn compile_patterns(patterns: &[String]) -> Vec { + patterns + .iter() + .filter_map(|pattern| Regex::new(pattern).ok()) + .collect() +} + +pub(crate) fn matches_any(patterns: &[Regex], bundle_name: &str) -> bool { + patterns.iter().any(|regex| regex.is_match(bundle_name)) +} + diff --git a/assets-updater/src/core/retry.rs b/assets-updater/src/core/retry.rs new file mode 100644 index 0000000..921247f --- /dev/null +++ b/assets-updater/src/core/retry.rs @@ -0,0 +1,83 @@ +use std::fmt::Display; +use std::future::Future; +use std::time::Duration; +use log::warn; +use tokio::time::sleep; + +use crate::core::config::RetryConfig; + +pub async fn retry_async( + config: &RetryConfig, + operation: &str, + mut op: Op, + should_retry: ShouldRetry, +) -> Result +where + E: Display, + Op: FnMut(usize) -> Fut, + Fut: Future>, + ShouldRetry: Fn(&E) -> bool, +{ + let attempts = config.attempts.max(1); + for attempt in 1..=attempts { + match op(attempt).await { + Ok(value) => return Ok(value), + Err(err) if attempt < attempts && should_retry(&err) => { + let delay = backoff_delay(config, attempt); + warn!( + "operation '{}' failed after {} attempt(s) with error: {}, retrying in {} ms", + operation, + attempt, + err, + delay.as_millis() + ); + sleep(delay).await; + } + Err(err) => return Err(err), + } + } + + unreachable!("retry_async must return from within the attempt loop") +} + +pub fn retry_sync( + config: &RetryConfig, + operation: &str, + mut op: Op, + should_retry: ShouldRetry, +) -> Result +where + E: Display, + Op: FnMut(usize) -> Result, + ShouldRetry: Fn(&E) -> bool, +{ + let attempts = config.attempts.max(1); + for attempt in 1..=attempts { + match op(attempt) { + Ok(value) => return Ok(value), + Err(err) if attempt < attempts && should_retry(&err) => { + let delay = backoff_delay(config, attempt); + warn!( + "operation '{}' failed after {} attempt(s) with error: {}, retrying in {} ms", + operation, + attempt, + err, + delay.as_millis() + ); + std::thread::sleep(delay); + } + Err(err) => return Err(err), + } + } + + unreachable!("retry_sync must return from within the attempt loop") +} + +fn backoff_delay(config: &RetryConfig, attempt: usize) -> Duration { + let base = config.initial_backoff_ms.max(1); + let max = config.max_backoff_ms.max(base); + let multiplier = 1u64 + .checked_shl(attempt.saturating_sub(1) as u32) + .unwrap_or(u64::MAX); + Duration::from_millis(base.saturating_mul(multiplier).min(max)) +} diff --git a/assets-updater/src/lib.rs b/assets-updater/src/lib.rs new file mode 100644 index 0000000..5a7ca06 --- /dev/null +++ b/assets-updater/src/lib.rs @@ -0,0 +1 @@ +pub mod core; diff --git a/client/Cargo.toml b/client/Cargo.toml new file mode 100644 index 0000000..4e13e6f --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "client" +version = "0.1.0" +edition = "2024" + +[dependencies] +common = { path = "../common" } +communicator = { path = "../communicator" } + +bytes = { workspace = true } +serde = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] } +structopt = { workspace = true } +lazy_static = { workspace = true } +yaml_serde = { workspace = true } +log = { workspace = true } +simplelog = { workspace = true } +anyhow = { workspace = true } +h2 = { workspace = true } +serde_json = { workspace = true } +tokio-util = "0.7.18" diff --git a/client/src/config.rs b/client/src/config.rs new file mode 100644 index 0000000..3909f95 --- /dev/null +++ b/client/src/config.rs @@ -0,0 +1,20 @@ +use common::updater::SyncContext; +use communicator::ConnectConfig; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ClientConfig { + pub log_level: Option, + #[serde(flatten, default)] + pub connect: ConnectConfig, + pub profiles: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + #[serde(flatten)] + pub sync_context: SyncContext, + pub path: String, + pub interval: Option +} diff --git a/client/src/http.rs b/client/src/http.rs new file mode 100644 index 0000000..f687dcb --- /dev/null +++ b/client/src/http.rs @@ -0,0 +1,58 @@ +use crate::config::Profile; +use anyhow::anyhow; +use bytes::Bytes; +use common::http::{CloseRequest, DownloadRequest, SyncResponse}; +use common::stream::client_receive; +use communicator::http::{json_from_response, request, response_to_async_read, text_from_response}; +use h2::client; +use std::path::Path; +use std::pin::pin; + +pub async fn sync( + client: &mut client::SendRequest, + profile: &Profile, +) -> anyhow::Result { + let mut response = request( + client, + "/sync", + serde_json::to_string(&profile.sync_context)?, + ) + .await?; + if !response.status().is_success() { + let body = text_from_response(&mut response).await?; + Err(anyhow!("Failed to request '/sync': {}", body))?; + } + + json_from_response(&mut response).await +} + +pub async fn download( + client: &mut client::SendRequest, + req: &DownloadRequest, + profile: &Profile, +) -> anyhow::Result<()> { + let mut response = request(client, "/download", serde_json::to_string(req)?).await?; + if !response.status().is_success() { + let body = text_from_response(&mut response).await?; + Err(anyhow!("Failed to request '/download': {}", body))?; + } + + client_receive( + pin!(response_to_async_read(response)), + Path::new(&profile.path), + ) + .await +} + +pub async fn close( + client: &mut client::SendRequest, + req: &CloseRequest, +) -> anyhow::Result<()> { + let mut response = request(client, "/close", serde_json::to_string(req)?).await?; + if response.status().is_success() { + return Ok(()); + } + + let body = text_from_response(&mut response).await?; + Err(anyhow!("Failed to close request: {}", body)) +} diff --git a/client/src/main.rs b/client/src/main.rs new file mode 100644 index 0000000..cb409a5 --- /dev/null +++ b/client/src/main.rs @@ -0,0 +1,175 @@ +use crate::config::{ClientConfig, Profile}; +use crate::task::run; +use communicator::{Identity, TunnelEndpoint, TunnelListener, connect_tunnel}; +use lazy_static::lazy_static; +use log::{LevelFilter, error, info}; +use simplelog::{ColorChoice, Config, TermLogger, TerminalMode}; +use std::fs; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use structopt::StructOpt; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::time::sleep; +use tokio_util::sync::CancellationToken; + +mod config; +mod http; +mod task; + +#[derive(StructOpt)] +struct CommandOpt { + #[structopt(short = "p", long)] + pub profile: Vec, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let log_level = LevelFilter::from_str( + CONFIG + .log_level + .clone() + .unwrap_or("INFO".to_string()) + .as_str(), + ) + .unwrap_or(LevelFilter::Info); + + TermLogger::init( + log_level, + Config::default(), + TerminalMode::Mixed, + ColorChoice::Auto, + )?; + + let command_opts = CommandOpt::from_args(); + + if command_opts.profile.is_empty() { + error!("No profile specified. Use -p or --profile to specify at least one profile."); + std::process::exit(1); + } + + let profiles = command_opts + .profile + .iter() + .map(|p| { + let profile = CONFIG.profiles.get(p.as_str()); + match profile { + Some(profile) => Ok((p.to_string(), profile.clone())), + None => Err(anyhow::anyhow!("Profile `{}` not found in config", p)), + } + }) + .collect::, _>>()?; + + let mut tasks = vec![]; + for profile in profiles { + let profile = Arc::new(profile.clone()); + let semaphore = Arc::new(Semaphore::new(1)); + let cancel_token = CancellationToken::new(); + let post_task = { + async |profile: Arc<(String, Profile)>, + permit: OwnedSemaphorePermit, + cancel_token: CancellationToken| { + match profile.1.interval { + None => { + cancel_token.cancel(); + } + Some(interval) => { + sleep(Duration::from_secs(interval)).await; + } + } + drop(permit); + } + }; + for server_conf in &CONFIG.connect.server { + let server_conf = server_conf.clone().into_tunnel_config(Identity::Client)?; + let url = server_conf.url.clone(); + let server = TunnelListener::bind(server_conf).await?; + let semaphore = semaphore.clone(); + let cancel_token = cancel_token.clone(); + let profile = profile.clone(); + info!("tcp server started on {}", url); + tasks.push(tokio::task::spawn(async move { + loop { + let endpoint = server + .accept() + .await + .map_err(|e| error!("Failed to accept connection: {}", e)); + let endpoint = if let Ok(endpoint) = endpoint { + endpoint + } else { + continue; + }; + if let TunnelEndpoint::Client(client) = endpoint { + if cancel_token.is_cancelled() { + return; + } + let permit = semaphore.clone().acquire_owned().await.unwrap(); + let result = run(client, profile.clone()).await; + match result { + Ok(true) => { + post_task(profile.clone(), permit, cancel_token.clone()).await; + } + Err(error) => { + error!("{}", error); + } + _ => {} + } + } + } + })); + } + + for client_conf in &CONFIG.connect.client { + let client_conf = client_conf.clone().into_tunnel_config(Identity::Client); + let semaphore = semaphore.clone(); + let cancel_token = cancel_token.clone(); + let profile = profile.clone(); + info!("tcp client started for {}", client_conf.url); + tasks.push(tokio::task::spawn(async move { + loop { + if cancel_token.is_cancelled() { + return; + } + let endpoint = connect_tunnel(client_conf.clone()) + .await + .map_err(|e| error!("Failed to accept connection: {}", e)); + let endpoint = if let Ok(endpoint) = endpoint { + endpoint + } else { + continue; + }; + if let TunnelEndpoint::Client(client) = endpoint { + if cancel_token.is_cancelled() { + return; + } + let permit = semaphore.clone().acquire_owned().await.unwrap(); + let result = run(client, profile.clone()).await; + match result { + Ok(true) => { + post_task(profile.clone(), permit, cancel_token.clone()).await; + } + Err(error) => { + error!("{}", error); + } + _ => {} + } + } + sleep(Duration::from_secs(10)).await; + } + })); + } + } + + for task in tasks { + let _ = task.await.map_err(|e| error!("{}", e)); + } + + Ok(()) +} +lazy_static! { + pub static ref CONFIG: ClientConfig = { + let raw = fs::read_to_string("sekai-unpacker-client.yaml").unwrap(); + let config: ClientConfig = yaml_serde::from_str(raw.as_str()).unwrap(); + config + }; +} diff --git a/client/src/task.rs b/client/src/task.rs new file mode 100644 index 0000000..2b04d67 --- /dev/null +++ b/client/src/task.rs @@ -0,0 +1,146 @@ +use crate::config::Profile; +use crate::http::{close, download, sync}; +use common::http::{CloseRequest, DownloadRequest}; +use communicator::ClientManager; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::sync::{RwLock, Semaphore}; + +pub async fn run( + client: Arc, + profile: Arc<(String, Profile)>, +) -> anyhow::Result { + info!("[{}]: Starting sync", profile.0); + let sync_resp = sync(&mut client.get_client().await?, &profile.1).await?; + let id = sync_resp.id; + let local_manifest = Arc::new( + AutoSaveManifest::new( + 5, + Path::new(&profile.1.path) + .join("manifest.json") + .to_path_buf(), + ) + .await?, + ); + let manifest_snapshot = { local_manifest.manifest.read().await.clone() }; + let tasks = sync_resp + .tasks + .into_iter() + .filter(|task| { + let bundle_name = &task.bundle_path; + match manifest_snapshot.bundles.get(bundle_name) { + Some(local_hash) => local_hash != &task.bundle_hash, + None => true, + } + }) + .collect::>(); + info!("[{}]: Collected {} tasks", profile.0, tasks.len()); + let n = 5; + let semaphore = Arc::new(Semaphore::new(n)); + let mut handles = Vec::new(); + for task in tasks { + let permit = semaphore.clone().acquire_owned().await?; + let client = client.clone(); + let id = id.clone(); + let local_manifest = local_manifest.clone(); + let profile = profile.clone(); + + handles.push(tokio::task::spawn(async move { + let req = DownloadRequest { + id: id.clone(), + task: task.clone(), + }; + let result = download(&mut client.get_client().await.unwrap(), &req, &profile.1).await; + if let Err(e) = result + && let Some(_) = e.downcast_ref::() + { + download(&mut client.get_client().await.unwrap(), &req, &profile.1) + .await + .unwrap(); + } + + local_manifest + .add_bundle(task.bundle_path.clone(), task.bundle_hash.clone()) + .await + .unwrap(); + drop(permit); + })); + } + let mut succeed = 0; + let mut failed = 0; + for handle in handles { + let r = handle.await; + if let Err(e) = r { + error!("{}", e); + failed += 1; + } else { + succeed += 1; + } + } + local_manifest.save().await?; + info!( + "[{}]: Sync finished with {} succeed, {} failed", + profile.0, succeed, failed + ); + let req = CloseRequest { id: id.clone() }; + close(&mut client.get_client().await?, &req).await?; + + Ok(failed == 0) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Manifest { + #[serde(default)] + bundles: HashMap, +} + +pub struct AutoSaveManifest { + manifest: Arc>, + counter: AtomicUsize, + save_interval: usize, + storage_path: PathBuf, +} + +impl AutoSaveManifest { + pub async fn new(interval: usize, path: PathBuf) -> anyhow::Result { + Ok(Self { + manifest: Arc::new(RwLock::new(serde_json::from_str( + &tokio::fs::read_to_string(&path) + .await + .unwrap_or("{}".to_owned()), + )?)), + counter: AtomicUsize::new(0), + save_interval: interval, + storage_path: path, + }) + } + + pub async fn add_bundle(&self, key: String, value: String) -> anyhow::Result<()> { + { + let mut w = self.manifest.write().await; + w.bundles.insert(key, value); + } + + let current_count = self.counter.fetch_add(1, Ordering::SeqCst) + 1; + + if current_count.is_multiple_of(self.save_interval) { + self.save().await?; + } + + Ok(()) + } + + pub async fn save(&self) -> anyhow::Result<()> { + let data = { + let r = self.manifest.read().await; + serde_json::to_vec(&*r)? + }; + + tokio::fs::write(&self.storage_path, data).await?; + Ok(()) + } +} diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 0000000..c0206bc --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "common" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { workspace = true, features = ["io-util", "fs"] } +anyhow = { workspace = true } +h2 = { workspace = true } +bytes = { workspace = true } +serde = { workspace = true, features = ["derive"] } \ No newline at end of file diff --git a/common/src/http.rs b/common/src/http.rs new file mode 100644 index 0000000..dc4b6b9 --- /dev/null +++ b/common/src/http.rs @@ -0,0 +1,19 @@ +use crate::updater::DownloadTask; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncResponse { + pub id: String, + pub tasks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadRequest { + pub id: String, + pub task: DownloadTask, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloseRequest { + pub id: String, +} diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 0000000..b0883b5 --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,3 @@ +pub mod stream; +pub mod updater; +pub mod http; \ No newline at end of file diff --git a/common/src/stream.rs b/common/src/stream.rs new file mode 100644 index 0000000..03e56ee --- /dev/null +++ b/common/src/stream.rs @@ -0,0 +1,85 @@ +use bytes::{BufMut, Bytes, BytesMut}; +use h2::SendStream; +use std::path::Path; +use tokio::fs::File; +use tokio::io::{AsyncRead, AsyncReadExt}; + +pub async fn server_send_files>( + mut send_stream: SendStream, + base: P, + files: &[P], +) -> Result<(), Box> { + let mut header = BytesMut::with_capacity(4); + header.put_u32(files.len() as u32); + send_stream.send_data(header.freeze(), false)?; + + for (i, path_ref) in files.iter().enumerate() { + let path = path_ref.as_ref(); + let mut file = File::open(path).await?; + let metadata = file.metadata().await?; + + let file_name = path + .strip_prefix(base.as_ref()) + .map(|n| n.to_string_lossy().to_string())?; + let name_bytes = file_name.as_bytes(); + let file_size = metadata.len(); + + let mut meta_buf = BytesMut::with_capacity(2 + name_bytes.len() + 8); + meta_buf.put_u16(name_bytes.len() as u16); + meta_buf.put_slice(name_bytes); + meta_buf.put_u64(file_size); + + send_stream.send_data(meta_buf.freeze(), false)?; + + let mut buffer = vec![0u8; 8192]; + let mut sent_size = 0u64; + + while sent_size < file_size { + let n = file.read(&mut buffer).await?; + if n == 0 { + break; + } + + let chunk = Bytes::copy_from_slice(&buffer[..n]); + + let is_last_chunk = (i == files.len() - 1) && (sent_size + n as u64 == file_size); + + send_stream.send_data(chunk, is_last_chunk)?; + + sent_size += n as u64; + } + } + + Ok(()) +} + +pub async fn client_receive( + mut response_body_reader: impl AsyncRead + Unpin, + file_root: &Path, +) -> anyhow::Result<()> { + let file_count = response_body_reader.read_u32().await?; + + for _ in 0..file_count { + let name_len = response_body_reader.read_u16().await?; + + let mut name_buf = vec![0u8; name_len as usize]; + response_body_reader.read_exact(&mut name_buf).await?; + let file_name = String::from_utf8(name_buf)?; + + let data_len = response_body_reader.read_u64().await?; + + let path = file_root.join(&file_name); + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut file = File::create(&path).await?; + + let mut take = response_body_reader.take(data_len); + tokio::io::copy(&mut take, &mut file).await?; + + response_body_reader = take.into_inner(); + } + Ok(()) +} diff --git a/common/src/updater.rs b/common/src/updater.rs new file mode 100644 index 0000000..3184163 --- /dev/null +++ b/common/src/updater.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncContext { + pub region: String, + #[serde(default)] + pub filters: RegionFiltersConfig, + #[serde(default)] + pub export: RegionExportConfig, + #[serde(default)] + pub asset_version: Option, + #[serde(default)] + pub asset_hash: Option, +} + + + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RegionFiltersConfig { + #[serde(default)] + pub start_app: Vec, + #[serde(default)] + pub on_demand: Vec, + #[serde(default)] + pub skip: Vec, + #[serde(default)] + pub file_ext: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RegionExportConfig { + #[serde(default)] + pub by_category: bool, + #[serde(default)] + pub usm: UsmExportConfig, + #[serde(default)] + pub acb: AcbExportConfig, + #[serde(default)] + pub hca: HcaExportConfig, + #[serde(default)] + pub images: ImageExportConfig, + #[serde(default)] + pub video: VideoExportConfig, + #[serde(default)] + pub audio: AudioExportConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct UsmExportConfig { + pub export: bool, + pub decode: bool, +} + +impl Default for UsmExportConfig { + fn default() -> Self { + Self { + export: true, + decode: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AcbExportConfig { + pub export: bool, + pub decode: bool, +} + +impl Default for AcbExportConfig { + fn default() -> Self { + Self { + export: true, + decode: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HcaExportConfig { + pub decode: bool, +} + +impl Default for HcaExportConfig { + fn default() -> Self { + Self { decode: true } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ImageExportConfig { + pub convert_to_webp: bool, + pub remove_png: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct VideoExportConfig { + pub convert_to_mp4: bool, + pub direct_usm_to_mp4_with_ffmpeg: bool, + pub remove_m2v: bool, +} + +impl Default for VideoExportConfig { + fn default() -> Self { + Self { + convert_to_mp4: true, + direct_usm_to_mp4_with_ffmpeg: false, + remove_m2v: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioExportConfig { + pub convert_to_mp3: bool, + pub convert_to_flac: bool, + pub remove_wav: bool, +} + +impl Default for AudioExportConfig { + fn default() -> Self { + Self { + convert_to_mp3: true, + convert_to_flac: false, + remove_wav: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadTask { + pub download_path: String, + pub bundle_path: String, + pub bundle_hash: String, + pub category: AssetCategory, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub enum AssetCategory { + StartApp, + OnDemand, + Other(String), +} + +impl<'de> Deserialize<'de> for AssetCategory { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Treat nil/null as Other("") — matches Go's zero-value coercion. + let raw = Option::::deserialize(deserializer)?.unwrap_or_default(); + Ok(match raw.as_str() { + "StartApp" | "startApp" => Self::StartApp, + "OnDemand" | "onDemand" => Self::OnDemand, + other => Self::Other(other.to_string()), + }) + } +} diff --git a/communicator/Cargo.toml b/communicator/Cargo.toml new file mode 100644 index 0000000..6322837 --- /dev/null +++ b/communicator/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "communicator" +version = "0.1.0" +edition = "2024" + +[dependencies] +bytes = { workspace = true } +h2 = { workspace = true } +http = { workspace = true } +log = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net", "rt-multi-thread", "io-util", "time"] } +tokio-rustls = { workspace = true } +rustls-pemfile = { workspace = true } +webpki-roots = { workspace = true } +anyhow = { workspace = true } +tokio-util = { workspace = true } +futures-util = { workspace = true } +futures = "0.3.32" diff --git a/communicator/src/config.rs b/communicator/src/config.rs new file mode 100644 index 0000000..12f0f25 --- /dev/null +++ b/communicator/src/config.rs @@ -0,0 +1,48 @@ +use crate::stream::Identity; +use crate::{ClientTunnelConfig, ServerTunnelConfig, SslConfig}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConnectConfig { + #[serde(default)] + pub server: Vec, + #[serde(default)] + pub client: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TcpServerTunnelConfig { + pub url: String, + #[serde(flatten)] + pub cert: Option, + pub token: String, +} + +impl TcpServerTunnelConfig { + pub fn into_tunnel_config(self, identity: Identity) -> anyhow::Result { + Ok(ServerTunnelConfig { + identity, + url: self.url, + cert: self.cert, + token: self.token, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TcpClientTunnelConfig { + pub host: Option, + pub url: String, + pub token: String, +} + +impl TcpClientTunnelConfig { + pub fn into_tunnel_config(self, identity: Identity) -> ClientTunnelConfig { + ClientTunnelConfig { + identity, + host: self.host, + url: self.url, + token: self.token, + } + } +} \ No newline at end of file diff --git a/communicator/src/http.rs b/communicator/src/http.rs new file mode 100644 index 0000000..f650403 --- /dev/null +++ b/communicator/src/http.rs @@ -0,0 +1,216 @@ +use crate::stream::ServerManager; +use bytes::{BufMut, Bytes, BytesMut}; +use futures_util::stream::{self}; +use h2::server::SendResponse; +use h2::{RecvStream, client}; +use http::{Request, Response, StatusCode}; +use log::{debug, error}; +use serde::de::DeserializeOwned; +use std::collections::HashMap; +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use tokio_util::io::StreamReader; + +type BoxFuture = Pin + Send>>; +type Handler = Arc< + dyn Fn(Request, SendResponse) -> BoxFuture> + + Send + + Sync, +>; + +pub struct Router { + routes: HashMap, +} + +impl Default for Router { + fn default() -> Self { + Self::new() + } +} + +impl Router { + pub fn new() -> Self { + Self { + routes: HashMap::new(), + } + } + + pub fn add_route(&mut self, path: &str, handler: F) + where + F: Fn(Request, SendResponse) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.routes.insert( + path.to_string(), + Arc::new(move |req, res| Box::pin(handler(req, res))), + ); + } + + pub async fn dispatch( + &self, + req: Request, + mut res: SendResponse, + ) -> Result<(), h2::Error> { + let path = req.uri().path(); + debug!("Received request for path: {}", path); + if let Some(handler) = self.routes.get(path) { + handler(req, res).await + } else { + // 404 + let response = Response::builder().status(404).body(()).unwrap(); + res.send_response(response, true)?; + Ok(()) + } + } +} + +pub struct Server { + router: Arc, +} + +impl Server { + pub fn new(router: Arc) -> Self { + Self { router } + } + + pub async fn on_conn(&self, server: Arc) -> anyhow::Result<()> { + let router = self.router.clone(); + + while let Some(result) = server.accept().await { + let (request, respond) = result?; + let r = router.clone(); + tokio::spawn(async move { + if let Err(e) = r.dispatch(request, respond).await { + error!("Handler error: {:?}", e); + } + }); + } + Ok(()) + } +} + +pub fn send(mut res: SendResponse, status: u16, content_type: &str, body: String) { + let response = Response::builder() + .status(status) + .header("content-type", content_type) + .body(()) + .unwrap(); + + if let Ok(mut send_stream) = res.send_response(response, false) { + let _ = send_stream.send_data(Bytes::from(body), true); + } +} + +pub fn send_error(mut res: SendResponse, error: anyhow::Error) { + let response = Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/plain") + .body(()) + .unwrap(); + + if let Ok(mut send_stream) = res.send_response(response, false) { + let error_msg = format!("Internal Server Error: {}", error); + let _ = send_stream.send_data(Bytes::from(error_msg), true); + } +} + +pub async fn json_from_request(req: &mut Request) -> anyhow::Result +where + T: DeserializeOwned, +{ + let body_stream = req.body_mut(); + let mut buf = BytesMut::new(); + + while let Some(chunk) = body_stream.data().await { + let data = chunk?; + + let len = data.len(); + buf.put(data); + + body_stream.flow_control().release_capacity(len)?; + } + + if buf.is_empty() { + return Err(anyhow::anyhow!("Request body is empty")); + } + + let result = serde_json::from_slice(&buf)?; + Ok(result) +} + +pub async fn request( + client: &mut client::SendRequest, + path: &str, + body: String, +) -> anyhow::Result> { + let request = Request::builder() + .method("POST") + .uri("http://0.0.0.0".to_owned() + path) + .header("content-type", "application/json") + .body(())?; + + let (response, mut send_stream) = client.send_request(request, false)?; + send_stream.send_data(Bytes::from(body), true)?; + Ok(response.await?) +} + +async fn bytes_from_response(response: &mut Response) -> anyhow::Result { + let body_stream = response.body_mut(); + let mut buf = BytesMut::new(); + + while let Some(chunk) = body_stream.data().await { + let data = chunk?; + + let len = data.len(); + buf.put(data); + + body_stream.flow_control().release_capacity(len)?; + } + + Ok(buf.freeze()) +} + +pub async fn text_from_response(response: &mut Response) -> anyhow::Result { + let buf = bytes_from_response(response).await?; + + if buf.is_empty() { + return Ok(String::new()); + } + + Ok(String::from_utf8_lossy(&buf).to_string()) +} + +pub async fn json_from_response(response: &mut Response) -> anyhow::Result +where + T: DeserializeOwned, +{ + let buf = bytes_from_response(response).await?; + + if buf.is_empty() { + return Err(anyhow::anyhow!("Request body is empty")); + } + + let result = serde_json::from_slice(&buf)?; + Ok(result) +} + +pub fn response_to_async_read(res: Response) -> impl tokio::io::AsyncRead { + let body = res.into_body(); + + let byte_stream = stream::unfold(body, |mut body| async move { + match body.data().await { + Some(Ok(bytes)) => { + let len = bytes.len(); + if let Err(e) = body.flow_control().release_capacity(len) { + return Some((Err(io::Error::other(e)), body)); + } + Some((Ok::<_, io::Error>(bytes), body)) + } + Some(Err(e)) => Some((Err(io::Error::other(e)), body)), + None => None, + } + }); + + StreamReader::new(byte_stream) +} diff --git a/communicator/src/lib.rs b/communicator/src/lib.rs new file mode 100644 index 0000000..707814d --- /dev/null +++ b/communicator/src/lib.rs @@ -0,0 +1,9 @@ +mod config; +pub mod http; +mod stream; + +pub use config::{ConnectConfig, TcpClientTunnelConfig, TcpServerTunnelConfig}; +pub use stream::{ + ClientManager, ClientTunnelConfig, Identity, ServerManager, ServerTunnelConfig, SslConfig, + TunnelEndpoint, TunnelListener, connect_tunnel, +}; diff --git a/communicator/src/stream.rs b/communicator/src/stream.rs new file mode 100644 index 0000000..07032d4 --- /dev/null +++ b/communicator/src/stream.rs @@ -0,0 +1,611 @@ +use anyhow::anyhow; +use bytes::Bytes; +use h2::{RecvStream, client, server}; +use http::Request; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fs::File; +use std::io::BufReader; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Mutex, Notify}; +use tokio::time::{Duration, sleep, timeout}; +use tokio_rustls::rustls; +use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName}; +use tokio_rustls::{TlsAcceptor, TlsConnector}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Identity { + Server, + Client, +} + +impl Identity { + pub fn as_u8(&self) -> u8 { + match self { + Identity::Server => b'S', + Identity::Client => b'C', + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientTunnelConfig { + pub host: Option, + pub url: String, + pub token: String, + pub identity: Identity, +} + +impl Default for ClientTunnelConfig { + fn default() -> Self { + Self { + host: None, + url: "127.0.0.1:3333".to_string(), + token: "super_secret_magic_token".to_string(), + identity: Identity::Client, + } + } +} + +unsafe impl Send for ClientTunnelConfig {} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SslConfig { + pub cert: String, + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerTunnelConfig { + pub url: String, + pub cert: Option, + pub token: String, + pub identity: Identity, +} + +impl Default for ServerTunnelConfig { + fn default() -> Self { + Self { + url: "127.0.0.1:3333".to_string(), + cert: None, + token: "super_secret_magic_token".to_string(), + identity: Identity::Client, + } + } +} + +static CLIENT_HELLO: &[u8] = b"Sekai unpacker client hello"; +static SERVER_HELLO: &[u8] = b"Sekai unpacker server hello"; +static TLS_BOOTSTRAP_MAGIC: &[u8] = b"SPTLSP1"; +static RESUME_MAGIC: &[u8] = b"SPRESM1"; + +pub enum TunnelEndpointRaw { + Client(Box>), + Server(Box>), +} + +#[derive(Clone)] +pub enum TunnelEndpoint { + Client(Arc), + Server(Arc), +} + +pub struct ClientManager { + pub session_id: AtomicU64, + pub current_client: Mutex>>, + pub config: Option, + pub notify: Arc, +} + +impl ClientManager { + pub async fn get_client(&self) -> anyhow::Result> { + loop { + let cached = { self.current_client.lock().await.clone() }; + if let Some(mut c) = cached { + if futures::future::poll_fn(|cx| c.poll_ready(cx)) + .await + .is_ok() + { + return Ok(c); + } + debug!("Client physical connection dead, preparing to recover..."); + } + + let mut lock = self.current_client.lock().await; + if lock.is_some() + && futures::future::poll_fn(|cx| lock.as_mut().unwrap().poll_ready(cx)) + .await + .is_ok() + { + continue; + } + + if let Some(config) = &self.config { + let mut retry_delay = Duration::from_secs(1); + let mut sid = self.session_id.load(Ordering::Relaxed); + + let new_client = loop { + match do_client_reconnect(config, &mut sid).await { + Ok(TunnelEndpointRaw::Client(c)) => { + self.session_id.store(sid, Ordering::Relaxed); + break c; + } + Ok(_) => return Err(anyhow::anyhow!("Identity mismatch on reconnect")), + Err(e) => { + error!("Reconnect failed: {}. Retrying in {:?}...", e, retry_delay); + drop(e); + sleep(retry_delay).await; + retry_delay = std::cmp::min(retry_delay * 2, Duration::from_secs(30)); + } + } + }; + *lock = Some(*new_client.clone()); + return Ok(*new_client); + } else { + *lock = None; + drop(lock); + match timeout(Duration::from_secs(15), self.notify.notified()).await { + Ok(_) => continue, + Err(_) => { + anyhow::bail!("Session did not reconnect within 15s. Throwing error!") + } + } + } + } + } +} + +pub struct ServerManager { + pub session_id: AtomicU64, + pub current_server: Mutex>>, + pub config: Option, + pub notify: Arc, +} + +impl ServerManager { + pub async fn accept( + &self, + ) -> Option, server::SendResponse), h2::Error>> { + loop { + let mut conn_guard = self.current_server.lock().await; + if let Some(conn) = conn_guard.as_mut() { + if let Some(res) = conn.accept().await { + return Some(res); + } + *conn_guard = None; + log::warn!("Server physical connection dropped, waiting for recovery..."); + } + drop(conn_guard); + + if let Some(config) = &self.config { + let mut retry_delay = Duration::from_secs(1); + let mut sid = self.session_id.load(Ordering::Relaxed); + + let new_server = loop { + match do_client_reconnect(config, &mut sid).await { + Ok(TunnelEndpointRaw::Server(s)) => { + self.session_id.store(sid, Ordering::Relaxed); + break s; + } + Ok(_) => { + error!("Identity mismatch on reconnect"); + return None; + } + Err(e) => { + error!("Reconnect failed: {}. Retrying in {:?}...", e, retry_delay); + drop(e); + sleep(retry_delay).await; + retry_delay = std::cmp::min(retry_delay * 2, Duration::from_secs(30)); + } + } + }; + *self.current_server.lock().await = Some(*new_server); + continue; + } else { + match timeout(Duration::from_secs(15), self.notify.notified()).await { + Ok(_) => continue, + Err(_) => { + error!( + "Session did not reconnect within 15s. Throwing error (returning None)!" + ); + return None; + } + } + } + } + } +} + +enum ResumeResult { + NotResume(TcpStream), + NewSession(TunnelEndpointRaw, u64), + ResumedExisting, + Invalid, +} + +pub struct TunnelListener { + listener: TcpListener, + config: ServerTunnelConfig, + pending_plain_sessions: Mutex>, + next_session_id: AtomicU64, + active_sessions: Mutex>, +} + +impl TunnelListener { + pub async fn bind(config: ServerTunnelConfig) -> anyhow::Result { + let listener = TcpListener::bind(&config.url).await?; + info!("TCP tunnel listener bound to {}", &config.url); + Ok(Self { + listener, + config, + pending_plain_sessions: Mutex::new(HashSet::new()), + next_session_id: AtomicU64::new(1), + active_sessions: Mutex::new(HashMap::new()), + }) + } + + pub async fn accept(&self) -> anyhow::Result { + loop { + let (stream, peer_addr) = self.listener.accept().await?; + debug!("[{}] Connected on tcp", peer_addr); + + if is_tls_client_hello(&stream).await { + if let Err(e) = self.handle_tls_bootstrap(stream, peer_addr).await { + error!("[{}] TLS bootstrap failed: {}", peer_addr, e); + } + continue; + } + + let mut stream = match self.try_resume_plain_session(stream, peer_addr).await? { + ResumeResult::NewSession(ep_raw, sid) => { + let ep = wrap_raw_endpoint(sid, ep_raw, None); + self.active_sessions.lock().await.insert(sid, ep.clone()); + return Ok(ep); + } + ResumeResult::ResumedExisting => continue, + ResumeResult::Invalid => continue, + ResumeResult::NotResume(s) => s, + }; + + if perform_server_handshake(&mut stream, &self.config.token, self.config.identity) + .await + .is_err() + { + debug!("[{}] Plain handshake failed", peer_addr); + continue; + } + + debug!( + "[{}] Plain handshake completed, upgrading to H2...", + peer_addr + ); + let ep_raw = upgrade_to_h2_raw(stream, self.config.identity) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let sid = self.next_session_id.fetch_add(1, Ordering::Relaxed); + let ep = wrap_raw_endpoint(sid, ep_raw, None); + self.active_sessions.lock().await.insert(sid, ep.clone()); + return Ok(ep); + } + } + + async fn handle_tls_bootstrap( + &self, + stream: TcpStream, + peer_addr: std::net::SocketAddr, + ) -> Result<(), Box> { + let cert_cfg = self + .config + .cert + .as_ref() + .ok_or("TLS is not enabled on server")?; + + let acceptor = TlsAcceptor::from(build_server_tls_config(cert_cfg)?); + let mut tls_stream = acceptor.accept(stream).await?; + + perform_server_handshake(&mut tls_stream, &self.config.token, self.config.identity).await?; + + let mut magic = vec![0u8; TLS_BOOTSTRAP_MAGIC.len()]; + tls_stream.read_exact(&mut magic).await?; + if magic != TLS_BOOTSTRAP_MAGIC { + return Err("TLS bootstrap marker mismatch".into()); + } + + let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed); + { + let mut pending = self.pending_plain_sessions.lock().await; + pending.insert(session_id); + } + + tls_stream.write_all(TLS_BOOTSTRAP_MAGIC).await?; + tls_stream.write_all(&session_id.to_be_bytes()).await?; + debug!( + "[{}] TLS bootstrap done, issued plain-H2 session {}", + peer_addr, session_id + ); + Ok(()) + } + + async fn try_resume_plain_session( + &self, + mut stream: TcpStream, + peer_addr: std::net::SocketAddr, + ) -> anyhow::Result { + let mut peek_buf = vec![0u8; RESUME_MAGIC.len()]; + let n = stream.peek(&mut peek_buf).await?; + if n < RESUME_MAGIC.len() || peek_buf != RESUME_MAGIC { + return Ok(ResumeResult::NotResume(stream)); + } + + let mut magic = vec![0u8; RESUME_MAGIC.len()]; + stream.read_exact(&mut magic).await?; + let mut sid_buf = [0u8; 8]; + stream.read_exact(&mut sid_buf).await?; + let session_id = u64::from_be_bytes(sid_buf); + + let is_pending = { + let mut pending = self.pending_plain_sessions.lock().await; + pending.remove(&session_id) + }; + + if is_pending { + let ep_raw = upgrade_to_h2_raw(stream, self.config.identity) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + return Ok(ResumeResult::NewSession(ep_raw, session_id)); + } + + let active = self.active_sessions.lock().await.get(&session_id).cloned(); + if let Some(ep) = active { + let ep_raw = upgrade_to_h2_raw(stream, self.config.identity) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + update_endpoint(&ep, ep_raw).await; + info!( + "[{}] Successfully resumed existing session {}", + peer_addr, session_id + ); + return Ok(ResumeResult::ResumedExisting); + } + + error!("[{}] Invalid plain-H2 session {}", peer_addr, session_id); + Ok(ResumeResult::Invalid) + } +} + +async fn is_tls_client_hello(stream: &TcpStream) -> bool { + let mut header = [0u8; 3]; + match stream.peek(&mut header).await { + Ok(n) if n >= 3 => header[0] == 0x16 && header[1] == 0x03 && (1..=4).contains(&header[2]), + _ => false, + } +} + +async fn perform_server_handshake( + stream: &mut S, + token: &str, + identity: Identity, +) -> Result<(), Box> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let mut client_hello_buf = vec![0u8; CLIENT_HELLO.len()]; + stream.read_exact(&mut client_hello_buf).await?; + if client_hello_buf != CLIENT_HELLO { + return Err("Client Hello mismatch".into()); + } + + let mut token_buf = vec![0u8; token.len()]; + stream.read_exact(&mut token_buf).await?; + if String::from_utf8_lossy(&token_buf) != token { + return Err("Wrong token".into()); + } + + stream.write_all(SERVER_HELLO).await?; + + let mut peer_id_buf = [0u8; 1]; + stream.read_exact(&mut peer_id_buf).await?; + stream.write_all(&[identity.as_u8()]).await?; + + if peer_id_buf[0] == identity.as_u8() { + return Err("Identity collision with peer".into()); + } + + Ok(()) +} + +async fn perform_client_handshake( + stream: &mut S, + token: &str, + identity: Identity, +) -> anyhow::Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + stream.write_all(CLIENT_HELLO).await?; + stream.write_all(token.as_bytes()).await?; + + let mut server_hello_buf = vec![0u8; SERVER_HELLO.len()]; + stream.read_exact(&mut server_hello_buf).await?; + if server_hello_buf != SERVER_HELLO { + return Err(anyhow!("Server Hello mismatch")); + } + + stream.write_all(&[identity.as_u8()]).await?; + let mut peer_id_buf = [0u8; 1]; + stream.read_exact(&mut peer_id_buf).await?; + + if peer_id_buf[0] == identity.as_u8() { + return Err(anyhow!("Identity collision with server")); + } + + Ok(()) +} + +fn build_server_tls_config( + cert_cfg: &SslConfig, +) -> Result, Box> { + let cert_file = File::open(&cert_cfg.cert)?; + let mut cert_reader = BufReader::new(cert_file); + let cert_chain: Vec> = + rustls_pemfile::certs(&mut cert_reader).collect::, _>>()?; + if cert_chain.is_empty() { + return Err("No certificate found in PEM".into()); + } + + let key_file = File::open(&cert_cfg.key)?; + let mut key_reader = BufReader::new(key_file); + let private_key = + rustls_pemfile::private_key(&mut key_reader)?.ok_or("No private key found in PEM")?; + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, private_key)?; + Ok(Arc::new(server_config)) +} + +fn build_client_tls_connector() -> TlsConnector { + let root_store = + rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + TlsConnector::from(Arc::new(config)) +} + +async fn upgrade_to_h2_raw( + stream: TcpStream, + identity: Identity, +) -> anyhow::Result { + match identity { + Identity::Client => { + let (h2_client, h2_conn) = client::handshake(stream).await?; + tokio::spawn(async move { + if let Err(e) = h2_conn.await { + debug!("H2 connection driver finished/error: {:?}", e); + } + }); + Ok(TunnelEndpointRaw::Client(Box::new(h2_client))) + } + Identity::Server => { + let h2_conn = server::handshake(stream).await?; + Ok(TunnelEndpointRaw::Server(Box::new(h2_conn))) + } + } +} + +fn wrap_raw_endpoint( + sid: u64, + raw: TunnelEndpointRaw, + config: Option, +) -> TunnelEndpoint { + match raw { + TunnelEndpointRaw::Client(c) => TunnelEndpoint::Client(Arc::new(ClientManager { + session_id: AtomicU64::new(sid), + current_client: Mutex::new(Some(*c)), + config, + notify: Arc::new(Notify::new()), + })), + TunnelEndpointRaw::Server(s) => TunnelEndpoint::Server(Arc::new(ServerManager { + session_id: AtomicU64::new(sid), + current_server: Mutex::new(Some(*s)), + config, + notify: Arc::new(Notify::new()), + })), + } +} + +async fn update_endpoint(ep: &TunnelEndpoint, ep_raw: TunnelEndpointRaw) { + match (ep, ep_raw) { + (TunnelEndpoint::Client(mgr), TunnelEndpointRaw::Client(c)) => { + *mgr.current_client.lock().await = Some(*c); + mgr.notify.notify_waiters(); + } + (TunnelEndpoint::Server(mgr), TunnelEndpointRaw::Server(s)) => { + *mgr.current_server.lock().await = Some(*s); + mgr.notify.notify_waiters(); + } + _ => error!("Identity mismatch during session resume!"), + } +} + +pub async fn connect_tunnel(config: ClientTunnelConfig) -> Result> { + info!("Connecting to tunnel at {}", &config.url); + let mut sid = 0; + let ep_raw = do_client_reconnect(&config, &mut sid).await?; + Ok(wrap_raw_endpoint(sid, ep_raw, Some(config))) +} + +async fn do_client_reconnect( + config: &ClientTunnelConfig, + current_sid: &mut u64, +) -> anyhow::Result { + if *current_sid != 0 { + match resume_tunnel_client(config, *current_sid).await { + Ok(ep) => { + info!("Resumed existing session {}", current_sid); + return Ok(ep); + } + Err(e) => { + log::warn!("Resume failed: {}. Falling back to full bootstrap.", e); + } + } + } + + if let Some(host) = config.host.clone() { + let (sid, _) = bootstrap_tls_and_get_sid(config, &host).await?; + *current_sid = sid; + resume_tunnel_client(config, sid).await + } else { + let mut stream = TcpStream::connect(&config.url).await?; + perform_client_handshake(&mut stream, &config.token, config.identity).await?; + let raw = upgrade_to_h2_raw(stream, config.identity).await?; + *current_sid = 0; + Ok(raw) + } +} + +async fn bootstrap_tls_and_get_sid( + config: &ClientTunnelConfig, + host: &str, +) -> anyhow::Result<(u64, ())> { + let connector = build_client_tls_connector(); + let tcp = TcpStream::connect(&config.url).await?; + let server_name = ServerName::try_from(host.to_string()) + .map_err(|_| anyhow!("Invalid TLS host: {}", host))? + .to_owned(); + + let mut tls_stream = connector + .connect(server_name, tcp) + .await + .map_err(|e| anyhow!("TLS handshake failed (server may not support TLS): {}", e))?; + + perform_client_handshake(&mut tls_stream, &config.token, config.identity).await?; + + tls_stream.write_all(TLS_BOOTSTRAP_MAGIC).await?; + let mut magic = vec![0u8; TLS_BOOTSTRAP_MAGIC.len()]; + tls_stream.read_exact(&mut magic).await?; + if magic != TLS_BOOTSTRAP_MAGIC { + return Err(anyhow!("TLS bootstrap response mismatch")); + } + + let mut sid_buf = [0u8; 8]; + tls_stream.read_exact(&mut sid_buf).await?; + Ok((u64::from_be_bytes(sid_buf), ())) +} + +async fn resume_tunnel_client( + config: &ClientTunnelConfig, + session_id: u64, +) -> anyhow::Result { + let mut plain_stream = TcpStream::connect(&config.url).await?; + plain_stream.write_all(RESUME_MAGIC).await?; + plain_stream.write_all(&session_id.to_be_bytes()).await?; + upgrade_to_h2_raw(plain_stream, config.identity).await +} diff --git a/sekai-unpacker-client.yaml b/sekai-unpacker-client.yaml new file mode 100644 index 0000000..f45ee8c --- /dev/null +++ b/sekai-unpacker-client.yaml @@ -0,0 +1,39 @@ +log_level: "DEBUG" + +client: + - url: "127.0.0.1:3333" + token: abc + host: "local.bluemangoo.net" + +profiles: + cn: + region: cn + interval: 3 + filters: + start_app: + - "thumbnail" + on_demand: [ ] + skip: [ ] + file_ext: [ ] + export: + by_category: false + usm: + export: true + decode: true + acb: + export: true + decode: true + hca: + decode: true + images: + convert_to_webp: false + remove_png: false + video: + convert_to_mp4: false + direct_usm_to_mp4_with_ffmpeg: false + remove_m2v: false + audio: + convert_to_mp3: false + convert_to_flac: false + remove_wav: false + path: "./data/cn" \ No newline at end of file diff --git a/sekai-unpacker-server.yaml b/sekai-unpacker-server.yaml new file mode 100644 index 0000000..354241d --- /dev/null +++ b/sekai-unpacker-server.yaml @@ -0,0 +1,59 @@ +log_level: "DEBUG" + +server: + - url: 127.0.0.1:3333 + token: abc + cert: "D:\\WorkDir\\Nginx\\cert\\_.bluemangoo.net\\_.bluemangoo.net-chain.pem" + key: "D:\\WorkDir\\Nginx\\cert\\_.bluemangoo.net\\_.bluemangoo.net-key.pem" + +execution: + proxy: "" + timeout_seconds: 300 + allow_cancel: true + batch_save_size: 50 + retry: + attempts: 4 + initial_backoff_ms: 1000 + max_backoff_ms: 4000 + +tools: + ffmpeg_path: "ffmpeg" + asset_studio_cli_path: "D:\\Workspace\\AssetStudio\\AssetStudioCLI\\bin\\Release\\net9.0\\AssetStudioModCLI.exe" + +concurrency: + download: 4 + upload: 4 + acb: 8 + usm: 4 + hca: 16 + +regions: + jp: + enabled: true + provider: + kind: colorful_palette + asset_info_url_template: "" + asset_bundle_url_template: "" + profile: "production" + profile_hashes: { assetbundleHostHash: cf2d2388 } + required_cookies: true + crypto: + aes_key_hex: "6732666343305a637a4e394d544a3631" + aes_iv_hex: "6d737833495630693958453575595a31" + runtime: + unity_version: "2022.3.21f1" + + cn: + enabled: true + provider: + kind: nuverse + asset_version_url: "https://lf3-mkcncdn-tos.dailygn.com/obj/rt-game-lf/gdl_app_5236/Mainland/{app_version}/Release/cn_online/ios/version" + app_version: "5.2.0" + asset_info_url_template: "https://lf3-mkcncdn-tos.dailygn.com/obj/sf-game-lf/gdl_app_5236/AssetBundle/{app_version}/Release/cn_online/ios{asset_version}/AssetBundleInfoNew.json" + asset_bundle_url_template: "https://lf3-mkcncdn-tos.dailygn.com/obj/sf-game-lf/gdl_app_5236/AssetBundle/{app_version}/Release/cn_online/{bundle_path}" + required_cookies: false + crypto: + aes_key_hex: "6732666343305a637a4e394d544a3631" + aes_iv_hex: "6d737833495630693958453575595a31" + runtime: + unity_version: "2022.3.21f1" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..a2b04b3 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2024" + +[dependencies] +common = { path = "../common" } +communicator = { path = "../communicator" } +assets-updater = { path = "../assets-updater" } + +tokio = { workspace = true, features = ["rt-multi-thread", "rt", "macros"] } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +yaml_serde = { workspace = true } +moka = { workspace = true, features = ["future"] } +uuid = { workspace = true, features = ["v4"] } +h2 = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +lazy_static = { workspace = true } +log = { workspace = true } +simplelog = { workspace = true } diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..56787e8 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,12 @@ +use assets_updater::core::config::AppConfig; +use communicator::ConnectConfig; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServerConfig { + pub log_level: Option, + #[serde(flatten, default)] + pub connect: ConnectConfig, + #[serde(flatten, default)] + pub updater_config: AppConfig, +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..6fa8d78 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,115 @@ +mod config; +mod router; +mod session; + +use crate::config::ServerConfig; +use crate::router::build_routers; +use crate::session::SessionStore; +use assets_updater::core::asset_execution::AssetExecutionContext; +use communicator::http::Server; +use communicator::{Identity, TunnelEndpoint, TunnelListener, connect_tunnel}; +use lazy_static::lazy_static; +use log::{LevelFilter, error, info}; +use moka::future::Cache; +use simplelog::{ColorChoice, Config, TermLogger, TerminalMode}; +use std::fs; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::sleep; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let log_level = LevelFilter::from_str( + CONFIG + .log_level + .clone() + .unwrap_or("INFO".to_string()) + .as_str(), + ) + .unwrap_or(LevelFilter::Info); + + TermLogger::init( + log_level, + Config::default(), + TerminalMode::Mixed, + ColorChoice::Auto, + )?; + + let routers = Arc::new(build_routers()); + let http_server = Arc::new(Server::new(routers.clone())); + + let mut tasks = vec![]; + + for server_conf in &CONFIG.connect.server { + let server_conf = server_conf.clone().into_tunnel_config(Identity::Server)?; + let url = server_conf.url.clone(); + let server = TunnelListener::bind(server_conf).await?; + info!("tcp server started on {}", url); + let http_server = http_server.clone(); + tasks.push(tokio::task::spawn(async move { + loop { + let endpoint = server + .accept() + .await + .map_err(|e| error!("Failed to accept connection: {}", e)); + let endpoint = if let Ok(endpoint) = endpoint { + endpoint + } else { + continue; + }; + if let TunnelEndpoint::Server(connection) = endpoint { + let result = http_server.on_conn(connection).await; + if let Err(e) = result { + error!("Failed to handle connection: {}", e); + } + } + } + })); + } + + for client_conf in &CONFIG.connect.client { + let client_conf = client_conf.clone().into_tunnel_config(Identity::Server); + let http_server = http_server.clone(); + info!("tcp client started for {}", client_conf.url); + tasks.push(tokio::task::spawn(async move { + loop { + let endpoint = connect_tunnel(client_conf.clone()) + .await + .map_err(|e| error!("Failed to accept connection: {}", e)); + let endpoint = if let Ok(endpoint) = endpoint { + endpoint + } else { + continue; + }; + if let TunnelEndpoint::Server(connection) = endpoint { + let result = http_server.on_conn(connection).await; + if let Err(e) = result { + error!("Failed to handle connection: {}", e); + } + } + sleep(Duration::from_secs(10)).await; + } + })); + } + + for task in tasks { + let _ = task.await.map_err(|e| error!("{}", e)); + } + + Ok(()) +} + +lazy_static! { + pub static ref SESSION_STORE: SessionStore = SessionStore::new( + Cache::builder() + .time_to_idle(Duration::from_hours(3)) + .max_capacity(10000), + ); + pub static ref CONFIG: ServerConfig = { + let raw = fs::read_to_string("sekai-unpacker-server.yaml").unwrap(); + let config: ServerConfig = yaml_serde::from_str(raw.as_str()).unwrap(); + config.updater_config.validate().unwrap(); + config + }; +} diff --git a/server/src/router/close.rs b/server/src/router/close.rs new file mode 100644 index 0000000..4ecbfc3 --- /dev/null +++ b/server/src/router/close.rs @@ -0,0 +1,46 @@ +use crate::SESSION_STORE; +use bytes::Bytes; +use common::http::CloseRequest; +use communicator::http::{json_from_request, send, send_error}; +use h2::RecvStream; +use h2::server::SendResponse; +use http::Request; + +pub async fn close( + mut request: Request, + send_response: SendResponse, +) -> Result<(), h2::Error> { + let body = json_from_request(&mut request).await; + if let Err(error) = body { + send_error(send_response, error); + return Ok(()); + } + let req_body: CloseRequest = body.unwrap(); + let context = SESSION_STORE.remove(&req_body.id).await; + match context { + Some(_) => { + send( + send_response, + 200, + "application/json", + serde_json::json!({ + "msg": "OK" + }) + .to_string(), + ); + } + None => { + send( + send_response, + 500, + "application/json", + serde_json::json!({ + "msg": "invalid session id" + }) + .to_string(), + ); + } + } + + Ok(()) +} diff --git a/server/src/router/download.rs b/server/src/router/download.rs new file mode 100644 index 0000000..b503839 --- /dev/null +++ b/server/src/router/download.rs @@ -0,0 +1,91 @@ +use crate::{CONFIG, SESSION_STORE}; +use assets_updater::core::export_pipeline::{find_files, find_files_by_extensions}; +use bytes::Bytes; +use common::http::DownloadRequest; +use common::stream::server_send_files; +use communicator::http::{json_from_request, send, send_error}; +use h2::RecvStream; +use h2::server::SendResponse; +use http::{Request, Response}; + +pub async fn download( + mut request: Request, + mut send_response: SendResponse, +) -> Result<(), h2::Error> { + let body = json_from_request(&mut request).await; + if let Err(error) = body { + send_error(send_response, error); + return Ok(()); + } + let req_body: DownloadRequest = body.unwrap(); + + let id = req_body.id; + let context = SESSION_STORE.get(&id).await; + if context.is_none() { + send( + send_response, + 200, + "application/json", + serde_json::json!({ + "msg": "invalid session id" + }) + .to_string(), + ); + return Ok(()); + } + let context = context.unwrap(); + let dir = context + .download(&req_body.task, &CONFIG.updater_config) + .await; + if let Err(error) = dir { + send_error(send_response, error.into()); + return Ok(()); + } + let dir = dir.unwrap(); + + let files = if !dir.is_dir() { + if context.sync_context.filters.file_ext.is_empty() + || dir.extension().is_some_and(|t| { + context + .sync_context + .filters + .file_ext + .contains(&t.to_str().unwrap().to_lowercase()) + }) + { + vec![dir.clone()] + } else { + vec![] + } + } else { + let files = if context.sync_context.filters.file_ext.is_empty() { + find_files(&dir) + } else { + find_files_by_extensions(&dir, &context.sync_context.filters.file_ext) + }; + if let Err(error) = files { + send_error(send_response, error.into()); + return Ok(()); + } + files.unwrap() + }; + + let response = Response::builder() + .status(200) + .header("content-type", "application/x-sekai-stream") + .body(()) + .unwrap(); + + let dir_base = std::env::temp_dir() + .join("sekai-updater") + .join("extract") + .join(&context.sync_context.region); + + if let Ok(send_stream) = send_response.send_response(response, false) { + let _ = server_send_files(send_stream, dir_base, &files).await; + } + + let _ = std::fs::remove_file(dir); + + Ok(()) +} diff --git a/server/src/router/mod.rs b/server/src/router/mod.rs new file mode 100644 index 0000000..ba023a2 --- /dev/null +++ b/server/src/router/mod.rs @@ -0,0 +1,18 @@ +mod close; +mod download; +mod sync; + +use crate::router::close::close; +use crate::router::download::download; +use crate::router::sync::sync_route; +use communicator::http::Router; + +pub fn build_routers() -> Router { + let mut router = Router::new(); + + router.add_route("/sync", sync_route); + router.add_route("/download", download); + router.add_route("/close", close); + + router +} diff --git a/server/src/router/sync.rs b/server/src/router/sync.rs new file mode 100644 index 0000000..9ddd297 --- /dev/null +++ b/server/src/router/sync.rs @@ -0,0 +1,55 @@ +use crate::{CONFIG, SESSION_STORE}; +use assets_updater::core::asset_execution::{ + should_download_bundle, AssetExecutionContext, +}; +use assets_updater::core::regions::select_region; +use bytes::Bytes; +use common::http::SyncResponse; +use common::updater::SyncContext; +use communicator::http::{json_from_request, send, send_error}; +use h2::server::SendResponse; +use h2::RecvStream; +use http::Request; + +pub async fn sync_route( + mut request: Request, + send_response: SendResponse, +) -> Result<(), h2::Error> { + let body = json_from_request(&mut request).await; + if let Err(error) = body { + send_error(send_response, error); + return Ok(()); + } + let sync_context: SyncContext = body.unwrap(); + + let region = select_region(&CONFIG.updater_config, &sync_context.region); + if let Err(error) = region { + send_error(send_response, error.into()); + return Ok(()); + } + let region = region.unwrap(); + let exec = AssetExecutionContext::new(&CONFIG.updater_config, &sync_context, region); + if let Err(error) = exec { + send_error(send_response, error.into()); + return Ok(()); + } + let mut exec = exec.unwrap(); + let tasks = exec.fetch_tasks().await; + if let Err(error) = tasks { + send_error(send_response, error.into()); + return Ok(()); + } + let tasks = tasks + .unwrap() + .into_iter() + .filter(|task| should_download_bundle(&sync_context, &task.download_path, &task.category)) + .collect::>(); + let id = SESSION_STORE.put(exec).await; + + let resp = serde_json::to_string(&SyncResponse { id, tasks }).unwrap(); + + send(send_response, 200, "application/json", resp); + + Ok(()) +} + diff --git a/server/src/session.rs b/server/src/session.rs new file mode 100644 index 0000000..6aaf399 --- /dev/null +++ b/server/src/session.rs @@ -0,0 +1,31 @@ +use moka::future::{Cache, CacheBuilder}; +use std::hash::RandomState; +use uuid::Uuid; + +pub struct SessionStore { + cache: Cache, +} + +impl SessionStore +where + T: Clone + Send + Sync + 'static, +{ + pub fn new(builder: CacheBuilder>) -> Self { + let cache = builder.build(); + Self { cache } + } + + pub async fn put(&self, data: T) -> String { + let id = Uuid::new_v4().to_string(); // 生成唯一的 UUID + self.cache.insert(id.clone(), data).await; + id + } + + pub async fn get(&self, id: &str) -> Option { + self.cache.get(id).await + } + + pub async fn remove(&self, id: &str) -> Option { + self.cache.remove(id).await + } +}