diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..041a60a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2258 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aaoffline" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "chrono", + "clap", + "clap-verbosity-flag", + "colored", + "env_logger", + "exitcode", + "futures", + "human-panic", + "indicatif", + "itertools", + "log", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_with", + "tokio", +] + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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 = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +dependencies = [ + "backtrace", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" + +[[package]] +name = "cc" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "clap" +version = "4.5.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-verbosity-flag" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54381ae56ad222eea3f529c692879e9c65e07945ae48d3dc4d1cb18dbec8cf44" +dependencies = [ + "clap", + "log", +] + +[[package]] +name = "clap_builder" +version = "4.5.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width 0.1.14", + "windows-sys 0.52.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[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 = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "exitcode" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.7.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" + +[[package]] +name = "human-panic" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80b84a66a325082740043a6c28bbea400c129eac0d3a27673a1de971e44bf1f7" +dependencies = [ + "anstream", + "anstyle", + "backtrace", + "os_info", + "serde", + "serde_derive", + "toml", + "uuid", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "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 = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", + "serde", +] + +[[package]] +name = "indicatif" +version = "0.17.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", +] + +[[package]] +name = "ipnet" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "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 = "libc" +version = "0.2.168" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.52.0", +] + +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "os_info" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ca711d8b83edbb00b44d504503cd247c9c0bd8b0fa2694f2a1a3d8165379ce" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.7.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "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 = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap 2.7.0", + "serde", + "serde_spanned", + "toml_datetime", +] + +[[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.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +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 = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + +[[package]] +name = "web-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +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 = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[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", + "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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ef07505 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "aaoffline" +description = "Downloads cases from Ace Attorney Online to be playable offline" +repository = "https://github.com/falko17/aaoffline" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +anyhow = { version = "1.0.94", features = ["backtrace"] } +bytes = "1.9.0" +chrono = "0.4.39" +clap = { version = "4.5.23", features = ["derive"] } +clap-verbosity-flag = "3.0.1" +colored = "2.1.0" +env_logger = "0.11.5" +exitcode = "1.1.2" +futures = "0.3.31" +human-panic = "2.0.2" +indicatif = "0.17.9" +itertools = "0.13.0" +log = "0.4.22" +regex = "1.11.1" +reqwest = { version = "0.12.9" } +serde = { version = "1.0.216", features = ["derive"] } +serde_json = "1.0.133" +serde_with = { version = "3.11.0", features = ["chrono"] } +tokio = { version = "1.42.0", features = ["full"] } + +[profile.release] +codegen-units = 1 +lto = true +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04c9ba1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Falko Galperin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14a1bc5 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Ace Attorney Offline + +Downloads cases from [Ace Attorney Online](https://aaonline.fr) to be playable offline. + +> [!WARNING] +> This is in an early state and may not work correctly. Currently, I'm aware of the following problems: +> +> 1. Psyche locks won't work correctly. +> 2. All default sprites are downloaded (even if not all of them are used), leading to quite a big download size (≈ 150 MB). This takes some effort to fix, so I haven't implemented it yet. + +## Usage + +[Releases](https://github.com/falko17/aaoffline/releases) are provided for download, but you can also simply build the tool yourself (see below). + +Cases can be downloaded by just putting the trial ID as an argument to `aaoffline`: + +```bash +aaoffline YOUR_ID_HERE +``` + +Or, even simpler, you can pass the URL to the case directly: + +```bash +aaoffline "http://www.aaonline.fr/player.php?trial_id=YOUR_ID_HERE" +``` + +By default, the case will be put into a directory with the case ID as its name (you can change this by just passing a different directory name as another argument). +The downloaded case can then be downloaded by opening the `index.html` file in the output directory. + +There are some additional parameters you can set, such as `--concurrent-downloads` to choose a different number of parallel downloads to use[^1], or `--player-version` to choose a specific commit of the player (e.g., if a case only worked on an older version). +To get an overview of available options, just run `aaoffline --help`. + +[^1]: This is set to 5 by default, but a higher number can lead to significantly faster downloads. Don't overdo it, though, or some servers may block you. + +## Building + +Building `aaoffline` should be straightforward: + +```bash +cargo build --release +``` + +Afterwards, you can find the built `aaoffline` executable inside `target/release`. diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..7033e37 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,94 @@ +//! Contains constants, such as regular expressions or strings. + +/// Regular expressions used by this crate. +pub(crate) mod re { + use std::sync::LazyLock; + + use regex::Regex; + + pub(crate) static PHP_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)<\?php(.*?)\?>").unwrap()); + + pub(crate) static CASE_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"https?://(?:www\.)?aaonline\.fr/player\.php\?trial_id=(\d+)").unwrap() + }); + + pub(crate) static TRIAL_INFORMATION_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)var trial_information(?: = JSON\.parse\("(.*?)"\))?;"#).unwrap() + }); + + pub(crate) static TRIAL_DATA_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)var initial_trial_data = JSON\.parse\("(.*?)"\);"#).unwrap() + }); + + pub(crate) static DEFAULT_PROFILES_NB_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)var default_profiles_nb = JSON\.parse\("(.*?)"\);"#).unwrap() + }); + + pub(crate) static DEFAULT_PROFILES_STARTUP_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)var default_profiles_startup = JSON\.parse\("(.*?)"\);"#).unwrap() + }); + + pub(crate) static DEFAULT_PLACES_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"(?s)var default_places = (\{.*?\});"#).unwrap()); + + pub(crate) static CONFIG_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"(?s)var cfg = (\{.*?\});"#).unwrap()); + + pub(crate) static MODULE_REGEX: LazyLock = LazyLock::new(|| { + // I'm sorry for the mess below. As qntm succinctly put it, the plural of regex is regrets. + Regex::new(r#"(?sm)Modules\.load\(new Object\(\{\s*name\s*:\s*['"](.*?)['"]\s*,\s*dependencies\s*:\s*(\[.*?\]),\s*init\s*:\s*function\(\)\s*\{(.*?)\}\s*^\}\)\);"#).unwrap() + }); + + pub(crate) static CSS_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#""#).unwrap() + }); + + pub(crate) static STYLE_INCLUDE_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"includeStyle\(['"](.*?)['"]\);"#).unwrap()); + + pub(crate) static LANGUAGE_INCLUDE_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)Languages\.requestFiles\(\[([^\]]*)\], function\(\)\{\s*(.*?)\s*\}\);"#) + .unwrap() + }); + + pub(crate) static LANGUAGE_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"var lang = new Object\(\);"#).unwrap()); + + pub(crate) static CSS_SRC_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"[:\s]url\("?([^")]*)"?\)"#).unwrap()); + + pub(crate) static SRC_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?:src=["']([^"']+)["']|\.src\s*=\s*['"]([^'"]*?)['"])"#).unwrap() + }); + + pub(crate) static HOWLER_REGEX: LazyLock = LazyLock::new(|| { + Regex::new( + r"includeScript\('howler\.js/howler\.min', false, '', function\(\)\{([^}]*?)\}\);", + ) + .unwrap() + }); + + pub(crate) static VOICE_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)function getVoiceUrl\(voice_id,\s*ext\)\s*\{(.*?)\}").unwrap() + }); + + pub(crate) static DEFAULT_SPRITES_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)getDefaultSpriteUrl\(base, sprite_id, status\)\s*\{(.*?)\}").unwrap() + }); + + pub(crate) static PRELOAD_PLACES_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"preloadPlaceImages\(default_places\[i\], img_container\)").unwrap() + }); + + pub(crate) static GOOGLE_ANALYTICS_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"(?s)"#).unwrap()); +} + +pub(crate) const UPDATE_MESSAGE: &str = + "This means a new player has been released and the script needs to be updated."; + +pub(crate) const BRIDGE_URL: &str = "https://aaonline.fr/bridge.js.php"; + +pub(crate) const BITBUCKET_URL: &str = + "https://bitbucket.org/AceAttorneyOnline/aao-game-creation-engine/raw/"; diff --git a/src/data.rs b/src/data.rs new file mode 100644 index 0000000..9d62a3f --- /dev/null +++ b/src/data.rs @@ -0,0 +1,41 @@ +//! Contains models for data structures used in the Ace Attorney Online player. + +use std::error::Error; +use std::fmt::Display; + +use anyhow::{Context, Result}; +use log::trace; +use regex::Regex; +use serde::de::DeserializeOwned; + +#[derive(Debug)] +struct RegexNotMatched; + +impl Display for RegexNotMatched { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Regex did not match the input text.") + } +} + +impl Error for RegexNotMatched {} + +fn retrieve_escaped_json(regex: &Regex, text: &str) -> Result { + let extracted = regex + .captures(text) + .context("Trial script seemingly changed format, this means the script needs to be updated to work with the newest AAO version.")? + .get(1) + .ok_or(RegexNotMatched)? + .as_str() + .replace(r"\\", r"\") + .replace(r#"\""#, "\"") + .replace(r"\'", "'") + .replace(r"\/", "/"); + trace!("{extracted}"); + serde_json::from_str(&extracted).context("Could not parse trial data. The script needs to be updated to be able to handle this trial.") +} + +pub(crate) mod site; + +pub(crate) mod case; + +pub(crate) mod player; diff --git a/src/data/case.rs b/src/data/case.rs new file mode 100644 index 0000000..3c172cc --- /dev/null +++ b/src/data/case.rs @@ -0,0 +1,112 @@ +//! Contains data models related to the case that is being downloaded. + +use anyhow::{Context, Result}; + +use chrono::{DateTime, Utc}; + +use colored::Colorize; + +use log::{debug, trace}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use serde_with::formats::Flexible; +use serde_with::TimestampSeconds; + +use std::fmt::Display; + +use crate::constants::re; + +#[serde_with::serde_as] +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct TrialInformation { + author: String, + author_id: u32, + can_read: bool, + can_write: bool, + format: String, + id: u32, + language: String, + #[serde_as(as = "TimestampSeconds")] + last_edit_date: DateTime, + sequence: Option, + pub(crate) title: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Sequence { + title: String, + list: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SequenceEntry { + id: u32, + title: String, +} + +impl Display for TrialInformation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let title: &str = if let Some(sequence) = &self.sequence { + &format!("\"{}\" (Sequence: \"{}\")", self.title, sequence.title) + } else { + &format!("\"{}\"", self.title) + }; + write!( + f, + "{} by {} [last edited on {}]", + title.bold(), + self.author.italic(), + self.last_edit_date + ) + } +} + +#[derive(Debug)] +pub(crate) struct Case { + pub(crate) trial_information: TrialInformation, + pub(crate) trial_data: Value, +} + +impl Case { + pub(crate) async fn retrieve_from_id(case_id: u32) -> Result { + let trial_script = reqwest::get(format!( + "https://aaonline.fr/trial.js.php?trial_id={}", + case_id + )).await + .context( + "Could not download trial data from aaonline.fr. Please check your internet connection." + )? + .error_for_status() + .context("aaonline.fr trial data seems to be inaccessible.")? + .text().await?; + + let trial_information = + super::retrieve_escaped_json(&re::TRIAL_INFORMATION_REGEX, &trial_script)?; + + let trial_data = super::retrieve_escaped_json(&re::TRIAL_DATA_REGEX, &trial_script)?; + debug!("{:?}", trial_information); + trace!("{:?}", trial_data); + + // FIXME: Retrieve default data immediately after this step! + + Ok(Case { + trial_information, + trial_data, + }) + } + + pub(crate) fn serialize_to_js(&self) -> Result { + // We already retrieved trial information and data. + // We will reserialize it to JSON to include any changes we made. + let ser_trial_info = serde_json::to_string(&self.trial_information)?; + let ser_trial_data = serde_json::to_string(&self.trial_data)?; + Ok(format!("var trial_information = {ser_trial_info};\nvar initial_trial_data = {ser_trial_data};\n")) + } +} + +impl Display for Case { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.trial_information) + } +} diff --git a/src/data/player.rs b/src/data/player.rs new file mode 100644 index 0000000..2ad34da --- /dev/null +++ b/src/data/player.rs @@ -0,0 +1,487 @@ +//! Contains data model related to the case player and its scripts. + +use crate::constants::{re, BITBUCKET_URL}; +use crate::download::{download_url, AssetDownloader}; +use crate::transform::php; +use crate::Args; +use anyhow::{Context, Result}; + +use indicatif::ProgressBar; +use log::{debug, trace, warn}; + +use serde_json::Value; + +use std::collections::HashSet; + +use std::ops::Range; + +use super::case::Case; +use super::site::SiteData; + +type ModuleTransformer = fn(&Player, &str, &mut String) -> Result<()>; + +#[derive(Debug)] +pub(crate) struct PlayerScripts { + pub(crate) scripts: Option, + encountered_modules: HashSet, + args: Args, +} + +impl PlayerScripts { + async fn retrieve_js_text(name: &str, player_version: &str) -> Result { + let url = if name == "default_data" { + // This is a special case—we can unfortunately not use the source code of AAO here + // and need to access the rendered version from aaonline.fr, since this is a PHP file. + "https://aaonline.fr/default_data.js.php" + } else if name == "trial" { + // This one is also a PHP file, but we don't need the PHP-generated data as we already + // retrieved it previously. + &format!("{BITBUCKET_URL}/{player_version}/trial.js.php") + } else { + &format!("{BITBUCKET_URL}/{player_version}/Javascript/{name}.js") + }; + reqwest::get(url).await + .with_context(|| { + "Could not download scripts from AAO repository. Please check your internet connection." + })? + .error_for_status() + .context("AAO script code seems to be inaccessible.")? + .text().await.context("Script could not be decoded as text") + } + + /// Retrieves the JS module with the given [name] and returns the JS code for it. + /// + /// If it has any dependencies, these will be recursively retrieved and put before the code of + /// the target module with the given [name]. + async fn retrieve_js_module( + &mut self, + player: &Player, + name: &str, + pb: Option<&ProgressBar>, + module_transformer: ModuleTransformer, + ) -> Result { + if name == "dom_loaded" || name == "page_loaded" || self.encountered_modules.contains(name) + { + // Page is already loaded. + return Ok(String::new()); + } + debug!("Retrieving JS module {name}"); + self.encountered_modules.insert(name.to_string()); + + let mut text = Self::retrieve_js_text(name, &self.args.player_version).await?; + if let Some(x) = pb { + x.inc(1) + } + + module_transformer(player, name, &mut text)?; + + let captures = re::MODULE_REGEX + .captures(&text) + .context("AAO JS script seemingly changed format, this means the script needs to be updated to work with the newest AAO version.")?; + let mod_content = captures.get(0).unwrap(); + let mod_name = captures.get(1).unwrap().as_str(); + assert_eq!(name, mod_name); + let dep_text = captures.get(2).unwrap().as_str().replace("'", "\""); + let dep_value = + serde_json::from_str::(&dep_text).context("Could not parse dependency array")?; + let deps: Vec<&str> = dep_value + .as_array() + .context("Dependency array is not actually an array")? + .iter() + .map(|y| y.as_str()) + .collect::>>() + .context("Dependency array contains some non-strings")?; + let init = captures.get(3).unwrap().as_str(); + + // First, we add any dependencies of this module at the top. + let mut mod_text = String::new(); + for dependency in deps { + mod_text.push_str( + &Box::pin(self.retrieve_js_module(player, dependency, pb, module_transformer)) + .await?, + ); + } + // Then, a comment identifying this module to make debugging easier. + mod_text.push_str(&format!("// {name}.js\n\n")); + // Then its init function. This needs to be an actual function (or lambda) because it may + // contain `return` statements. We will execute it after every script has been loaded. + mod_text.push_str(&format!("initScripts.push(() => {{{init}}});\n")); + // And finally, the module content itself (without the module declaration). + trace!("{:?}", mod_content); + text.replace_range(mod_content.start()..mod_content.end(), "\n"); + text = text.replace(&format!("Modules.complete('{name}')"), "\n"); + mod_text.push_str(&text); + // The following is necessary due to some naming conflicts that otherwise occur. + mod_text = mod_text.replace("SoundHowler.", "window.SoundHowler."); + Ok(mod_text) + } + + pub(crate) async fn retrieve_player_scripts( + &mut self, + player: &Player, + pb: &ProgressBar, + transform_module: ModuleTransformer, + ) -> Result<()> { + // Each JavaScript module has three things (AFAICT): + // 1. A name. + // 2. Depdendencies, as an array of other names that should be loaded before this one. + // 3. An init function that should be called after dependencies are loaded. + // + // We want the case to work fully offline, so we need to handle the dependency resolution + // at download time (i.e., now). The entry point for these is 'player.js'. + + pb.inc_length(37); + let config = serde_json::to_string(&player.site_data.site_paths)?; + let common_js = download_url( + format!( + "{BITBUCKET_URL}/{}/Javascript/common.js", + self.args.player_version + ) + .as_str(), + &self.args.http_handling, + ) + .await?; + pb.inc(1); + self.scripts = Some(format!( + "var cfg = {config}; +function getFileVersion(path_components) +{{ + // We are not using file versions here. + return ''; +}} +{} + +let initScripts = []; +{} +window.addEventListener('load', function() {{ + // Execute all init functions in order. + initScripts.forEach((x) => x()); +}}, false);\n", + String::from_utf8(common_js.1.to_vec())?, + self.retrieve_js_module(player, "player", Some(pb), transform_module) + .await? + )); + Ok(()) + } +} + +#[derive(Debug)] +pub(crate) struct Player { + pub(crate) site_data: SiteData, + pub(crate) args: Args, + pub(crate) case: Case, + pub(crate) player: Option, + pub(crate) scripts: Option, +} + +impl Player { + pub(crate) async fn new(args: Args, case: Case) -> Result { + let default_text = + PlayerScripts::retrieve_js_text("default_data", &args.player_version).await?; + let site_data = SiteData::from_site_data(&default_text).await?; + let mut player = Player { + site_data, + args: args.clone(), + case, + scripts: None, + player: None, + }; + player.scripts = Some(PlayerScripts { + scripts: Some(default_text), + encountered_modules: HashSet::new(), + args: args.clone(), + }); + Ok(player) + } + + fn transform_module(&self, name: &str, content: &mut String) -> Result<()> { + // These modules require special handling: + if name == "trial" { + // This is really a PHP script, so we need to replace its blocks first. + php::transform_trial_blocks(self, content) + } else if name == "default_data" { + // And here we need to insert our modified default data, to avoid the default + // resources being retrieved from the AAO server. + self.site_data.default_data.write_default_module(content) + } else { + Ok(()) + } + } + + pub(crate) async fn retrieve_player(&mut self) -> Result<()> { + let mut player = reqwest::get(format!( + "{BITBUCKET_URL}/{}/player.php", + self.args.player_version + )) + .await + .with_context(|| { + "Could not download player from AAO repository. Please check your internet connection." + })? + .error_for_status() + .context("AAO player code seems to be inaccessible.")? + .text() + .await?; + trace!("Player: {player}"); + + player.insert(0, '\n'); + self.player = Some(player); + Ok(()) + } + + // Merge function adapted from https://stackoverflow.com/a/54118457. + fn merge(a: &mut Value, b: Value) { + if let Value::Object(a) = a { + if let Value::Object(b) = b { + for (k, v) in b { + // Keep entries that are not in b undisturbed. + if !v.is_null() { + Self::merge(a.entry(k).or_insert(Value::Null), v); + } + } + + return; + } + } + + *a = b; + } + + pub(crate) async fn retrieve_scripts(&mut self, pb: &ProgressBar) -> Result<()> { + let mut scripts = self.scripts.take().expect("Scripts not initialized!"); + let transformer: ModuleTransformer = + |player, name, content| player.transform_module(name, content); + scripts + .retrieve_player_scripts(self, pb, transformer) + .await?; + self.scripts = Some(scripts); + Ok(()) + } + + pub(crate) fn transform_player(&mut self) -> Result<()> { + php::transform_player_blocks(self) + } + + pub(crate) async fn retrieve_player_misc_sources( + &mut self, + output: String, + pb: &ProgressBar, + ) -> Result<()> { + let mut replacements: Vec<(Range, String)> = Vec::new(); + let player = self.player.as_ref().unwrap(); + // We need to remove the Google Analytics tag at the bottom of the page. + if let Some(m) = re::GOOGLE_ANALYTICS_REGEX.find(player) { + replacements.push((m.range(), String::new())); + } else { + warn!("Could not find Google Analytics tag in player, skipping."); + } + + let lang_dir = self.site_data.site_paths.lang_dir.clone(); + let downloader = AssetDownloader::new(self.args.clone(), output, &mut self.site_data); + let css_caps: Vec<_> = re::CSS_REGEX.captures_iter(player).collect(); + let style_caps: Vec<_> = re::STYLE_INCLUDE_REGEX.captures_iter(player).collect(); + let src_caps: Vec<_> = re::SRC_REGEX.captures_iter(player).collect(); + pb.inc_length((css_caps.len() + style_caps.len() + src_caps.len() + 1) as u64); + + for css in css_caps { + let whole = css.get(0).unwrap(); + let group = css.get(1).unwrap(); + let result = download_url(group.as_str(), &self.args.http_handling).await; + pb.inc(1); + + if let Ok((_, content)) = result { + replacements.push(( + whole.range(), + format!("", String::from_utf8(content.to_vec())?), + )); + } else if let Err(e) = result { + warn!("Could not download CSS file, skipping: {e}"); + } + } + + // We also need to handle any dynamic CSS inclusions. + for include in style_caps { + let whole = include.get(0).unwrap(); + let group = include.get(1).unwrap(); + let result = download_url( + &format!("CSS/{}.css", group.as_str()), + &self.args.http_handling, + ) + .await; + pb.inc(1); + if let Ok((_, content)) = result { + replacements.push((whole.range(), String::new())); + // Now, we need to put the CSS thing into a ", String::from_utf8(content.to_vec())?), + )); + } else if let Err(e) = result { + warn!("Could not download CSS file, skipping: {e}"); + } + } + + // Additionally, we need to download the language data. + // TODO: Make some warnings into errors + let lang = re::LANGUAGE_INCLUDE_REGEX + .captures(player) + .context("Could not find language data in source.")?; + let config_lang = &self.args.language; + let func_end = lang.get(0).unwrap().end(); + let group = lang.get(1).unwrap(); + let callback = lang.get(2).unwrap(); + trace!("{}", &group.as_str()); + let lang_files = + serde_json::from_str::(&format!("[{}]", &group.as_str().replace("'", "\"")))?; + let lang_files: Vec<_> = lang_files + .as_array() + .context("languages must be array")? + .iter() + .map(|x| x.as_str().expect("language file must be string")) + .collect(); + let mut lang_json = Value::Null; + pb.inc_length(lang_files.len() as u64); + for lang_file in lang_files { + let (_, content) = download_url(&format!("{lang_dir}/{config_lang}/{lang_file}.js"), &self.args.http_handling).await + .context(format!("Could not download language files for {lang_file}. Please make sure the given language {} exists.", self.args.language))?; + pb.inc(1); + let lang = serde_json::from_slice::(&content)?; + Self::merge(&mut lang_json, lang); + } + + // First, we need to replace the langauge object. + let lang_range = re::LANGUAGE_REGEX + .find(player) + .context("Could not find language definition in source.")? + .range(); + replacements.push(( + lang_range, + format!("var lang = {};", serde_json::to_string(&lang_json)?), + )); + + // Then, we need to make sure the script doesn't try to dynamically retrieve languages. + // We do this by just setting the list of files to retrieve to an empty list. + replacements.push((group.range(), String::new())); + // We also need to call the provided callback immediately. + // To do this, we first empty the original callback... + replacements.push((callback.range(), String::new())); + // ...then we add the callback code as a normal block directly below. + replacements.push((func_end..func_end + 1, String::from(callback.as_str()))); + + // And we download any sources present in the JavaScript or CSS. + for src in src_caps { + let group = src.get(1).or_else(|| src.get(2)).unwrap(); + if group.as_str().starts_with("data:") { + // No need to do anything about data URLs. + continue; + } + let result = downloader.download_url(group.as_str()).await; + if let Ok(path) = result { + replacements.push((group.range(), path)); + } else if let Err(e) = result { + warn!("Could not download asset, skipping: {e}"); + } + pb.inc(1); + } + + // We need howler.js for sound effects. + if let Some(howler) = re::HOWLER_REGEX.captures(player) { + let configuration = howler.get(1).unwrap().as_str(); + let result = download_url( + &format!( + "{BITBUCKET_URL}/{}/Javascript/howler.js/howler.min.js", + self.args.player_version + ), + &self.args.http_handling, + ) + .await; + pb.inc(1); + if let Ok((_, content)) = result { + // We will include Howler directly, as well as its configuration below. + let output = format!( + "{}\n{}", + String::from_utf8(content.to_vec())?, + configuration + ); + replacements.push((howler.get(0).unwrap().range(), output)); + // We need to use the HTML5 audio option for Howler, or we'll run into CORS errors. + const PRELOAD: &str = "preload: true"; + let preload_pos = player.find(PRELOAD).context("preload option not present")?; + replacements.push(( + preload_pos + PRELOAD.len()..preload_pos + PRELOAD.len(), + String::from(", html5: true"), + )); + } else if let Err(e) = result { + warn!("Could not download Howler.js, skipping: {e}"); + } + } else { + warn!("Could not find Howler.js in player, skipping."); + } + + // Since the default voices are always retrieved from the server, we need to change the + // getVoiceUrl function to point to our local files. + if let Some(voice) = re::VOICE_REGEX.captures(player) { + let group = voice.get(1).unwrap(); + let output = + String::from("return 'assets/voice_singleblip_' + (-voice_id) + '.' + ext;"); + replacements.push((group.range(), output)); + } else { + warn!("Could not find getVoiceUrl in player, skipping."); + } + + // We need to do the same for the default sprites. + if let Some(default_sprites) = re::DEFAULT_SPRITES_REGEX.captures(player) { + let group = default_sprites.get(1).unwrap(); + let output = + String::from("return 'assets/' + base + '_' + sprite_id + '_' + status + '.gif';"); + replacements.push((group.range(), output)); + } else { + warn!( + "Could not find default sprites in player, skipping. Some sprites may be missing." + ); + } + + if let Some(default_places) = re::PRELOAD_PLACES_REGEX.find(player) { + replacements.push((default_places.range(), String::new())); + } else { + warn!("Could not find default place preloading in player, skipping."); + } + + replacements.sort_by(|a, b| b.0.start.cmp(&a.0.start)); + for (range, output) in replacements { + self.player.as_mut().unwrap().replace_range(range, &output); + } + + // We also need to retrieve any dependencies present in the CSS. + // This needs to be done AFTER already inserting the CSS, because these need to be applied + // on the CSS itself. + debug!("Downloading CSS dependencies..."); + let player = self.player.as_ref().unwrap(); + let mut replacements: Vec<(Range, String)> = Vec::new(); + let css_src_caps: Vec<_> = re::CSS_SRC_REGEX.captures_iter(player).collect(); + pb.inc_length(css_src_caps.len() as u64); + + for src in css_src_caps { + let group = src.get(1).unwrap(); + if group.as_str().ends_with("/tick.png") { + // This is actually commented out, so we can skip it. + pb.inc(1); + continue; + } + let result = downloader + .download_url(&format!("CSS/{}", group.as_str())) + .await; + if let Ok(path) = result { + replacements.push((group.range(), path)); + } else if let Err(e) = result { + warn!("Could not download CSS dependency, skipping: {e}"); + } + pb.inc(1); + } + // Order replacements by reverse order of position so we can safely replace them. + replacements.sort_by(|a, b| b.0.start.cmp(&a.0.start)); + for (range, output) in replacements { + self.player.as_mut().unwrap().replace_range(range, &output); + } + Ok(()) + } +} diff --git a/src/data/site.rs b/src/data/site.rs new file mode 100644 index 0000000..1ee4b90 --- /dev/null +++ b/src/data/site.rs @@ -0,0 +1,191 @@ +//! Contains data models related to the configuration of Ace Attorney Online. + +use anyhow::{Context, Result}; +use log::{error, trace}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use std::collections::{HashMap, HashSet}; + +use crate::constants::{re, BRIDGE_URL}; + +#[derive(Debug)] +pub(crate) struct DefaultData { + pub(crate) default_profiles_nb: HashMap, + pub(crate) default_profiles_startup: HashSet, + pub(crate) default_places: Value, +} + +impl DefaultData { + pub(crate) fn write_default_module(&self, module: &mut String) -> Result<()> { + // We are here for the second time. + // Time to write the default_places back. + let default_places_text = serde_json::to_string(&self.default_places)?; + let default_places_match = re::DEFAULT_PLACES_REGEX + .find(module) + .expect("Default places did not match!"); + module.replace_range( + default_places_match.range(), + &format!("var default_places = {default_places_text};"), + ); + Ok(()) + } + + fn from_default_module(module: &str) -> Result { + // We are here for the first time. + let nb_value = + super::retrieve_escaped_json::(&re::DEFAULT_PROFILES_NB_REGEX, module)?; + let default_profiles_nb = if let Value::Object(nb_map) = nb_value { + nb_map + .into_iter() + .map(|x| { + ( + x.0, + x.1.as_i64() + .expect("Default profile values must be integers!"), + ) + }) + .collect() + } else { + error!("Default profiles number map is not an object!"); + std::process::exit(exitcode::DATAERR); + }; + + let startup_value = + super::retrieve_escaped_json::(&re::DEFAULT_PROFILES_STARTUP_REGEX, module)?; + let default_profiles_startup = if let Value::Object(startup_map) = startup_value { + startup_map.into_iter().map(|x| x.0).collect() + } else { + error!("Default profiles startup map is not an object!"); + std::process::exit(exitcode::DATAERR); + }; + + let default_places = + super::retrieve_escaped_json::(&re::DEFAULT_PLACES_REGEX, module)?; + Ok(DefaultData { + default_profiles_nb, + default_profiles_startup, + default_places, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SitePaths { + bg_subdir: String, + cache_dir: String, + css_dir: String, + defaultplaces_subdir: String, + evidence_subdir: String, + forum_path: String, + icon_subdir: String, + js_dir: String, + pub(crate) lang_dir: String, + locks_subdir: String, + music_dir: String, + picture_dir: String, + popups_subdir: String, + site_name: String, + sounds_dir: String, + startup_subdir: String, + still_subdir: String, + talking_subdir: String, + trialdata_backups_dir: String, + trialdata_deleted_dir: String, + trialdata_dir: String, + voices_dir: String, +} + +impl SitePaths { + pub(crate) fn get_subdir(&self, name: &str) -> &str { + match name { + "bg" => &self.bg_subdir, + "defaultplaces" => &self.defaultplaces_subdir, + "evidence" => &self.evidence_subdir, + "icon" => &self.icon_subdir, + "locks" => &self.locks_subdir, + "popups" => &self.popups_subdir, + "startup" => &self.startup_subdir, + "still" => &self.still_subdir, + "talking" => &self.talking_subdir, + _ => panic!("Unknown subdir requested!"), + } + } + + pub(crate) fn default_icon(&self) -> String { + format!( + "https://aaonline.fr/{}/{}/Inconnu.png", + self.picture_dir, self.icon_subdir + ) + } + + pub(crate) fn sprite_path<'a>(&'a self, kind: &'a str, base: &'a str) -> Vec<&'a str> { + vec![&self.picture_dir, self.get_subdir(kind), base] + } + + pub(crate) fn icon_path(&self) -> Vec<&str> { + vec![&self.picture_dir, &self.icon_subdir] + } + + pub(crate) fn evidence_path(&self) -> Vec<&str> { + vec![&self.picture_dir, &self.evidence_subdir] + } + pub(crate) fn bg_path(&self) -> Vec<&str> { + vec![&self.picture_dir, &self.bg_subdir] + } + pub(crate) fn popup_path(&self) -> Vec<&str> { + vec![&self.picture_dir, &self.popups_subdir] + } + pub(crate) fn music_path(&self) -> Vec<&str> { + vec![&self.music_dir] + } + pub(crate) fn sound_path(&self) -> Vec<&str> { + vec![&self.sounds_dir] + } + pub(crate) fn voice_path(&self) -> Vec<&str> { + vec![&self.voices_dir] + } + + pub(crate) async fn retrieve_from_bridge() -> Result { + // We only need to retrieve the bridge script because we need to know the configuration of + // aaonline.fr. We don't need it for the JS module system, as we'll handle that manually. + let bridge = reqwest::get(BRIDGE_URL).await + .context( + "Could not download site configuration from aaonline.fr. Please check your internet connection." + )? + .error_for_status() + .context("aaonline.fr site configuration seems to be inaccessible.")? + .text().await?; + trace!("{}", bridge); + let config_text = re::CONFIG_REGEX + .captures(&bridge) + .context("Bridge script seemingly changed format, this means the script needs to be updated to work with the newest AAO version.")? + .get(1) + .expect("No captured content in site configuration") + .as_str(); + trace!("{}", config_text); + let config: Self = serde_json::from_str(config_text) + .context("Could not parse site configuration. The script needs to be updated.")?; + trace!("{:?}", config); + + Ok(config) + } +} + +#[derive(Debug)] +pub(crate) struct SiteData { + pub(crate) default_data: DefaultData, + pub(crate) site_paths: SitePaths, +} + +impl SiteData { + pub(crate) async fn from_site_data(default_mod: &str) -> Result { + let site_paths = SitePaths::retrieve_from_bridge().await?; + let default_data = DefaultData::from_default_module(default_mod)?; + Ok(SiteData { + default_data, + site_paths, + }) + } +} diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..d667f32 --- /dev/null +++ b/src/download.rs @@ -0,0 +1,586 @@ +//! Contains functions and methods for downloading case data. + +use anyhow::{anyhow, Context, Result}; +use bytes::Bytes; +use futures::{stream, FutureExt, StreamExt}; +use indicatif::ProgressBar; +use itertools::Itertools; +use log::{debug, error, trace, warn}; +use regex::Regex; +use serde_json::Value; +use std::collections::HashSet; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use crate::data::case::Case; +use crate::data::site::SiteData; +use crate::{Args, HttpHandling}; + +// Returns output file path and file content. +pub(crate) async fn download_url( + url: &str, + http_handling: &HttpHandling, +) -> Result<(String, Bytes)> { + debug!("Downloading {url}..."); + let (target, output) = if url.starts_with("http") { + let url = if url.starts_with("http://") { + match http_handling { + HttpHandling::AllowInsecure => url, + HttpHandling::RedirectToHttps => &url.replacen("http://", "https://", 1), + HttpHandling::Disallow => { + return Err(anyhow!("Blocking insecure HTTP request to {url}.")) + } + } + } else { + url + }; + ( + url.to_string(), + format!("assets/{}", url.split('/').last().unwrap()), + ) + } else { + // Assume this is a relative URL. + let relative = url.trim_start_matches('/').to_string(); + ( + format!("https://aaonline.fr/{relative}"), + format!("assets/{}", url.split('/').last().unwrap_or(url)), + ) + }; + + // Then, download the file as bytes (since it may be binary). + let content = reqwest::get(&target) + .await + .with_context(|| { + format!("Could not download file from {target}. Please check your internet connection.") + })? + .error_for_status() + .with_context(|| format!("File at {target} seems to be inaccessible."))? + .bytes() + .await?; + Ok((output, content)) // Relative URL need not contain case prefix. +} + +#[derive(Debug, Hash, Clone, PartialEq, Eq)] +struct AssetDownload { + url: String, + path: String, + ignore_inaccessible: bool, +} + +#[derive(Debug)] +pub(crate) struct AssetDownloader<'a> { + args: Args, + site_data: &'a mut SiteData, + collector: AssetCollector, + output: String, +} + +#[derive(Debug)] +struct AssetCollector { + collected: Vec>, + default_icon_url: String, +} + +impl AssetCollector { + fn new(default_icon_url: String) -> AssetCollector { + AssetCollector { + collected: vec![], + default_icon_url, + } + } + + fn hash(value: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() + } + + fn path_exists(&self, path: &str) -> bool { + self.collected + .iter() + .any(|x| x.as_ref().ok().map_or(false, |x| x.path == path)) + } + + fn new_path(&self, url: &str, path: &str) -> String { + // We first need to split the path into filename and extension. + let split = path + .trim_end_matches('/') + .rsplit_once('/') + .map(|x| x.1) + .unwrap_or(path) + .rsplit_once('.'); + let ext = split.map(|x| x.1).unwrap_or("bin"); + let name = split.map(|x| x.0).unwrap_or(path); + let first_choice = format!("assets/{name}.{ext}"); + // If we used this filename already, we need to append a hash. + if self.path_exists(&first_choice) { + format!("assets/{name}-{}.{ext}", Self::hash(url)) + } else { + first_choice + } + } + + fn get_path(&self, url: &str, file: &str) -> String { + // If we have an existing download for this URL, we need to use the same filename here. + self.collected + .iter() + .filter_map(|x| x.as_ref().ok()) + .find(|x| x.url == url) + .map_or_else(|| self.new_path(url, file), |x| x.path.clone()) + } + + fn make_download( + &self, + file_value: &mut Value, + path_components: Option>, + external: Option, + default_extension: Option<&str>, + filename: Option<&str>, + ignore_inaccessible: bool, + ) -> Result { + let mut file = file_value + .as_str() + .unwrap_or(&self.default_icon_url) + .to_string(); + if !file + .trim_end_matches('/') + .rsplit_once('/') + .map(|x| x.1) + .unwrap_or(&file) + .contains('.') + { + if let Some(ext) = default_extension { + file = format!("{file}.{ext}"); + } + } + let url = if !external.unwrap_or(true) { + &format!( + "https://aaonline.fr/{}/{file}", + path_components + .context("Non-external path needs path components!")? + .join("/") + ) + } else if file.starts_with("http") { + &file + } else { + &format!("https://aaonline.fr/{file}") + }; + let path = self.get_path(url, filename.unwrap_or(&file)); + if filename.is_some_and(|x| path != x) { + return Err(anyhow!( + "Filename mismatch: Should be {path}, but was {}", + filename.unwrap() + )); + } + let url = Regex::new(r"([^:/])/{2,}") + .unwrap() + .replace_all(url, "$1/") + .to_string(); + trace!("{url} to {path}"); + // Reassign icon to contain new name. + *file_value = Value::String(path.clone()); + Ok(AssetDownload { + url, + path, + ignore_inaccessible, + }) + } + + fn collect_download( + &mut self, + file_value: &mut Value, + path_components: Option>, + external: Option, + default_extension: Option<&str>, + ) { + self.collected.push(self.make_download( + file_value, + path_components, + external, + default_extension, + None, + false, + )) + } + + fn collect_download_with_name( + &mut self, + file_value: &mut Value, + name: &str, + path_components: Option>, + external: Option, + ignore_inaccessible: bool, + ) { + self.collected.push(self.make_download( + file_value, + path_components, + external, + None, + Some(name), + ignore_inaccessible, + )) + } + + fn get_unique_downloads(&mut self) -> Vec> { + let mut encountered_downloads: HashSet = HashSet::new(); + self.collected + .drain(0..self.collected.len()) + .filter(|x| { + x.as_ref() + .ok() + .is_none_or(|x| encountered_downloads.insert(x.clone())) + }) + .collect() + } +} + +impl<'a> AssetDownloader<'a> { + pub(crate) fn new( + args: Args, + output: String, + site_data: &'a mut SiteData, + ) -> AssetDownloader<'a> { + let default_icon_path = site_data.site_paths.default_icon(); + AssetDownloader { + args, + site_data, + output, + collector: AssetCollector::new(default_icon_path), + } + } + + // Downloads URLs in parallel with the configured number of concurrent downloads. + async fn download_assets( + &self, + assets: Vec, + pb: &ProgressBar, + ) -> Vec<(AssetDownload, Result<()>)> { + stream::iter(assets) + .map(|asset| async move { + ( + asset.clone(), + self.download_asset(&asset) + .map(|x| { + pb.inc(1); + x.map(|_| ()) + }) + .await, + ) + }) + .buffer_unordered(self.args.concurrent_downloads) + .collect() + .await + } + + async fn download_asset(&self, asset: &AssetDownload) -> Result { + let (_, content) = download_url(&asset.url, &self.args.http_handling).await?; + self.write_asset(&asset.path, &content) + .map(|_| asset.path.clone()) + } + + pub(crate) async fn download_url(&self, url: &str) -> Result { + let (path, content) = download_url(url, &self.args.http_handling).await?; + self.write_asset(&path, &content).map(|_| path) + } + + fn write_asset(&self, path: &str, content: &[u8]) -> Result<()> { + // Write to file. We may need to create the containing directories first. + debug!("Writing {path}..."); + let file_output = format!("{}/{}", self.output, path); + assert!(path.starts_with("assets/")); + std::fs::create_dir_all(file_output.rsplit_once('/').unwrap().0).with_context(|| { + format!("Could not create directory for {path}. Please check your permissions.",) + })?; + std::fs::write(&file_output, content).with_context(|| { + format!("Could not write file to {path}. Please check your permissions.",) + })?; + Ok(()) + } + + pub(crate) async fn download_case_data( + &mut self, + case: &mut Case, + pb: &ProgressBar, + ) -> Result<()> { + let paths = &self.site_data.site_paths; + let data = case + .trial_data + .as_object_mut() + .context("Trial data must be an object")?; + + const SPRITE_KINDS: [&str; 3] = ["talking", "still", "startup"]; + + // Download default sprites. We don't know which ones end up actually used + // (well, we could find out, but it takes a lot of effort which I don't want to put in + // right now), so we just download all of them. + for (base, num) in &self.site_data.default_data.default_profiles_nb { + for i in 1..=*num { + for kind in SPRITE_KINDS { + if kind == "startup" + && !self + .site_data + .default_data + .default_profiles_startup + .contains(&format!("{base}/{i}")) + { + continue; + } + self.collector.collect_download_with_name( + &mut Value::String(format!("{i}.gif")), + &format!("assets/{base}_{i}_{kind}.gif"), + Some(paths.sprite_path(kind, base)), + Some(false), + // Some default animations may actually be missing even though they're + // declared in the array, such as Redd/2.gif. + true, + ); + } + } + } + + // Download the profiles. + for profile in data["profiles"] + .as_array_mut() + .unwrap() + .iter_mut() + .filter_map(|x| x.as_object_mut()) + { + if profile["icon"].as_str().is_none_or(|x| x.is_empty()) { + // This does not use an external URL. + // To avoid too many bookkeeping shenanigans here, we just + // override icon with the URL to the base AAO asset as if it were external. + profile["icon"] = profile["base"] + .as_str() + .map(|x| Value::String(format!("{}/{x}.png", paths.icon_path().join("/")))) + .unwrap_or(Value::Null); + } + self.collector + .collect_download(&mut profile["icon"], None, Some(true), Some("png")); + //profile["base"] = Value::Null; + + // Profiles may also contain custom sprites. + for custom in profile["custom_sprites"] + .as_array_mut() + .unwrap() + .iter_mut() + .map(|x| x.as_object_mut().expect("Custom sprite must be object")) + { + for kind in SPRITE_KINDS { + if custom[kind].as_str().is_none_or(|x| x.is_empty()) { + continue; + } + self.collector.collect_download( + &mut custom[kind], + None, + Some(true), + Some("gif"), + ); + } + } + } + + // Download the evidence. + for evidence in data["evidence"] + .as_array_mut() + .unwrap() + .iter_mut() + .filter_map(|x| x.as_object_mut()) + { + // Evidence can contain two types of assets: + // 1.) Icons. + let external = evidence["icon_external"].as_bool(); + self.collector.collect_download( + &mut evidence["icon"], + Some(paths.evidence_path()), + external, + Some("png"), + ); + evidence["icon_external"] = Value::Bool(true); + + // 2.) "Check button data", which may be an image or a sound. + // NOTE: It seems like this isn't actually preloaded by the player. Is that intentional? + for check_data in evidence["check_button_data"] + .as_array_mut() + .unwrap() + .iter_mut() + .map(|x| x.as_object_mut().unwrap()) + // If this is just text, we can safely ignore it. + .filter(|x| x["type"].as_str().unwrap_or("text") != "text") + { + self.collector + .collect_download(&mut check_data["content"], None, None, None); + } + } + + // Download the places. + // Default places are of the form {id: place,...} where we're only interested in the place. + let default_places = self + .site_data + .default_data + .default_places + .as_object_mut() + .context("Default places must be map")? + .values_mut(); + for place in data["places"] + .as_array_mut() + .unwrap() + .iter_mut() + .chain(default_places) + .filter_map(|x| x.as_object_mut()) + { + // Download place background itself. + if let Some(background) = place["background"].as_object_mut() { + // This may just be a color instead of ana actual image. + // (In the case of default places). + if background.contains_key("image") { + let external = background["external"] + .as_bool() + .or_else(|| background["external"].as_i64().map(|x| x == 1)) + .context("external must be bool")?; + self.collector.collect_download( + &mut background["image"], + Some(paths.bg_path()), + Some(external), + Some("jpg"), + ); + background["external"] = Value::Bool(true); + } + } + + // Download background objects. + for bg_object in place["background_objects"] + .as_array_mut() + .map(|x| x.iter_mut().filter_map(|y| y.as_object_mut())) + .context("Background objects must be in an array!")? + { + if !bg_object["external"] + .as_bool() + .or_else(|| bg_object["external"].as_i64().map(|x| x == 1)) + .unwrap_or(false) + { + warn!("Found non-external background object, even though these should always be external! Skipping."); + continue; + } + self.collector + .collect_download(&mut bg_object["image"], None, Some(true), None); + } + + // Download foreground objects. + for fg_object in place["foreground_objects"] + .as_array_mut() + .map(|x| x.iter_mut().filter_map(|y| y.as_object_mut())) + .context("Background objects must be in an array!")? + { + if !fg_object["external"] + .as_bool() + .or_else(|| fg_object["external"].as_i64().map(|x| x == 1)) + .unwrap_or(false) + { + warn!("Found non-external foreground object, even though these should always be external! Skipping."); + continue; + } + self.collector + .collect_download(&mut fg_object["image"], None, Some(true), None); + } + } + + // Download the popups. + for popup in data["popups"] + .as_array_mut() + .unwrap() + .iter_mut() + .filter_map(|x| x.as_object_mut()) + { + let external = popup["external"] + .as_bool() + .context("External must be bool!")?; + self.collector.collect_download( + &mut popup["path"], + Some(paths.popup_path()), + Some(external), + Some("gif"), + ); + popup["external"] = Value::Bool(true); + } + + // Download the music. + for music in data["music"] + .as_array_mut() + .unwrap() + .iter_mut() + .filter_map(|x| x.as_object_mut()) + { + let external = music["external"] + .as_bool() + .context("External must be bool!")?; + self.collector.collect_download( + &mut music["path"], + Some(paths.music_path()), + Some(external), + Some("mp3"), + ); + music["external"] = Value::Bool(true); + } + // Download the sound. + for sound in data["sounds"] + .as_array_mut() + .unwrap() + .iter_mut() + .filter_map(|x| x.as_object_mut()) + { + let external = sound["external"] + .as_bool() + .context("External must be bool!")?; + self.collector.collect_download( + &mut sound["path"], + Some(paths.sound_path()), + Some(external), + Some("mp3"), + ); + sound["external"] = Value::Bool(true); + } + + // Download the voices. These are not present in the trial data, since there are no custom + // voices. + const VOICE_EXT: [&str; 3] = ["opus", "wav", "mp3"]; + for i in 1..=3 { + for ext in VOICE_EXT { + self.collector.collect_download( + &mut Value::String(format!("voice_singleblip_{i}.{ext}")), + Some(paths.voice_path()), + Some(false), + None, + ); + } + } + + let downloads = self.collector.get_unique_downloads(); + pb.inc_length(downloads.len() as u64); + let (successes, failures): (Vec<_>, Vec<_>) = downloads.into_iter().partition_result(); + for (asset, err) in self + .download_assets(successes, pb) + .await + .into_iter() + .filter_map(|x| x.1.err().map(|e| (Some(x.0), e))) + .chain(failures.into_iter().map(|e| (None, e))) + { + if asset.as_ref().is_some_and(|x| x.ignore_inaccessible) { + continue; + } + error!( + "Could not download asset at {}: {err}{}", + asset + .map(|x| x.url) + .unwrap_or(String::from("[UNKNOWN URL]")), + if self.args.continue_on_asset_error { + " (continuing anyway)" + } else { + " (set --continue-on-asset-error to ignore this)" + } + ); + if !self.args.continue_on_asset_error { + return Err(anyhow!("Could not download asset: {err}")); + } + } + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..80d35cc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,312 @@ +mod constants; +mod data; +mod download; +mod transform; + +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, ValueEnum}; +use colored::Colorize; +use constants::re; +use data::case::Case; +use data::player::Player; +use download::AssetDownloader; +use human_panic::setup_panic; +use indicatif::{MultiProgress, ProgressBar}; +use log::{error, info, Level}; +use serde::Serialize; +use std::borrow::Cow; +use std::io::{self}; +use std::path::Path; +use std::time::Duration; + +#[cfg(debug_assertions)] +use clap_verbosity_flag::DebugLevel; +#[cfg(not(debug_assertions))] +use clap_verbosity_flag::InfoLevel; + +/// How to handle insecure HTTP requests. +#[derive(Debug, ValueEnum, Clone, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +enum HttpHandling { + /// Fail when an insecure HTTP request is encountered. + Disallow, + + /// Allow insecure HTTP requests. + AllowInsecure, + + /// Try redirecting insecure HTTP requests to HTTPS. + #[default] + RedirectToHttps, +} + +/// Downloads an Ace Attorney Online case to be playable offline. +/// +/// Simply pass the URL (i.e., `https://aaonline.fr/player.php?trial_id=YOUR_ID`) to this script. +/// You can also directly pass the ID instead. +/// NOTE: Currently, all default sprites are downloaded, which may lead to a high download size. +#[derive(Parser, Debug, Clone)] +#[command(version, about, arg_required_else_help(true))] +struct Args { + /// The URL to the case, or its ID. + #[arg()] + case: String, + + /// The output directory for the case. + #[arg()] + output: Option, + + /// The branch or commit name of Ace Attorney Online that shall be used for the player. + #[arg(short, long, default_value_t = String::from("master"))] + player_version: String, + + /// The language to download the player in. + #[arg(short, long, default_value_t = String::from("en"))] + language: String, + + /// Whether to continue when an asset for the case could not be downloaded. + #[arg(long, default_value_t = false)] + continue_on_asset_error: bool, + + /// Whether to overwrite any existing output files. + #[arg(short, long, default_value_t = false)] + overwrite_existing: bool, + + /// How many concurrent downloads to allow. + #[arg(short('j'), long, default_value_t = 5)] + concurrent_downloads: usize, + + /// How to handle insecure HTTP requests. + #[arg(long, value_enum, default_value_t)] + http_handling: HttpHandling, + + #[cfg(not(debug_assertions))] + #[command(flatten)] + verbose: clap_verbosity_flag::Verbosity, + + #[cfg(debug_assertions)] + #[command(flatten)] + verbose: clap_verbosity_flag::Verbosity, + // TODO: Offer option for single HTML file +} + +#[derive(Debug)] +struct MainContext { + args: Args, + output: String, + case_id: u32, + pb: indicatif::ProgressBar, + multi_progress: MultiProgress, + player: Option, +} + +impl MainContext { + fn new(args: Args) -> Result { + let id: u32 = if let Ok(id) = args.case.parse() { + id + } else if let Some(captures) = re::CASE_REGEX.captures(&args.case) { + captures + .get(1) + .expect("No captured content in case URL") + .as_str() + .parse() + .context("Case ID in given URL is not a valid number!")? + } else { + return Err(anyhow!( + "Could not parse case ID from input {}. Please provide a valid case URL or ID.", + args.case + )); + }; + let output = args.output.clone().unwrap_or_else(|| id.to_string()); + let multi_progress = MultiProgress::new(); + Ok(MainContext { + args, + case_id: id, + output, + pb: multi_progress.add(ProgressBar::new_spinner()), + multi_progress, + player: None, + }) + } + + fn show_step(&self, step: u8, text: &str) { + self.pb + .set_message(format!("{} {text}", format!("[{step}/8]").dimmed())); + self.pb.enable_steady_tick(Duration::from_millis(50)); + } + + fn should_hide_pb(args: &Args) -> bool { + args.verbose.log_level().is_some_and(|x| x > Level::Info) + } + + fn add_progress(&self, max: u64) -> ProgressBar { + if Self::should_hide_pb(&self.args) { + // The progress bar would just be annoying together with that many log messages. + ProgressBar::hidden() + } else { + self.multi_progress.add(ProgressBar::new(max)) + } + } + + fn finish_progress(&self, pb: &ProgressBar, msg: impl Into>) { + pb.finish_with_message(msg); + self.multi_progress.remove(pb); + } + + fn cleanup_data(&self) { + assert!(self.output != "/", "We will not remove /!"); + // We will simply remove everything under the folder, or the file, if the output is a file. + if Path::new(&self.output).is_file() { + std::fs::remove_file(&self.output).unwrap_or_else(|e| { + if let io::ErrorKind::NotFound = e.kind() { + // Ignore if already deleted. + } else { + error!( + "Could not remove file {}: {e}. Please remove it manually.", + self.output + ); + } + }); + } else { + std::fs::remove_dir_all(&self.output).unwrap_or_else(|e| { + if let io::ErrorKind::NotFound = e.kind() { + // Ignore if already deleted. + } else { + error!( + "Could not remove directory {}: {e}. Please remove it manually.", + self.output + ); + } + }); + } + } + + fn clean_on_fail(&self, res: Result<()>) -> Result<()> { + res.inspect_err(|_| self.cleanup_data()) + } + + async fn retrieve_case_info(&mut self) -> Result { + Case::retrieve_from_id(self.case_id).await + } + + async fn retrieve_site_config(&mut self, case: Case) -> Result<()> { + self.player = Some(Player::new(self.args.clone(), case).await?); + Ok(()) + } + + async fn download_case_data(&mut self) -> Result<()> { + let pb = self.add_progress(0); + let player = self.player.as_mut().unwrap(); + let case = &mut player.case; + let site_data = &mut player.site_data; + let mut handler = AssetDownloader::new(self.args.clone(), self.output.clone(), site_data); + let result = handler.download_case_data(case, &pb).await; + self.finish_progress(&pb, "Case data downloaded."); + self.clean_on_fail(result) + } + + async fn retrieve_player(&mut self) -> Result<()> { + let result = self.player.as_mut().unwrap().retrieve_player().await; + self.clean_on_fail(result) + } + + async fn retrieve_player_scripts(&mut self) -> Result<()> { + let pb = self.add_progress(0); + let result = self.player.as_mut().unwrap().retrieve_scripts(&pb).await; + self.finish_progress(&pb, "Player scripts retrieved."); + self.clean_on_fail(result) + } + + fn transform_player_blocks(&mut self) -> Result<()> { + let result = self.player.as_mut().unwrap().transform_player(); + self.clean_on_fail(result) + } + + async fn retrieve_player_sources(&mut self) -> Result<()> { + let pb = self.add_progress(0); + let result = self + .player + .as_mut() + .unwrap() + .retrieve_player_misc_sources(self.output.clone(), &pb) + .await; + self.finish_progress(&pb, "All player sources downloaded."); + self.clean_on_fail(result) + } + + fn output_player(&self) -> Result<()> { + std::fs::write( + format!("{}/index.html", self.output), + self.player.as_ref().unwrap().player.as_ref().unwrap(), + ) + .with_context(|| { + format!( + "Could not write player to file {}/index.html. Please check your permissions.", + self.output + ) + })?; + self.finish_progress( + &self.pb, + format!("Case successfully written to {}/index.html!", self.output) + .bold() + .green() + .to_string(), + ); + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + setup_panic!(); + let args = Args::parse(); + env_logger::builder() + .format_timestamp(None) + .filter_level(args.verbose.log_level_filter()) + .init(); + let mut ctx = MainContext::new(args)?; + + if Path::new(&ctx.output).exists() { + if ctx.args.overwrite_existing { + info!("Output exists already, deleting..."); + ctx.cleanup_data(); + } else { + error!( + "Output at {} already exists. Please remove it or use --overwrite-existing.", + ctx.output + ); + std::process::exit(exitcode::DATAERR); + } + } + + ctx.show_step(1, "Retrieving case information..."); + let case = ctx.retrieve_case_info().await?; + ctx.pb.finish_and_clear(); + info!("Case identified as {case}"); + ctx.pb = ctx + .multi_progress + .add(indicatif::ProgressBar::new_spinner()); + + ctx.show_step(2, "Retrieving site configuration..."); + ctx.retrieve_site_config(case).await?; + + // TODO: Ask to download whole sequence + ctx.show_step(3, "Downloading trial assets... (This may take a while)"); + ctx.download_case_data().await?; + + ctx.show_step(4, "Retrieving player..."); + ctx.retrieve_player().await?; + + ctx.show_step(5, "Retrieving player scripts..."); + ctx.retrieve_player_scripts().await?; + + ctx.show_step(6, "Transforming player..."); + ctx.transform_player_blocks()?; + + ctx.show_step(7, "Retrieving additional external player sources..."); + ctx.retrieve_player_sources().await?; + + ctx.show_step(8, "Writing player to file..."); + ctx.output_player()?; + + Ok(()) +} diff --git a/src/transform.rs b/src/transform.rs new file mode 100644 index 0000000..4721fcc --- /dev/null +++ b/src/transform.rs @@ -0,0 +1,245 @@ +//! Contains methods and models related to the AAO player transformation. + +/// Contains methods and models related to detecting and transforming PHP blocks. +pub(crate) mod php { + use std::sync::LazyLock; + + use anyhow::Result; + use log::{error, trace, warn}; + use regex::Regex; + + use crate::constants::{re, UPDATE_MESSAGE}; + use crate::data::player::Player; + + /// A matched block within the player. + #[derive(Clone, Debug)] + struct FoundPhpBlock { + start: usize, + end: usize, + replaced: Option, + } + + impl FoundPhpBlock { + fn new(start: usize, end: usize) -> FoundPhpBlock { + FoundPhpBlock { + start, + end, + replaced: None, + } + } + fn new_unexpected(start: usize, end: usize) -> FoundPhpBlock { + FoundPhpBlock { + start, + end, + replaced: Some(String::new()), + } + } + } + + /// An expected block within the player. + #[derive(Debug)] + struct ExpectedPhpBlock { + /// A human-readable ID for this block to uniquely identify it. + id: &'static str, + start: usize, + end: usize, + /// A regex to detect this PHP block. + detector: LazyLock, + /// A function with which the contents of this PHP block can be transformed. + replacer: Option Result>, + } + + impl ExpectedPhpBlock { + fn expect_match(&self, other: &FoundPhpBlock) { + if (other.start, other.end) != (self.start, self.end) { + warn!( + "Expected PHP block {} to be at character range ({}–{}), but was at ({}–{}). {UPDATE_MESSAGE}", + self.id, self.start, self.end, other.start, other.end + ); + } + } + + fn replace(&self, player_scripts: &Player) -> Result { + if let Some(replacer) = self.replacer { + replacer(player_scripts) + } else { + Ok(String::new()) + } + } + + fn matches(&self, text: &str) -> bool { + trace!( + "Matching PHP block {} with {} to {text}...", + self.id, + self.detector.to_string() + ); + self.detector.is_match(text) + } + + const fn new( + id: &'static str, + start: usize, + end: usize, + detector: LazyLock, + replacer: Option Result>, + ) -> ExpectedPhpBlock { + ExpectedPhpBlock { + id, + start, + end, + detector, + replacer, + } + } + } + + fn transform_blocks( + scripts: &Player, + source: &mut String, + blocks: &[ExpectedPhpBlock], + ) -> Result<()> { + let mut visited: Vec = Vec::with_capacity(blocks.len()); + let mut replacements: Vec = Vec::new(); + for block_match in re::PHP_REGEX.captures_iter(source) { + let text = block_match + .get(1) + .expect("No captured content in PHP block") + .as_str() + .to_string(); + let whole_match = block_match.get(0).unwrap(); + let start = whole_match.start(); + let end = whole_match.end(); + trace!("PHP block: {text}"); + + let visited_until_now = visited.clone(); + let copied_text = text.clone(); + let result: Vec<_> = blocks + .iter() + .enumerate() + .filter(move |x| !visited_until_now.contains(&x.0) && x.1.matches(&copied_text)) + .collect(); + + let replacement = if result.is_empty() { + warn!("Unexpected PHP block at ({start}–{end}). Removing from HTML.",); + FoundPhpBlock::new_unexpected(start, end) + } else if result.len() > 1 { + error!( + "Invalid ({}) matches for PHP block at ({start}–{end}). {UPDATE_MESSAGE}", + result.len(), + ); + std::process::exit(exitcode::SOFTWARE); + } else { + let result = result[0]; + let mut block = FoundPhpBlock::new(start, end); + result.1.expect_match(&block); + block.replaced = Some(result.1.replace(scripts)?); + + // Mark block as visited. + visited.push(result.0); + + block + }; + + replacements.push(replacement); + } + + // Sort replacements by reverse order of position so we can safely replace them. + replacements.sort_by(|a, b| b.start.cmp(&a.start)); + + for replacement in replacements { + let start = replacement.start; + let end = replacement.end; + let replaced = if let Some(replaced) = replacement.replaced { + replaced + } else { + warn!( + "Unhandled PHP block at ({}–{}). Removing from HTML.", + start, end + ); + String::new() + }; + source.replace_range(start..end, &replaced); + } + + Ok(()) + } + + pub(crate) fn transform_trial_blocks(player: &Player, source: &mut String) -> Result<()> { + static EXPECTED_TRIAL_BLOCKS: [ExpectedPhpBlock; 2] = [ + ExpectedPhpBlock::new( + "common_render", + 0, + 238, + LazyLock::new(|| Regex::new(r"include\('common_render\.php'\);").unwrap()), + None, + ), + ExpectedPhpBlock::new( + "trial_data", + 1063, + 2712, + LazyLock::new(|| Regex::new(r"var trial_information;").unwrap()), + Some(|x| x.case.serialize_to_js()), + ), + ]; + + transform_blocks(player, source, &EXPECTED_TRIAL_BLOCKS) + } + + pub(crate) fn transform_player_blocks(player: &mut Player) -> Result<()> { + static EXPECTED_PLAYER_BLOCKS: [ExpectedPhpBlock; 5] = [ + ExpectedPhpBlock::new( + "common_render", + 1, + 40, + LazyLock::new(|| Regex::new(r"include\('common_render\.php'\);").unwrap()), + None, + ), + ExpectedPhpBlock::new( + "language", + 224, + 272, + LazyLock::new(|| Regex::new(r"echo language_backend\(.*\)").unwrap()), + Some(|x| Ok(x.args.language.clone())), + ), + ExpectedPhpBlock::new( + "script", + 276, + 396, + LazyLock::new(|| Regex::new(r"include\('bridge\.js\.php'\);").unwrap()), + Some(|x| { + Ok(x.scripts + .as_ref() + .unwrap() + .scripts + .as_ref() + .unwrap() + .clone()) + }), + ), + ExpectedPhpBlock::new( + "title", + 417, + 530, + LazyLock::new(|| { + Regex::new(r"echo 'Ace Attorney Online - Trial Player \(Loading\)';").unwrap() + }), + Some(|x| Ok(x.case.trial_information.title.clone())), + ), + ExpectedPhpBlock::new( + "heading", + 996, + 1082, + LazyLock::new(|| Regex::new(r"echo 'Loading trial \.\.\.';").unwrap()), + Some(|x| Ok(x.case.trial_information.title.clone())), + ), + ]; + + let mut playertext = player.player.as_mut().unwrap().clone(); + transform_blocks(player, &mut playertext, &EXPECTED_PLAYER_BLOCKS)?; + player.player = Some(playertext); + Ok(()) + } +} + +/// Contains methods and models related to detecting and transforming JavaScript scripts. +pub(crate) mod js {}