diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml.dis similarity index 100% rename from .github/workflows/test.yml rename to .github/workflows/test.yml.dis diff --git a/.gitignore b/.gitignore index c59a80cd..2d5ca726 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,3 @@ __pycache__ # Do not keep tests and coverage results .pytest_cache .coverage - -# To avoid cluttering when switching branch with 'rust-rewrite' branch. -/rust diff --git a/codecov.yml b/codecov.yml index e00ce3d6..8c176592 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,2 +1,3 @@ github_checks: annotations: false +comment: false diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 00000000..5d140ef8 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,5 @@ +/target + +*.obj +*.exe +*.exe.old diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 00000000..dc258ac8 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,2867 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[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.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.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 = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" +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 = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" +dependencies = [ + "nix", + "windows-sys 0.59.0", +] + +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + +[[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 = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "elsa" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98e71ae4df57d214182a2e5cb90230c0192c6ddfcaa05c36453d46a54713e10" +dependencies = [ + "stable_deref_trait", +] + +[[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 = "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 = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "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-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[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-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix 0.38.44", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[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", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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 = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[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 = "hyper" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +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 = "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 = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "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 = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[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 = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_info" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a604e53c24761286860eba4e2c8b23a0161526476b1de520139d69cdb85a6b5" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.1", + "serde", +] + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[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 = "portablemc" +version = "5.0.0-beta.0" +dependencies = [ + "chrono", + "dirs", + "dunce", + "elsa", + "gethostname", + "indexmap", + "jsonwebtoken", + "md5", + "once_cell", + "os_info", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha1", + "tempfile", + "thiserror 2.0.11", + "tokio", + "uuid", + "windows-registry", + "xmlparser", + "zip", +] + +[[package]] +name = "portablemc-cli" +version = "5.0.0-beta.0" +dependencies = [ + "chrono", + "clap", + "ctrlc", + "jsonwebtoken", + "portablemc", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.11", + "uuid", + "webbrowser", + "xmlparser", + "zip", +] + +[[package]] +name = "portablemc-ffi" +version = "5.0.0-beta.0" +dependencies = [ + "portablemc", + "serde", + "serde_json", + "tempfile", + "uuid", +] + +[[package]] +name = "portablemc-py" +version = "5.0.0-beta.0" +dependencies = [ + "portablemc", + "pyo3", + "uuid", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe09249128b3173d092de9523eaa75136bf7ba85e0d69eca241c7939c933cc" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd3927b5a78757a0d71aa9dff669f903b1eb64b54142a9bd9f757f8fde65fd7" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab6bb2102bd8f991e7749f130a70d05dd557613e39ed2deeee8e9ca0c4d548d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91871864b353fd5ffcb3f91f2f703a22a9797c91b9ab497b1acac7b07ae509c7" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43abc3b80bc20f3facd86cd3c60beed58c3e2aa26213f3cda368de39c60a27e4" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.11", +] + +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +dependencies = [ + "base64 0.22.1", + "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", + "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 0.2.15", + "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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.9.3", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" + +[[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 = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.11", + "time", +] + +[[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.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +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 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix 1.0.5", + "windows-sys 0.59.0", +] + +[[package]] +name = "terminal_size" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl 2.0.11", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +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 = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.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 = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243" + +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" +dependencies = [ + "serde", + "sha1_smol", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea9fe1ebb156110ff855242c1101df158b822487e4957b0556d9ffce9db0f535" +dependencies = [ + "block2", + "core-foundation 0.10.0", + "home", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[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.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[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 = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[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 = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", +] + +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "rand", + "sha1", + "thiserror 2.0.11", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..e6696c2f --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,65 @@ +[workspace] +members = ["portablemc", "portablemc-cli", "portablemc-ffi", "portablemc-py"] +resolver = "2" + +[workspace.package] +edition = "2021" +version = "5.0.0-beta.0" +authors = ["Théo Rozier "] +homepage = "https://github.com/mindstorm38/portablemc" +repository = "https://github.com/mindstorm38/portablemc" +rust-version = "1.85.1" + +[workspace.dependencies] +portablemc = { path = "portablemc", version = "=5.0.0-beta.0" } + +# Utils +thiserror = "2.0.3" + +# Serde & co. +serde = { version = "1.0.215", features = ["derive"] } +serde_path_to_error = "0.1.16" +serde_json = "1.0.133" + +# Parsers +xmlparser = "0.13.6" + +# Data types +uuid = { version = "1.11.0", features = ["serde"] } +chrono = { version = "0.4.39", features = ["serde"] } + +# Regex, we intentionally disable the Unicode feature to avoid bloating us. +regex = { version = "1.11.1", default-features = false, features = ["std", "perf", "unicode-perl"] } + +# Crypto +sha1 = "0.10.6" +md5 = "0.7.0" +jsonwebtoken = "9.3.0" + +# Data structures +indexmap = "2.7.0" +elsa = "1.10" +zip = "2.2.1" +slab = "0.4.9" + +# Sync +once_cell = "1.20.2" + +# OS +gethostname = "0.5.0" +dunce = "1.0.5" +os_info = "3.8.2" +dirs = "6.0.0" +webbrowser = "1.0.3" +windows-registry = "0.2.0" + +# Async +tokio = "1.41.1" +reqwest = "0.12.9" + +# CLI +clap = "4.5.21" +ctrlc = "3.4.5" + +# Testing +tempfile = "3.19.1" diff --git a/rust/portablemc-cli/Cargo.toml b/rust/portablemc-cli/Cargo.toml new file mode 100644 index 00000000..a745df75 --- /dev/null +++ b/rust/portablemc-cli/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "portablemc-cli" +description = "Command line utility for launching Minecraft quickly and reliably with included support for Mojang versions and popular mod loaders." +categories = ["games", "command-line-utilities"] +edition.workspace = true +version.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +publish = true + +[dependencies] +portablemc.workspace = true + +thiserror.workspace = true + +serde.workspace = true +serde_path_to_error.workspace = true +serde_json.workspace = true + +xmlparser.workspace = true + +chrono.workspace = true +uuid.workspace = true + +reqwest.workspace = true + +jsonwebtoken.workspace = true + +zip.workspace = true + +webbrowser.workspace = true + +clap = { workspace = true, features = ["derive", "wrap_help", "env"] } +ctrlc.workspace = true + +[[bin]] +name = "portablemc" +path = "src/main.rs" +doc = false diff --git a/rust/portablemc-cli/src/cmd/auth.rs b/rust/portablemc-cli/src/cmd/auth.rs new file mode 100644 index 00000000..a961244d --- /dev/null +++ b/rust/portablemc-cli/src/cmd/auth.rs @@ -0,0 +1,223 @@ +//! Implementation of the 'auth' command. + +use std::process::ExitCode; + +use portablemc::msa::{Account, Auth, AuthError}; +use uuid::Uuid; + +use crate::output::LogLevel; +use crate::parse::AuthArgs; + +use super::{Cli, log_msa_auth_error, log_msa_database_error}; + + +pub fn auth(cli: &mut Cli, args: &AuthArgs) -> ExitCode { + if let Some(forget_name) = &args.forget { + auth_account_action(cli, forget_name, AccountAction::Forget) + } else if let Some(refresh_name) = &args.refresh { + auth_account_action(cli, refresh_name, AccountAction::Refresh) + } else if args.list { + auth_list(cli) + } else { + auth_login(cli, args.no_browser) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AccountAction { + Forget, + Refresh, +} + +fn auth_account_action(cli: &mut Cli, name: &str, action: AccountAction) -> ExitCode { + + let res = + if let Ok(uuid) = Uuid::parse_str(&name) { + match action { + AccountAction::Forget => cli.msa_db.remove_from_uuid(uuid), + AccountAction::Refresh => cli.msa_db.load_from_uuid(uuid), + } + } else { + match action { + AccountAction::Forget => cli.msa_db.remove_from_username(&name), + AccountAction::Refresh => cli.msa_db.load_from_username(&name), + } + }; + + let account = match res { + Ok(Some(account)) => account, + Ok(None) => { + + cli.out.log("auth_account_not_found") + .arg(name) + .warning(format_args!("No account found for: {name}")); + + return ExitCode::SUCCESS; + + } + Err(error) => { + log_msa_database_error(cli, &error); + return ExitCode::FAILURE; + } + }; + + match action { + AccountAction::Forget => { + + cli.out.log("auth_account_forgot") + .arg(account.uuid()) + .arg(account.username()) + .success(format_args!("Forgot account {} ({})", account.username(), account.uuid())); + + ExitCode::SUCCESS + + } + AccountAction::Refresh => { + + if refresh_account(cli, account, false) { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + + } + } + +} + +pub(crate) fn refresh_account(cli: &mut Cli, mut account: Account, silent: bool) -> bool { + + cli.out.log("auth_account_refresh_profile") + .arg(account.uuid()) + .arg(account.username()) + .line(if silent { LogLevel::Info } else { LogLevel::Pending }, + format_args!("Refreshing account profile for {}", account.uuid())); + + let mut refreshed_token = false; + + match account.request_profile() { + Ok(()) => {} + Err(AuthError::OutdatedToken) => { + + cli.out.log("auth_account_refresh_token") + .arg(account.uuid()) + .arg(account.username()) + .pending(format_args!("Refreshing account token for {}", account.uuid())); + + match account.request_refresh() { + Ok(()) => { + refreshed_token = true; + } + Err(error) => { + log_msa_auth_error(cli, &error); + return false; + } + } + + } + Err(error) => { + log_msa_auth_error(cli, &error); + return false; + } + }; + + cli.out.log("auth_account_refreshed") + .arg(account.uuid()) + .arg(account.username()) + .line(if silent && !refreshed_token { LogLevel::Info } else { LogLevel::Success }, + format_args!("Refreshed account as {} ({})", account.username(), account.uuid())); + + // Once the account is refreshed, store it! + match cli.msa_db.store(account) { + Ok(()) => true, + Err(error) => { + log_msa_database_error(cli, &error); + false + } + } + +} + +fn auth_list(cli: &mut Cli) -> ExitCode { + + let iter = match cli.msa_db.load_iter() { + Ok(iter) => iter, + Err(error) => { + log_msa_database_error(cli, &error); + return ExitCode::FAILURE; + } + }; + + // Now we construct the table... + let mut table = cli.out.table(2); + + { + let mut row = table.row(); + row.cell("username").format("Username"); + row.cell("uuid").format("UUID"); + } + + table.sep(); + + for account in iter { + let mut row = table.row(); + row.cell(account.username()); + row.cell(account.uuid()); + } + + ExitCode::SUCCESS + +} + +fn auth_login(cli: &mut Cli, no_browser: bool) -> ExitCode { + + let auth = Auth::new(&cli.msa_azure_app_id); + + cli.out.log("auth_request_device_code") + .pending("Requesting authentication device code..."); + + let code_flow = match auth.request_device_code() { + Ok(ret) => ret, + Err(error) => { + log_msa_auth_error(cli, &error); + return ExitCode::FAILURE; + } + }; + + cli.out.log("auth_device_code") + .arg(code_flow.verification_uri()) + .arg(code_flow.user_code()) + .success(code_flow.message()); + + if cli.out.is_human() && !no_browser { + if webbrowser::open(code_flow.verification_uri()).is_ok() { + cli.out.log("auth_webbrowser_opened") + .additional("Your web browser has been opened"); + } + } + + cli.out.log("auth_wait") + .pending("Waiting for authentication to complete..."); + + let account = match code_flow.wait() { + Ok(account) => account, + Err(error) => { + log_msa_auth_error(cli, &error); + return ExitCode::FAILURE; + } + }; + + cli.out.log("auth_account_authenticated") + .arg(account.uuid()) + .arg(account.username()) + .success(format_args!("Authenticated account as {} ({})", account.username(), account.uuid())); + + match cli.msa_db.store(account) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + log_msa_database_error(cli, &error); + ExitCode::FAILURE + } + } + +} diff --git a/rust/portablemc-cli/src/cmd/mod.rs b/rust/portablemc-cli/src/cmd/mod.rs new file mode 100644 index 00000000..222955eb --- /dev/null +++ b/rust/portablemc-cli/src/cmd/mod.rs @@ -0,0 +1,1113 @@ +//! Implementing the logic for the different CLI commands. + +mod start; +mod search; +mod auth; + +use std::process::{self, ExitCode}; +use std::path::{Path, PathBuf}; +use std::collections::HashSet; +use std::time::Instant; +use std::error::Error; +use std::io; + +use portablemc::base::{self, LoadedLibrary, LoadedVersion}; +use portablemc::{download, mojang, fabric, forge, msa}; +use portablemc::maven::Gav; + +use crate::parse::{CliArgs, CliCmd, CliOutput}; +use crate::output::{Output, LogLevel}; +use crate::format::{self, BytesFmt}; + + +const DEFAULT_AZURE_APP_ID: &str = "708e91b5-99f8-4a1d-80ec-e746cbb24771"; +const DEFAULT_MSA_DB_FILE: &str = "portablemc_msa.json"; + + +pub fn main(args: &CliArgs) -> ExitCode { + + // We can set only one Ctrl-C handler for the whole CLI, so we set it here and access + // the various known resources that we should shutdown. + ctrlc::set_handler(|| { + + // No unwrap to avoid panicking if poisoned. + if let Ok(mut guard) = start::GAME_CHILD.lock() { + if let Some(mut child) = guard.take() { + let _ = child.kill(); + } + } + + process::exit(0); + + }).unwrap(); + + // Create the adequate output handle depending on the output and verbose options. + let mut out = match args.output { + CliOutput::Human => Output::human(match args.verbose { + 0 => LogLevel::Pending, + 1.. => LogLevel::Info, + }), + CliOutput::Machine => Output::tab_separated(), + }; + + // Ensure that we can have a main directory for Minecraft, needed for all commands. + let Some(main_dir) = args.main_dir.as_deref() + .or_else(|| base::default_main_dir()) + .map(Path::to_path_buf) else { + + out.log("error_missing_main_dir") + .error("There is no default main directory for your platform, please specify it using --main-dir") + .additional("This directory is used to define derived directories for the various commands"); + + return ExitCode::FAILURE; + + }; + + let msa_db_file = args.msa_db_file.clone() + .unwrap_or_else(|| main_dir.join(DEFAULT_MSA_DB_FILE)); + let msa_azure_app_id = args.msa_azure_app_id.clone() + .unwrap_or_else(|| DEFAULT_AZURE_APP_ID.to_string()); + + let mut cli = Cli { + out, + main_dir, + msa_db: msa::Database::new(msa_db_file), + msa_azure_app_id, + }; + + legacy_check(&mut cli); + + match &args.cmd { + CliCmd::Start(start_args) => start::start(&mut cli, start_args), + CliCmd::Search(search_args) => search::search(&mut cli, search_args), + CliCmd::Auth(auth_args) => auth::auth(&mut cli, auth_args), + } + +} + +fn legacy_check(cli: &mut Cli) { + + const LEGACY_FILES: [&str; 2] = ["portablemc_auth.json", "portablemc_version_manifest.json"]; + + // Cleanup any legacy files from the older Python version. + let mut files = Vec::new(); + for file_name in LEGACY_FILES { + let file = cli.main_dir.join(file_name); + if file.exists() { + files.push(file); + } + } + + if files.is_empty() { + return; + } + + let mut log = cli.out.log("warn_legacy_file"); + log.args(files.iter().map(|file| file.display())); + log.warning("The following files were used in older versions of the launcher and you can safely delete them:"); + for file in files { + log.additional(file.display()); + } + +} + + +/// Shared CLI data. +#[derive(Debug)] +pub struct Cli { + pub out: Output, + pub main_dir: PathBuf, + pub msa_db: msa::Database, + pub msa_azure_app_id: String, +} + +/// Generic handler for various event handlers type (download and installers). +#[derive(Debug)] +pub struct LogHandler<'a> { + /// Handle to the output. + out: &'a mut Output, + /// If a download is running, this contains the instant it started, for speed calc. + download_start: Option, + /// When an installer with different supported APIs (for finding game or loader + /// versions) is used, this defines the id used for log messages. + api_id: &'static str, + /// For the same reason as above, this field is used for human-readable messages. + api_name: &'static str, + /// The LWJGL version loaded. + loaded_lwjgl_version: Option, + /// The JVM major version being loaded. + jvm_major_version: u32, +} + +impl<'a> LogHandler<'a> { + + pub fn new(out: &'a mut Output) -> Self { + Self { + out, + download_start: None, + api_id: "", + api_name: "", + loaded_lwjgl_version: None, + jvm_major_version: 0, + } + } + + fn set_api(&mut self, api_id: &'static str, api_name: &'static str) { + self.api_id = api_id; + self.api_name = api_name; + } + + pub fn set_fabric_loader(&mut self, loader: fabric::Loader) { + let (api_id, api_name) = fabric_id_name(loader); + self.set_api(api_id, api_name); + } + + pub fn set_forge_loader(&mut self, loader: forge::Loader) { + let (api_id, api_name) = forge_id_name(loader); + self.set_api(api_id, api_name); + } + +} + +impl download::Handler for LogHandler<'_> { + fn progress(&mut self, count: u32, total_count: u32, size: u32, total_size: u32) { + + if self.download_start.is_none() { + self.download_start = Some(Instant::now()); + } + + let elapsed = self.download_start.unwrap().elapsed(); + let speed = size as f32 / elapsed.as_secs_f32(); + + if count == total_count { + self.download_start = None; + } + + // No logging when no size is actually downloaded, for example when downloading + // already cached files. But if these files needs to be re-downloaded, then the + // download will be shown. + if size == 0 { + return; + } + + let progress = (size as f32 / total_size as f32).min(1.0) * 100.0; + let (speed_fmt, speed_suffix) = format::number_si_unit(speed); + let (size_fmt, size_suffix) = format::number_si_unit(size as f32); + + let mut log = self.out.log_background("download"); + if count == total_count { + log.message(format_args!("{speed_fmt:.1} {speed_suffix}B/s {size_fmt:.0} {size_suffix}B ({count})")); + } else { + log.message(format_args!("{speed_fmt:.1} {speed_suffix}B/s {progress:.1}% ({count}/{total_count})")); + } + + log.arg(format_args!("{count}/{total_count}")); + log.arg(format_args!("{size}/{total_size}")); + log.arg(format_args!("{}", elapsed.as_secs_f32())); + log.arg(format_args!("{speed}")); + + } +} + +impl base::Handler for LogHandler<'_> { + + fn loaded_features(&mut self, features: &HashSet) { + + let mut buffer = String::new(); + for version in features.iter() { + if !buffer.is_empty() { + buffer.push_str(", "); + } else { + buffer.push_str(&version); + } + } + + if buffer.is_empty() { + buffer.push_str("{}"); + } + + self.out.log("loaded_features") + .args(features.iter()) + .info(format_args!("Features loaded: {buffer}")); + + } + + fn load_hierarchy(&mut self, root_version: &str) { + self.out.log("load_hierarchy") + .arg(root_version) + .info(format_args!("Hierarchy loading from {root_version}")); + } + + fn loaded_hierarchy(&mut self, hierarchy: &[LoadedVersion]) { + + let mut buffer = String::new(); + for version in hierarchy { + if !buffer.is_empty() { + buffer.push_str(" -> "); + } + buffer.push_str(&version.name()); + } + + self.out.log("loaded_hierarchy") + .args(hierarchy.iter().map(|v| v.name())) + .info(format_args!("Hierarchy loaded: {buffer}")); + + } + + fn load_version(&mut self, version: &str, file: &Path) { + self.out.log("load_version") + .arg(version) + .pending(format_args!("Loading version {version}")) + .info(format_args!("Loading version metadata: {}", file.display())); + } + + fn loaded_version(&mut self, version: &str, _file: &Path) { + self.out.log("loaded_version") + .arg(version) + .success(format_args!("Loaded version {version}")); + } + + fn load_client(&mut self) { + self.out.log("load_client") + .pending("Loading client"); + } + + fn loaded_client(&mut self, file: &Path) { + self.out.log("loaded_client") + .arg(file.display()) + .success("Loaded client"); + } + + fn load_libraries(&mut self) { + self.out.log("load_libraries") + .pending("Loading libraries"); + } + + fn loaded_libraries(&mut self, libraries: &[LoadedLibrary]) { + + self.out.log("loaded_libraries") + .args(libraries.iter().map(|lib| &lib.gav)) + .pending(format_args!("Loaded {} libraries, now verifying", libraries.len())); + + self.loaded_lwjgl_version = libraries.iter() + .find(|lib| ("org.lwjgl", "lwjgl") == (lib.gav.group(), lib.gav.artifact())) + .map(|lib| lib.gav.version().to_string()); + + } + + fn loaded_libraries_files(&mut self, class_files: &[PathBuf], natives_files: &[PathBuf]) { + + self.out.log("loaded_libraries_files") + .success(format_args!("Loaded and verified {}+{} libraries", class_files.len(), natives_files.len())); + + self.out.log("loaded_class_files") + .args(class_files.iter().map(|p| p.display())); + self.out.log("loaded_natives_files") + .args(natives_files.iter().map(|p| p.display())); + + // Just an information for debug. + if let Some(lwjgl_version) = self.loaded_lwjgl_version.as_deref() { + self.out.log("loaded_lwjgl_version") + .arg(lwjgl_version) + .info(format_args!("Loaded LWJGL version: {lwjgl_version}")); + } + + } + + fn no_logger(&mut self) { + self.out.log("no_logger") + .success("No logger"); + } + + fn load_logger(&mut self, id: &str) { + self.out.log("load_logger") + .arg(id) + .pending(format_args!("Loading logger {id}")); + } + + fn loaded_logger(&mut self, id: &str) { + self.out.log("loaded_logger") + .arg(id) + .success(format_args!("Loaded logger {id}")); + } + + fn no_assets(&mut self) { + self.out.log("no_assets") + .success("No assets"); + } + + fn load_assets(&mut self, id: &str) { + self.out.log("assets_loading") + .arg(id) + .pending(format_args!("Loading assets {id}")); + } + + fn loaded_assets(&mut self, id: &str, count: usize) { + self.out.log("assets_loaded") + .arg(id) + .arg(count) + .pending(format_args!("Loaded {count} assets {id}")); + } + + fn verified_assets(&mut self, id: &str, count: usize) { + self.out.log("verified_assets") + .arg(id) + .arg(count) + .success(format_args!("Loaded and verified {count} assets {id}")); + } + + fn load_jvm(&mut self, major_version: u32) { + self.jvm_major_version = major_version; + self.out.log("load_jvm") + .arg(major_version) + .pending(format_args!("Loading JVM (major version {major_version})")); + } + + fn found_jvm_system_version(&mut self, file: &Path, version: &str, compatible: bool) { + + let compatible_str = if compatible { "compatible" } else { "incompatible" }; + + self.out.log("found_jvm_system_version") + .arg(file.display()) + .arg(version) + .arg(compatible) + .info(format_args!("Found system JVM at {}, version {version}, {compatible_str}", file.display())); + + } + + fn warn_jvm_unsupported_dynamic_crt(&mut self) { + self.out.log("warn_jvm_unsupported_dynamic_crt") + .info("Couldn't find a Mojang JVM because your launcher is compiled with a static C runtime"); + } + + fn warn_jvm_unsupported_platform(&mut self) { + self.out.log("warn_jvm_unsupported_platform") + .info("Couldn't find a Mojang JVM because your platform is not supported"); + } + + fn warn_jvm_missing_distribution(&mut self) { + self.out.log("warn_jvm_missing_distribution") + .info("Couldn't find a Mojang JVM because the required distribution was not found"); + } + + fn loaded_jvm(&mut self, file: &Path, version: Option<&str>, compatible: bool) { + + { + let mut log = self.out.log("loaded_jvm"); + log.arg(file.display()); + log.args(version); + + if let Some(version) = version { + log.success(format_args!("Loaded JVM ({version})")); + } else { + log.success(format_args!("Loaded JVM (unknown version)")); + } + + log.info(format_args!("Loaded JVM at {}", file.display())); + + } + + if !compatible { + + self.out.log("warn_jvm_likely_incompatible") + .warning(format_args!("Loaded JVM is likely incompatible with the game version, which requires major version {}", + self.jvm_major_version)); + + } + + } + + fn download_resources(&mut self) -> bool { + self.out.log("download_resources") + .pending("Downloading"); + true + } + + fn downloaded_resources(&mut self) { + self.out.log("resources_downloaded") + .success("Downloaded"); + } + + fn extracted_binaries(&mut self, dir: &Path) { + self.out.log("binaries_extracted") + .arg(dir.display()) + .info(format_args!("Binaries extracted to {}", dir.display())); + } + +} + +impl mojang::Handler for LogHandler<'_> { + + fn invalidated_version(&mut self, version: &str) { + self.out.log("invalidated_version") + .arg(version) + .info(format_args!("Version {version} invalidated")); + } + + fn fetch_version(&mut self, version: &str) { + self.out.log("fetch_version") + .arg(version) + .pending(format_args!("Fetching version {version}")); + } + + fn fetched_version(&mut self, version: &str) { + self.out.log("fetched_version") + .arg(version) + .success(format_args!("Fetched version {version}")); + } + + fn fixed_legacy_quick_play(&mut self) { + self.out.log("fixed_legacy_quick_play") + .info("Fixed: legacy quick play"); + } + + fn fixed_legacy_proxy(&mut self, host: &str, port: u16) { + self.out.log("fixed_legacy_proxy") + .arg(host) + .arg(port) + .info(format_args!("Fixed: legacy proxy ({host}:{port})")); + } + + fn fixed_legacy_merge_sort(&mut self) { + self.out.log("fixed_legacy_merge_sort") + .info("Fixed: legacy merge sort"); + } + + fn fixed_legacy_resolution(&mut self) { + self.out.log("fixed_legacy_resolution") + .info("Fixed: legacy resolution"); + } + + fn fixed_broken_authlib(&mut self) { + self.out.log("fixed_broken_authlib") + .info("Fixed: broken authlib"); + } + + fn warn_unsupported_quick_play(&mut self) { + self.out.log("warn_unsupported_quick_play") + .warning("Quick play has been requested but is not supported"); + } + + fn warn_unsupported_resolution(&mut self) { + self.out.log("warn_unsupported_resolution") + .warning("Resolution has been requested but is not supported"); + } + +} + +impl fabric::Handler for LogHandler<'_> { + + fn fetch_loader_version(&mut self, game_version: &str, loader_version: &str) { + let (api_id, api_name) = (self.api_id, self.api_name); + self.out.log(format_args!("{api_id}_fetch_loader")) + .arg(game_version) + .arg(loader_version) + .pending(format_args!("Fetching {api_name} loader {loader_version} for {game_version}")); + } + + fn fetched_loader_version(&mut self, game_version: &str, loader_version: &str) { + let (api_id, api_name) = (self.api_id, self.api_name); + self.out.log(format_args!("{api_id}_fetched_loader")) + .arg(game_version) + .arg(loader_version) + .info(format_args!("Fetched {api_name} loader {loader_version} for {game_version}")); + } + +} + +impl forge::Handler for LogHandler<'_> { + + fn installing(&mut self, tmp_dir: &Path, reason: forge::InstallReason) { + + let api_id = self.api_id; + let (reason_code, log_level, reason_desc) = match reason { + forge::InstallReason::MissingVersionMetadata => + ("missing_version_metadata", LogLevel::Success, "The version metadata is absent, installing"), + forge::InstallReason::MissingCoreLibrary => + ("missing_universal_client", LogLevel::Warn, "The core loader library is absent, reinstalling"), + forge::InstallReason::MissingClientExtra => + ("missing_client_extra", LogLevel::Warn, "The client extra is absent, reinstalling"), + forge::InstallReason::MissingClientSrg => + ("missing_client_srg", LogLevel::Warn, "The client srg is absent, reinstalling"), + forge::InstallReason::MissingPatchedClient => + ("missing_patched_client", LogLevel::Warn, "The patched client is absent, reinstalling"), + forge::InstallReason::MissingUniversalClient => + ("missing_universal_client", LogLevel::Warn, "The universal client is absent, reinstalling"), + }; + + self.out.log(format_args!("{api_id}_installing")) + .arg(reason_code) + .newline() // Don't overwrite the failed line. + .line(log_level, reason_desc) + .info(format_args!("Installing in temporary directory: {}", tmp_dir.display())); + + } + + fn fetch_installer(&mut self, version: &str) { + let api_id = self.api_id; + self.out.log(format_args!("{api_id}_fetch_installer")) + .arg(version) + .pending(format_args!("Fetching installer {version}")); + } + + fn fetched_installer(&mut self, version: &str) { + let api_id = self.api_id; + self.out.log(format_args!("{api_id}_fetched_installer")) + .arg(version) + .success(format_args!("Fetched installer {version}")); + } + + fn installing_game(&mut self) { + let api_id = self.api_id; + self.out.log(format_args!("{api_id}_game_installing")) + .success("Installing the game version required by the installer"); + } + + fn fetch_installer_libraries(&mut self) { + let api_id = self.api_id; + self.out.log(format_args!("{api_id}_installer_libraries_fetching")) + .pending(format_args!("Fetching installer libraries")); + } + + fn fetched_installer_libraries(&mut self) { + let api_id = self.api_id; + self.out.log(format_args!("{api_id}_installer_libraries_fetched")) + .success(format_args!("Fetched installer libraries")); + } + + fn run_installer_processor(&mut self, name: &Gav, task: Option<&str>) { + + let api_id = self.api_id; + let desc = match (name.artifact(), task) { + ("installertools", Some("MCP_DATA")) => + "Generating MCP data", + ("installertools", Some("DOWNLOAD_MOJMAPS")) => + "Downloading Mojang mappings", + ("installertools", Some("MERGE_MAPPING")) => + "Merging MCP and Mojang mappings", + ("jarsplitter", _) => + "Splitting client with mappings", + ("ForgeAutoRenamingTool", _) => + "Renaming client with mappings (Forge)", + ("AutoRenamingTool", _) if name.group() == "net.neoforged" => + "Renaming client with mappings (NeoForge)", + ("vignette", _) => + "Renaming client with mappings (Vignette)", + ("binarypatcher", _) => + "Patching client", + ("SpecialSource", _) => + "Renaming client with mappings (SpecialSource)", + _ => name.as_str() + }; + + self.out.log(format_args!("{api_id}_installer_processor")) + .arg(name.as_str()) + .args(task) + .success(format_args!("{desc}")); + + } + + fn installed(&mut self) { + let api_id = self.api_id; + self.out.log(format_args!("{api_id}_installed")) + .success("Loader installed, retrying to start the game"); + } + +} + +/// Log a base error on the given logger output. +pub fn log_base_error(cli: &mut Cli, error: &base::Error) { + + use base::Error; + + let out = &mut cli.out; + + match error { + Error::HierarchyLoop { version } => { + out.log("error_hierarchy_loop") + .arg(&version) + .error(format_args!("Version {version} appears twice in the hierarchy, causing an infinite hierarchy loop")); + } + Error::VersionNotFound { version } => { + out.log("error_version_not_found") + .arg(&version) + .error(format_args!("Version {version} not found")); + } + Error::AssetsNotFound { id } => { + out.log("error_assets_not_found") + .arg(&id) + .error(format_args!("Assets {id} not found although it is needed by the version")); + } + Error::ClientNotFound { } => { + out.log("error_client_not_found") + .error("Client JAR file not found and no download information is available"); + } + Error::LibraryNotFound { gav } => { + out.log("error_library_not_found") + .error(format_args!("Library {gav} not found and no download information is available")); + } + Error::JvmNotFound { major_version } => { + let mut log = out.log("error_jvm_not_found"); + log.error(format_args!("No compatible JVM found for the game version, which requires major version {major_version}")); + log.additional("You can enable verbose mode to learn more about potential JVM rejections"); + if *major_version <= 8 { + log.additional("Note that JVM version 8 and prior versions are not compatible with other versions"); + } + } + Error::MainClassNotFound { } => { + out.log("error_main_class_not_found") + .error("No main class specified in version metadata"); + } + Error::DownloadResourcesCancelled { } => { + panic!("should not happen because the handler does not cancel downloading"); + } + Error::Download { batch } => { + log_download_error(cli, batch); + } + Error::Internal { error, origin } => { + log_internal_error(cli, &**error, &origin); + } + _ => todo!(), + } + +} + +/// Log a mojang error on the given logger output. +pub fn log_mojang_error(cli: &mut Cli, error: &mojang::Error) { + + use mojang::Error; + + let out = &mut cli.out; + + match error { + Error::Base(error) => log_base_error(cli, error), + Error::LwjglFixNotFound { version } => { + out.log("error_lwjgl_fix_not_found") + .arg(&version) + .error(format_args!("Failed to fix LWJGL to version '{version}' as requested with --lwjgl argument")) + .additional("The version might be too old (< 3.2.3)") + .additional("Your platform might not be supported for this version"); + } + _ => todo!(), + } + +} + +pub fn log_fabric_error(cli: &mut Cli, error: &fabric::Error, loader: fabric::Loader) { + + use fabric::Error; + + let out = &mut cli.out; + let (api_id, api_name) = fabric_id_name(loader); + + match *error { + Error::Mojang(ref error) => log_mojang_error(cli, error), + Error::LatestVersionNotFound { ref game_version, stable } => { + + let stable_str = if stable { "stable" } else { "unstable" }; + let mut log = out.log(format_args!("error_{api_id}_latest_version_not_found")); + log.arg(stable_str); + log.args(game_version.as_ref()); + + if let Some(game_version) = game_version { + log.error(format_args!("Failed to find {api_name} latest {stable_str} loader version for {game_version}")); + if stable { + log.additional("The loader might not yet support any stable version for this game version"); + } else { + log.additional("The loader have zero version supported for this game version, likely an issue on their side"); + } + } else { + log.error(format_args!("Failed to find {api_name} latest {stable_str} game version")); + if stable { + log.additional("The loader might not yet support any stable game version"); + } else { + log.additional("The loader have zero game version supported, likely an issue on their side"); + } + } + + } + Error::GameVersionNotFound { ref game_version } => { + out.log(format_args!("error_{api_id}_game_version_not_found")) + .arg(&game_version) + .error(format_args!("{api_name} loader has not support for {game_version} game version")); + } + Error::LoaderVersionNotFound { ref game_version, ref loader_version } => { + out.log(format_args!("error_{api_id}_loader_version_not_found")) + .arg(&game_version) + .arg(&loader_version) + .error(format_args!("{api_name} loader has no version {loader_version} for game version {game_version}")); + } + _ => todo!(), + } + +} + +pub fn log_forge_error(cli: &mut Cli, error: &forge::Error, loader: forge::Loader) { + + use forge::Error; + + let out = &mut cli.out; + let (api_id, api_name) = forge_id_name(loader); + + const CONTACT_DEV: &str = "This version of the loader might not be supported by PortableMC, please contact developers on https://github.com/mindstorm38/portablemc/issues"; + + match *error { + Error::Mojang(ref error) => log_mojang_error(cli, error), + Error::LatestVersionNotFound { ref game_version, stable } => { + + let stable_str = if stable { "stable" } else { "unstable" }; + let mut log = out.log(format_args!("error_{api_id}_latest_version_not_found")); + log.arg(stable_str); + log.arg(&game_version); + log.error(format_args!("Failed to find {api_name} latest {stable_str} loader version for {game_version}")); + log.additional("This game version might not yet be supported by the loader"); + if stable { + log.additional(format_args!("You can try to relax this by also targeting unstable loader versions with {api_id}:{game_version}:unstable")); + } + + } + Error::InstallerNotFound { ref version } => { + out.log(format_args!("error_{api_id}_installer_not_found")) + .arg(&version) + .error(format_args!("{api_name} loader has no installer for {version}")) + .additional("Note that really old versions have no installer and therefore are not supported by PortableMC"); + } + Error::MavenMetadataMalformed { } => { + out.log(format_args!("error_{api_id}_maven_metadata_malformed")) + .error(format_args!("{api_name} loader has an malformed maven metadata")) + .additional("Likely an issue on the loader's API side"); + } + Error::InstallerProfileNotFound { } => { + out.log(format_args!("error_{api_id}_installer_profile_not_found")) + .error(format_args!("{api_name} installer has no installer profile")) + .additional(CONTACT_DEV); + } + Error::InstallerProfileIncoherent { } => { + out.log(format_args!("error_{api_id}_installer_profile_incoherent")) + .error(format_args!("{api_name} installer profile is incoherent with what should've been downloaded")) + .additional(CONTACT_DEV); + } + Error::InstallerVersionMetadataNotFound { } => { + out.log(format_args!("error_{api_id}_installer_version_metadata_not_found")) + .error(format_args!("{api_name} installer has no embedded version metadata")) + .additional(CONTACT_DEV); + } + Error::InstallerFileNotFound { ref entry } => { + out.log(format_args!("error_{api_id}_installer_file_not_found")) + .arg(&entry) + .error(format_args!("{api_name} installer is missing a required file: {entry}")) + .additional(CONTACT_DEV); + } + Error::InstallerInvalidProcessor { ref name } => { + out.log(format_args!("error_{api_id}_installer_invalid_processor")) + .arg(&name) + .error(format_args!("{api_name} installer has an invalid processor: {name}")) + .additional(CONTACT_DEV); + } + Error::InstallerProcessorFailed { ref name, ref output } => { + + let mut log = out.log(format_args!("error_{api_id}_installer_processor_failed")); + log.arg(&name); + + if let Some(code) = output.status.code() { + log.arg(code); + } else { + log.arg(""); + } + + log.error(format_args!("{api_name} installer processor failed ({}):", output.status)); + + let stdout = std::str::from_utf8(&output.stdout).ok(); + let stderr = std::str::from_utf8(&output.stderr).ok(); + + if let Some(stdout) = stdout { + log.arg(stdout); + log.additional(format_args!("stdout: {stdout}")); + } else { + log.arg(format_args!("{:?}", output.stdout)); + log.additional(format_args!("stdout: {}", output.stdout.escape_ascii())); + } + + if let Some(stderr) = stderr { + log.arg(stderr); + log.additional(format_args!("stderr: {stderr}")); + } else { + log.arg(format_args!("{:?}", output.stderr)); + log.additional(format_args!("stderr: {}", output.stdout.escape_ascii())); + } + + log.additional(CONTACT_DEV); + + } + Error::InstallerProcessorInvalidOutput { ref name, ref file, ref expected_sha1 } => { + out.log(format_args!("error_{api_id}_installer_processor_invalid_output")) + .arg(&name) + .arg(file.display()) + .error(format_args!("{api_name} installer processor {name} produced invalid output:")) + .additional(format_args!("At: {}", file.display())) + .additional(format_args!("Expected: {:x}", BytesFmt(&expected_sha1[..]))) + .additional(CONTACT_DEV); + } + _ => todo!(), + } + +} + +/// Common function to log a download error. +pub fn log_download_error(cli: &mut Cli, batch: &download::BatchResult) { + + use download::EntryErrorKind; + + if !batch.has_errors() { + return; + } + + // error_download + cli.out.log("error_download") + .arg(batch.errors_count()) + .arg(batch.len()) + .newline() + .error(format_args!("Failed to download {} out of {} entries...", batch.errors_count(), batch.len())); + + // error_download_entry [error_data...] + for error in batch.iter_errors() { + + let mut log = cli.out.log("error_download_entry"); + log.arg(error.url()); + log.arg(error.file().display()); + + log.additional(format_args!("{}", error.url())); + log.additional(format_args!("-> {}", error.file().display())); + + match error.kind() { + EntryErrorKind::InvalidSize => { + log.arg("invalid_size"); + log.additional(format_args!(" Invalid size")); + } + EntryErrorKind::InvalidSha1 => { + log.arg("invalid_size"); + log.additional(format_args!(" Invalid SHA-1")); + } + EntryErrorKind::InvalidStatus(status) => { + log.arg("invalid_status"); + log.arg(status); + log.additional(format_args!(" Invalid status: {status}")); + } + EntryErrorKind::Internal(error) => { + if let Some(error) = error.downcast_ref::() { + + log.arg("io"); + if let Some(error_kind_code) = io_error_kind_code(&error) { + log.arg(error_kind_code); + } else { + log.arg(format_args!("unknown:{error}")); + } + log.additional(format_args!(" I/O error: {error}")); + + } else if let Some(error) = error.downcast_ref::() { + + log.arg("request"); + log.arg(&error); + log.args(error.source()); + if let Some(source) = error.source() { + log.additional(format_args!(" {error} (source: {source})")); + } else { + log.additional(format_args!(" {error}")); + } + + } else { + + log.arg("internal"); + log.arg(error); + log.additional(format_args!(" Internal error: {error}")); + + } + } + } + + } + +} + +/// Common function to log an internal and generic error. +pub fn log_internal_error(cli: &mut Cli, error: &(dyn std::error::Error + Send + Sync + 'static), origin: &str) { + if let Some(error) = error.downcast_ref::() { + log_io_error(cli, error, origin); + } else if let Some(error) = error.downcast_ref::() { + log_reqwest_error(cli, error, origin); + } else if let Some(error) = error.downcast_ref::() { + log_json_error(cli, error, None, origin); + } else if let Some(error) = error.downcast_ref::>() { + log_json_error(cli, error.inner(), Some(error.path()), origin); + } else if let Some(error) = error.downcast_ref::() { + log_zip_error(cli, error, origin); + } else if let Some(error) = error.downcast_ref::() { + cli.out.log("error_jwt") + .error(format_args!("JWT error: {error}")); + } else { + cli.out.log("error_internal") + .arg(error) + .newline() + .error(format_args!("Internal error: {error}")) + .additional(format_args!("At {origin}")); + } +} + +/// Common function to log an I/O error to the user. +pub fn log_io_error(cli: &mut Cli, error: &io::Error, origin: &str) { + + let mut log = cli.out.log("error_io"); + + if let Some(error_kind_code) = io_error_kind_code(&error) { + log.arg(error_kind_code); + } else { + log.arg(format_args!("unknown:{error}")); + } + + log.arg(origin); + + // Newline because I/O errors are unexpected and we want to keep any previous context. + log.newline() + .error(format_args!("I/O error: {error}")) + .additional(format_args!("At {origin}")); + +} + +/// Common function to log a reqwest (HTTP) error. +pub fn log_reqwest_error(cli: &mut Cli, error: &reqwest::Error, origin: &str) { + let mut log = cli.out.log("error_reqwest"); + log.args(error.url()); + log.args(error.source()); + log.arg(origin); + log.newline(); + log.error(format_args!("Reqwest error: {error}")); + if let Some(source) = error.source() { + log.additional(format_args!("At {source}")); + } + log.additional(format_args!("At {origin}")); +} + +/// Common function to log a JSON serde error. +pub fn log_json_error(cli: &mut Cli, error: &serde_json::Error, path: Option<&serde_path_to_error::Path>, origin: &str) { + + let mut log = cli.out.log("error_json"); + log.arg(error); + + if let Some(path) = path { + log.arg(path); + } else { + log.arg(""); + } + + log.arg(origin) + .newline() + .error(format_args!("JSON error: {error}")) + .additional(format_args!("At {origin}")); + +} + +/// Common function to log a ZIP archive error. +pub fn log_zip_error(cli: &mut Cli, error: &zip::result::ZipError, origin: &str) { + cli.out.log("error_zip") + .arg(error) + .arg(origin) + .newline() + .error(format_args!("ZIP error: {error}")) + .additional(format_args!("At {origin}")); +} + +/// Log a database error. +pub fn log_msa_auth_error(cli: &mut Cli, error: &msa::AuthError) { + match error { + msa::AuthError::Declined => { + cli.out.log("error_auth_declined") + .error("Authorization request has been declined"); + } + msa::AuthError::TimedOut => { + cli.out.log("error_auth_timed_out") + .error("Authorization timed out"); + } + msa::AuthError::OutdatedToken => { + cli.out.log("error_auth_outdated_token") + .error("Outdated authentication token"); + } + msa::AuthError::DoesNotOwnGame => { + cli.out.log("error_auth_does_not_own_game") + .error("The account you logged in doesn't own Minecraft"); + } + msa::AuthError::InvalidStatus(status) => { + cli.out.log("error_auth_invalid_status") + .arg(status) + .error(format_args!("Invalid status while authenticating: {status}")); + } + msa::AuthError::Unknown(error) => { + cli.out.log("error_auth_unknown") + .arg(&error) + .error(format_args!("Unknown authentication error: {error}")); + } + msa::AuthError::Internal(error) => { + log_internal_error(cli, &**error, "microsoft authentication"); + } + _ => todo!() + } +} + +/// Log a database error. +pub fn log_msa_database_error(cli: &mut Cli, error: &msa::DatabaseError) { + match error { + msa::DatabaseError::Io(error) => log_io_error(cli, error, &format!("{}", cli.msa_db.file().display())), + msa::DatabaseError::Corrupted => { + cli.out.log("error_msa_database_corrupted") + .error("The authentication database is corrupted and cannot be recovered automatically") + .additional(format_args!("At {}", cli.msa_db.file().display())); + } + msa::DatabaseError::WriteFailed => { + cli.out.log("error_msa_database_write_failed") + .error("Unknown error while writing the authentication database, operation cancelled") + .additional(format_args!("At {}", cli.msa_db.file().display())); + } + _ => todo!() + } +} + +fn io_error_kind_code(error: &io::Error) -> Option<&'static str> { + use io::ErrorKind; + Some(match error.kind() { + ErrorKind::NotFound => "not_found", + ErrorKind::PermissionDenied => "permission_denied", + ErrorKind::ConnectionRefused => "connection_refused", + ErrorKind::ConnectionReset => "connection_reset", + ErrorKind::ConnectionAborted => "connection_aborted", + ErrorKind::NotConnected => "not_connected", + ErrorKind::AddrInUse => "addr_in_use", + ErrorKind::AddrNotAvailable => "addr_not_available", + ErrorKind::BrokenPipe => "broken_pipe", + ErrorKind::AlreadyExists => "already_exists", + ErrorKind::WouldBlock => "would_block", + ErrorKind::InvalidInput => "invalid_input", + ErrorKind::InvalidData => "invalid_data", + ErrorKind::TimedOut => "timed_out", + ErrorKind::WriteZero => "write_zero", + ErrorKind::Interrupted => "interrupted", + ErrorKind::Unsupported => "unsupported", + ErrorKind::UnexpectedEof => "unexpected_eof", + ErrorKind::OutOfMemory => "out_of_memory", + _ => return None, + }) +} + +fn fabric_id_name(loader: fabric::Loader) -> (&'static str, &'static str) { + match loader { + fabric::Loader::Fabric => ("fabric", "Fabric"), + fabric::Loader::Quilt => ("quilt", "Quilt"), + fabric::Loader::LegacyFabric => ("legacyfabric", "LegacyFabric"), + fabric::Loader::Babric => ("babric", "Babric"), + } +} + +fn forge_id_name(loader: forge::Loader) -> (&'static str, &'static str) { + match loader { + forge::Loader::Forge => ("forge", "Forge"), + forge::Loader::NeoForge => ("neoforge", "NeoForge"), + } +} diff --git a/rust/portablemc-cli/src/cmd/search.rs b/rust/portablemc-cli/src/cmd/search.rs new file mode 100644 index 00000000..f13c63c4 --- /dev/null +++ b/rust/portablemc-cli/src/cmd/search.rs @@ -0,0 +1,321 @@ +//! Implementation of the 'search' command. + +use std::process::ExitCode; +use std::fs; + +use chrono::{DateTime, Local, TimeDelta, Utc}; + +use portablemc::base::VersionChannel; +use portablemc::{mojang, fabric, forge}; + +use crate::parse::{SearchArgs, SearchKind, SearchChannel, SearchLatestChannel}; +use crate::format::{TimeDeltaFmt, DATE_FORMAT}; + +use super::{Cli, LogHandler, log_mojang_error, log_forge_error, log_reqwest_error, log_io_error}; + + +pub fn search(cli: &mut Cli, args: &SearchArgs) -> ExitCode { + + match args.kind { + SearchKind::Mojang => search_mojang(cli, args), + SearchKind::Local => search_local(cli, args), + SearchKind::Fabric => search_fabric(cli, args, fabric::Loader::Fabric, false), + SearchKind::FabricGame => search_fabric(cli, args, fabric::Loader::Fabric, true), + SearchKind::Quilt => search_fabric(cli, args, fabric::Loader::Quilt, false), + SearchKind::QuiltGame => search_fabric(cli, args, fabric::Loader::Quilt, true), + SearchKind::Legacyfabric => search_fabric(cli, args, fabric::Loader::LegacyFabric, false), + SearchKind::LegacyfabricGame => search_fabric(cli, args, fabric::Loader::LegacyFabric, true), + SearchKind::Babric => search_fabric(cli, args, fabric::Loader::Babric, false), + SearchKind::BabricGame => search_fabric(cli, args, fabric::Loader::Babric, true), + SearchKind::Forge => search_forge(cli, args, forge::Loader::Forge), + SearchKind::NeoForge => search_forge(cli, args, forge::Loader::NeoForge), + } + +} + +fn search_mojang(cli: &mut Cli, args: &SearchArgs) -> ExitCode { + + use mojang::Manifest; + + // Initial requests... + let mut handler = LogHandler::new(&mut cli.out); + let manifest = match Manifest::request(&mut handler) { + Ok(manifest) => manifest, + Err(e) => { + log_mojang_error(cli, &e); + return ExitCode::FAILURE; + } + }; + + let today = Utc::now(); + + // Now we construct the table... + let mut table = cli.out.table(3); + + { + let mut row = table.row(); + row.cell("name").format("Name"); + row.cell("channel").format("Channel"); + row.cell("release_date").format("Release date"); + } + + table.sep(); + + // This is an exclusive argument. + let only_name = args.latest.as_ref().map(|channel| { + match channel { + SearchLatestChannel::Release => manifest.latest_release_name(), + SearchLatestChannel::Snapshot => manifest.latest_snapshot_name(), + } + }); + + // Finally displaying version(s). + for version in manifest.iter().take(args.limit) { + + if let Some(only_name) = only_name { + if version.name() != only_name { + continue; + } + } else { + + if !args.match_filter(version.name()) { + continue; + } + + if !args.match_channel(match version.channel() { + VersionChannel::Release => SearchChannel::Release, + VersionChannel::Snapshot => SearchChannel::Snapshot, + VersionChannel::Beta => SearchChannel::Beta, + VersionChannel::Alpha => SearchChannel::Alpha, + }) { + continue; + } + + } + + let mut row = table.row(); + row.cell(version.name()); + + let (channel_id, channel_fmt, is_latest) = match version.channel() { + VersionChannel::Release => ("release", "Release", manifest.latest_release_name() == version.name()), + VersionChannel::Snapshot => ("snapshot", "Snapshot", manifest.latest_snapshot_name() == version.name()), + VersionChannel::Beta => ("beta", "Beta", false), + VersionChannel::Alpha => ("alpha", "Alpha", false), + }; + + if is_latest { + row.cell(format_args!("{channel_id}*")).format(format_args!("{channel_fmt}*")); + } else { + row.cell(format_args!("{channel_id}")).format(format_args!("{channel_fmt}")); + } + + // Raw output is RFC3339 of FixedOffset time, format is local time. + let mut cell = row.cell(&version.release_time().to_rfc3339()); + let local_release_date = version.release_time().with_timezone(&Local); + let local_release_data_fmt: _ = version.release_time().format(DATE_FORMAT); + let delta = today.signed_duration_since(&local_release_date); + + if is_latest || version.channel() == VersionChannel::Release || delta <= TimeDelta::weeks(4) { + cell.format(format_args!("{} ({})", local_release_data_fmt, TimeDeltaFmt(delta))); + } else { + cell.format(format_args!("{}", local_release_data_fmt)); + } + + } + + ExitCode::SUCCESS + +} + +fn search_local(cli: &mut Cli, args: &SearchArgs) -> ExitCode { + + let versions_dir = cli.main_dir.join("versions"); + + let reader = match fs::read_dir(&versions_dir) { + Ok(reader) => reader, + Err(e) => { + log_io_error(cli, &e, &format!("{}", versions_dir.display())); + return ExitCode::FAILURE; + } + }; + + // Construct the table. + let mut table = cli.out.table(2); + + { + let mut row = table.row(); + row.cell("name").format("Name"); + row.cell("last_modified_date").format("Last modified date"); + } + + table.sep(); + + for entry in reader.take(args.limit) { + + let Ok(entry) = entry else { continue }; + let Ok(entry_type) = entry.file_type() else { continue }; + if !entry_type.is_dir() { continue }; + + let mut version_dir = entry.path(); + let Some(version_id) = version_dir.file_name().unwrap().to_str() else { continue }; + let version_id = version_id.to_string(); + + version_dir.push(&version_id); + version_dir.as_mut_os_string().push(".json"); + + let Ok(version_metadata) = version_dir.metadata() else { continue }; + let Ok(version_last_modified) = version_metadata.modified() else { continue }; + let version_last_modified = DateTime::::from(version_last_modified); + + if !args.match_filter(&version_id) { + continue; + } + + // We use the local timezone for both raw and format cells. + let mut row = table.row(); + row.cell(&version_id); + row.cell(&version_last_modified.to_rfc3339()) + .format(version_last_modified.format(DATE_FORMAT)); + + } + + ExitCode::SUCCESS + +} + +fn search_fabric(cli: &mut Cli, args: &SearchArgs, loader: fabric::Loader, game: bool) -> ExitCode { + + use fabric::Api; + + let api = Api::new(loader); + + if game { + + let versions = match api.request_game_versions() { + Ok(v) => v, + Err(e) => { + log_reqwest_error(cli, &e, "request fabric game versions"); + return ExitCode::FAILURE; + } + }; + + let mut table = cli.out.table(2); + + { + let mut row = table.row(); + row.cell("game_version").format("Game version"); + row.cell("channel").format("Channel"); + } + + table.sep(); + + for version in versions.iter().take(args.limit) { + + if !args.match_filter(version.name()) { + continue; + } + + if !args.match_channel(SearchChannel::new_stable_or_unstable(version.is_stable())) { + continue; + } + + let mut row = table.row(); + row.cell(version.name()); + row.cell(if version.is_stable() { "stable" } else { "unstable" }) + .format(if version.is_stable() { "Stable" } else { "Unstable" }); + + } + + } else { + + let versions = match api.request_loader_versions(None) { + Ok(v) => v, + Err(e) => { + log_reqwest_error(cli, &e, "request fabric loader versions"); + return ExitCode::FAILURE; + } + }; + + let mut table = cli.out.table(2); + + { + let mut row = table.row(); + row.cell("loader_version").format("Loader version"); + row.cell("channel").format("Channel"); + } + + table.sep(); + + for version in versions.iter().take(args.limit) { + + if !args.match_filter(version.name()) { + continue; + } + + if !args.match_channel(SearchChannel::new_stable_or_unstable(version.is_stable())) { + continue; + } + + let mut row = table.row(); + row.cell(version.name()); + row.cell(if version.is_stable() { "stable" } else { "unstable" }) + .format(if version.is_stable() { "Stable" } else { "Unstable" }); + + } + + } + + ExitCode::SUCCESS + +} + +fn search_forge(cli: &mut Cli, args: &SearchArgs, loader: forge::Loader) -> ExitCode { + + use forge::Repo; + + // Start by requesting the repository! + let repo = match Repo::request(loader) { + Ok(repo) => repo, + Err(e) => { + log_forge_error(cli, &e, loader); + return ExitCode::FAILURE; + } + }; + + // Now we construct the table... + let mut table = cli.out.table(3); + + { + let mut row = table.row(); + row.cell("version").format("Version"); + row.cell("game_version").format("Game version"); + row.cell("channel").format("Channel"); + } + + table.sep(); + + for version in repo.iter().take(args.limit) { + + if !args.match_filter(version.name()) { + continue; + } + + if !args.match_game_version(version.game_version()) { + continue; + } + + if !args.match_channel(SearchChannel::new_stable_or_unstable(version.is_stable())) { + continue; + } + + let mut row = table.row(); + row.cell(version.name()); + row.cell(version.game_version()); + row.cell(if version.is_stable() { "stable" } else { "unstable" }) + .format(if version.is_stable() { "Stable" } else { "Unstable" }); + + } + + ExitCode::SUCCESS + +} diff --git a/rust/portablemc-cli/src/cmd/start.rs b/rust/portablemc-cli/src/cmd/start.rs new file mode 100644 index 00000000..e1ba77c8 --- /dev/null +++ b/rust/portablemc-cli/src/cmd/start.rs @@ -0,0 +1,738 @@ +//! Implementation of the 'start' command. + +use std::path::PathBuf; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::io::{self, BufRead, BufReader}; +use std::sync::Mutex; + +use chrono::{DateTime, Local, Utc}; + +use portablemc::base::{self, Game, JvmPolicy, LoadedLibrary}; +use portablemc::mojang::{self, FetchExclude, QuickPlay}; +use portablemc::{download, fabric, forge}; + +use crate::parse::{StartArgs, StartResolution, StartVersion, StartJvmPolicy}; +use crate::format::TIME_FORMAT; +use crate::output::LogLevel; + +use super::{Cli, LogHandler, log_io_error, log_mojang_error, log_fabric_error, log_forge_error, log_msa_database_error}; + + +/// The child is shared in order to be properly killed when the launcher exits, because +/// it's not the case on Windows by default. +pub static GAME_CHILD: Mutex> = Mutex::new(None); + + +pub fn start(cli: &mut Cli, args: &StartArgs) -> ExitCode { + + match args.version { + StartVersion::Mojang { + ref version, + } => { + start_mojang(version.clone(), cli, args) + } + StartVersion::MojangRelease | + StartVersion::MojangSnapshot => { + + let handler = LogHandler::new(&mut cli.out); + let repo = match mojang::Manifest::request(handler) { + Ok(repo) => repo, + Err(e) => { + log_mojang_error(cli, &e); + return ExitCode::FAILURE; + } + }; + + let version = match args.version { + StartVersion::MojangRelease => repo.latest_release_name(), + StartVersion::MojangSnapshot => repo.latest_snapshot_name(), + _ => unreachable!(), + }; + + start_mojang(version.to_string(), cli, args) + + } + StartVersion::Fabric { + loader, + ref game_version, + ref loader_version, + } => { + start_fabric(loader, game_version.clone(), loader_version.clone(), cli, args) + } + StartVersion::Forge { + loader, + ref version, + } => { + start_forge(loader, version.clone().into(), cli, args) + } + StartVersion::ForgeLatest { + loader, + ref game_version, + stable, + } => { + + let game_version = match game_version { + Some(game_version) => game_version.clone(), + None => { + + let handler = LogHandler::new(&mut cli.out); + let manifest = match mojang::Manifest::request(handler) { + Ok(repo) => repo, + Err(e) => { + log_mojang_error(cli, &e); + return ExitCode::FAILURE; + } + }; + + manifest.latest_release_name().to_string() + + } + }; + + let version = if stable { + forge::Version::Stable(game_version) + } else { + forge::Version::Unstable(game_version) + }; + + start_forge(loader, version, cli, args) + + } + } + +} + +/// Main entrypoint for starting a Mojang version from its name. +fn start_mojang( + version: String, + cli: &mut Cli, + args: &StartArgs, +) -> ExitCode { + + let mut inst = mojang::Installer::new(version); + if !apply_mojang_args(&mut inst, &mut *cli, args) { + return ExitCode::FAILURE; + } + + let log_handler = LogHandler::new(&mut cli.out); + let start_handler = StartHandler::new(args); + + match inst.install((log_handler, start_handler)) { + Ok(game) => start_game(game, cli, args), + Err(e) => { + log_mojang_error(cli, &e); + return ExitCode::FAILURE; + } + } + +} + +/// Main entrypoint for starting a Fabric-based version. +fn start_fabric( + loader: fabric::Loader, + game_version: fabric::GameVersion, + loader_version: fabric::LoaderVersion, + cli: &mut Cli, + args: &StartArgs, +) -> ExitCode { + + let mut inst = fabric::Installer::new(loader, game_version.clone(), loader_version.clone()); + if !apply_mojang_args(inst.mojang_mut(), &mut *cli, args) { + return ExitCode::FAILURE; + } + + let mut log_handler = LogHandler::new(&mut cli.out); + log_handler.set_fabric_loader(loader); + let start_handler = StartHandler::new(args); + + match inst.install((log_handler, start_handler)) { + Ok(game) => start_game(game, cli, args), + Err(e) => { + log_fabric_error(cli, &e, loader); + return ExitCode::FAILURE; + } + } + +} + +/// Main entrypoint for starting a Forge/NeoForge version. +fn start_forge( + loader: forge::Loader, + version: forge::Version, + cli: &mut Cli, + args: &StartArgs, +) -> ExitCode { + + let mut inst = forge::Installer::new(loader, version); + if !apply_mojang_args(inst.mojang_mut(), &mut *cli, args) { + return ExitCode::FAILURE; + } + + let mut log_handler = LogHandler::new(&mut cli.out); + log_handler.set_forge_loader(inst.loader()); + let start_handler = StartHandler::new(args); + + match inst.install((log_handler, start_handler)) { + Ok(game) => start_game(game, cli, args), + Err(e) => { + log_forge_error(cli, &e, inst.loader()); + return ExitCode::FAILURE; + } + } + +} + +/// Main entrypoint for running the installed game. +fn start_game(game: Game, cli: &mut Cli, args: &StartArgs) -> ExitCode { + + // Build the command here so that we can debug it's arguments without launching. + let command = game.command(); + { + let mut log = cli.out.log("jvm_args"); + log.args(command.get_args().filter_map(|a| a.to_str())); + log.info("Arguments:"); + for arg in command.get_args().filter_map(|a| a.to_str()) { + log.additional(arg); + } + } + + if args.dry { + return ExitCode::SUCCESS; + } + + match run_command(cli, command) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + log_io_error(cli, &e, "run game"); + ExitCode::FAILURE + } + } + +} + +// Internal function to apply args to the base installer. +fn apply_base_args( + installer: &mut base::Installer, + cli: &mut Cli, + args: &StartArgs, +) -> bool { + + // installer.set_versions_dir(cli.versions_dir.clone()); + // installer.set_libraries_dir(cli.libraries_dir.clone()); + // installer.set_assets_dir(cli.assets_dir.clone()); + // installer.set_jvm_dir(cli.jvm_dir.clone()); + // installer.set_bin_dir(cli.bin_dir.clone()); + // installer.set_mc_dir(cli.mc_dir.clone()); + + installer.set_main_dir(cli.main_dir.clone()); + + if let Some(jvm_file) = &args.jvm { + installer.set_jvm_policy(JvmPolicy::Static(jvm_file.into())); + } else { + installer.set_jvm_policy(match args.jvm_policy { + StartJvmPolicy::System => JvmPolicy::System, + StartJvmPolicy::Mojang => JvmPolicy::Mojang, + StartJvmPolicy::SystemThenMojang => JvmPolicy::SystemThenMojang, + StartJvmPolicy::MojangThenSystem => JvmPolicy::MojangThenSystem, + }); + } + + true + +} + +// Internal function to apply args to the mojang installer. +fn apply_mojang_args( + installer: &mut mojang::Installer, + cli: &mut Cli, + args: &StartArgs, +) -> bool { + + // FIXME: For now, the telemetry client id is kept unset. + + if args.auth { + + let res = + if let Some(uuid) = args.uuid { + + if args.username.is_some() { + cli.out.log("warn_username_ignored") + .warning("You specified both '--uuid' (-i) and '--username' (-u) with '--auth' (-a), so '--username' will be ignored"); + } + + cli.msa_db.load_from_uuid(uuid) + + } else if let Some(username) = &args.username { + cli.msa_db.load_from_username(&username) + } else { + + cli.out.log("error_missing_auth_uuid_or_username") + .error("Missing '--uuid' (-i) or '--username' (-u), required when using '--auth' (-a)"); + + return false; + + }; + + let account = match res { + Ok(Some(account)) => account, + Ok(None) => { + + let mut log = cli.out.log("error_account_not_found"); + + if let Some(uuid) = args.uuid { + log.arg(&uuid); + log.error(format_args!("No account found for: {uuid}")); + } else if let Some(username) = &args.username { + log.arg(&username); + log.error(format_args!("No account found for username: {username}")); + } else { + unreachable!(); + } + + log.additional(format_args!("Use 'portablemc auth' command to log into your account")); + log.additional(format_args!("Use 'portablemc auth -l' to list stored accounts")); + return false; + + } + Err(error) => { + log_msa_database_error(cli, &error); + return false; + } + }; + + // Set the auth account here, because we give the ownership to the next function, + // and if it fails we don't care if we have already set auth. + installer.set_auth_msa(&account); + + if !super::auth::refresh_account(&mut *cli, account, true) { + return false; + } + + } else { + match (&args.username, args.uuid) { + (Some(username), None) => + installer.set_auth_offline_username(username.clone()), + (None, Some(uuid)) => + installer.set_auth_offline_uuid(uuid), + (Some(username), Some(uuid)) => + installer.set_auth_offline(uuid, username.clone()), + (None, None) => installer, // nothing + }; + } + + if !apply_base_args(installer.base_mut(), &mut *cli, args) { + return false; + } + + installer.set_disable_multiplayer(args.disable_multiplayer); + installer.set_disable_chat(args.disable_chat); + installer.set_demo(args.demo); + + if let Some(StartResolution { width, height }) = args.resolution { + installer.set_resolution(width, height); + } + + installer.set_fix_legacy_quick_play(!args.no_fix_legacy_quick_play); + installer.set_fix_legacy_proxy(!args.no_fix_legacy_proxy); + installer.set_fix_legacy_merge_sort(!args.no_fix_legacy_merge_sort); + installer.set_fix_legacy_resolution(!args.no_fix_legacy_resolution); + installer.set_fix_broken_authlib(!args.no_fix_broken_authlib); + + if let Some(lwjgl_version) = &args.fix_lwjgl { + installer.set_fix_lwjgl(lwjgl_version.to_string()); + } + + if args.fetch_exclude_all { + installer.add_fetch_exclude(FetchExclude::All); + } else { + // NOTE: For now we don't support regex patterns! + for exclude_name in &args.fetch_exclude { + installer.add_fetch_exclude(FetchExclude::Exact(exclude_name.clone())); + } + } + + if let Some(name) = &args.join_world { + installer.set_quick_play(QuickPlay::Singleplayer { name: name.clone() }); + } else if let Some(host) = &args.join_server { + installer.set_quick_play(QuickPlay::Multiplayer { + host: host.clone(), + port: args.join_server_port, + }); + } else if let Some(id) = &args.join_realms { + installer.set_quick_play(QuickPlay::Realms { id: id.clone() }); + } + + true + +} + +/// Internal function to run the game, separated in order to catch I/O errors. +fn run_command(cli: &mut Cli, mut command: Command) -> io::Result<()> { + + // Keep the guard while we are launching the command. + let mut child_guard = GAME_CHILD.lock().unwrap(); + assert!(child_guard.is_none(), "more than one game run at a time"); + + cli.out.log("launching") + .pending("Launching..."); + + command.stdout(Stdio::piped()); + command.stderr(Stdio::inherit()); + + let mut child = command.spawn()?; + + cli.out.log("launched") + .arg(child.id()) + .success("Launched"); + + // Take the stdout pipe and put the child in the shared location, only then we + // release the guard so any handled Ctrl-C will terminate it. + let mut pipe = BufReader::new(child.stdout.take().unwrap()); + *child_guard = Some(child); + drop(child_guard); + + let mut buffer = Vec::new(); + let mut xml = None::; + let mut child_guard = None; + + // Read line by line, but not into a string because we don't really know if the + // output will be UTF-8 compliant, so we store raw bytes in the buffer. + while pipe.read_until(b'\n', &mut buffer)? != 0 { + + let Ok(buffer_str) = std::str::from_utf8(&buffer) else { + buffer.clear(); + continue + }; + + if xml.is_none() && buffer_str.trim_ascii_start().starts_with(" ("trace", "TRACE", LogLevel::Raw), + XmlLogLevel::Debug => ("debug", "DEBUG", LogLevel::Raw), + XmlLogLevel::Info => ("info", "INFO", LogLevel::Raw), + XmlLogLevel::Warn => ("warn", "WARN", LogLevel::RawWarn), + XmlLogLevel::Error => ("error", "ERROR", LogLevel::RawError), + XmlLogLevel::Fatal => ("fatal", "FATAL", LogLevel::RawFatal), + }; + + let mut log = cli.out.log("log_xml"); + log .arg(level_code) + .arg(xml_log_time.to_rfc3339()) + .arg(&xml_log.logger) + .arg(&xml_log.thread) + .arg(&xml_log.message) + .line(log_level, format_args!("[{}] [{}] [{}] {}", + xml_log_time.format(TIME_FORMAT), + xml_log.thread, + level_name, + xml_log.message)); + + if let Some(throwable) = &xml_log.throwable { + log.line(LogLevel::RawError, format_args!(" {throwable}")); + } + + } + } else { + + let buffer_str = buffer_str.trim_ascii(); + + let mut log_level = LogLevel::Raw; + if buffer_str.contains("WARN") { + log_level = LogLevel::RawWarn; + } else if buffer_str.contains("ERROR") { + log_level = LogLevel::RawError; + } else if buffer_str.contains("SEVERE") || buffer_str.contains("FATAL") { + log_level = LogLevel::RawFatal; + } + + cli.out.log("log_raw") + .arg(&buffer_str) + .line(log_level, &buffer_str); + + } + + buffer.clear(); + + // We don't really know if this line will execute in case of a Ctrl-C, which will + // take the child to kill it itself, so it might be absent here. We also put it + // in an option that allows us to keep the guard for the .wait after the loop. + let guard: _ = child_guard.insert(GAME_CHILD.lock().unwrap()); + let Some(child) = guard.as_mut() else { break }; + + // If child is terminated, we keep the guard and break. + if child.try_wait()?.is_some() { + break; + } + + // Release the guard if we continue the loop. + drop(child_guard.take().unwrap()); + + } + + // Do not lock again if we did in the loop before breaking... + let guard: _ = child_guard.get_or_insert_with(|| GAME_CHILD.lock().unwrap()); + + // This time we take the child because we will wait indefinitely on it. + let Some(mut child) = guard.take() else { + return Ok(()); + }; + + // In the end, we'll only log that when the game is gently terminated. + let status = child.wait()?; + cli.out.log("terminated") + .arg(status.code().unwrap_or_default()) + .info(format_args!("Terminated: {}", status.code().unwrap_or_default())); + + Ok(()) + +} + +/// Internal structure used to continuously parse the stream of XML logs out of the game. +#[derive(Debug, Default)] +struct XmlLogParser { + /// The buffer used to stack buffers while we have a parsing error at the end of it. + buffer: String, + /// Queue of logs returned when fully parsed. + logs: Vec, + /// The current state, or tag, we are decoding. + state: XmlLogState, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum XmlLogState { + #[default] + None, + Event, + Message, + Throwable, +} + +#[derive(Debug, Default)] +struct XmlLog { + logger: String, + time: DateTime, + level: XmlLogLevel, + thread: String, + message: String, + throwable: Option, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum XmlLogLevel { + Trace, + Debug, + #[default] + Info, + Warn, + Error, + Fatal, +} + +impl XmlLogParser { + + /// Feed the given buffer of tokens into the parser, any parsed log will be returned + /// by the iterator. No iterator is returned if the parsing fails. + pub fn feed(&mut self, buffer: &str) -> impl Iterator + '_ { + + use xmlparser::{Tokenizer, Token, ElementEnd}; + + // Use the buffer instead of the input if required. + let full_buffer = if !self.buffer.is_empty() { + self.buffer.push_str(buffer); + &*self.buffer + } else { + buffer + }; + + let mut tokenizer = Tokenizer::from_fragment(full_buffer, 0..full_buffer.len()); + let mut error = false; + let mut last_pos = 0; + + for token in &mut tokenizer { + + let token = match token { + Ok(token) => token, + Err(_) => { + + if self.buffer.is_empty() { + // If we are not yet using the buffer, initialize it. + self.buffer.push_str(&buffer[last_pos..]); + } else { + // If we did use the buffer, we need to cut all successful token. + self.buffer.drain(..last_pos); + } + + error = true; + break; + + } + }; + + // Save the last position the tokenizer was successful, so we cut everything + // up to this part in case of error. + last_pos = token.span().start() + token.span().len(); + + match token { + Token::ElementStart { prefix, local, .. } => { + + match (self.state, &*prefix, &*local) { + (XmlLogState::None, "log4j", "Event") => { + // While we are not in None state, then we are operating on + // the last log of that vector. + self.logs.push(XmlLog::default()); + self.state = XmlLogState::Event; + } + (XmlLogState::Event, "log4j", "Message") => { + self.state = XmlLogState::Message; + } + (XmlLogState::Event, "log4j", "Throwable") => { + self.state = XmlLogState::Throwable; + } + _ => continue, + } + + } + Token::ElementEnd { end: ElementEnd::Close(prefix, local), .. } => { + + match (self.state, &*prefix, &*local) { + (XmlLogState::Event, "log4j", "Event") => { + self.state = XmlLogState::None; + } + (XmlLogState::Event, _, _) => continue, + (XmlLogState::Message, "log4j", "Message") => { + self.state = XmlLogState::Event; + } + (XmlLogState::Message, _, _) => continue, + (XmlLogState::Throwable, "log4j", "Throwable") => { + self.state = XmlLogState::Event; + } + (XmlLogState::Throwable, _, _) => continue, + _ => continue, + } + + } + Token::ElementEnd { .. } => { // For '>' or '/>' + continue; + } + Token::Attribute { local, prefix, value, .. } => { + + if self.state != XmlLogState::Event { + continue; + } + + // Valid because we are in event state, so the last log is built. + let log = self.logs.last_mut().unwrap(); + + match (&*prefix, &*local) { + ("", "logger") => { + log.logger = value.to_string(); + } + ("", "timestamp") => { + let timestamp = value.parse::().unwrap_or(0); + log.time = DateTime::::from_timestamp_millis(timestamp).unwrap(); + } + ("", "level") => { + log.level = match &*value { + "TRACE" => XmlLogLevel::Trace, + "DEBUG" => XmlLogLevel::Debug, + "INFO" => XmlLogLevel::Info, + "WARN" => XmlLogLevel::Warn, + "ERROR" => XmlLogLevel::Error, + "FATAL" => XmlLogLevel::Fatal, + _ => continue, + }; + } + ("", "thread") => { + log.thread = value.to_string(); + } + _ => continue, + } + + } + Token::Text { text } | + Token::Cdata { text, .. } => { + + if self.state == XmlLogState::None { + continue; + } + + let log = self.logs.last_mut().unwrap(); + let text = text.trim_ascii(); + + match self.state { + XmlLogState::Message => log.message = text.to_string(), + XmlLogState::Throwable => log.message = text.to_string(), + _ => continue, + } + + } + _ => continue, + } + + } + + if !error { + // Clear the internal buffer, in case it was used and parsing was successful. + self.buffer.clear(); + } + + if self.state != XmlLogState::None { + self.logs.drain(..self.logs.len() - 1) + } else { + self.logs.drain(..) + } + + } + +} + +/// The start handler that apply modifications to the game installation. +struct StartHandler<'a> { + args: &'a StartArgs, +} + +impl<'a> StartHandler<'a> { + + pub fn new(args: &'a StartArgs) -> Self { + Self { + args, + } + } + +} + +impl download::Handler for StartHandler<'_> { } +impl base::Handler for StartHandler<'_> { + + fn filter_libraries(&mut self, libraries: &mut Vec) { + + if self.args.exclude_lib.is_empty() { + return; // When no filter... + } + + libraries.retain(|lib| { + // If any pattern matches: .any(...) -> !true -> false (don't keep) + !self.args.exclude_lib.iter() + .any(|pattern| pattern.matches(&lib.gav)) + }); + + } + + fn filter_libraries_files(&mut self, class_files: &mut Vec, natives_files: &mut Vec) { + class_files.extend_from_slice(&self.args.include_class); + natives_files.extend_from_slice(&self.args.include_natives); + } + +} + +impl mojang::Handler for StartHandler<'_> { } +impl fabric::Handler for StartHandler<'_> { } +impl forge::Handler for StartHandler<'_> { } diff --git a/rust/portablemc-cli/src/format.rs b/rust/portablemc-cli/src/format.rs new file mode 100644 index 00000000..7660a05a --- /dev/null +++ b/rust/portablemc-cli/src/format.rs @@ -0,0 +1,83 @@ +//! Various formatting utilities. + +use std::fmt; + +use chrono::TimeDelta; + + +/// Common human-readable date format. +pub const DATE_FORMAT: &str = "%a %b %e %T %Y"; +/// Common human-readable time format (for logs). +pub const TIME_FORMAT: &str = "%T"; + +/// Find the SI unit of a given number and return the number scaled down to that unit. +pub fn number_si_unit(num: f32) -> (f32, char) { + match num { + ..=999.0 => (num, ' '), + ..=999_999.0 => (num / 1_000.0, 'k'), + ..=999_999_999.0 => (num / 1_000_000.0, 'M'), + _ => (num / 1_000_000_000.0, 'G'), + } +} + +/// A wrapper that can be used to format a time delta for human-readable format. +#[derive(Debug)] +pub struct TimeDeltaFmt(pub TimeDelta); + +impl fmt::Display for TimeDeltaFmt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + + let years = self.0.num_days() / 365; + if years > 0 { + return write!(f, "{years} years ago"); + } + + // All of this is really wrong but it gives a good, human-friendly, idea. + let months = self.0.num_days() / 30; + if months > 0 { + return write!(f, "{months} months ago"); + } + + let weeks = self.0.num_days() / 7; + if weeks > 0 { + return write!(f, "{weeks} weeks ago"); + } + + let days = self.0.num_days(); + if days > 0 { + return write!(f, "{days} days ago"); + } + + let hours = self.0.num_hours(); + if hours > 0 { + return write!(f, "{hours} hours ago"); + } + + let minutes = self.0.num_minutes(); + write!(f, "{minutes} minutes ago") + + } +} + + +/// A helper structure for pretty printing of bytes. It provides format implementations +/// for upper and lower hex formatters (`{:x}`, `{:X}`). +pub struct BytesFmt<'a>(pub &'a [u8]); + +impl fmt::UpperHex for BytesFmt<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + f.write_fmt(format_args!("{:02X}", byte))?; + } + Ok(()) + } +} + +impl fmt::LowerHex for BytesFmt<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + f.write_fmt(format_args!("{:02x}", byte))?; + } + Ok(()) + } +} diff --git a/rust/portablemc-cli/src/main.rs b/rust/portablemc-cli/src/main.rs new file mode 100644 index 00000000..8660f724 --- /dev/null +++ b/rust/portablemc-cli/src/main.rs @@ -0,0 +1,20 @@ +//! PortableMC CLI. + +#![deny(unsafe_code)] + +pub mod parse; +pub mod format; +pub mod output; +pub mod cmd; + +use std::process::ExitCode; + +use clap::Parser; + +use parse::CliArgs; + + +/// Entry point. +fn main() -> ExitCode { + cmd::main(&CliArgs::parse()) +} diff --git a/rust/portablemc-cli/src/output.rs b/rust/portablemc-cli/src/output.rs new file mode 100644 index 00000000..3bf56f9e --- /dev/null +++ b/rust/portablemc-cli/src/output.rs @@ -0,0 +1,707 @@ +//! Various utilities to ease outputting human or machine readable text. + +use std::fmt::{self, Display, Write as _}; +use std::io::{IsTerminal, Write}; +use std::{env, io}; + + +/// An abstraction for outputting to any format on stdout, the goal is to provide an +/// interface for outputting at the same time both human readable and machine outputs. +/// +/// The different supported output formats are basically split in two kins: machine +/// readable and human readable. All functions in this abstraction are machine-oriented, +/// that means that by default the human representation will be the machine one (or no +/// representation at all), but the different handles returned can be used to customize +/// or add human representation. +#[derive(Debug, Clone)] +pub struct Output { + /// Mode-specific data. + mode: OutputMode, + /// Are cursor escape code supported on stdout. + escape_cursor_cap: bool, + /// Are color escape code supported on stdout. + escape_color_cap: bool, +} + +#[derive(Debug, Clone)] +enum OutputMode { + Human(OutputHuman), + TabSep(OutputTabSep), +} + +#[derive(Debug, Clone)] +struct OutputHuman { + /// All log lines below this level are discarded. + log_level: LogLevel, + /// Set to true when the current line the cursor should be on is a new one (empty). + log_newline: bool, + /// Set to true when the previous log line has been successfully displayed (regarding + /// the log level). + log_last_level: LogLevel, + /// Line buffer that will be printed for each line. + log_line: String, + /// Storing the rendered background log. + log_background: String, + /// This buffer contains all rendered cells for human-readable. + table_buffer: String, + /// For each cell, ordered by column and then by row, containing the index where the + /// cell's content ends in the shared buffer. + table_cells: Vec, + /// Stores for each separator the index of the row it's placed before. + table_separators: Vec, +} + +#[derive(Debug, Clone)] +struct OutputTabSep { + /// Line buffer that will be printed when the log is dropped. + buffer: String, +} + +impl Output { + + pub fn human(log_level: LogLevel) -> Self { + Self::new(OutputMode::Human(OutputHuman { + log_level, + log_newline: true, + log_last_level: LogLevel::Info, + log_line: String::new(), + log_background: String::new(), + table_buffer: String::new(), + table_cells: Vec::new(), + table_separators: Vec::new(), + })) + } + + pub fn tab_separated() -> Self { + Self::new(OutputMode::TabSep(OutputTabSep { + buffer: String::new(), + })) + } + + fn new(mode: OutputMode) -> Self { + + let term_dumb = !io::stdout().is_terminal() || (cfg!(unix) && env::var_os("TERM").map(|term| term == "dumb").unwrap_or_default()); + let no_color = env::var_os("NO_COLOR").map(|s| !s.is_empty()).unwrap_or_default(); + + Self { + mode, + escape_cursor_cap: !term_dumb, + escape_color_cap: !term_dumb && !no_color, + } + + } + + /// Return true if the output mode is human-readable. + #[inline] + pub fn is_human(&self) -> bool { + matches!(self.mode, OutputMode::Human(_)) + } + + /// Log an information with a simple code referencing it, the given code is the + /// machine-readable code, to add human-readable line use the returned handle. + #[inline] + pub fn log(&mut self, code: impl Display) -> Log<'_, false> { + self._log(code) + } + + /// A special log type that is interpreted as a background task, on machine readable + /// outputs it acts as a regular log, but on human-readable outputs it will be + /// displayed at the end of the current line. + #[inline] + pub fn log_background(&mut self, code: impl Display) -> Log<'_, true> { + self._log(code) + } + + /// Internal implementation detail used to be generic over being background. + #[inline] + fn _log(&mut self, code: impl Display) -> Log<'_, BG> { + + match &mut self.mode { + OutputMode::Human(_mode) => { + // // Save the cursor at the beginning of the new line. + // if self.escape_cursor_cap && mode.log_newline { + // print!("\x1b[s"); + // } + } + OutputMode::TabSep(mode) => { + debug_assert!(mode.buffer.is_empty()); + write!(mode.buffer, "{code}").unwrap(); + } + } + + Log { + output: self, + } + + } + + /// Enter table mode, this is exclusive with other modes. + pub fn table(&mut self, columns: usize) -> TableOutput<'_> { + + assert_ne!(columns, 0); + + match &mut self.mode { + OutputMode::Human(mode) => { + + if !mode.log_newline { + println!(); + mode.log_newline = true; + mode.log_line.clear(); + mode.log_background.clear(); + } + + debug_assert!(mode.table_buffer.is_empty()); + debug_assert!(mode.table_cells.is_empty()); + debug_assert!(mode.table_separators.is_empty()); + + } + OutputMode::TabSep(mode) => { + debug_assert!(mode.buffer.is_empty()); + } + }; + + TableOutput { + output: self, + columns, + } + + } + +} + +/// A handle to a log line, allows adding more context to the log. +#[derive(Debug)] +pub struct Log<'a, const BG: bool> { + /// Exclusive access to output. + output: &'a mut Output, +} + +impl Log<'_, BG> { + + // Reminder: + // \x1b[s save current cursor position + // \x1b[u restore saved cursor position + // \x1b[K clear the whole line + + /// Append an argument for machine-readable output. + pub fn arg(&mut self, arg: impl Display) -> &mut Self { + if let OutputMode::TabSep(mode) = &mut self.output.mode { + write!(mode.buffer, "\t{}", EscapeTabSeparatedValue(arg)).unwrap(); + } + self + } + + /// Append many arguments for machine-readable output. + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + D: Display, + { + if let OutputMode::TabSep(mode) = &mut self.output.mode { + for arg in args { + write!(mode.buffer, "\t{}", EscapeTabSeparatedValue(arg)).unwrap(); + } + } + self + } + + /// Internal function to flush the line and background buffers, should only be + /// called in human readable mode. + fn flush_human_line(&mut self, newline: bool) { + + let OutputMode::Human(mode) = &mut self.output.mode else { panic!() }; + + let mut lock = io::stdout().lock(); + + if self.output.escape_cursor_cap { + // If supporting cursor escape code, we don't use carriage return but instead + // we use cursor save/restore position in order to easily support wrapping. + if mode.log_newline { + // If the line is currently empty, save the cursor position! + lock.write_all(b"\x1b[s").unwrap(); + } else { + // If the line is not empty, restore saved cursor position and clear line. + lock.write_all(b"\x1b[u\x1b[K").unwrap(); + } + } else { + lock.write_all(b"\r").unwrap(); + } + + lock.write_all(mode.log_line.as_bytes()).unwrap(); + if !mode.log_line.is_empty() && !mode.log_background.is_empty() { + lock.write_all(b" -- ").unwrap(); + } + lock.write_all(mode.log_background.as_bytes()).unwrap(); + + if newline { + + mode.log_line.clear(); + mode.log_background.clear(); + mode.log_newline = true; + + lock.write_all(b"\n").unwrap(); + + } else { + mode.log_newline = false; + } + + lock.flush().unwrap(); + + } + +} + +impl Log<'_, false> { + + /// Only relevant for human-readable messages, it forces a newline to be added if the + /// current line's level is "pending" without overwriting is + pub fn newline(&mut self) -> &mut Self { + if let OutputMode::Human(mode) = &mut self.output.mode { + if !mode.log_newline { + println!(); + mode.log_line.clear(); + mode.log_background.clear(); + mode.log_newline = true; + } + } + self + } + + /// Append a human-readable message to this log with an associated level, level is + /// only relevant here because machine-readable outputs are always verbose. + pub fn line(&mut self, level: LogLevel, message: impl Display) -> &mut Self { + if let OutputMode::Human(mode) = &mut self.output.mode { + let last_level = std::mem::replace(&mut mode.log_last_level, level); + if level >= mode.log_level { + + let (name, color) = match level { + LogLevel::Info => ("INFO", "\x1b[34m"), + LogLevel::Pending => ("..", ""), + LogLevel::Success => ("OK", "\x1b[92m"), + LogLevel::Warn => ("WARN", "\x1b[33m"), + LogLevel::Error => ("ERRO", "\x1b[31m"), + LogLevel::Additional => { + mode.log_last_level = last_level; // Cancel the change. + if last_level < mode.log_level { + return self; + } else { + ("a", "") + } + } + LogLevel::Raw => ("r", ""), + LogLevel::RawWarn => ("r", "\x1b[33m"), + LogLevel::RawError => ("r", "\x1b[31m"), + LogLevel::RawFatal => ("r", "\x1b[1;31m"), + }; + + mode.log_line.clear(); + if name == "a" { + write!(mode.log_line, " {message}").unwrap(); + } else if name == "r" { + if !self.output.escape_color_cap || color.is_empty() { + write!(mode.log_line, "{message}").unwrap(); + } else { + write!(mode.log_line, "{color}{message}\x1b[0m").unwrap(); + } + } else { + if !self.output.escape_color_cap || color.is_empty() { + write!(mode.log_line, "[{name:^6}] {message}").unwrap(); + } else { + write!(mode.log_line, "[{color}{name:^6}\x1b[0m] {message}").unwrap(); + } + } + + self.flush_human_line(level != LogLevel::Pending); + + } + } + self + } + + #[inline] + pub fn info(&mut self, message: impl Display) -> &mut Self { + self.line(LogLevel::Info, message) + } + + #[inline] + pub fn pending(&mut self, message: impl Display) -> &mut Self { + self.line(LogLevel::Pending, message) + } + + #[inline] + pub fn success(&mut self, message: impl Display) -> &mut Self { + self.line(LogLevel::Success, message) + } + + #[inline] + pub fn warning(&mut self, message: impl Display) -> &mut Self { + self.line(LogLevel::Warn, message) + } + + #[inline] + pub fn error(&mut self, message: impl Display) -> &mut Self { + self.line(LogLevel::Error, message) + } + + #[inline] + pub fn additional(&mut self, message: impl Display) -> &mut Self { + self.line(LogLevel::Additional, message) + } + +} + +impl Log<'_, true> { + + /// Set the human-readable message of this background log. Note that this will + /// overwrite any background message currently written on the current log line. + pub fn message(&mut self, message: impl Display) -> &mut Self { + if let OutputMode::Human(mode) = &mut self.output.mode { + + mode.log_background.clear(); + write!(mode.log_background, "{message}").unwrap(); + + self.flush_human_line(false); + + } + self + } + +} + +impl Drop for Log<'_, BACKGROUND> { + fn drop(&mut self) { + match &mut self.output.mode { + OutputMode::Human(_) => { + // Do nothing in human mode because the message is always immediately + // flushed to stdout, the buffers may not be empty because if we don't + // add a newline then the buffer is kept for being rewritten on next log. + } + OutputMode::TabSep(mode) => { + // Not in human-readable mode, the buffer has not already been flushed. + let mut lock = io::stdout().lock(); + lock.write_all(mode.buffer.as_bytes()).unwrap(); + mode.buffer.clear(); + lock.write_all(b"\n").unwrap(); + lock.flush().unwrap(); + } + } + + } +} + +/// Level for a human-readable log line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + /// This log is something indicative, discarded when not in verbose mode. + Info = 0, + /// This log indicate something is in progress and the definitive state is unknown. + /// If the next log is another pending or a success, it will overwrite this log, if + /// not the next log will be printed on the next line. + Pending = 1, + /// This log indicate a success. + Success = 2, + /// This log is a warning. + Warn = 3, + /// This log is an error. + Error = 4, + /// An additional log, related to the previous one. This will only be displayed if + /// the previous log has been displayed, its level will be the same as the previous + /// log (discarded by the level or not), but without the header. + Additional = 100, + /// A raw log line to be displayed without the header. + Raw = 200, + /// Same as [`Self::Raw`] but warning-colored if supported by the terminal. + RawWarn = 201, + /// Same as [`Self::Raw`] but error-colored if supported by the terminal. + RawError = 202, + /// Same as [`Self::Raw`] but fatal-colored if supported by the terminal. + RawFatal = 203, +} + +/// The output table mode, used to build a table. +#[derive(Debug)] +pub struct TableOutput<'a> { + /// Exclusive access to output. + output: &'a mut Output, + /// Number of columns. + columns: usize, +} + +impl<'a> TableOutput<'a> { + + /// Create a new row, returning a handle for writing its cells. + pub fn row(&mut self) -> Row<'_> { + + match &mut self.output.mode { + OutputMode::Human(mode) => { + // Just to ensure that cells count is padded when 'Row' is dropped. + debug_assert!(mode.table_cells.len().checked_rem(self.columns).unwrap_or(0) == 0); + } + OutputMode::TabSep(mode) => { + debug_assert!(mode.buffer.is_empty()); + write!(mode.buffer, "row").unwrap(); + } + } + + Row { + output: &mut self.output, + column_remaining: self.columns, + } + + } + + /// Insert a separator, this is used for human-readable format but also for + /// machine-readable formats in order to separate different sections of data + /// (they still have the same number of columns), such as the header from the + /// rest of the data. + pub fn sep(&mut self) { + + match &mut self.output.mode { + OutputMode::Human(mode) => { + // Just to ensure that cells count is padded when 'Row' is dropped. + debug_assert!(mode.table_cells.len().checked_rem(self.columns).unwrap_or(0) == 0); + mode.table_separators.push(mode.table_cells.len() / self.columns); + } + OutputMode::TabSep(mode) => { + debug_assert!(mode.buffer.is_empty()); + println!("sep"); + } + } + + } + +} + +impl Drop for TableOutput<'_> { + fn drop(&mut self) { + + if let OutputMode::Human(mode) = &mut self.output.mode { + + let mut columns_width = vec![0usize; self.columns]; + + // Initially compute maximum width of each column. + let mut column = 0usize; + let mut last_idx = 0usize; + for idx in mode.table_cells.iter().copied() { + + columns_width[column] = columns_width[column].max(idx - last_idx); + last_idx = idx; + + column += 1; + if column == self.columns { + column = 0; + } + + } + + // Small closure just to write a separator. + let write_separator: _ = |writer: &mut io::StdoutLock<'_>, join: &str| { + for (col, width) in columns_width.iter().copied().enumerate() { + if col != 0 { + writer.write_all(join.as_bytes()).unwrap(); + } + write!(writer, "{:─ { + output: &'a mut Output, + column_remaining: usize, +} + +impl Row<'_> { + + /// Insert a new cell to that row with the given machine-readable content, to add + /// a formatted human-readable string, use the returned cell handle. By default, + /// the human representation is the given content. + #[track_caller] + pub fn cell(&mut self, content: impl Display) -> Cell<'_> { + + if self.column_remaining == 0 { + panic!("no remaining column"); + } + + match &mut self.output.mode { + OutputMode::Human(mode) => { + write!(mode.table_buffer, "{content}").unwrap(); + mode.table_cells.push(mode.table_buffer.len()); + } + OutputMode::TabSep(mode) => { + write!(mode.buffer, "\t{content}").unwrap(); + } + } + + self.column_remaining -= 1; + + Cell { + output: &mut self.output, + } + + } + +} + +impl Drop for Row<'_> { + fn drop(&mut self) { + match &mut self.output.mode { + OutputMode::Human(mode) => { + for _ in 0..self.column_remaining { + mode.table_cells.push(mode.table_buffer.len()); + } + } + OutputMode::TabSep(mode) => { + for _ in 0..self.column_remaining { + mode.buffer.push('\t'); + } + println!("{}", mode.buffer); + mode.buffer.clear(); + } + } + } +} + +/// A handle for customizing metadata and human-readable format. +#[derive(Debug)] +pub struct Cell<'a> { + output: &'a mut Output, +} + +impl Cell<'_> { + + /// Format this cell differently from the raw data, only for human-readable. + /// Calling this twice will overwrite the first format. + pub fn format(&mut self, message: D) -> &mut Self { + + if let OutputMode::Human(mode) = &mut self.output.mode { + // We pop the last cell because it can, and should only be this one. + mode.table_cells.pop().unwrap(); + // Truncate the old cell's content. + mode.table_buffer.truncate(mode.table_cells.last().copied().unwrap_or(0)); + // Rewrite the content. + write!(mode.table_buffer, "{message}").unwrap(); + mode.table_cells.push(mode.table_buffer.len()); + } + + self + + } + +} + +/// Internal display wrapper to escape any newline character '\n' by a literal escape +/// "\\n", this is used for tab-separated output to avoid early line return before end +/// of the line. +struct EscapeTabSeparatedValue(T); + +impl fmt::Display for EscapeTabSeparatedValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + + struct Wrapper<'a, 'b> { + f: &'a mut fmt::Formatter<'b>, + } + + impl fmt::Write for Wrapper<'_, '_> { + + fn write_str(&mut self, mut s: &str) -> fmt::Result { + 'out: loop { + for (i, ch) in s.char_indices() { + + let repl = match ch { + '\n' => "\\n", + '\t' => "\\t", + _ => continue, + }; + + self.f.write_str(&s[..i])?; + self.f.write_str(repl)?; + s = &s[i + 1..]; + continue 'out; + + } + break; // In case no more escapable character... + } + self.f.write_str(s)?; + Ok(()) + } + + fn write_char(&mut self, c: char) -> fmt::Result { + match c { + '\n' => self.f.write_str("\\n"), + '\t' => self.f.write_str("\\t"), + _ => self.f.write_char(c) + } + } + + } + + let mut wrapper = Wrapper { f }; + write!(wrapper, "{}", self.0) + + } +} diff --git a/rust/portablemc-cli/src/parse.rs b/rust/portablemc-cli/src/parse.rs new file mode 100644 index 00000000..478061d2 --- /dev/null +++ b/rust/portablemc-cli/src/parse.rs @@ -0,0 +1,794 @@ +//! Implementation of the command line parser, using clap struct derivation. + +use std::path::PathBuf; +use std::str::FromStr; + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use uuid::Uuid; + +use portablemc::{fabric, forge}; +use portablemc::maven::Gav; + + +// ================= // +// MAIN COMMAND // +// ================= // + +/// Command line utility for launching Minecraft quickly and reliably with included +/// support for Mojang versions and popular mod loaders. +#[derive(Debug, Parser)] +#[command(name = "portablemc", version, author, disable_help_subcommand = true, max_term_width = 140)] +pub struct CliArgs { + #[command(subcommand)] + pub cmd: CliCmd, + /// Enable verbose output, the more -v argument you put, the more verbose the + /// launcher will be. + #[arg(short, env = "PMC_VERBOSE", action = clap::ArgAction::Count)] + pub verbose: u8, + /// Change the default output format of the launcher. + #[arg(long, env = "PMC_OUTPUT", default_value = "human")] + pub output: CliOutput, + /// Set the directory where versions, libraries, assets, JVM and where the game's run. + /// + /// If left unspecified, this argument defaults to the standard Minecraft directory + /// for your system: in '%USERPROFILE%/AppData/Roaming' on Windows, + /// '$HOME/Library/Application Support/minecraft' on macOS and '$HOME/.minecraft' on + /// other systems. If the launcher fails to find the default directory then it will + /// abort any command exit with a failure telling you to specify it. + /// + /// This argument might not always be used by a command, you can specify it through + /// environment variables if more practical. + #[arg(long, env = "PMC_MAIN_DIR", value_name = "PATH")] + pub main_dir: Option, + /// Set the path to the Microsoft Authentication database for caching session tokens. + /// + /// When unspecified, this argument is derived from the '--main-dir' path: + /// '/portablemc_msa.json'. This file uses a JSON human-readable format. + /// + /// This argument might not always be used by a command, you can specify it through + /// environment variables if more practical. + #[arg(long, env = "PMC_MSA_DB_FILE", value_name = "PATH")] + pub msa_db_file: Option, + /// Change the default Azure application ID used by the launcher. + /// + /// The Azure application ID is used for interacting with the Microsoft authentication + /// API used for authentication of Minecraft accounts. When not specified, the default + /// (and hidden) launcher's application ID is used. + #[arg(long, env = "PMC_MSA_AZURE_APP_ID", value_name = "APP_ID")] + pub msa_azure_app_id: Option, +} + +#[derive(Debug, Subcommand)] +pub enum CliCmd { + Start(StartArgs), + Search(SearchArgs), + Auth(AuthArgs), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum CliOutput { + /// Human readable output, it depends on the actual command being used and is not + /// guaranteed to be stable across releases, for that you should prefer using + /// 'tabular' output for example. With this format, the verbosity is used to + /// show more informative data. + Human, + /// Machine output mode to allow parsing by other programs, using tab ('\t', 0x09) + /// separated values where the first value defines which kind of data to follow on + /// the line, a line return ('\n', 0x0A) is used to split every line. If any line + /// return or tab is encoded into a value within the line, it is escaped with the + /// two characters '\n' (for line return) or '\t' (for tab), these are the only two + /// escapes used. This mode is always verbose, and verbosity will not have any effect + /// on it. If the launcher exit with a failure code, you should expect finding a log + /// message prefixed with `error_`, describing the error(s) causing the exit. + Machine, +} + +// ================= // +// START COMMAND // +// ================= // + +/// Start the game. +/// +/// This command is the main entrypoint for installing and then launching the game, +/// it works with many different versions, this includes official Mojang versions +/// but also popular mod loaders, such as Fabric, Quilt, Forge, NeoForge and +/// LegacyFabric. It ensures that the version is properly installed prior to launching +/// it. +#[derive(Debug, Args)] +pub struct StartArgs { + /// The version to launch (see more with '--help'). + /// + /// You can provide this argument with colon-separated ':' syntax, in such case the + /// first part defines the kind of installer, supported values are: mojang, fabric, + /// quilt, forge, neoforge, legacyfabric and babric. + /// When not using the colon-separated syntax, this will defaults to the 'mojang' + /// installer. Below are detailed each installer. + /// + /// - mojang:[release|snapshot|] => use 'release' (default if absent) or + /// 'snapshot' to install and launch the latest version of that type, or you can + /// use any valid version id provided by Mojang (you can search for them using the + /// 'portablemc search' command). + /// This also supports any local version that is already installed, with support + /// for inheriting other versions: a generic rule of the Mojang installer is that + /// each version in the hierarchy that is known by the Mojang's version manifest + /// will be checked for validity (file hash) and fetched if needed. + /// You can manually exclude versions from this rule using '--exclude-fetch' with + /// each version you don't want to fetch (see this argument's help). The Mojang's + /// version manifest is only accessed for resolving 'release', 'snapshot' or a + /// non-excluded version, if it's not yet cached this will require internet. + /// + /// - fabric:[[:[]]] => install and launch a given + /// Mojang version with the Fabric mod loader. Both versions can be omitted (empty) + /// to use the latest stable versions available, but you can also manually specify + /// 'stable' or 'unstable', which are equivalent as Mojang release and snapshot + /// but at the discretion of the Fabric API. If the version is not yet installed, + /// it will requires internet to access the Fabric API. See https://fabricmc.net/. + /// + /// - quilt:[[:[]]] => same as 'fabric' installer, + /// but using the Quilt API for missing versions. See https://quiltmc.org/. + /// + /// - legacyfabric:[[:[]]] => same as 'fabric', + /// but using the LegacyFabric API for missing versions. This installer can be + /// used for using the Fabric mod loader on Mojang versions prior to 1.14. + /// See https://legacyfabric.net/. + /// + /// - babric:[:[]] => same as 'fabric', but using the Babric API + /// for missing versions. This mod loader is specifically made to support Fabric + /// only on Mojang b1.7.3, so it's useless to specify the game version like + /// other Fabric-like loaders, both 'stable' and 'unstable' would be equivalent + /// to 'b1.7.3'. See https://babric.github.io/. + /// + /// - forge:: | forge:[][:stable|unstable] => the + /// syntax is a bit cumbersome because you can either specify the full loader version + /// such as '1.21.4-54.0.12' but you must leave the first parameter empty, or you + /// can specify a Mojang game version with optional second parameter that specifies + /// if you target the latest 'stable' (default) or 'unstable' loader version. + /// See https://minecraftforge.net/. + /// + /// - neoforge:: | neoforge:[][:stable|unstable] => same + /// as 'forge', but using the NeoForge repository. See https://neoforged.net/. + #[arg(default_value = "release")] + pub version: StartVersion, + /// Only ensures that the game is installed but don't launch the game. This can be + /// used to debug installation paths while using verbose output. + #[arg(long)] + pub dry: bool, + /// Set the binaries directory where all binary objects are extracted before running + /// the game, a sub-directory is created inside this directory that is uniquely named + /// after a hash of the version's libraries. + /// + /// When unspecified, this argument is derived from the '--main-dir' path: + /// '/bin/'. + /// + /// This argument might not always be used by a command, you can specify it through + /// environment variables if more practical. + #[arg(long, env = "PMC_BIN_DIR", value_name = "PATH")] + pub bin_dir: Option, + /// Set the directory where the game is run from, the game will use this directory + /// to put options, saves, screenshots and access texture or resource packs and any + /// other user related stuff. + /// + /// When unspecified, this argument is equal to the '--main-dir' path. + /// + /// This argument might not always be used by a command, you can specify it through + /// environment variables if more practical. + #[arg(long, env = "PMC_MC_DIR", value_name = "PATH")] + pub mc_dir: Option, + /// Disable the multiplayer buttons (>= 1.16). + #[arg(long)] + pub disable_multiplayer: bool, + /// Disable the online chat (>= 1.16). + #[arg(long)] + pub disable_chat: bool, + /// Enable demo mode for the game. + #[arg(long)] + pub demo: bool, + /// Change the resolution of the game window (x, >= 1.6). + #[arg(long)] + pub resolution: Option, + /// Disable the legacy quick play fix for older versions without Quick Play support. + /// + /// When starting versions older than 1.20 (23w14a) where Quick Play was not supported + /// by the client, this fix tries to use legacy arguments instead, such as --server + /// and --port, this is enabled by default. + #[arg(long)] + pub no_fix_legacy_quick_play: bool, + /// Disable the legacy proxy fix to old online resources. + /// + /// When starting older alpha, beta and release up to 1.5, this allows legacy online + /// resources such as skins to be properly requested. The implementation is currently + /// using `betacraft.uk` proxies, this is enabled by default. + #[arg(long)] + pub no_fix_legacy_proxy: bool, + /// Disable the legacy merge sort fix on really old versions. + /// + /// When starting older alpha and beta versions, this adds a JVM argument to use the + /// legacy merge sort `java.util.Arrays.useLegacyMergeSort=true`, this is required on + /// some old versions to avoid crashes, this is enabled by default. + #[arg(long)] + pub no_fix_legacy_merge_sort: bool, + /// Disable the legacy resolution fix on older versions without resolution arguments. + /// + /// When starting older versions that don't support modern resolution arguments, this + /// fix will add arguments to force resolution of the initial window, this is enabled + /// by default. + #[arg(long)] + pub no_fix_legacy_resolution: bool, + /// Disable the broken AuthLib fix on 1.16.4 and 1.16.5. + /// + /// Versions 1.16.4 and 1.16.5 uses authlib:2.1.28 which cause multiplayer button + /// (and probably in-game chat) to be disabled, this can be fixed by switching to + /// version 2.2.30 of authlib, this is enabled by default. + #[arg(long)] + pub no_fix_broken_authlib: bool, + /// Change the LWJGL version used by the game (LWJGL >= 3.2.3). + /// + /// This argument will cause all LWJGL libraries of the game to be changed to the + /// given version, this applies to natives as well. In addition to simply changing + /// the versions, this will also add natives that are missing, such as ARM. + /// + /// It's not guaranteed to work with every version of Minecraft and downgrading + /// LWJGL version is not recommended. + #[arg(long, value_name = "VERSION")] + pub fix_lwjgl: Option, + /// Exclude the given version from validity check and fetching. + /// + /// This is used by the Mojang installer and all installers relying on it to exclude + /// version from being validated and fetched from the Mojang's version manifest, as + /// described in 'VERSION' help. You can use --fetch-exclude-all to exclude all + /// versions and therefore prevent any fetching of the Mojang's manifest. + /// + /// This argument can be specified multiple times. + #[arg(long, value_name = "VERSION")] + pub fetch_exclude: Vec, + /// Exclude all versions from validity check and fetching. + /// + /// See --fetch-exclude, note that this is incompatible with --fetch-exclude. + #[arg(long, conflicts_with = "fetch_exclude")] + pub fetch_exclude_all: bool, + /// Use a filter to exclude Java libraries from the installation. + /// + /// The filter is checked against each GAV (Group-Artifact-Version) of each library + /// resolved in the version metadata and remove each library matching the filter. + /// It's using the following syntax, this is almost the same as a standard GAV but + /// it allows having an asterisk '*' as a placeholder for any of the 'group', + /// 'artifact' or 'version': ::[:][@]. + /// + /// A typical use case for this argument would be to exclude some natives-providing + /// library (such as LWJGL libraries with 'natives' classifier) and then provide + /// those natives manually using '--include-bin' argument. Known usage of this + /// argument has been for supporting MUSL-only systems, because LWJGL binaries are + /// only provided for glibc (see #110 and #112 on GitHub). + /// + /// This argument can be specified multiple times. + #[arg(long, value_name = "FILTER")] + pub exclude_lib: Vec, + /// Include a natives file in the binaries directory, usually shared objects or + /// archives (ZIP or JAR) that contains such files. + /// + /// Those files are symlinked (or copied if not possible) to the binaries directory + /// where the game will check for natives to load. The main use case is for including + /// shared objects (.so, .dll, .dylib), in case of versioned .so files like we can + /// see on UNIX systems, the version is discarded when linked or copied to the bin + /// directory (/usr/lib/foo.so.1.22.2 -> foo.so). + /// + /// Read the help message of '--exclude-lib' for a typical use case. + /// + /// This argument can be specified multiple times. + #[arg(long, value_name = "PATH")] + pub include_natives: Vec, + /// Include a class file in the class path of the launching game, this should usually + /// be a JAR archive. + /// + /// This argument can be specified multiple times. + #[arg(long, value_name = "PATH")] + pub include_class: Vec, + /// The path to the JVM executable, 'java' (or 'javaw.exe' on Windows). + /// + /// This is used to launch the game, it has a special use-case with Forge and NeoForge + /// loader versions where that JVM executable is also used to run the installer + /// processors. + /// + /// Note that when this argument is specified, you cannot specify the '--jvm-policy' + /// argument. + #[arg(long, value_name = "PATH")] + pub jvm: Option, + /// The policy for finding or installing the JVM executable. + #[arg(long, value_name = "POLICY", conflicts_with = "jvm", default_value = "system-then-mojang")] + pub jvm_policy: StartJvmPolicy, + /// Automatically join the given singleplayer world after game has been launched. + /// + /// Note that this may not work on older version that did not support the "Quick Play" + /// feature. + /// + /// This is incompatible with other Quick Play modes. + #[arg(long, value_name = "WORLD_NAME", conflicts_with = "join_server", conflicts_with = "join_realms")] + pub join_world: Option, + /// Automatically join the given server after game has been launched. + /// + /// Note that this may not work on older version that did not support the "Quick Play" + /// feature nor the legacy game's `--server` argument. + /// + /// This is incompatible with other Quick Play modes. + #[arg(long, value_name = "HOST", conflicts_with = "join_world", conflicts_with = "join_realms")] + pub join_server: Option, + /// Complement to the `--join-server` argument to specify the server port. + #[arg(long, value_name = "PORT", requires = "join_server", default_value_t = 25565)] + pub join_server_port: u16, + /// Automatically join a Realms server from its id after game has been launched. + /// + /// Note that this may not work on older version that did not support the "Quick Play" + /// feature. + /// + /// This is incompatible with other Quick Play modes. + #[arg(long, value_name = "ID", conflicts_with = "join_server", conflicts_with = "join_world")] + pub join_realms: Option, + /// Change the default username of the player. + /// + /// When the '--auth' (-a) flag is enabled, this argument is used, after the + /// '--uuid' (-i) one, to find the authenticated account to start the game with. + #[arg(short = 'u', long, value_name = "NAME")] + pub username: Option, + /// Change the default UUID of the player. + /// + /// When the '--auth' (-a) flag is enabled, this argument is used, before the + /// '--username' (-u) one, to find the authenticated account to start the game with. + #[arg(short = 'i', long)] + pub uuid: Option, + /// Enable authentication for the username or UUID. + /// + /// When enabled, the launcher will look for specified '--uuid', or '--username' as + /// a fallback, it will then pick the matching account and start the game with it, + /// the account is refreshed if needed. IT MEANS that you must first login into + /// your account using the 'portablemc auth' command before starting the game with + /// the account. + /// + /// If the account is not found, the launcher won't start the game and will show an + /// error. + /// + /// Note that '--username' (-u) argument is completely ignored if the '--uuid' (-i) + /// is specified, only one of them can be used at the same time with this flag. + /// You can combine this flag with one of these argument, for example '-au ' + /// or '-ai '. + #[arg(short = 'a', long)] + pub auth: bool, +} + +/// Represent all possible version the launcher can start. +#[derive(Debug, Clone)] +pub enum StartVersion { + Mojang { + version: String, + }, + MojangRelease, + MojangSnapshot, + Fabric { + loader: fabric::Loader, + game_version: fabric::GameVersion, + loader_version: fabric::LoaderVersion, + }, + Forge { + loader: forge::Loader, + version: String, + }, + ForgeLatest { + loader: forge::Loader, + game_version: Option, // None for targeting "release" + stable: bool, + } +} + +impl FromStr for StartVersion { + + type Err = String; + + fn from_str(s: &str) -> Result { + + // Extract the kind (defaults to mojang) and the parameters. + let (kind, rest) = s.split_once(':') + .unwrap_or(("mojang", s)); + + // Then split the rest into all parts. + let parts = rest.split(':').collect::>(); + debug_assert!(!parts.is_empty()); + + // Compute max parts count and immediately discard + let max_parts = match kind { + "raw" => 1, + "mojang" => 1, + "fabric" | "quilt" | "legacyfabric" | "babric" => 2, + "forge" | "neoforge" => 2, + _ => return Err(format!("unknown installer kind: {kind}")), + }; + + if parts.len() > max_parts { + return Err(format!("too much parameters for this installer kind")); + } + + let version = match kind { + "mojang" => { + match parts[0] { + "" | + "release" => Self::MojangRelease { }, + "snapshot" => Self::MojangSnapshot { }, + version => Self::Mojang { version: version.to_string() }, + } + } + "fabric" | "quilt" | "legacyfabric" | "babric" => { + Self::Fabric { + loader: match kind { + "fabric" => fabric::Loader::Fabric, + "quilt" => fabric::Loader::Quilt, + "legacyfabric" => fabric::Loader::LegacyFabric, + "babric" => fabric::Loader::Babric, + _ => unreachable!(), + }, + game_version: match parts[0] { + "" | + "stable" => fabric::GameVersion::Stable, + "unstable" => fabric::GameVersion::Unstable, + id => fabric::GameVersion::Name(id.to_string()), + }, + loader_version: match parts.get(1).copied() { + None | Some("" | "stable") => fabric::LoaderVersion::Stable, + Some("unstable") => fabric::LoaderVersion::Unstable, + Some(id) => fabric::LoaderVersion::Name(id.to_string()), + }, + } + } + "forge" | "neoforge" => { + + let loader = match kind { + "forge" => forge::Loader::Forge, + "neoforge" => forge::Loader::NeoForge, + _ => unreachable!(), + }; + + match parts.get(1).copied() { + None | + Some("" | "stable" | "unstable") => { + Self::ForgeLatest { + loader, + game_version: match parts[0] { + "" | "release" => None, + id => Some(id.to_string()), + }, + stable: match parts.get(1).copied() { + None | Some("" | "stable") => true, + Some("unstable") => false, + _ => unreachable!(), + }, + } + } + Some(other) => { + + if !parts[0].is_empty() { + return Err(format!("first parameter should be empty when specifying full loader version")); + } + + Self::Forge { + loader, + version: other.to_string(), + } + + } + } + + } + _ => unreachable!() + }; + + Ok(version) + + } + +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum StartJvmPolicy { + /// The installer will try to find a suitable JVM executable in the path, searching + /// a `java` (or `javaw.exe` on Windows) executable. On operating systems where it's + /// supported, this will also check for known directories (on Arch for example). + /// If the version needs a specific JVM major version, each candidate executable is + /// checked and a warning is triggered to notify that the version is not suited. + /// The install fails if none of those versions is valid. + System, + /// The installer will try to find a suitable JVM to install from Mojang-provided + /// distributions, if no JVM is available for the platform and for the required + /// distribution then the install fails. + Mojang, + /// The installer search system and then mojang as a fallback. + SystemThenMojang, + /// The installer search Mojang and then system as a fallback. + MojangThenSystem, +} + +/// Represent an optional initial resolution for the game window. +#[derive(Debug, Clone, Copy)] +pub struct StartResolution { + pub width: u16, + pub height: u16, +} + +impl FromStr for StartResolution { + + type Err = String; + + fn from_str(s: &str) -> Result { + + let Some((width, height)) = s.split_once('x') else { + return Err(format!("invalid resolution syntax, expecting x")) + }; + + Ok(Self { + width: width.parse().map_err(|e| format!("invalid resolution width: {e}"))?, + height: height.parse().map_err(|e| format!("invalid resolution height: {e}"))?, + }) + + } + +} + +/// Represent a pattern for excluding library. +#[derive(Debug, Clone)] +pub struct StartExcludeLibPattern(Gav); + +impl FromStr for StartExcludeLibPattern { + + type Err = String; + + fn from_str(s: &str) -> Result { + Gav::from_str(s) + .map_err(|()| format!("invalid exclude lib pattern, expected ::[:][@]")) + .map(Self) + } + +} + +impl StartExcludeLibPattern { + + /// Return true if that pattern matches the given GAV. + pub fn matches(&self, gav: &Gav) -> bool { + + /// Internal function to match a haystack against a pattern that may contain an + /// asterisk '*' that allows wildcard matching. Only the first asterisk is used. + fn match_wildcard(pattern: &str, mut haystack: &str) -> bool { + + let Some((left, right)) = pattern.split_once('*') else { + return pattern == haystack; + }; + + if left.is_empty() && right.is_empty() { + return true; // Match everything + } + + if !left.is_empty() { + if haystack.starts_with(left) { + // Strip of the left part from the haystack. + haystack = &haystack[left.len()..]; + } else { + return false; + } + } + + right.is_empty() || haystack.ends_with(right) + + } + + if !match_wildcard(self.0.group(), gav.group()) { + return false; + } + + if !match_wildcard(self.0.artifact(), gav.artifact()) { + return false; + } + + if !match_wildcard(self.0.version(), gav.version()) { + return false; + } + + match (self.0.classifier(), gav.classifier()) { + (Some(pattern), Some(haystack)) if !match_wildcard(pattern, haystack) => return false, + (Some(_), None) | + (None, Some(_)) => return false, + _ => (), + } + + match (self.0.extension(), gav.extension()) { + (Some(pattern), Some(haystack)) if !match_wildcard(pattern, haystack) => return false, + (Some(_), None) | + (None, Some(_)) => return false, + _ => (), + } + + true + + } + +} + +// ================= // +// SEARCH COMMAND // +// ================= // + +/// Search for versions. +/// +/// By default this command will search for official Mojang version but you can change +/// this behavior and search for local or mod loaders versions with the -k (--kind) +/// argument. Note that the displayed table layout depends on the kind. How the +/// query string is interpreted depends on the kind. +#[derive(Debug, Args)] +pub struct SearchArgs { + /// The search filter string. + /// + /// You can give multiple filters that will apply to various texts depending on the + /// search king. In general this will apply to the leftmost column, so the version + /// name in most of the cases. + pub filter: Vec, + /// Select the target of the search query. + #[arg(short, long, default_value = "mojang")] + pub kind: SearchKind, + /// Limit the number of rows of results. + /// + /// Because search results are sorted by descending versions, this will keep only the + /// given number of most recent versions. One exception to this are local versions, + /// which are in the same order as your OS give them when listing their directories. + #[arg(short, long, default_value_t = usize::MAX, hide_default_value = true)] + pub limit: usize, + /// Only keep versions of given channel. + /// + /// This argument can be given multiple times to specify multiple channels to match + /// in an OR logic. + /// + /// [supported search kinds: mojang, forge, neoforge] + #[arg(long)] + pub channel: Vec, + /// Only show the latest version of the given channel. + /// + /// This argument can be specified only once and is incompatible with any other + /// filters, it also don't work with any channel, some channels have no information + /// about their "latest" version as it doesn't make sense, like the latest Mojang's + /// beta was released 13 years ago, this cannot be described as the "latest" version + /// of the game. + /// + /// [supported search kinds: mojang] + #[arg(long, conflicts_with_all = ["filter", "channel"])] + pub latest: Option, + /// Only keep loader versions that targets the given game version. + /// + /// [supported search kinds: forge, neoforge] + #[arg(long)] + pub game_version: Vec, +} + +impl SearchArgs { + + /// Return true if the given haystack contains one of the string filters. Return true + /// if no string filter. + pub fn match_filter(&self, haystack: &str) -> bool { + self.filter.is_empty() || self.filter.iter().any(|s| haystack.contains(s)) + } + + /// Return true if the given search channel is selected. Return true if no filter. + pub fn match_channel(&self, channel: SearchChannel) -> bool { + self.channel.is_empty() || self.channel.contains(&channel) + } + + /// Return true if the given game version is present, exactly, in one of the filter. + /// Return true if no filter. + pub fn match_game_version(&self, game_version: &str) -> bool { + self.game_version.is_empty() || self.game_version.iter().any(|v| v == game_version) + } + +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum SearchKind { + /// Search for official versions released by Mojang, including release and snapshots. + Mojang, + /// Search for locally installed versions, located in the versions directory. + Local, + /// Search for Fabric loader versions. + Fabric, + /// Search for Fabric supported game versions. + FabricGame, + /// Search for Quilt loader versions. + Quilt, + /// Search for Quilt supported game versions. + QuiltGame, + /// Search for LegacyFabric loader versions. + Legacyfabric, + /// Search for LegacyFabric supported game versions. + LegacyfabricGame, + /// Search for Babric loader versions. + Babric, + /// Search for Babric supported game versions. + BabricGame, + /// Search for Forge loader versions. + Forge, + /// Search for NeoForge loader versions. + #[value(name = "neoforge")] + NeoForge, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SearchChannel { + /// Filter versions by release channel (only for mojang). + Release, + /// Filter versions by snapshot channel (only for mojang). + Snapshot, + /// Filter versions by beta channel (only for mojang). + Beta, + /// Filter versions by alpha channel (only for mojang). + Alpha, + /// Filter versions by stable channel (only for mod loaders). + Stable, + /// Filter versions by unstable channel (only for mod loaders). + Unstable, +} + +impl SearchChannel { + + pub fn new_stable_or_unstable(stable: bool) -> Self { + if stable { + SearchChannel::Stable + } else { + SearchChannel::Unstable + } + } + +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SearchLatestChannel { + /// Select the latest release version. + Release, + /// Select the latest snapshot version. + Snapshot, +} + +// ================= // +// AUTH COMMAND // +// ================= // + +/// Manage the authentication sessions. +/// +/// By default, this command will start a new authentication flow with the Microsoft +/// authentication service, when completed this will add the newly authenticated session +/// to the authentication database (specified with '--msa-db-file' argument). +/// +/// If this command fails to load and/or store the database, its exit code is 1 (failure). +#[derive(Debug, Args)] +pub struct AuthArgs { + /// Prevent the launcher from opening your system's web browser with the + /// authentication page. + /// + /// When the '--output' mode is 'human', the launcher will try to open your system's + /// web browser with the Microsoft authentication page, this flag disables this + /// behavior. + #[arg(long)] + pub no_browser: bool, + /// Forget the given authenticated session by its UUID, or username as a fallback. + /// + /// You'll no longer be able to authenticate with this session when starting the + /// game, you'll have to authenticate again. If not account is matching the given + /// UUID or username, then the database is not rewritten, and a warning message is + /// issued, but the exit code is always 0 (success). + #[arg(short, long, exclusive = true)] + pub forget: Option, + /// Refresh the given account, updating the username if it has been modified. + /// + /// If the profile cannot be refreshed, a request for refreshing + /// + /// Note that this procedure is automatically done on game's start, so you don't need + /// to run this before starting the game with an account. You may want to use this + /// in order to update the database and list the updated accounts. + #[arg(short, long, exclusive = true)] + pub refresh: Option, + /// List all currently authenticated sessions, by username and UUID, that can be used + /// with the start command to authenticate. + #[arg(short, long, exclusive = true)] + pub list: bool, +} diff --git a/rust/portablemc-ffi/Cargo.toml b/rust/portablemc-ffi/Cargo.toml new file mode 100644 index 00000000..61f1b71b --- /dev/null +++ b/rust/portablemc-ffi/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "portablemc-ffi" +description = "Bindings to PortableMC for any language." +edition.workspace = true +version.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +portablemc.workspace = true + +uuid.workspace = true + +[dev-dependencies] +serde.workspace = true +serde_json.workspace = true + +tempfile.workspace = true + +[lib] +name = "portablemc_ffi" +crate-type = ["staticlib", "cdylib"] diff --git a/rust/portablemc-ffi/include/portablemc.h b/rust/portablemc-ffi/include/portablemc.h new file mode 100644 index 00000000..f8676bcb --- /dev/null +++ b/rust/portablemc-ffi/include/portablemc.h @@ -0,0 +1,257 @@ +#ifndef _PORTABLEMC_H +#define _PORTABLEMC_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// An array of 16 bytes representing an UUID. +typedef uint8_t pmc_uuid[16]; + +/// The code of an error that can be retrieved via +typedef enum { + // Uncategorized + PMC_ERR_UNSET = 0x00, + PMC_ERR_INTERNAL = 0x01, + // MSA auth + PMC_ERR_MSA_AUTH_DECLINED = 0x10, + PMC_ERR_MSA_AUTH_TIMED_OUT, + PMC_ERR_MSA_AUTH_OUTDATED_TOKEN, + PMC_ERR_MSA_AUTH_DOES_NOT_OWN_GAME, + PMC_ERR_MSA_AUTH_INVALID_STATUS, + PMC_ERR_MSA_AUTH_UNKNOWN, + // MSA database + PMC_ERR_MSA_DATABASE_IO = 0x20, + PMC_ERR_MSA_DATABASE_CORRUPTED, + PMC_ERR_MSA_DATABASE_WRITE_FAILED, + // Standard installer + PMC_ERR_STANDARD_HIERARCHY_LOOP = 0x30, + PMC_ERR_STANDARD_VERSION_NOT_FOUND, + PMC_ERR_STANDARD_ASSETS_NOT_FOUND, + PMC_ERR_STANDARD_CLIENT_NOT_FOUND, + PMC_ERR_STANDARD_LIBRARY_NOT_FOUND, + PMC_ERR_STANDARD_JVM_NOT_FOUND, + PMC_ERR_STANDARD_MAIN_CLASS_NOT_FOUND, + PMC_ERR_STANDARD_DOWNLOAD_RESOURCES_CANCELLED, + PMC_ERR_STANDARD_DOWNLOAD +} pmc_err_tag; + +/// PMC_ERR_INTERNAL +typedef struct { + const char *origin; +} pmc_err_data_internal; + +/// PMC_ERR_MSA_AUTH_INVALID_STATUS +typedef struct { + uint16_t status; +} pmc_err_data_msa_auth_invalid_status; + +/// PMC_ERR_MSA_AUTH_UNKNOWN +typedef struct { + const char *message; +} pmc_err_data_msa_auth_unknown; + +/// PMC_ERR_STANDARD_HIERARCHY_LOOP +typedef struct { + const char *version; +} pmc_err_std_hierarchy_loop; + +/// PMC_ERR_STANDARD_VERSION_NOT_FOUND +typedef struct { + const char *version; +} pmc_err_std_version_not_found; + +/// PMC_ERR_STANDARD_ASSETS_NOT_FOUND +typedef struct { + const char *id; +} pmc_err_std_assets_not_found; + +/// PMC_ERR_STANDARD_JVM_NOT_FOUND +typedef struct { + uint32_t major_version; +} pmc_err_std_jvm_not_found; + +/// The union of all data types for errors. +typedef union { + int _none; // Ensure alignment for tag + pmc_err_data_internal internal; + pmc_err_data_msa_auth_invalid_status msa_auth_invalid_status; + pmc_err_data_msa_auth_unknown msa_auth_unknown; + pmc_err_std_hierarchy_loop std_hierarchy_loop; + pmc_err_std_version_not_found std_version_not_found; + pmc_err_std_assets_not_found std_assets_not_found; + pmc_err_std_jvm_not_found std_jvm_not_found; +} pmc_err_data; + +/// Generic error type, you should usually use this type by defining a null-pointer to it +/// and passing a pointer to that pointer to any function that accepts it. If an error +/// happens, the function will allocate an error and then write its pointer in the given +/// location. The error should be freed afterward. +/// +/// This structure has a known layout in C. +typedef struct { + /// Tag of the error. + pmc_err_tag tag; + /// The data of the tag, that can be used depending on the error tag. + pmc_err_data data; + /// The descriptive human-readable message for the error. + const char *message; +} pmc_err; + +/// Microsoft Account authenticator. +typedef struct pmc_msa_auth pmc_msa_auth; + +/// Microsoft Account device code flow authenticator. +typedef struct pmc_msa_device_code_flow pmc_msa_device_code_flow; + +/// Microsoft Account device code flow authenticator. +typedef struct pmc_msa_account pmc_msa_account; + +/// A file-backed database for storing accounts. +typedef struct pmc_msa_database pmc_msa_database; + +/// A structure representing an installed game. +typedef struct pmc_game pmc_game; + +/// The installer that supports the minimal standard format for version metadata with +/// support for libraries, assets and loggers automatic installation. By defaults, it +/// also supports finding a suitable JVM for running the game. +typedef struct pmc_base pmc_base; + +/// The tag for the pmc_jvm_policy tagged union. +typedef enum { + PMC_JVM_POLICY_STATIC, + PMC_JVM_POLICY_SYSTEM, + PMC_JVM_POLICY_MOJANG, + PMC_JVM_POLICY_SYSTEM_THEN_MOJANG, + PMC_JVM_POLICY_MOJANG_THEN_SYSTEM, +} pmc_jvm_policy_tag; + +/// The JVM policy tagged union, only the static policy requires an explicit path value. +typedef struct { + pmc_jvm_policy_tag tag; + const char *static_path; +} pmc_jvm_policy; + +typedef enum { + PMC_VERSION_CHANNEL_RELEASE, + PMC_VERSION_CHANNEL_SNAPSHOT, + PMC_VERSION_CHANNEL_BETA, + PMC_VERSION_CHANNEL_ALPHA, +} pmc_version_channel; + +typedef struct { + const char *name; + const char *dir; + pmc_version_channel channel; +} pmc_loaded_version; + +typedef enum { + PMC_EVENT_FILTER_FEATURES, + PMC_EVENT_LOADED_FEATURES, + PMC_EVENT_LOAD_HIERARCHY, + PMC_EVENT_LOADED_HIERARCHY, + PMC_EVENT_LOAD_VERSION, + PMC_EVENT_NEED_VERSION, + PMC_EVENT_LOADED_VERSION, + PMC_EVENT_LOAD_CLIENT, + PMC_EVENT_LOADED_CLIENT, + PMC_EVENT_LOAD_LIBRARIES, + PMC_EVENT_FILTER_LIBRARIES, + PMC_EVENT_LOADED_LIBRARIES, + PMC_EVENT_FILTER_LIBRARIES_FILES, + PMC_EVENT_LOADED_LIBRARIES_FILES, +} pmc_standard_event_tag; + +typedef union { + +} pmc_standard_event_data; + +typedef struct { + pmc_standard_event_tag tag; + pmc_standard_event_data data; +} pmc_standard_event; + +typedef void (*pmc_standard_handler)(const pmc_standard_event *event); + + +/// A generic function to free any pointer that has been returned by a PortableMC +/// function, unless const or if explicitly stated. +void pmc_free(void *ptr); + +/// Create a new authenticator with the given Azure application id (client id). +pmc_msa_auth *pmc_msa_auth_new(const char *app_id); +/// Return the Azure application id (client id) configured for that auth object. +char *pmc_msa_auth_app_id(const pmc_msa_auth *auth); +char *pmc_msa_auth_language_code(const pmc_msa_auth *auth); +void pmc_msa_auth_set_language_code(pmc_msa_auth *auth, const char *code); +pmc_msa_device_code_flow *pmc_msa_auth_request_device_code(const pmc_msa_auth *auth, pmc_err **err); + +char *pmc_msa_device_code_flow_app_id(const pmc_msa_device_code_flow *flow); +char *pmc_msa_device_code_flow_user_code(const pmc_msa_device_code_flow *flow); +char *pmc_msa_device_code_flow_verification_uri(const pmc_msa_device_code_flow *flow); +char *pmc_msa_device_code_flow_message(const pmc_msa_device_code_flow *flow); +pmc_msa_account *pmc_msa_device_code_flow_wait(const pmc_msa_device_code_flow *flow, pmc_err **err); + +char *pmc_msa_account_app_id(const pmc_msa_account *acc); +char *pmc_msa_account_access_token(const pmc_msa_account *acc); +pmc_uuid *pmc_msa_account_uuid(const pmc_msa_account *acc); +char *pmc_msa_account_username(const pmc_msa_account *acc); +char *pmc_msa_account_xuid(const pmc_msa_account *acc); +void pmc_msa_account_request_profile(pmc_msa_account *acc, pmc_err **err); +void pmc_msa_account_request_refresh(pmc_msa_account *acc, pmc_err **err); + +pmc_msa_database *pmc_msa_database_new(const char *file); +char *pmc_msa_database_file(const pmc_msa_database *database); +pmc_msa_account *pmc_msa_database_load_from_uuid(const pmc_msa_database *database, const pmc_uuid *uuid, pmc_err **err); +pmc_msa_account *pmc_msa_database_load_from_username(const pmc_msa_database *database, const char *username, pmc_err **err); +pmc_msa_account *pmc_msa_database_remove_from_uuid(const pmc_msa_database *database, const pmc_uuid *uuid, pmc_err **err); +pmc_msa_account *pmc_msa_database_remove_from_username(const pmc_msa_database *database, const char *username, pmc_err **err); +void pmc_msa_database_store(const pmc_msa_database *database, pmc_msa_account *acc, pmc_err **err); + +char *pmc_game_jvm_file(const pmc_game *game); +char *pmc_game_mc_dir(const pmc_game *game); +char *pmc_game_main_class(const pmc_game *game); +char *pmc_game_jvm_args(const pmc_game *game); +char *pmc_game_game_args(const pmc_game *game); + +pmc_base *pmc_base_new(const char *version); +char *pmc_base_version(const pmc_base *inst); +void pmc_base_set_version(pmc_base *inst, const char *version); +char *pmc_base_versions_dir(const pmc_base *inst); +void pmc_base_set_versions_dir(pmc_base *inst, const char *dir); +char *pmc_base_libraries_dir(const pmc_base *inst); +void pmc_base_set_libraries_dir(pmc_base *inst, const char *dir); +char *pmc_base_assets_dir(const pmc_base *inst); +void pmc_base_set_assets_dir(pmc_base *inst, const char *dir); +char *pmc_base_jvm_dir(const pmc_base *inst); +void pmc_base_set_jvm_dir(pmc_base *inst, const char *dir); +char *pmc_base_bin_dir(const pmc_base *inst); +void pmc_base_set_bin_dir(pmc_base *inst, const char *dir); +char *pmc_base_mc_dir(const pmc_base *inst); +void pmc_base_set_mc_dir(pmc_base *inst, const char *dir); +void pmc_base_set_main_dir(pmc_base *inst, const char *dir); +bool pmc_base_strict_assets_check(const pmc_base *inst); +void pmc_base_set_strict_assets_check(pmc_base *inst, bool strict); +bool pmc_base_strict_libraries_check(const pmc_base *inst); +void pmc_base_set_strict_libraries_check(pmc_base *inst, bool strict); +bool pmc_base_strict_jvm_check(const pmc_base *inst); +void pmc_base_set_strict_jvm_check(pmc_base *inst, bool strict); +pmc_jvm_policy *pmc_base_jvm_policy(const pmc_base *inst); +void pmc_base_set_jvm_policy(pmc_base *inst, pmc_jvm_policy policy); +char *pmc_base_launcher_name(const pmc_base *inst); +void pmc_base_set_launcher_name(pmc_base *inst, const char *name); +char *pmc_base_launcher_version(const pmc_base *inst); +void pmc_base_set_launcher_version(pmc_base *inst, const char *version); +pmc_game *pmc_base_install(pmc_base *inst, const pmc_standard_handler *handler, pmc_err **err); + +#ifdef __cplusplus +} +#endif + +#endif // _PORTABLEMC_H \ No newline at end of file diff --git a/rust/portablemc-ffi/src/alloc.rs b/rust/portablemc-ffi/src/alloc.rs new file mode 100644 index 00000000..c05a06ca --- /dev/null +++ b/rust/portablemc-ffi/src/alloc.rs @@ -0,0 +1,421 @@ +//! Memory allocation management through the FFI boundaries, allowing a unified free +//! function for every object. + +use std::alloc::{self, Layout, handle_alloc_error}; +use std::fmt::{Arguments, Write}; +use std::ffi::{c_char, c_void}; +use std::mem::offset_of; +use std::cell::RefCell; +use std::ptr; + +#[cfg(debug_assertions)] +use std::any::TypeId; + +use crate::cstr_bytes_from_str; + + +/// Internal type alias for the drop function pointer. +type DropFn = unsafe fn(value_ptr: *mut T); + +/// A generic C structure for the extern box. +/// +/// This type should never be instantiated, nor read/write as-is. +#[repr(C)] +struct ExternBox { + /// When debug assertions are enabled, this is used to check, before doing unsafe + /// stuff, that the interpreted type is correct. + #[cfg(debug_assertions)] + type_id: TypeId, + /// The number of values ('value' is also counted). + len: usize, + /// The drop function, which is also responsible for deallocating the whole structure, + /// we only give it the pointer to the value, so depending on the box type it can do + /// different things. + /// + /// Note that this field should not be accessed directly, because its actual offset + /// might be different depending on the alignment and the fact that we make sure that + /// it gets placed JUST BEFORE the value. Read [`extern_box_layout`]. + drop: DropFn, + /// The actual value being stored end pointed to in the FFI. + value: T, +} + +/// Internal function that compute the layout for allocating a `ExternBox` with `len` +/// values. +fn extern_box_layout(len: usize) -> Layout { + + // We start by allocating the real extern box layout, which may add a padding between + // the drop fn pointer and the value, to ensure that both are properly padded. + // Alignment will only be used if 'align_of(value) > align_of(drop)', we might + // get a padding (it also depends on the extension value) that is a multiple + // of 'align_of(drop)' and so we can also move that drop fn pointer at the end of + // that padding just before the value. + // + // Using 'Type = (size, align)' notation for types, and 'field(size)' for fields. + // + // With DropFn = (8, 8), T = (N, 16), usize = (8, 8) + // -> ExternBox { size(8), drop(8), _(16), value(N) } + // => In this case we move the drop fn into the padding. + // + // With DropFn = (8, 4), T = (N, 8), usize = (4, 4) + // -> ExternBox { size(4), drop(8), _(4), value(N) } + // => In this case we move the drop by 4 bytes, it will still be aligned. + // + // With DropFn = (8, 8), T = (N, 32), usize = (4, 4) + // -> ExternBox { size(4), _(4), drop(8), _(16), value(N) } + // => We still have the space to put drop at the end of the padding! + let layout = Layout::new::>(); + + // The ExternBox type only contains one value, we need to adjust for "len" values. + let size = offset_of!(ExternBox, value) + len * size_of::(); + + // SAFETY: Align is a power-of-two because it come from another layout. + unsafe { Layout::from_size_align_unchecked(size, layout.align()).pad_to_align() } + +} + +/// This function is only active when debug assert are effective, it checks that the +/// type id of the stored type is the same as the given pointer's type. +/// +/// SAFETY: This function is special because its role is to ensure that the stored type +/// id correspond to the given pointer's type, so if this is not guaranteed by the caller +/// then either this function will cause UB, panic or segfault (reading unaccessible +/// memory). This is a best-effort to catch UB if our logic is flawed. +#[track_caller] +unsafe fn extern_box_debug_assert(value_ptr: *mut T) { + let _ = value_ptr; // To avoid unused if not debug assertions. + #[cfg(debug_assertions)] + unsafe { + + let type_id = value_ptr + .wrapping_byte_sub(offset_of!(ExternBox, value)) + .wrapping_byte_add(offset_of!(ExternBox, type_id)) + .cast::() + .read(); + + assert_eq!(type_id, TypeId::of::(), "incoherent type id causing unsafe"); + + } +} + +/// Internal function to dealloc the given extern box. This DOES NOT drop the value. +/// +/// SAFETY: The given extern box pointer should be pointing to an initialized and valid +/// extern box of that exact type! +unsafe fn extern_box_free_unchecked(ptr: *mut ExternBox) { + unsafe { + + let len = ptr + .byte_add(offset_of!(ExternBox, len)) + .cast::() + .read(); + + // We can reconstruct the layout because we have the length. + let layout = extern_box_layout::(len); + alloc::dealloc(ptr.cast(), layout); + + } +} + +/// Drop the given extern-boxed value and then free the full allocation. +/// +/// SAFETY: The given extern box pointer should be pointing to an initialized and valid +/// extern box's value of that exact type! +pub unsafe fn extern_box_drop_unchecked(value_ptr: *mut T) { + + /// This guard is internally used to ensure that, despite any panic, the + /// allocation will be freed! + struct FreeGuard(*mut ExternBox); + impl Drop for FreeGuard { + fn drop(&mut self) { + // SAFETY: The SAFETY conditions of the super method applies here. + unsafe { + extern_box_free_unchecked(self.0); + } + } + } + + // SAFETY: We know that this points to 'ExternBox.value' and we access the + // fields safely using offset_of!. + unsafe { + + extern_box_debug_assert(value_ptr); + + let ptr = value_ptr + .byte_sub(offset_of!(ExternBox, value)) + .cast::>(); + + let len = ptr + .byte_add(offset_of!(ExternBox, len)) + .cast::() + .read(); + + let guard = FreeGuard(ptr); + std::ptr::slice_from_raw_parts_mut(value_ptr, len).drop_in_place(); + drop(guard); + + } + +} + +/// Allocate a raw extern box, returning the pointer to uninitialized value(s). +/// The number of values to put in the allocation must be given by 'len'. +#[inline] +fn extern_box_raw(len: usize) -> *mut T { + + let layout = extern_box_layout::(len); + + // SAFETY: Size can't be one, because we at least have the drop fn pointer. + let ptr = unsafe { alloc::alloc(layout).cast::>() }; + if ptr.is_null() { + handle_alloc_error(layout); + } + + // SAFETY: Read below. + #[cfg(debug_assertions)] + unsafe { + ptr.byte_add(offset_of!(ExternBox, type_id)) + .cast::() + .write(TypeId::of::()); + } + + // SAFETY: We point to the different fields safely using offset_of!, read the comment + // about layout in 'extern_box_layout' to understand that writing the drop fn pointer + // just before the value is always valid. + unsafe { + + ptr.byte_add(offset_of!(ExternBox, len)) + .cast::() + .write(len); + + ptr.byte_add(offset_of!(ExternBox, value)) + .byte_sub(size_of::>()) + .cast::>() + .write(extern_box_drop_unchecked::); + + ptr.byte_add(offset_of!(ExternBox, value)).cast::() + + } + +} + +/// Allocate the given object in a special box that also embed the drop function. +#[inline] +pub fn extern_box(value: T) -> *mut T { + // SAFETY: The function has reserved enough space to write one value. + let ptr = extern_box_raw::(1); + unsafe { ptr.write(value); } + ptr +} + +/// Allocate the given object in a special box that also embed the drop function. +#[inline] +pub fn extern_box_option(value: Option) -> *mut T { + match value { + Some(value) => extern_box(value), + None => ptr::null_mut(), + } +} + +/// Allocate the given slice of object in a special box that also embed the drop function. +#[inline] +pub fn extern_box_slice(slice: &[T]) -> *mut T { + // SAFETY: The function has reserved enough space to write all values. + let ptr = extern_box_raw::(slice.len()); + unsafe { ptr.copy_from_nonoverlapping(slice.as_ptr(), slice.len());} + ptr +} + +/// Allocate a C-string from some bytes slice representing a UTF-8 string that may contain +/// a nul byte, any nul byte will truncate early the cstr, the rest will be ignored. +pub fn extern_box_cstr_from_str>(s: S) -> *mut c_char { + + // Immediately safely find the CStr from the input UTF-8 string. + let cstr = cstr_bytes_from_str(s.as_ref()); + + // Add 1 for the terminating nul. + // NOTE: We don't directly allocate a 'u8' type, even if this would be correct, + // because we want to put the right type_id in debug, that correspond to the ptr type. + let ptr = extern_box_raw::(cstr.len() + 1).cast::(); + + // SAFETY: The function has reserved enough space to write the string with nul. + unsafe { + ptr.copy_from_nonoverlapping(cstr.as_ptr(), cstr.len()); + ptr.byte_add(cstr.len()).write(0); + } + + ptr.cast() + +} + +/// Allocate a C-string from the string bytes that are formatted with the given args. +pub fn extern_box_cstr_from_fmt(args: Arguments<'_>) -> *mut c_char { + + thread_local! { + // We use this thread local to + static BUF: RefCell = RefCell::new(String::new()); + } + + BUF.with_borrow_mut(|buf| { + // When borrowing, we expect the string to be empty! + if let Ok(_) = buf.write_fmt(args) { + let ptr = extern_box_cstr_from_str(buf.as_str()); + buf.clear(); + ptr + } else { + ptr::null_mut() + } + }) + +} + +/// Free the extern box pointing to the given value and return the given value. +/// +/// SAFETY: You must ensure that the value does point to an extern-boxed value that has +/// no yet been freed nor taken, exactly of the given type. +/// +/// FIXME: This only works if the extern box contains at least one value in length. +#[inline] +pub unsafe fn extern_box_take(value_ptr: *mut T) -> T { + + debug_assert!(!value_ptr.is_null()); + + // SAFETY: This function pre-condition ensure correctness of the reads. + unsafe { + + extern_box_debug_assert(value_ptr); + + // Start by reading the value, now the value at that position should never be + // read again, so we free that function. + let read = value_ptr.read(); + + // Now get the pointer to the extern box we want to free! + let ptr = value_ptr + .byte_sub(offset_of!(ExternBox, value)) + .cast::>(); + + // We're juste freeing the memory, not dropping the value, because we should not. + extern_box_free_unchecked(ptr); + + read + + } + +} + +// ======= +// Binding +// ======= + +/// SAFETY: You must ensure that the value does point to an extern-boxed value that has +/// no yet been freed. The pointer may be null, in which case nothing happens. +#[no_mangle] +pub unsafe extern "C" fn pmc_free(value_ptr: *mut c_void) { + + // Ignore null pointers, this can be used to simplify some code. + if value_ptr.is_null() { + return; + } + + unsafe { + + // SAFETY: Read the documentation of 'extern_box_layout' to understand the layout + // and the reason for why the drop fn pointer is placed just before the value. + let drop = value_ptr + .byte_sub(size_of::>()) + .cast::>() + .read(); + + // This drop function, as defined in 'extern_box_drop'. + drop(value_ptr); + + } + +} + +#[cfg(test)] +mod tests { + + use std::fmt::Debug; + use super::*; + + + #[repr(align(16))] + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] + struct Align16([u8; 16]); + + #[repr(align(32))] + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] + struct Align32([u8; 32]); + + #[test] + fn layout() { + + fn for_type() { + assert_eq!(Layout::new::>(), Layout::new::()); + assert_eq!(extern_box_layout::(0).size(), offset_of!(ExternBox, value)); + assert_eq!(extern_box_layout::(1).size(), size_of::>()); + assert_eq!(extern_box_layout::(9).size(), size_of::>() + size_of::() * 8); + } + + for_type::(); + for_type::(); + for_type::(); + for_type::(); + for_type::(); + for_type::(); + + } + + #[test] + fn structure() { + + fn for_value(value: T) { + unsafe { + + let ptr = extern_box(value); + assert_eq!(ptr.read(), value, "incoherent read value"); + + let drop = ptr + .byte_sub(size_of::>()) + .cast::>(); + assert!(drop.is_aligned(), "unaligned drop function"); + + extern_box_drop_unchecked(ptr); + + } + } + + for_value(0x12u8); + for_value(0x1234u16); + for_value(0x12345678u32); + for_value(0x123456789ABCDEF0u64); + for_value(Align16::default()); + for_value(Align32::default()); + + } + + #[test] + fn special() { + assert_eq!(extern_box_option(None::), ptr::null_mut()); + } + + #[test] + fn cstr() { + + /// NOTE: c_len don't count nul. + fn for_str(s: &str, c_len: usize) { + let cstr = extern_box_cstr_from_str(s); + let cstr_slice = unsafe { std::slice::from_raw_parts(cstr.cast::(), c_len + 1) }; + assert_eq!(&cstr_slice[..c_len], &s.as_bytes()[..c_len], "incoherent cstr"); + assert_eq!(cstr_slice[c_len], 0, "missing nul"); + unsafe { extern_box_drop_unchecked(cstr); } + } + + for_str("Hello world!", 12); + for_str("Hello world!\0", 12); + for_str("Hello world!\0rest", 12); + + } + +} diff --git a/rust/portablemc-ffi/src/err.rs b/rust/portablemc-ffi/src/err.rs new file mode 100644 index 00000000..7c60ddc1 --- /dev/null +++ b/rust/portablemc-ffi/src/err.rs @@ -0,0 +1,82 @@ +//! Utilities for easier error handling around the `raw::pmc_err` type. + +use std::ffi::CStr; +use std::pin::Pin; +use std::ptr; + +use crate::alloc::{extern_box, extern_box_drop_unchecked}; +use crate::raw; + + +#[inline] +pub fn extern_err_static(tag: raw::pmc_err_tag, data: raw::pmc_err_data, message: &'static CStr) -> *mut raw::pmc_err { + extern_box(raw::pmc_err { + tag, + data, + message: message.as_ptr(), + }) +} + +#[inline] +pub fn extern_err(tag: raw::pmc_err_tag, data: raw::pmc_err_data, message: String) -> *mut raw::pmc_err { + + let owned_message = Pin::new(crate::ensure_nul_terminated(message)); + + extern_box(ExternErr { + inner: raw::pmc_err { + tag, + data, + message: owned_message.as_ptr(), + }, + owned_message, + owned: (), + }) + +} + +/// A trait to bundle an error into an extern `pmc_err` allocated object. +pub trait IntoExternErr { + fn into(self) -> *mut raw::pmc_err; +} + +/// If this result is an error, then the error is extracted and moved into an extern +/// error, using [`extern_err`], and written in the pointer. Note that if the pointer +/// of the error is not null, then it is freed anyway, error or not. +#[inline] +pub fn extern_err_with(err_ptr: *mut *mut raw::pmc_err, func: F) -> Result +where + E: IntoExternErr, + F: FnOnce() -> Result, +{ + + // If the given pointer isn't null, then we read it, and if this pointer isn't null + // we free the old error first and set it null. + if !err_ptr.is_null() { + // SAFETY: A pointer is copy and we requires that it's not null and points to + // an initialized pointer, even if null. + let old_err = unsafe { err_ptr.replace(ptr::null_mut()) }; + if !old_err.is_null() { + // SAFETY: The caller ensure that if there was a pointer, it was a Err ptr. + unsafe { extern_box_drop_unchecked(old_err); } + } + } + + match func() { + Ok(val) => Ok(val), + Err(err) => { + // SAFETY: Write the extern error's pointer we just allocated. We are + // replacing the null pointer we stored above. + unsafe { err_ptr.write(err.into()); } + Err(()) + } + } + +} + +/// Implementation of the `pmc_err` type. +#[repr(C)] +struct ExternErr { + inner: raw::pmc_err, + owned_message: Pin>, + owned: O, +} diff --git a/rust/portablemc-ffi/src/lib.rs b/rust/portablemc-ffi/src/lib.rs new file mode 100644 index 00000000..811e38ab --- /dev/null +++ b/rust/portablemc-ffi/src/lib.rs @@ -0,0 +1,63 @@ +//! PortableMC FFI bindings for external languages such as C. +//! +//! The goal is to have an extensible and as complete as possible C interface to allow +//! any other language to bind onto it, because almost all languages can bind to a C +//! (shared) object. +//! +//! In this library, the naming scheme is simple. All functions that are exported and +//! therefore also defined in the header file are prefixed with `pmc_`, they should use +//! the extern "C" ABI. +#![deny(unsafe_op_in_unsafe_fn)] + +pub mod raw; + +pub mod alloc; +pub mod err; + +pub mod msa; + +pub mod standard; + + +use std::borrow::Cow; +use std::ffi::{c_char, CStr}; + + +/// Load a UTF-8 string from a C nul-terminated pointer, this returns none if the given +/// string is not UTF-8. +/// +/// # SAFETY +/// +/// It starts by creating a [`CStr`] from the given pointer, so the caller must uphold +/// the same safety guarantees than [`CStr::from_ptr`]. +#[inline] +pub unsafe fn str_from_cstr_ptr<'a>(cstr: *const c_char) -> Option<&'a str> { + let cstr = unsafe { CStr::from_ptr(cstr) }; + cstr.to_str().ok() +} + +/// Same as [`str_from_cstr_ptr`] but returning an owned string with replacement +/// characters for +#[inline] +pub unsafe fn str_lossy_from_cstr_ptr<'a>(cstr: *const c_char) -> Cow<'a, str> { + let cstr = unsafe { CStr::from_ptr(cstr) }; + cstr.to_string_lossy() +} + +/// Get a bytes slice of cstr of the string, optionally until a nul-terminating char. +/// The nul char is not included! +#[inline] +pub fn cstr_bytes_from_str(s: &str) -> &[u8] { + let bytes = s.as_bytes(); + let len = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + &bytes[..len] +} + +#[inline] +pub fn ensure_nul_terminated(bytes: impl Into>) -> Box<[u8]> { + let mut bytes = Vec::::from(bytes); + let len = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + bytes.truncate(len); + bytes.push(0); + bytes.into_boxed_slice() +} diff --git a/rust/portablemc-ffi/src/msa.rs b/rust/portablemc-ffi/src/msa.rs new file mode 100644 index 00000000..c7355073 --- /dev/null +++ b/rust/portablemc-ffi/src/msa.rs @@ -0,0 +1,280 @@ +//! MSA bindings for C. + +use std::ffi::c_char; +use std::ptr; + +use portablemc::msa::{Account, Auth, AuthError, Database, DatabaseError, DeviceCodeFlow}; +use uuid::Uuid; + +use crate::alloc::{extern_box, extern_box_option, extern_box_cstr_from_str, extern_box_take}; +use crate::err::{extern_err_with, extern_err_static, extern_err, IntoExternErr}; +use crate::str_from_cstr_ptr; +use crate::raw; + + +// ======= +// Module errors +// ======= + +impl IntoExternErr for AuthError { + + fn into(self) -> *mut raw::pmc_err { + + use raw::pmc_err_tag::*; + + let (tag, message) = match self { + AuthError::Declined => ( + PMC_ERR_MSA_AUTH_DECLINED, + c"Declined"), + AuthError::TimedOut => ( + PMC_ERR_MSA_AUTH_TIMED_OUT, + c"Timed out"), + AuthError::OutdatedToken => ( + PMC_ERR_MSA_AUTH_OUTDATED_TOKEN, + c"Minecraft profile token is outdated, you can try to refresh the profile"), + AuthError::DoesNotOwnGame => ( + PMC_ERR_MSA_AUTH_DOES_NOT_OWN_GAME, + c"This Microsoft account does not own Minecraft"), + AuthError::InvalidStatus(status) => return extern_err( + PMC_ERR_MSA_AUTH_INVALID_STATUS, + raw::pmc_err_data_msa_auth_invalid_status { status }.into(), + format!("An unknown HTTP status has been received: {status}")), + AuthError::Unknown(message) => return extern_err( + PMC_ERR_MSA_AUTH_UNKNOWN, + raw::pmc_err_data_msa_auth_unknown { message: }, + format!("An unknown error happened: {message}")), + AuthError::Internal(e) => return extern_err( + PMC_ERR_INTERNAL, + raw::pmc_err_data_internal { origin: ptr::null() }, + e.to_string()), + _ => todo!(), + }; + + extern_err_static(tag, raw::pmc_err_data::default(), message) + + } + +} + +impl ExposedError for AuthError { + + fn code(&self) -> u8 { + match self { + AuthError::Declined => err::code::MSA_AUTH_DECLINED, + AuthError::TimedOut => err::code::MSA_AUTH_TIMED_OUT, + AuthError::OutdatedToken => err::code::MSA_AUTH_OUTDATED_TOKEN, + AuthError::DoesNotOwnGame => err::code::MSA_AUTH_DOES_NOT_OWN_GAME, + AuthError::InvalidStatus(_) => err::code::MSA_AUTH_INVALID_STATUS, + AuthError::Unknown(_) => err::code::MSA_AUTH_UNKNOWN, + AuthError::Internal(_) => err::code::INTERNAL, + _ => todo!(), + } + } + + fn extern_data(&self) -> *mut () { + match *self { + AuthError::InvalidStatus(status) => extern_box(status).cast(), + AuthError::Unknown(ref error) => extern_box_cstr_from_str(error).cast(), + _ => ptr::null_mut(), + } + } + +} + +impl ExposedError for DatabaseError { + fn code(&self) -> u8 { + match self { + DatabaseError::Io(_) => err::code::MSA_DATABASE_IO, + DatabaseError::Corrupted => err::code::MSA_DATABASE_CORRUPTED, + DatabaseError::WriteFailed => err::code::MSA_DATABASE_WRITE_FAILED, + _ => todo!(), + } + } +} + +// ======= +// Binding for Auth +// ======= + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_auth_new(app_id: *const c_char) -> *mut Auth { + + let Some(app_id) = (unsafe { str_from_cstr_ptr(app_id) }) else { + return ptr::null_mut(); + }; + + extern_box(Auth::new(app_id)) + +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_auth_app_id(auth: &Auth) -> *mut c_char { + extern_box_cstr_from_str(auth.app_id()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_auth_language_code(auth: &Auth) -> *mut c_char { + auth.language_code().map(extern_box_cstr_from_str).unwrap_or(ptr::null_mut()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_auth_set_language_code(auth: &mut Auth, code: *const c_char) { + + let Some(code) = (unsafe { str_from_cstr_ptr(code) }) else { + return; + }; + + auth.set_language_code(code); + +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_auth_request_device_code(auth: &Auth, err: *mut *mut Err) -> *mut DeviceCodeFlow { + extern_err_with(err, || { + auth.request_device_code().map(extern_box) + }).unwrap_or(ptr::null_mut()) +} + +// ======= +// Binding for DeviceCodeFlow +// ======= + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_device_code_flow_app_id(flow: &DeviceCodeFlow) -> *mut c_char { + extern_box_cstr_from_str(flow.app_id()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_device_code_flow_user_code(flow: &DeviceCodeFlow) -> *mut c_char { + extern_box_cstr_from_str(flow.user_code()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_device_code_flow_verification_uri(flow: &DeviceCodeFlow) -> *mut c_char { + extern_box_cstr_from_str(flow.verification_uri()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_device_code_flow_message(flow: &DeviceCodeFlow) -> *mut c_char { + extern_box_cstr_from_str(flow.message()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_device_code_flow_wait(flow: &DeviceCodeFlow, err: *mut *mut Err) -> *mut Account { + extern_err_with(err, || { + flow.wait().map(extern_box) + }).unwrap_or(ptr::null_mut()) +} + +// ======= +// Binding for Account +// ======= + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_account_app_id(acc: &Account) -> *mut c_char { + extern_box_cstr_from_str(acc.app_id()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_account_access_token(acc: &Account) -> *mut c_char { + extern_box_cstr_from_str(acc.access_token()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_account_uuid(acc: &Account) -> *mut pmc_uuid { + extern_box(acc.uuid().as_bytes().clone()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_account_username(acc: &Account) -> *mut c_char { + extern_box_cstr_from_str(acc.username()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_account_xuid(acc: &Account) -> *mut c_char { + extern_box_cstr_from_str(acc.xuid()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_account_request_profile(acc: &mut Account, err: *mut *mut Err) { + let _ = extern_err_with(err, || { + acc.request_profile() + }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_account_request_refresh(acc: &mut Account, err: *mut *mut Err) { + let _ = extern_err_with(err, || { + acc.request_refresh() + }); +} + +// ======= +// Binding for Database +// ======= + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_database_new(file: *const c_char) -> *mut Database { + + let Some(path) = (unsafe { str_from_cstr_ptr(file) }) else { + return ptr::null_mut(); + }; + + extern_box(Database::new(path)) + +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_database_file(db: &Database) -> *mut c_char { + db.file().as_os_str().to_str().map(extern_box_cstr_from_str).unwrap_or(ptr::null_mut()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_database_load_from_uuid(db: &Database, uuid: *const pmc_uuid, err: *mut *mut Err) -> *mut Account { + extern_err_with(err, || { + let uuid = Uuid::from_bytes(unsafe { *uuid }); + db.load_from_uuid(uuid).map(extern_box_option) + }).unwrap_or(ptr::null_mut()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_database_load_from_username(db: &Database, username: *const c_char, err: *mut *mut Err) -> *mut Account { + extern_err_with(err, || { + + let Some(username) = (unsafe { str_from_cstr_ptr(username) }) else { + return Ok(ptr::null_mut()); + }; + + db.load_from_username(username).map(extern_box_option) + + }).unwrap_or(ptr::null_mut()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_database_remove_from_uuid(db: &Database, uuid: *const pmc_uuid, err: *mut *mut Err) -> *mut Account { + extern_err_with(err, || { + let uuid = Uuid::from_bytes(unsafe { *uuid }); + db.remove_from_uuid(uuid).map(extern_box_option) + }).unwrap_or(ptr::null_mut()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_database_remove_from_username(db: &Database, username: *const c_char, err: *mut *mut Err) -> *mut Account { + extern_err_with(err, || { + + let Some(username) = (unsafe { str_from_cstr_ptr(username) }) else { + return Ok(ptr::null_mut()); + }; + + db.remove_from_username(username).map(extern_box_option) + + }).unwrap_or(ptr::null_mut()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_msa_database_store(db: &Database, acc: *mut Account, err: *mut *mut Err) { + let _ = extern_err_with(err, || { + let acc = unsafe { extern_box_take(acc) }; + db.store(acc) + }); +} diff --git a/rust/portablemc-ffi/src/raw/generated.rs b/rust/portablemc-ffi/src/raw/generated.rs new file mode 100644 index 00000000..7ed6a41c --- /dev/null +++ b/rust/portablemc-ffi/src/raw/generated.rs @@ -0,0 +1,190 @@ +/* automatically generated by rust-bindgen 0.71.1 */ + +#[doc = " An array of 16 bytes representing an UUID."] +pub type pmc_uuid = [u8; 16usize]; +#[repr(i32)] +#[doc = " The code of an error that can be retrieved via"] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum pmc_err_tag { + PMC_ERR_UNSET = 0, + PMC_ERR_INTERNAL = 1, + PMC_ERR_MSA_AUTH_DECLINED = 16, + PMC_ERR_MSA_AUTH_TIMED_OUT = 17, + PMC_ERR_MSA_AUTH_OUTDATED_TOKEN = 18, + PMC_ERR_MSA_AUTH_DOES_NOT_OWN_GAME = 19, + PMC_ERR_MSA_AUTH_INVALID_STATUS = 20, + PMC_ERR_MSA_AUTH_UNKNOWN = 21, + PMC_ERR_MSA_DATABASE_IO = 32, + PMC_ERR_MSA_DATABASE_CORRUPTED = 33, + PMC_ERR_MSA_DATABASE_WRITE_FAILED = 34, + PMC_ERR_STANDARD_HIERARCHY_LOOP = 48, + PMC_ERR_STANDARD_VERSION_NOT_FOUND = 49, + PMC_ERR_STANDARD_ASSETS_NOT_FOUND = 50, + PMC_ERR_STANDARD_CLIENT_NOT_FOUND = 51, + PMC_ERR_STANDARD_LIBRARY_NOT_FOUND = 52, + PMC_ERR_STANDARD_JVM_NOT_FOUND = 53, + PMC_ERR_STANDARD_MAIN_CLASS_NOT_FOUND = 54, + PMC_ERR_STANDARD_DOWNLOAD_RESOURCES_CANCELLED = 55, + PMC_ERR_STANDARD_DOWNLOAD = 56, +} +#[doc = " PMC_ERR_INTERNAL"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_err_data_internal { + pub origin: *const ::std::ffi::c_char, +} +#[doc = " PMC_ERR_MSA_AUTH_INVALID_STATUS"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_err_data_msa_auth_invalid_status { + pub status: u16, +} +#[doc = " PMC_ERR_MSA_AUTH_UNKNOWN"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_err_data_msa_auth_unknown { + pub message: *const ::std::ffi::c_char, +} +#[doc = " PMC_ERR_STANDARD_HIERARCHY_LOOP"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_err_std_hierarchy_loop { + pub version: *const ::std::ffi::c_char, +} +#[doc = " PMC_ERR_STANDARD_VERSION_NOT_FOUND"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_err_std_version_not_found { + pub version: *const ::std::ffi::c_char, +} +#[doc = " PMC_ERR_STANDARD_ASSETS_NOT_FOUND"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_err_std_assets_not_found { + pub id: *const ::std::ffi::c_char, +} +#[doc = " PMC_ERR_STANDARD_JVM_NOT_FOUND"] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_err_std_jvm_not_found { + pub major_version: u32, +} +#[doc = " The union of all data types for errors."] +#[repr(C)] +#[derive(Copy, Clone)] +pub union pmc_err_data { + pub _none: ::std::ffi::c_int, + pub internal: pmc_err_data_internal, + pub msa_auth_invalid_status: pmc_err_data_msa_auth_invalid_status, + pub msa_auth_unknown: pmc_err_data_msa_auth_unknown, + pub std_hierarchy_loop: pmc_err_std_hierarchy_loop, + pub std_version_not_found: pmc_err_std_version_not_found, + pub std_assets_not_found: pmc_err_std_assets_not_found, + pub std_jvm_not_found: pmc_err_std_jvm_not_found, +} +#[doc = " Generic error type, you should usually use this type by defining a null-pointer to it\n and passing a pointer to that pointer to any function that accepts it. If an error\n happens, the function will allocate an error and then write its pointer in the given\n location. The error should be freed afterward.\n\n This structure has a known layout in C."] +#[repr(C)] +#[derive(Copy, Clone)] +pub struct pmc_err { + #[doc = " Tag of the error."] + pub tag: pmc_err_tag, + #[doc = " The data of the tag, that can be used depending on the error tag."] + pub data: pmc_err_data, + #[doc = " The descriptive human-readable message for the error."] + pub message: *const ::std::ffi::c_char, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_msa_auth { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_msa_device_code_flow { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_msa_account { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_msa_database { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_game { + _unused: [u8; 0], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_standard { + _unused: [u8; 0], +} +#[doc = " The installer that supports the minimal standard format for version metadata with\n support for libraries, assets and loggers automatic installation. By defaults, it\n also supports finding a suitable JVM for running the game."] +pub type pmc_base = pmc_standard; +#[repr(i32)] +#[doc = " The tag for the pmc_jvm_policy tagged union."] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum pmc_jvm_policy_tag { + PMC_JVM_POLICY_STATIC = 0, + PMC_JVM_POLICY_SYSTEM = 1, + PMC_JVM_POLICY_MOJANG = 2, + PMC_JVM_POLICY_SYSTEM_THEN_MOJANG = 3, + PMC_JVM_POLICY_MOJANG_THEN_SYSTEM = 4, +} +#[doc = " The JVM policy tagged union, only the static policy requires an explicit path value."] +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_jvm_policy { + pub tag: pmc_jvm_policy_tag, + pub static_path: *const ::std::ffi::c_char, +} +#[repr(i32)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum pmc_version_channel { + PMC_VERSION_CHANNEL_RELEASE = 0, + PMC_VERSION_CHANNEL_SNAPSHOT = 1, + PMC_VERSION_CHANNEL_BETA = 2, + PMC_VERSION_CHANNEL_ALPHA = 3, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pmc_loaded_version { + pub name: *const ::std::ffi::c_char, + pub dir: *const ::std::ffi::c_char, + pub channel: pmc_version_channel, +} +#[repr(i32)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum pmc_standard_event_tag { + PMC_EVENT_FILTER_FEATURES = 0, + PMC_EVENT_LOADED_FEATURES = 1, + PMC_EVENT_LOAD_HIERARCHY = 2, + PMC_EVENT_LOADED_HIERARCHY = 3, + PMC_EVENT_LOAD_VERSION = 4, + PMC_EVENT_NEED_VERSION = 5, + PMC_EVENT_LOADED_VERSION = 6, + PMC_EVENT_LOAD_CLIENT = 7, + PMC_EVENT_LOADED_CLIENT = 8, + PMC_EVENT_LOAD_LIBRARIES = 9, + PMC_EVENT_FILTER_LIBRARIES = 10, + PMC_EVENT_LOADED_LIBRARIES = 11, + PMC_EVENT_FILTER_LIBRARIES_FILES = 12, + PMC_EVENT_LOADED_LIBRARIES_FILES = 13, +} +#[repr(C)] +#[derive(Copy, Clone)] +pub union pmc_standard_event_data { + pub _address: u8, +} +#[repr(C)] +#[derive(Copy, Clone)] +pub struct pmc_standard_event { + pub tag: pmc_standard_event_tag, + pub data: pmc_standard_event_data, +} +pub type pmc_standard_handler = + ::std::option::Option; diff --git a/rust/portablemc-ffi/src/raw/mod.rs b/rust/portablemc-ffi/src/raw/mod.rs new file mode 100644 index 00000000..8784ebb6 --- /dev/null +++ b/rust/portablemc-ffi/src/raw/mod.rs @@ -0,0 +1,51 @@ +//! The C language type bindings. + +/// The header file contains the bindings to the C part of the header file. This file +/// should be generated with bindgen using the following command in order to only generate +/// layouts for structures, and not functions. Functions should be manually defined. +/// $ bindgen include/portablemc.h +/// -o src/raw/generated.rs +/// --generate types +/// --allowlist-type "pmc_.*" +/// --default-enum-style rust +/// --ctypes-prefix ::std::ffi +/// --no-layout-tests +/// --no-size_t-is-usize +#[allow(non_camel_case_types)] +mod generated; +pub use generated::*; + + +/// Internal macro to implement the [`From`] trait for each field to their union. +macro_rules! impl_union_from_field { + ( + for $union_type:ident, + $( $field:ident : $field_type:ident ),* $(,)? + ) => { + $( + impl From<$field_type> for $union_type { + #[inline] + fn from($field: $field_type) -> Self { + Self { $field } + } + } + )* + }; +} + +impl Default for pmc_err_data { + fn default() -> Self { + pmc_err_data { _none: 0 } + } +} + +impl_union_from_field! { + for pmc_err_data, + internal: pmc_err_data_internal, + msa_auth_invalid_status: pmc_err_data_msa_auth_invalid_status, + msa_auth_unknown: pmc_err_data_msa_auth_unknown, + std_hierarchy_loop: pmc_err_std_hierarchy_loop, + std_version_not_found: pmc_err_std_version_not_found, + std_assets_not_found: pmc_err_std_assets_not_found, + std_jvm_not_found: pmc_err_std_jvm_not_found, +} diff --git a/rust/portablemc-ffi/src/standard.rs b/rust/portablemc-ffi/src/standard.rs new file mode 100644 index 00000000..15dc5b7e --- /dev/null +++ b/rust/portablemc-ffi/src/standard.rs @@ -0,0 +1,284 @@ +//! Standard installer. + +use std::path::PathBuf; +use std::ffi::c_char; +use std::pin::Pin; +use std::ptr; + +use portablemc::base::{Installer, JvmPolicy, Game, Error}; + +use crate::alloc::{extern_box, extern_box_cstr_from_fmt, extern_box_cstr_from_str}; +use crate::{str_lossy_from_cstr_ptr, cstr_bytes_from_string}; +use crate::err::{self, extern_err_with, Err, ExposedError}; + + +// ======= +// Module errors +// ======= + +impl ExposedError for Error { + + fn code(&self) -> u8 { + match self { + Error::HierarchyLoop { .. } => err::code::STANDARD_HIERARCHY_LOOP, + Error::VersionNotFound { .. } => err::code::STANDARD_VERSION_NOT_FOUND, + Error::AssetsNotFound { .. } => err::code::STANDARD_ASSETS_NOT_FOUND, + Error::ClientNotFound { .. } => err::code::STANDARD_CLIENT_NOT_FOUND, + Error::LibraryNotFound { .. } => err::code::STANDARD_LIBRARY_NOT_FOUND, + Error::JvmNotFound { .. } => err::code::STANDARD_JVM_NOT_FOUND, + Error::MainClassNotFound { .. } => err::code::STANDARD_MAIN_CLASS_NOT_FOUND, + Error::DownloadResourcesCancelled { .. } => err::code::STANDARD_DOWNLOAD_RESOURCES_CANCELLED, + Error::Download { .. } => err::code::STANDARD_DOWNLOAD, + Error::Internal { .. } => err::code::INTERNAL, + _ => todo!(), + } + } + + fn extern_data(&self) -> *mut () { + match *self { + Error::HierarchyLoop { ref version } => extern_box_cstr_from_str(version).cast(), + Error::VersionNotFound { ref version } => extern_box_cstr_from_str(version).cast(), + Error::AssetsNotFound { ref id } => extern_box_cstr_from_str(id).cast(), + Error::LibraryNotFound { ref gav } => extern_box_cstr_from_str(gav.as_str()).cast(), + Error::JvmNotFound { major_version } => extern_box(major_version).cast(), + // TODO: Error::Download { .. } + Error::Internal { ref origin, .. } => extern_box_cstr_from_str(origin).cast(), + _ => ptr::null_mut(), + } + } + +} + +// ======= +// Binding for Installer +// ======= + +/// The external type defined in the header. +#[repr(C)] +#[allow(non_camel_case_types)] +pub enum pmc_jvm_policy_tag { + Static, + System, + Mojang, + SystemThenMojang, + MojangThenSystem, +} + +/// The external type defined in the header. +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct pmc_jvm_policy { + tag: pmc_jvm_policy_tag, + static_path: *const c_char, +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_new(version: *const c_char) -> *mut Installer { + extern_box(Installer::new(unsafe { str_lossy_from_cstr_ptr(version) })) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_version(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_str(inst.version()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_version(inst: &mut Installer, version: *const c_char) { + inst.set_version(unsafe { str_lossy_from_cstr_ptr(version) }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_versions_dir(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", inst.versions_dir().display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_versions_dir(inst: &mut Installer, dir: *const c_char) { + inst.set_versions_dir(unsafe { str_lossy_from_cstr_ptr(dir).to_string() }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_libraries_dir(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", inst.libraries_dir().display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_libraries_dir(inst: &mut Installer, dir: *const c_char) { + inst.set_libraries_dir(unsafe { str_lossy_from_cstr_ptr(dir).to_string() }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_assets_dir(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", inst.assets_dir().display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_assets_dir(inst: &mut Installer, dir: *const c_char) { + inst.set_assets_dir(unsafe { str_lossy_from_cstr_ptr(dir).to_string() }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_jvm_dir(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", inst.jvm_dir().display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_jvm_dir(inst: &mut Installer, dir: *const c_char) { + inst.set_jvm_dir(unsafe { str_lossy_from_cstr_ptr(dir).to_string() }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_bin_dir(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", inst.bin_dir().display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_bin_dir(inst: &mut Installer, dir: *const c_char) { + inst.set_bin_dir(unsafe { str_lossy_from_cstr_ptr(dir).to_string() }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_mc_dir(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", inst.mc_dir().display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_mc_dir(inst: &mut Installer, dir: *const c_char) { + inst.set_mc_dir(unsafe { str_lossy_from_cstr_ptr(dir).to_string() }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_main_dir(inst: &mut Installer, dir: *const c_char) { + inst.set_main_dir(unsafe { str_lossy_from_cstr_ptr(dir).to_string() }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_strict_assets_check(inst: &Installer) -> bool { + inst.strict_assets_check() +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_strict_assets_check(inst: &mut Installer, strict: bool) { + inst.set_strict_assets_check(strict); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_strict_libraries_check(inst: &Installer) -> bool { + inst.strict_libraries_check() +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_strict_libraries_check(inst: &mut Installer, strict: bool) { + inst.set_strict_libraries_check(strict); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_strict_jvm_check(inst: &Installer) -> bool { + inst.strict_jvm_check() +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_strict_jvm_check(inst: &mut Installer, strict: bool) { + inst.set_strict_jvm_check(strict); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_jvm_policy(inst: &Installer) -> *mut pmc_jvm_policy { + + /// This wrapper type is used to return the JVM policy allocated and, if static, + /// pointing to the inner buffer. The inner type that we return the pointer for + /// must be placed first. + #[repr(C)] + struct ExternJvmPolicy { + inner: pmc_jvm_policy, + owned_static_path: Option>>, + } + + let tag = match inst.jvm_policy() { + JvmPolicy::Static(_) => pmc_jvm_policy_tag::Static, + JvmPolicy::System => pmc_jvm_policy_tag::System, + JvmPolicy::Mojang => pmc_jvm_policy_tag::Mojang, + JvmPolicy::SystemThenMojang => pmc_jvm_policy_tag::SystemThenMojang, + JvmPolicy::MojangThenSystem => pmc_jvm_policy_tag::MojangThenSystem, + }; + + let owned_static_path = if let JvmPolicy::Static(static_path) = inst.jvm_policy() { + Some(Pin::new(cstr_bytes_from_string(format!("{}", static_path.display())))) + } else { + None + }; + + extern_box(ExternJvmPolicy { + inner: pmc_jvm_policy { + tag, + static_path: owned_static_path + .as_deref() + .map(|slice| slice.as_ptr().cast::()) + .unwrap_or(ptr::null()), + }, + owned_static_path, + }).cast() + +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_jvm_policy(inst: &mut Installer, policy: &pmc_jvm_policy) { + inst.set_jvm_policy(match policy.tag { + pmc_jvm_policy_tag::Static => + JvmPolicy::Static(PathBuf::from(unsafe { str_lossy_from_cstr_ptr(policy.static_path).to_string() })), + pmc_jvm_policy_tag::System => JvmPolicy::System, + pmc_jvm_policy_tag::Mojang => JvmPolicy::Mojang, + pmc_jvm_policy_tag::SystemThenMojang => JvmPolicy::SystemThenMojang, + pmc_jvm_policy_tag::MojangThenSystem => JvmPolicy::MojangThenSystem, + }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_launcher_name(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_str(inst.launcher_name()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_launcher_name(inst: &mut Installer, name: *const c_char) { + inst.set_launcher_name(unsafe { str_lossy_from_cstr_ptr(name) }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_launcher_version(inst: &Installer) -> *mut c_char { + extern_box_cstr_from_str(inst.launcher_version()) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_set_launcher_version(inst: &mut Installer, version: *const c_char) { + inst.set_launcher_version(unsafe { str_lossy_from_cstr_ptr(version) }); +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_standard_install(inst: &mut Installer, err: *mut *mut Err) -> *mut Game { + extern_err_with(err, || { + inst.install(()).map(extern_box) + }).unwrap_or(ptr::null_mut()) +} + +// TODO: + +// ======= +// Binding for Game +// ======= + +#[no_mangle] +pub unsafe extern "C" fn pmc_game_jvm_file(game: &Game) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", game.jvm_file.display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_game_mc_dir(game: &Game) -> *mut c_char { + extern_box_cstr_from_fmt(format_args!("{}", game.mc_dir.display())) +} + +#[no_mangle] +pub unsafe extern "C" fn pmc_game_main_class(game: &Game) -> *mut c_char { + extern_box_cstr_from_str(&game.main_class) +} + +// TODO: \ No newline at end of file diff --git a/rust/portablemc-ffi/tests/ffi.rs.dis b/rust/portablemc-ffi/tests/ffi.rs.dis new file mode 100644 index 00000000..cc88f794 --- /dev/null +++ b/rust/portablemc-ffi/tests/ffi.rs.dis @@ -0,0 +1,93 @@ +//! This test tries to compile with the local C compiler the test file, and then execute +//! it to check if everything works. + +use std::process::Command; +use std::path::PathBuf; +use std::fs; + + +#[test] +fn compile_link_exec() { + + static TEST_FILES: [&str; 1] = ["main.c"]; + + fs::create_dir_all(env!("CARGO_TARGET_TMPDIR")).unwrap(); + let tmp_dir = tempfile::Builder::new() + .prefix("") + .suffix(".ffi") + .tempdir_in(env!("CARGO_TARGET_TMPDIR")) + .unwrap() + .into_path(); + + println!("tmp_dir: {tmp_dir:?}"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let include_dir = manifest_dir.join("include"); + let ffi_dir = manifest_dir.join("tests").join("ffi"); + + let mut compilation_database = Vec::new(); + + for test_file in TEST_FILES { + + let src_file = ffi_dir.join(test_file); + println!("src_file: {src_file:?}"); + + let mut compile_cmd; + let mut out_file; + + if let Some(test_name) = test_file.strip_suffix(".c") { + + out_file = tmp_dir.join(test_name); + + if cfg!(target_family = "windows") && cfg!(target_env = "msvc") { + + compile_cmd = Command::new("cl.exe"); + compile_cmd.arg("/nologo"); + compile_cmd.arg(format!("/I{}", include_dir.display())); + compile_cmd.arg("/W4"); + compile_cmd.arg(format!("/Fo:{}", out_file.display())); + compile_cmd.arg(format!("/Fe:{}", out_file.display())); + compile_cmd.arg(src_file); + + out_file.as_mut_os_string().push(".exe"); + + } else { + panic!("No compiler found for your target!"); + } + + compilation_database.push(CommandObject { + directory: format!("{}", ffi_dir.display()), + arguments: compile_cmd.get_args().map(|arg| arg.to_string_lossy().to_string()).collect(), + file: test_name.to_string(), + }); + + } else { + panic!("This type of test file is not supported!"); + } + + println!("cmd: {compile_cmd:?}"); + println!("out_file: {out_file:?}"); + + let status = compile_cmd.spawn() + .unwrap() + .wait() + .unwrap(); + + println!("status: {status:?}"); + + } + + panic!(); + + // Only remove it here so when the test did not panic. + fs::remove_dir_all(&tmp_dir).unwrap(); + +} + + +#[derive(Debug, serde::Serialize)] +struct CommandObject { + directory: String, + arguments: Vec, + file: String, +} diff --git a/rust/portablemc-ffi/tests/ffi/main.c b/rust/portablemc-ffi/tests/ffi/main.c new file mode 100644 index 00000000..8db972c3 --- /dev/null +++ b/rust/portablemc-ffi/tests/ffi/main.c @@ -0,0 +1,38 @@ +// This file + +#include "../../include/portablemc.h" +#include + + +void handle_err(pmc_err *err); + +int main() { + + pmc_err *err = NULL; + + pmc_msa_auth *auth = pmc_msa_auth_new("appid"); + + pmc_msa_device_code_flow *flow = pmc_msa_auth_request_device_code(auth, &err); + if (err) { + handle_err(err); + return 1; + } + + return 0; + +} + +void handle_err(pmc_err *err) { + + switch (pmc_err_code(err)) { + case PMC_ERR_INTERNAL: + printf("Internal error\n"); + break; + default: + char *message = pmc_err_message(err); + printf("Unhandled error: %s\n", message); + pmc_free(message); + break; + } + +} diff --git a/rust/portablemc-py/.gitignore b/rust/portablemc-py/.gitignore new file mode 100644 index 00000000..d17d5fa3 --- /dev/null +++ b/rust/portablemc-py/.gitignore @@ -0,0 +1,3 @@ +/.env +/python/**/*.pyd +/python/**/*.so diff --git a/rust/portablemc-py/Cargo.toml b/rust/portablemc-py/Cargo.toml new file mode 100644 index 00000000..e570782d --- /dev/null +++ b/rust/portablemc-py/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "portablemc-py" +description = "The Python API to PortableMC." +edition.workspace = true +version.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +portablemc.workspace = true + +uuid.workspace = true + +pyo3 = { version = "0.23.4", features = ["extension-module"] } + +[lib] +name = "portablemc_py" +crate-type = ["cdylib"] diff --git a/rust/portablemc-py/pyproject.toml b/rust/portablemc-py/pyproject.toml new file mode 100644 index 00000000..9e572dfb --- /dev/null +++ b/rust/portablemc-py/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "portablemc" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "portablemc._portablemc" +python-source = "python" +include = ["*"] diff --git a/rust/portablemc-py/python/portablemc/__init__.py b/rust/portablemc-py/python/portablemc/__init__.py new file mode 100644 index 00000000..9bb8b80a --- /dev/null +++ b/rust/portablemc-py/python/portablemc/__init__.py @@ -0,0 +1,11 @@ +# Reimport from our native module '_portablemc'. +from ._portablemc import msa, base, mojang, fabric, forge # type: ignore + +# Our native module +import sys +sys.modules["portablemc.msa"] = msa +sys.modules["portablemc.base"] = base +sys.modules["portablemc.mojang"] = mojang +sys.modules["portablemc.fabric"] = fabric +sys.modules["portablemc.forge"] = forge +del sys diff --git a/rust/portablemc-py/python/portablemc/_portablemc/__init__.pyi b/rust/portablemc-py/python/portablemc/_portablemc/__init__.pyi new file mode 100644 index 00000000..4ac69326 --- /dev/null +++ b/rust/portablemc-py/python/portablemc/_portablemc/__init__.pyi @@ -0,0 +1 @@ +# Native module diff --git a/rust/portablemc-py/python/portablemc/_portablemc/base.pyi b/rust/portablemc-py/python/portablemc/_portablemc/base.pyi new file mode 100644 index 00000000..b39c1bdb --- /dev/null +++ b/rust/portablemc-py/python/portablemc/_portablemc/base.pyi @@ -0,0 +1,171 @@ +from typing import Self +from os import PathLike +from enum import Enum, auto +from subprocess import Popen + + +class Installer: + + def __new__(cls, version: str) -> Self: ... + + def __repr__(self) -> str: ... + + @property + def version(self) -> str: ... + @version.setter + def version(self, version: str): ... + + @property + def versions_dir(self) -> str: ... + @versions_dir.setter + def versions_dir(self, dir: str | PathLike[str]): ... + + @property + def libraries_dir(self) -> str: ... + @libraries_dir.setter + def libraries_dir(self, dir: str | PathLike[str]): ... + + @property + def assets_dir(self) -> str: ... + @assets_dir.setter + def assets_dir(self, dir: str | PathLike[str]): ... + + @property + def jvm_dir(self) -> str: ... + @jvm_dir.setter + def jvm_dir(self, dir: str | PathLike[str]): ... + + @property + def bin_dir(self) -> str: ... + @bin_dir.setter + def bin_dir(self, dir: str | PathLike[str]): ... + + @property + def mc_dir(self) -> str: ... + @mc_dir.setter + def mc_dir(self, dir: str | PathLike[str]): ... + + # Not a property because it sets all others dirs! + def set_main_dir(self, dir: str | PathLike[str]) -> None: ... + + @property + def strict_assets_check(self) -> bool: ... + @strict_assets_check.setter + def strict_assets_check(self, strict: bool): ... + + @property + def strict_libraries_check(self) -> bool: ... + @strict_libraries_check.setter + def strict_libraries_check(self, strict: bool): ... + + @property + def strict_jvm_check(self) -> bool: ... + @strict_jvm_check.setter + def strict_jvm_check(self, strict: bool): ... + + @property + def jvm_policy(self) -> str | JvmPolicy: ... + @jvm_policy.setter + def jvm_policy(self, policy: str | PathLike[str] | JvmPolicy): ... + + @property + def launcher_name(self) -> str: ... + @launcher_name.setter + def launcher_name(self, name: str): ... + + @property + def launcher_version(self) -> str: ... + @launcher_version.setter + def launcher_version(self, name: str): ... + + def install(self) -> Game: ... + + +class JvmPolicy(Enum): + System = auto() + Mojang = auto() + SystemThenMojang = auto() + MojangThenSystem = auto() + + +# class Handler: + +# def filter_features(self, features: set[str]): ... +# def loaded_features(self, features: set[str]): ... + +# def load_hierarchy(self, root_version: str): ... +# def loaded_hierarchy(self, hierarchy: list[LoadedVersion]): ... + +# def load_version(self, version: str, file: str): ... +# def need_version(self, version: str, file: str) -> bool: ... +# def loaded_version(self, version: str, file: str): ... + + +# class LoadedVersion: + +# @property +# def name(self) -> str: ... + +# @property +# def dir(self) -> str: ... + +# @property +# def channel(self) -> VersionChannel: ... + + +# class VersionChannel(Enum): +# Release = auto() +# Snapshot = auto() +# Beta = auto() +# Alpha = auto() + + +# class LoadedLibrary: + +# @property +# def gav(self) -> str: ... +# @gav.setter +# def gav(self, gav: str): ... + +# @property +# def path(self) -> str | None: ... +# @path.setter +# def path(self, path: str | PathLike[str] | None): ... + +# @property +# def download(self) -> LibraryDownload | None: ... +# @download.setter +# def download(self, download: LibraryDownload | None): ... + +# @property +# def natives(self) -> bool: ... +# @natives.setter +# def natives(self, natives: bool): ... + + +# class LibraryDownload: + +# @property +# def url(self) -> str: ... +# @url.setter +# def url(self, url: str): ... + +# @property +# def size(self) -> int | None: ... +# @size.setter +# def size(self, size: int | None): ... + +# @property +# def sha1(self) -> bytes | None: ... +# @sha1.setter +# def sha1(self, sha1: bytes | None): ... + + +class Game: + + def __repr__(self) -> str: ... + + def command(self) -> type[Popen]: ... + + +def default_main_dir() -> str | None: ... diff --git a/rust/portablemc-py/python/portablemc/_portablemc/fabric.pyi b/rust/portablemc-py/python/portablemc/_portablemc/fabric.pyi new file mode 100644 index 00000000..afc1ad7c --- /dev/null +++ b/rust/portablemc-py/python/portablemc/_portablemc/fabric.pyi @@ -0,0 +1,45 @@ +from typing import Self +from enum import Enum, auto + +from . import mojang, base + + +class Loader(Enum): + Fabric = auto() + Quilt = auto() + LegacyFabric = auto() + Babric = auto() + + +class GameVersion(Enum): + Stable = auto() + Unstable = auto() + + +class LoaderVersion(Enum): + Stable = auto() + Unstable = auto() + + +class Installer(mojang.Installer): + + def __new__(cls, loader: Loader, game_version: str | GameVersion = GameVersion.Stable, loader_version: str | LoaderVersion = LoaderVersion.Stable) -> Self: ... + + def __repr__(self) -> str: ... + + @property + def loader(self) -> Loader: ... + @loader.setter + def loader(self, loader: Loader): ... + + @property + def game_version(self) -> str | GameVersion: ... + @game_version.setter + def game_version(self, game_version: str | GameVersion): ... + + @property + def loader_version(self) -> str | LoaderVersion: ... + @loader_version.setter + def loader_version(self, loader_version: str | LoaderVersion): ... + + def install(self) -> base.Game: ... diff --git a/rust/portablemc-py/python/portablemc/_portablemc/forge.pyi b/rust/portablemc-py/python/portablemc/_portablemc/forge.pyi new file mode 100644 index 00000000..bfc31104 --- /dev/null +++ b/rust/portablemc-py/python/portablemc/_portablemc/forge.pyi @@ -0,0 +1,37 @@ +from typing import Self +from enum import Enum, auto + +from . import mojang, base + + +class Loader(Enum): + Forge = auto() + NeoForge = auto() + + +class Version: + class Stable(Version): + def __new__(cls, game_version: str) -> Self: ... + class Unstable(Version): + def __new__(cls, game_version: str) -> Self: ... + class Name(Version): + def __new__(cls, name: str) -> Self: ... + + +class Installer(mojang.Installer): + + def __new__(cls, loader: Loader, version: Version) -> Self: ... + + def __repr__(self) -> str: ... + + @property + def loader(self) -> Loader: ... + @loader.setter + def loader(self, loader: Loader): ... + + @mojang.Installer.version.getter + def version(self) -> Version: ... + @version.setter + def version(self, version: Version): ... + + def install(self) -> base.Game: ... diff --git a/rust/portablemc-py/python/portablemc/_portablemc/mojang.pyi b/rust/portablemc-py/python/portablemc/_portablemc/mojang.pyi new file mode 100644 index 00000000..7acf0e4d --- /dev/null +++ b/rust/portablemc-py/python/portablemc/_portablemc/mojang.pyi @@ -0,0 +1,109 @@ +from typing import Self +from os import PathLike +from uuid import UUID +from enum import Enum, auto + +from . import base, msa + + +class Installer(base.Installer): + + def __new__(cls, version: str | Version = Version.Release) -> Self: ... + + def __repr__(self) -> str: ... + + @base.Installer.version.getter + def version(self) -> str | Version: ... + @version.setter + def version(self, version: str | Version): ... + + # TODO: fetch exclude + + @property + def demo(self) -> bool: ... + @demo.setter + def demo(self, demo: bool): ... + + @property + def quick_play(self) -> QuickPlay | None: ... + @quick_play.setter + def quick_play(self, quick_play: QuickPlay | None): ... + + @property + def resolution(self) -> tuple[int, int] | None: ... + @resolution.setter + def resolution(self, resolution: tuple[int, int] | None): ... + + @property + def disable_multiplayer(self) -> bool: ... + @disable_multiplayer.setter + def disable_multiplayer(self, disable_multiplayer: bool): ... + + @property + def disable_chat(self) -> bool: ... + @disable_chat.setter + def disable_chat(self, disable_chat: bool): ... + + @property + def auth_uuid(self) -> bool: ... + @property + def auth_username(self) -> bool: ... + + def set_auth_offline(self, uuid: UUID, username: str) -> None: ... + def set_auth_offline_uuid(self, uuid: UUID) -> None: ... + def set_auth_offline_username(self, username: str) -> None: ... + def set_auth_offline_hostname(self) -> None: ... + def set_auth_msa(self, account: msa.Account) -> None: ... + + @property + def client_id(self) -> str: ... + @client_id.setter + def client_id(self, client_id: str): ... + + @property + def fix_legacy_quick_play(self) -> bool: ... + @fix_legacy_quick_play.setter + def fix_legacy_quick_play(self, fix: bool): ... + + @property + def fix_legacy_proxy(self) -> bool: ... + @fix_legacy_proxy.setter + def fix_legacy_proxy(self, fix: bool): ... + + @property + def fix_legacy_merge_sort(self) -> bool: ... + @fix_legacy_merge_sort.setter + def fix_legacy_merge_sort(self, fix: bool): ... + + @property + def fix_legacy_resolution(self) -> bool: ... + @fix_legacy_resolution.setter + def fix_legacy_resolution(self, fix: bool): ... + + @property + def fix_broken_authlib(self) -> bool: ... + @fix_broken_authlib.setter + def fix_broken_authlib(self, fix: bool): ... + + @property + def fix_lwjgl(self) -> str | None: ... + @fix_lwjgl.setter + def fix_lwjgl(self, lwjgl_version: str | None): ... + + def install(self) -> base.Game: ... + + +class Version(Enum): + Release = auto() + Snapshot = auto() + + +class QuickPlay: + class Path(QuickPlay): + def __new__(cls, path: str | PathLike[str]) -> Self: ... + class Singleplayer(QuickPlay): + def __new__(cls, name: str) -> Self: ... + class Multiplayer(QuickPlay): + def __new__(cls, host: str, port: int) -> Self: ... + class Realms(QuickPlay): + def __new__(cls, id: str) -> Self: ... diff --git a/rust/portablemc-py/python/portablemc/_portablemc/msa.pyi b/rust/portablemc-py/python/portablemc/_portablemc/msa.pyi new file mode 100644 index 00000000..1d096d1f --- /dev/null +++ b/rust/portablemc-py/python/portablemc/_portablemc/msa.pyi @@ -0,0 +1,66 @@ +from typing import Self, Iterator +from uuid import UUID +from os import PathLike + + +class Auth: + + def __new__(cls, app_id: str) -> Self: ... + + def __repr__(self) -> str: ... + + def request_device_code(self) -> DeviceCodeFlow: ... + + +class DeviceCodeFlow: + + def __repr__(self) -> str: ... + + @property + def user_code(self) -> str: ... + @property + def verification_uri(self) -> str: ... + @property + def message(self) -> str: ... + + def wait(self) -> Account: ... + + +class Account: + + def __repr__(self) -> str: ... + + @property + def app_id(self) -> str: ... + @property + def access_token(self) -> str: ... + @property + def uuid(self) -> UUID: ... + @property + def username(self) -> str: ... + @property + def xuid(self) -> str: ... + + def request_profile(self) -> None: ... + + def request_refresh(self) -> None: ... + + +class Database: + + def __new__(cls, file: str | PathLike[str]) -> Self: ... + + def __repr__(self) -> str: ... + + @property + def file(self) -> str: ... + + def load_iter(self) -> Iterator[Account]: ... + + def load_from_uuid(self, uuid: UUID) -> Account | None: ... + def load_from_username(self, username: str) -> Account | None: ... + + def remove_from_uuid(self, uuid: UUID) -> Account | None: ... + def remove_from_username(self, username: str) -> Account | None: ... + + def store(self, account: Account) -> None: ... diff --git a/rust/portablemc-py/python/portablemc/py.typed b/rust/portablemc-py/python/portablemc/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/rust/portablemc-py/src/base.rs b/rust/portablemc-py/src/base.rs new file mode 100644 index 00000000..d09b1fc9 --- /dev/null +++ b/rust/portablemc-py/src/base.rs @@ -0,0 +1,252 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use pyo3::types::{IntoPyDict, PyList}; +use pyo3::exceptions::PyValueError; +use pyo3::{intern, prelude::*}; + +use portablemc::base::{default_main_dir, Installer, Game, JvmPolicy}; + +use crate::installer::GenericInstaller; + + +/// Define the `_portablemc.base` submodule. +pub(super) fn py_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(py_default_main_dir, m)?)?; + Ok(()) +} + +#[pyfunction] +#[pyo3(name = "default_main_dir")] +fn py_default_main_dir() -> Option<&'static Path> { + default_main_dir() +} + +#[pyclass(name = "JvmPolicy", module = "portablemc.base", eq)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PyJvmPolicy { + System, + Mojang, + SystemThenMojang, + MojangThenSystem, +} + +#[derive(FromPyObject, IntoPyObject)] +pub enum PyJvmPolicyUnion { + Static(PathBuf), + Policy(PyJvmPolicy), +} + +#[pyclass(name = "Installer", module = "portablemc.base", frozen, subclass)] +pub struct PyInstaller(pub Arc>); + +#[pymethods] +impl PyInstaller { + + #[new] + fn __new__(version: &str) -> Self { + + let inst = Arc::new(Mutex::new( + GenericInstaller::Base(Installer::new(version.to_string())) + )); + + Self(inst) + + } + + fn __repr__(&self) -> String { + let guard = self.0.lock().unwrap(); + format!("", guard.base().version()) + } + + #[getter] + fn version(&self) -> String { + self.0.lock().unwrap().base().version().to_string() + } + + #[setter] + fn set_version(&self, version: String) { + self.0.lock().unwrap().base_mut().set_version(version); + } + + #[getter] + fn versions_dir(&self) -> PathBuf { + self.0.lock().unwrap().base().versions_dir().to_path_buf() + } + + #[setter] + fn set_versions_dir(&self, dir: PathBuf) { + self.0.lock().unwrap().base_mut().set_versions_dir(dir); + } + + #[getter] + fn libraries_dir(&self) -> PathBuf { + self.0.lock().unwrap().base().libraries_dir().to_path_buf() + } + + #[setter] + fn set_libraries_dir(&self, dir: PathBuf) { + self.0.lock().unwrap().base_mut().set_libraries_dir(dir); + } + + #[getter] + fn assets_dir(&self) -> PathBuf { + self.0.lock().unwrap().base().assets_dir().to_path_buf() + } + + #[setter] + fn set_assets_dir(&self, dir: PathBuf) { + self.0.lock().unwrap().base_mut().set_assets_dir(dir); + } + + #[getter] + fn jvm_dir(&self) -> PathBuf { + self.0.lock().unwrap().base().jvm_dir().to_path_buf() + } + + #[setter] + fn set_jvm_dir(&self, dir: PathBuf) { + self.0.lock().unwrap().base_mut().set_jvm_dir(dir); + } + + #[getter] + fn bin_dir(&self) -> PathBuf { + self.0.lock().unwrap().base().bin_dir().to_path_buf() + } + + #[setter] + fn set_bin_dir(&self, dir: PathBuf) { + self.0.lock().unwrap().base_mut().set_bin_dir(dir); + } + + #[getter] + fn mc_dir(&self) -> PathBuf { + self.0.lock().unwrap().base().mc_dir().to_path_buf() + } + + #[setter] + fn set_mc_dir(&self, dir: PathBuf) { + self.0.lock().unwrap().base_mut().set_mc_dir(dir); + } + + // No setter because it's a compound function, setting all paths below. + fn set_main_dir(&self, dir: PathBuf) { + self.0.lock().unwrap().base_mut().set_main_dir(dir); + } + + #[getter] + fn strict_assets_check(&self) -> bool { + self.0.lock().unwrap().base().strict_assets_check() + } + + #[setter] + fn set_strict_assets_check(&self, strict: bool) { + self.0.lock().unwrap().base_mut().set_strict_assets_check(strict); + } + + #[getter] + fn strict_libraries_check(&self) -> bool { + self.0.lock().unwrap().base().strict_libraries_check() + } + + #[setter] + fn set_strict_libraries_check(&self, strict: bool) { + self.0.lock().unwrap().base_mut().set_strict_libraries_check(strict); + } + + #[getter] + fn strict_jvm_check(&self) -> bool { + self.0.lock().unwrap().base().strict_jvm_check() + } + + #[setter] + fn set_strict_jvm_check(&self, strict: bool) { + self.0.lock().unwrap().base_mut().set_strict_jvm_check(strict); + } + + #[getter] + fn jvm_policy(&self) -> PyJvmPolicyUnion { + match self.0.lock().unwrap().base().jvm_policy() { + JvmPolicy::Static(file) => PyJvmPolicyUnion::Static(file.clone()), + JvmPolicy::System => PyJvmPolicyUnion::Policy(PyJvmPolicy::System), + JvmPolicy::Mojang => PyJvmPolicyUnion::Policy(PyJvmPolicy::Mojang), + JvmPolicy::SystemThenMojang => PyJvmPolicyUnion::Policy(PyJvmPolicy::SystemThenMojang), + JvmPolicy::MojangThenSystem => PyJvmPolicyUnion::Policy(PyJvmPolicy::MojangThenSystem), + } + } + + #[setter] + fn set_jvm_policy(&self, policy: PyJvmPolicyUnion) { + self.0.lock().unwrap().base_mut().set_jvm_policy(match policy { + PyJvmPolicyUnion::Static(file) => JvmPolicy::Static(file), + PyJvmPolicyUnion::Policy(PyJvmPolicy::System) => JvmPolicy::System, + PyJvmPolicyUnion::Policy(PyJvmPolicy::Mojang) => JvmPolicy::Mojang, + PyJvmPolicyUnion::Policy(PyJvmPolicy::SystemThenMojang) => JvmPolicy::SystemThenMojang, + PyJvmPolicyUnion::Policy(PyJvmPolicy::MojangThenSystem) => JvmPolicy::MojangThenSystem, + }); + } + + #[getter] + fn launcher_name(&self) -> String { + self.0.lock().unwrap().base().launcher_name().to_string() + } + + #[setter] + fn set_launcher_name(&self, name: String) { + self.0.lock().unwrap().base_mut().set_launcher_name(name); + } + + #[getter] + fn launcher_version(&self) -> String { + self.0.lock().unwrap().base().launcher_version().to_string() + } + + #[setter] + fn set_launcher_version(&self, version: String) { + self.0.lock().unwrap().base_mut().set_launcher_version(version); + } + + fn install(&self) -> PyResult { + self.0.lock().unwrap().base_mut().install(()) + .map(PyGame) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} + +#[pyclass(name = "Game", module = "portablemc.base", frozen)] +pub struct PyGame(pub Game); + +#[pymethods] +impl PyGame { + + fn command<'py>(this: &Bound<'py, Self>) -> PyResult> { + + let this = this.borrow(); + let game = &this.0; + + let mod_subprocess = PyModule::import(this.py(), intern!(this.py(), "subprocess"))?; + let ty_popen = mod_subprocess.getattr(intern!(this.py(), "Popen"))?; + + let mod_functools = PyModule::import(this.py(), intern!(this.py(), "functools"))?; + let func_partial = mod_functools.getattr(intern!(this.py(), "partial"))?; + + let args = PyList::empty(this.py()); + args.append(&game.jvm_file)?; + for arg in &game.jvm_args { + args.append(arg)?; + } + args.append(&game.main_class)?; + for arg in &game.game_args { + args.append(arg)?; + } + + let kwargs = [("cwd", &game.mc_dir)].into_py_dict(this.py())?; + + func_partial.call((&ty_popen, &args), Some(&kwargs)) + + } + +} diff --git a/rust/portablemc-py/src/fabric.rs b/rust/portablemc-py/src/fabric.rs new file mode 100644 index 00000000..16ffa540 --- /dev/null +++ b/rust/portablemc-py/src/fabric.rs @@ -0,0 +1,179 @@ +use std::sync::{Arc, Mutex}; +use std::fmt::Write as _; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use portablemc::fabric::{GameVersion, Installer, Loader, LoaderVersion}; + +use crate::installer::GenericInstaller; + + +/// Define the `_portablemc.fabric` submodule. +pub(super) fn py_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} + +#[pyclass(name = "Loader", module = "portablemc.fabric", eq)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyLoader { + Fabric, + Quilt, + LegacyFabric, + Babric, +} + +impl From for Loader { + fn from(value: PyLoader) -> Self { + match value { + PyLoader::Fabric => Loader::Fabric, + PyLoader::Quilt => Loader::Quilt, + PyLoader::LegacyFabric => Loader::LegacyFabric, + PyLoader::Babric => Loader::Babric, + } + } +} + +#[pyclass(name = "GameVersion", module = "portablemc.fabric", eq)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyGameVersion { + Stable, + Unstable, +} + +#[derive(FromPyObject, IntoPyObject)] +enum PyGameVersionUnion { + Version(PyGameVersion), + Name(String), +} + +impl From for GameVersion { + fn from(value: PyGameVersionUnion) -> Self { + match value { + PyGameVersionUnion::Version(PyGameVersion::Stable) => GameVersion::Stable, + PyGameVersionUnion::Version(PyGameVersion::Unstable) => GameVersion::Unstable, + PyGameVersionUnion::Name(name) => GameVersion::Name(name), + } + } +} + +#[pyclass(name = "LoaderVersion", module = "portablemc.fabric", eq)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyLoaderVersion { + Stable, + Unstable, +} + +#[derive(FromPyObject, IntoPyObject)] +enum PyLoaderVersionUnion { + Version(PyLoaderVersion), + Name(String), +} + +impl From for LoaderVersion { + fn from(value: PyLoaderVersionUnion) -> Self { + match value { + PyLoaderVersionUnion::Version(PyLoaderVersion::Stable) => LoaderVersion::Stable, + PyLoaderVersionUnion::Version(PyLoaderVersion::Unstable) => LoaderVersion::Unstable, + PyLoaderVersionUnion::Name(name) => LoaderVersion::Name(name), + } + } +} + +#[pyclass(name = "Installer", module = "portablemc.fabric", frozen, subclass, extends = crate::mojang::PyInstaller)] +pub(crate) struct PyInstaller(pub(crate) Arc>); + +#[pymethods] +impl PyInstaller { + + #[new] + #[pyo3(signature = (loader, game_version = PyGameVersionUnion::Version(PyGameVersion::Stable), loader_version = PyLoaderVersionUnion::Version(PyLoaderVersion::Stable)))] + fn __new__(loader: PyLoader, game_version: PyGameVersionUnion, loader_version: PyLoaderVersionUnion) -> PyClassInitializer { + + let inst = Arc::new(Mutex::new( + GenericInstaller::Fabric(Installer::new(loader.into(), game_version, loader_version)) + )); + + PyClassInitializer::from(crate::base::PyInstaller(Arc::clone(&inst))) + .add_subclass(crate::mojang::PyInstaller(Arc::clone(&inst))) + .add_subclass(Self(inst)) + + } + + fn __repr__(&self) -> String { + + let guard = self.0.lock().unwrap(); + let inst = guard.fabric(); + let mut buf = format!(" write!(buf, " game_version=GameVersion.Stable").unwrap(), + GameVersion::Unstable => write!(buf, " game_version=GameVersion.Unstable").unwrap(), + GameVersion::Name(name) => write!(buf, " game_version={name:?}").unwrap(), + } + + match inst.loader_version() { + LoaderVersion::Stable => write!(buf, " loader_version=LoaderVersion.Stable").unwrap(), + LoaderVersion::Unstable => write!(buf, " loader_version=LoaderVersion.Unstable").unwrap(), + LoaderVersion::Name(name) => write!(buf, " loader_version={name:?}").unwrap(), + } + + write!(buf, ">").unwrap(); + buf + + } + + #[getter] + fn loader(&self) -> PyLoader { + match self.0.lock().unwrap().fabric().loader() { + Loader::Fabric => PyLoader::Fabric, + Loader::Quilt => PyLoader::Quilt, + Loader::LegacyFabric => PyLoader::LegacyFabric, + Loader::Babric => PyLoader::Babric, + } + } + + #[setter] + fn set_loader(&self, loader: PyLoader) { + self.0.lock().unwrap().fabric_mut().set_loader(loader.into()); + } + + #[getter] + fn game_version(&self) -> PyGameVersionUnion { + match self.0.lock().unwrap().fabric().game_version() { + GameVersion::Stable => PyGameVersionUnion::Version(PyGameVersion::Stable), + GameVersion::Unstable => PyGameVersionUnion::Version(PyGameVersion::Unstable), + GameVersion::Name(name) => PyGameVersionUnion::Name(name.clone()), + } + } + + #[setter] + fn set_game_version(&self, game_version: PyGameVersionUnion) { + self.0.lock().unwrap().fabric_mut().set_game_version(game_version); + } + + #[getter] + fn loader_version(&self) -> PyLoaderVersionUnion { + match self.0.lock().unwrap().fabric().loader_version() { + LoaderVersion::Stable => PyLoaderVersionUnion::Version(PyLoaderVersion::Stable), + LoaderVersion::Unstable => PyLoaderVersionUnion::Version(PyLoaderVersion::Unstable), + LoaderVersion::Name(name) => PyLoaderVersionUnion::Name(name.clone()), + } + } + + #[setter] + fn set_loader_version(&self, loader_version: PyLoaderVersionUnion) { + self.0.lock().unwrap().fabric_mut().set_loader_version(loader_version); + } + + fn install(&self) -> PyResult { + self.0.lock().unwrap().fabric_mut().install(()) + .map(crate::base::PyGame) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} diff --git a/rust/portablemc-py/src/forge.rs b/rust/portablemc-py/src/forge.rs new file mode 100644 index 00000000..72ef660f --- /dev/null +++ b/rust/portablemc-py/src/forge.rs @@ -0,0 +1,111 @@ +use std::sync::{Arc, Mutex}; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use portablemc::forge::{Installer, Loader, Version}; + +use crate::installer::GenericInstaller; + + +/// Define the `_portablemc.forge` submodule. +pub(super) fn py_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} + +#[pyclass(name = "Loader", module = "portablemc.forge", eq)] +#[derive(Clone, Copy, PartialEq, Eq)] +enum PyLoader { + Forge, + NeoForge, +} + +impl From for Loader { + fn from(value: PyLoader) -> Self { + match value { + PyLoader::Forge => Loader::Forge, + PyLoader::NeoForge => Loader::NeoForge, + } + } +} + +#[pyclass(name = "Version", module = "portablemc.forge", eq)] +#[derive(Clone, PartialEq, Eq)] +enum PyVersion { + Stable(String), + Unstable(String), + Name(String), +} + +impl From for Version { + fn from(value: PyVersion) -> Self { + match value { + PyVersion::Stable(game_version) => Version::Stable(game_version), + PyVersion::Unstable(game_version) => Version::Unstable(game_version), + PyVersion::Name(name) => Version::Name(name), + } + } +} + +#[pyclass(name = "Installer", module = "portablemc.forge", frozen, subclass, extends = crate::mojang::PyInstaller)] +pub(crate) struct PyInstaller(pub(crate) Arc>); + +#[pymethods] +impl PyInstaller { + + #[new] + fn __new__(loader: PyLoader, version: PyVersion) -> PyClassInitializer { + + let inst = Arc::new(Mutex::new( + GenericInstaller::Forge(Installer::new(loader.into(), version)) + )); + + PyClassInitializer::from(crate::base::PyInstaller(Arc::clone(&inst))) + .add_subclass(crate::mojang::PyInstaller(Arc::clone(&inst))) + .add_subclass(Self(inst)) + + } + + fn __repr__(&self) -> String { + let guard = self.0.lock().unwrap(); + let inst = guard.forge(); + format!("", inst.loader(), inst.version()) + } + + #[getter] + fn loader(&self) -> PyLoader { + match self.0.lock().unwrap().forge().loader() { + Loader::Forge => PyLoader::Forge, + Loader::NeoForge => PyLoader::NeoForge, + } + } + + #[setter] + fn set_loader(&self, loader: PyLoader) { + self.0.lock().unwrap().forge_mut().set_loader(loader.into()); + } + + #[getter] + fn version(&self) -> PyVersion { + match self.0.lock().unwrap().forge().version() { + Version::Stable(game_version) => PyVersion::Stable(game_version.clone()), + Version::Unstable(game_version) => PyVersion::Unstable(game_version.clone()), + Version::Name(name) => PyVersion::Name(name.clone()), + } + } + + #[setter] + fn set_version(&self, version: PyVersion) { + self.0.lock().unwrap().forge_mut().set_version(version); + } + + fn install(&self) -> PyResult { + self.0.lock().unwrap().forge_mut().install(()) + .map(crate::base::PyGame) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} diff --git a/rust/portablemc-py/src/installer.rs b/rust/portablemc-py/src/installer.rs new file mode 100644 index 00000000..26c87db2 --- /dev/null +++ b/rust/portablemc-py/src/installer.rs @@ -0,0 +1,82 @@ +//! Generic installer data that is shared between all kind of installers, this is used +//! to allow inheritance. + +use portablemc::{base, mojang, fabric, forge}; + + +/// A generic class that can be shared inside a `Arc>` between. +#[derive(Debug)] +pub enum GenericInstaller { + Base(base::Installer), + Mojang(mojang::Installer), + Fabric(fabric::Installer), + Forge(forge::Installer), +} + +impl GenericInstaller { + + pub fn base(&self) -> &base::Installer { + match self { + GenericInstaller::Base(installer) => installer, + GenericInstaller::Mojang(installer) => installer.base(), + GenericInstaller::Fabric(installer) => installer.mojang().base(), + GenericInstaller::Forge(installer) => installer.mojang().base(), + } + } + + pub fn base_mut(&mut self) -> &mut base::Installer { + match self { + GenericInstaller::Base(installer) => installer, + GenericInstaller::Mojang(installer) => installer.base_mut(), + GenericInstaller::Fabric(installer) => installer.mojang_mut().base_mut(), + GenericInstaller::Forge(installer) => installer.mojang_mut().base_mut(), + } + } + + pub fn mojang(&self) -> &mojang::Installer { + match self { + GenericInstaller::Base(_) => panic!("not a mojang installer"), + GenericInstaller::Mojang(installer) => installer, + GenericInstaller::Fabric(installer) => installer.mojang(), + GenericInstaller::Forge(installer) => installer.mojang(), + } + } + + pub fn mojang_mut(&mut self) -> &mut mojang::Installer { + match self { + GenericInstaller::Base(_) => panic!("not a mojang installer"), + GenericInstaller::Mojang(installer) => installer, + GenericInstaller::Fabric(installer) => installer.mojang_mut(), + GenericInstaller::Forge(installer) => installer.mojang_mut(), + } + } + + pub fn fabric(&self) -> &fabric::Installer { + match self { + GenericInstaller::Fabric(installer) => installer, + _ => panic!("not a fabric installer"), + } + } + + pub fn fabric_mut(&mut self) -> &mut fabric::Installer { + match self { + GenericInstaller::Fabric(installer) => installer, + _ => panic!("not a fabric installer"), + } + } + + pub fn forge(&self) -> &forge::Installer { + match self { + GenericInstaller::Forge(installer) => installer, + _ => panic!("not a fabric installer"), + } + } + + pub fn forge_mut(&mut self) -> &mut forge::Installer { + match self { + GenericInstaller::Forge(installer) => installer, + _ => panic!("not a fabric installer"), + } + } + +} diff --git a/rust/portablemc-py/src/lib.rs b/rust/portablemc-py/src/lib.rs new file mode 100644 index 00000000..6d7b1472 --- /dev/null +++ b/rust/portablemc-py/src/lib.rs @@ -0,0 +1,44 @@ +//! Python binding for PortableMC. + +#![deny(unsafe_op_in_unsafe_fn)] + +mod uuid; + +mod msa; + +mod installer; +mod base; +mod mojang; +mod fabric; +mod forge; + +use pyo3::prelude::*; + + +#[pymodule] +#[pyo3(name = "_portablemc")] +fn py_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + + let msa = PyModule::new(m.py(), "msa")?; + msa::py_module(&msa)?; + m.add_submodule(&msa)?; + + let base = PyModule::new(m.py(), "base")?; + base::py_module(&base)?; + m.add_submodule(&base)?; + + let mojang = PyModule::new(m.py(), "mojang")?; + mojang::py_module(&mojang)?; + m.add_submodule(&mojang)?; + + let fabric = PyModule::new(m.py(), "fabric")?; + fabric::py_module(&fabric)?; + m.add_submodule(&fabric)?; + + let forge = PyModule::new(m.py(), "forge")?; + forge::py_module(&forge)?; + m.add_submodule(&forge)?; + + Ok(()) + +} diff --git a/rust/portablemc-py/src/mojang.rs b/rust/portablemc-py/src/mojang.rs new file mode 100644 index 00000000..6f7f52a2 --- /dev/null +++ b/rust/portablemc-py/src/mojang.rs @@ -0,0 +1,306 @@ +use std::sync::{Arc, Mutex}; +use std::fmt::Write as _; +use std::path::PathBuf; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use portablemc::mojang::{Installer, QuickPlay, Version}; + +use crate::installer::GenericInstaller; +use crate::uuid::PyUuid; +use crate::msa; + + +/// Define the `_portablemc.mojang` submodule. +pub(super) fn py_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} + +#[pyclass(name = "Version", module = "portablemc.mojang", eq)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PyVersion { + Release, + Snapshot, +} + +#[derive(FromPyObject, IntoPyObject)] +pub enum PyVersionUnion { + Version(PyVersion), + Name(String), +} + +impl From for Version { + fn from(value: PyVersionUnion) -> Self { + match value { + PyVersionUnion::Version(PyVersion::Release) => Version::Release, + PyVersionUnion::Version(PyVersion::Snapshot) => Version::Snapshot, + PyVersionUnion::Name(name) => Version::Name(name), + } + } +} + +#[pyclass(name = "QuickPlay", module = "portablemc.mojang", eq)] +#[derive(Clone, PartialEq, Eq)] +pub enum PyQuickPlay { + Path { + path: PathBuf, + }, + Singleplayer { + name: String, + }, + Multiplayer { + host: String, + port: u16, + }, + Realms { + id: String, + }, +} + +#[pyclass(name = "Installer", module = "portablemc.mojang", frozen, subclass, extends = crate::base::PyInstaller)] +pub struct PyInstaller(pub Arc>); + +#[pymethods] +impl PyInstaller { + + #[new] + #[pyo3(signature = (version = PyVersionUnion::Version(PyVersion::Release)))] + fn __new__(version: PyVersionUnion) -> PyClassInitializer { + + let inst = Arc::new(Mutex::new( + GenericInstaller::Mojang(Installer::new(version)) + )); + + PyClassInitializer::from(crate::base::PyInstaller(Arc::clone(&inst))) + .add_subclass(Self(inst)) + + } + + fn __repr__(&self) -> String { + + let guard = self.0.lock().unwrap(); + let inst = guard.mojang(); + let mut buf = format!(" write!(buf, " version=Version.Release").unwrap(), + Version::Snapshot => write!(buf, " version=Version.Snapshot").unwrap(), + Version::Name(name) => write!(buf, " version={name:?}").unwrap(), + } + + write!(buf, ">").unwrap(); + buf + + } + + #[getter] + fn version(&self) -> PyVersionUnion { + match self.0.lock().unwrap().mojang().version() { + Version::Release => PyVersionUnion::Version(PyVersion::Release), + Version::Snapshot => PyVersionUnion::Version(PyVersion::Snapshot), + Version::Name(name) => PyVersionUnion::Name(name.clone()), + } + } + + #[setter] + fn set_version(&self, version: PyVersionUnion) { + self.0.lock().unwrap().mojang_mut().set_version(version); + } + + // TODO: fetch exclude + + #[getter] + fn demo(&self) -> bool { + self.0.lock().unwrap().mojang().demo() + } + + #[setter] + fn set_demo(&self, demo: bool) { + self.0.lock().unwrap().mojang_mut().set_demo(demo); + } + + #[getter] + fn quick_play(&self) -> Option { + self.0.lock().unwrap().mojang().quick_play().map(|m| match m { + QuickPlay::Path { path } => PyQuickPlay::Path { path: path.clone() }, + QuickPlay::Singleplayer { name } => PyQuickPlay::Singleplayer { name: name.clone() }, + QuickPlay::Multiplayer { host, port } => PyQuickPlay::Multiplayer { host: host.clone(), port: *port }, + QuickPlay::Realms { id } => PyQuickPlay::Realms { id: id.clone() }, + }) + } + + #[setter] + fn set_quick_play(&self, quick_play: Option) { + let mut guard = self.0.lock().unwrap(); + match quick_play { + None => { + guard.mojang_mut().remove_quick_play(); + } + Some(quick_play) => { + guard.mojang_mut().set_quick_play(match quick_play { + PyQuickPlay::Path { path } => QuickPlay::Path { path }, + PyQuickPlay::Singleplayer { name } => QuickPlay::Singleplayer { name }, + PyQuickPlay::Multiplayer { host, port } => QuickPlay::Multiplayer { host, port }, + PyQuickPlay::Realms { id } => QuickPlay::Realms { id }, + }); + } + } + } + + #[getter] + fn resolution(&self) -> Option<(u16, u16)> { + self.0.lock().unwrap().mojang().resolution() + } + + #[setter] + fn set_resolution(&self, resolution: Option<(u16, u16)>) { + let mut guard = self.0.lock().unwrap(); + match resolution { + Some((width, height)) => { + guard.mojang_mut().set_resolution(width, height); + } + None => { + guard.mojang_mut().remove_resolution(); + } + } + } + + #[getter] + fn disable_multiplayer(&self) -> bool { + self.0.lock().unwrap().mojang().disable_multiplayer() + } + + #[setter] + fn set_disable_multiplayer(&self, disable_multiplayer: bool) { + self.0.lock().unwrap().mojang_mut().set_disable_multiplayer(disable_multiplayer); + } + + #[getter] + fn disable_chat(&self) -> bool { + self.0.lock().unwrap().mojang().disable_chat() + } + + #[setter] + fn set_disable_chat(&self, disable_chat: bool) { + self.0.lock().unwrap().mojang_mut().set_disable_chat(disable_chat); + } + + #[getter] + fn auth_uuid(&self) -> PyUuid { + self.0.lock().unwrap().mojang().auth_uuid().into() + } + + #[getter] + fn auth_username(&self) -> String { + self.0.lock().unwrap().mojang().auth_username().to_string() + } + + fn set_auth_offline(&self, uuid: PyUuid, username: String) { + self.0.lock().unwrap().mojang_mut().set_auth_offline(uuid.into(), username); + } + + fn set_auth_offline_uuid(&self, uuid: PyUuid) { + self.0.lock().unwrap().mojang_mut().set_auth_offline_uuid(uuid.into()); + } + + fn set_auth_offline_username(&self, username: String) { + self.0.lock().unwrap().mojang_mut().set_auth_offline_username(username); + } + + fn set_auth_offline_hostname(&self) { + self.0.lock().unwrap().mojang_mut().set_auth_offline_hostname(); + } + + fn set_auth_msa(&self, account: PyRef<'_, msa::PyAccount>) { + self.0.lock().unwrap().mojang_mut().set_auth_msa(&account.0); + } + + #[getter] + fn client_id(&self) -> String { + self.0.lock().unwrap().mojang().client_id().to_string() + } + + #[setter] + fn set_client_id(&self, client_id: String) { + self.0.lock().unwrap().mojang_mut().set_client_id(client_id); + } + + #[getter] + fn fix_legacy_quick_play(&self) -> bool { + self.0.lock().unwrap().mojang().fix_legacy_quick_play() + } + + #[setter] + fn set_fix_legacy_quick_play(&self, fix: bool) { + self.0.lock().unwrap().mojang_mut().set_fix_legacy_quick_play(fix); + } + + #[getter] + fn fix_legacy_proxy(&self) -> bool { + self.0.lock().unwrap().mojang().fix_legacy_proxy() + } + + #[setter] + fn set_fix_legacy_proxy(&self, fix: bool) { + self.0.lock().unwrap().mojang_mut().set_fix_legacy_proxy(fix); + } + + #[getter] + fn fix_legacy_merge_sort(&self) -> bool { + self.0.lock().unwrap().mojang().fix_legacy_merge_sort() + } + + #[setter] + fn set_fix_legacy_merge_sort(&self, fix: bool) { + self.0.lock().unwrap().mojang_mut().set_fix_legacy_merge_sort(fix); + } + + #[getter] + fn fix_legacy_resolution(&self) -> bool { + self.0.lock().unwrap().mojang().fix_legacy_resolution() + } + + #[setter] + fn set_fix_legacy_resolution(&self, fix: bool) { + self.0.lock().unwrap().mojang_mut().set_fix_legacy_resolution(fix); + } + + #[getter] + fn fix_broken_authlib(&self) -> bool { + self.0.lock().unwrap().mojang().fix_broken_authlib() + } + + #[setter] + fn set_fix_broken_authlib(&self, fix: bool) { + self.0.lock().unwrap().mojang_mut().set_fix_broken_authlib(fix); + } + + #[getter] + fn fix_lwjgl(&self) -> Option { + self.0.lock().unwrap().mojang().fix_lwjgl().map(str::to_string) + } + + #[setter] + fn set_fix_lwjgl(&self, lwjgl_version: Option) { + let mut guard = self.0.lock().unwrap(); + match lwjgl_version { + Some(lwjgl_version) => { + guard.mojang_mut().set_fix_lwjgl(lwjgl_version); + } + None => { + guard.mojang_mut().remove_fix_lwjgl(); + } + } + } + + fn install(&self) -> PyResult { + self.0.lock().unwrap().mojang_mut().install(()) + .map(crate::base::PyGame) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} diff --git a/rust/portablemc-py/src/msa.rs b/rust/portablemc-py/src/msa.rs new file mode 100644 index 00000000..58e3ab71 --- /dev/null +++ b/rust/portablemc-py/src/msa.rs @@ -0,0 +1,228 @@ +use std::path::{Path, PathBuf}; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use portablemc::msa::{Account, Auth, Database, DatabaseIter, DeviceCodeFlow}; + +use crate::uuid::PyUuid; + + +/// Define the `_portablemc.msa` submodule. +pub(super) fn py_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} + + +#[pyclass(name = "Auth", module = "portablemc.msa", frozen)] +pub struct PyAuth(pub Auth); + +#[pymethods] +impl PyAuth { + + #[new] + fn __new__(app_id: &str) -> Self { + Self(Auth::new(app_id)) + } + + fn __repr__(&self) -> String { + format!("", self.0.app_id()) + } + + #[getter] + #[inline] + fn app_id(&self) -> &str { + self.0.app_id() + } + + fn request_device_code(&self) -> PyResult { + self.0.request_device_code() + .map(PyDeviceCodeFlow) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} + + +#[pyclass(name = "DeviceCodeFlow", module = "portablemc.msa", frozen)] +pub struct PyDeviceCodeFlow(pub DeviceCodeFlow); + +#[pymethods] +impl PyDeviceCodeFlow { + + fn __repr__(&self) -> String { + format!("", + self.0.app_id(), + self.0.user_code(), + self.0.verification_uri()) + } + + #[getter] + #[inline] + fn app_id(&self) -> &str { + self.0.app_id() + } + + #[getter] + #[inline] + fn user_code(&self) -> &str { + self.0.user_code() + } + + #[getter] + #[inline] + fn verification_uri(&self) -> &str { + self.0.verification_uri() + } + + #[getter] + #[inline] + fn message(&self) -> &str { + self.0.message() + } + + fn wait(&self) -> PyResult { + self.0.wait() + .map(PyAccount) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} + + +#[pyclass(name = "Account", module = "portablemc.msa")] +pub struct PyAccount(pub Account); + +#[pymethods] +impl PyAccount { + + fn __repr__(&self) -> String { + format!("", + self.0.app_id(), + self.0.uuid().braced(), + self.0.username()) + } + + #[getter] + #[inline] + fn app_id(&self) -> &str { + self.0.app_id() + } + + #[getter] + #[inline] + fn access_token(&self) -> &str { + self.0.access_token() + } + + #[getter] + #[inline] + fn uuid(&self) -> PyUuid { + self.0.uuid().into() + } + + #[getter] + #[inline] + fn username(&self) -> &str { + self.0.username() + } + + #[getter] + #[inline] + fn xuid(&self) -> &str { + self.0.xuid() + } + + fn request_profile(&mut self) -> PyResult<()> { + self.0.request_profile() + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + + fn request_refresh(&mut self) -> PyResult<()> { + self.0.request_refresh() + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} + + +#[pyclass(name = "Database", module = "portablemc.msa", frozen)] +pub struct PyDatabase(pub Database); + +#[pymethods] +impl PyDatabase { + + #[new] + fn __new__(file: PathBuf) -> Self { + Self(Database::new(file)) + } + + fn __repr__(&self) -> String { + format!("", + self.0.file()) + } + + #[getter] + #[inline] + fn file(&self) -> &Path { + self.0.file() + } + + fn load_iter(&self) -> PyResult { + self.0.load_iter() + .map(PyDatabaseIter) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + + fn load_from_uuid(&self, uuid: PyUuid) -> PyResult> { + self.0.load_from_uuid(uuid.into()) + .map(|acc| acc.map(PyAccount)) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + + fn load_from_username(&self, username: String) -> PyResult> { + self.0.load_from_username(&username) + .map(|acc| acc.map(PyAccount)) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + + fn remove_from_uuid(&self, uuid: PyUuid) -> PyResult> { + self.0.remove_from_uuid(uuid.into()) + .map(|acc| acc.map(PyAccount)) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + + fn remove_from_username(&self, username: String) -> PyResult> { + self.0.remove_from_username(&username) + .map(|acc| acc.map(PyAccount)) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + + fn store(&self, account: PyRef<'_, PyAccount>) -> PyResult<()> { + self.0.store(account.0.clone()) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + } + +} + + +// Internal class! +#[pyclass(name = "_DatabaseIter", module = "portablemc.msa")] +pub struct PyDatabaseIter(pub DatabaseIter); + +#[pymethods] +impl PyDatabaseIter { + + fn __iter__(this: PyRef<'_, Self>) -> PyRef<'_, Self> { + this + } + + fn __next__(&mut self) -> Option { + self.0.next().map(PyAccount) + } + +} diff --git a/rust/portablemc-py/src/uuid.rs b/rust/portablemc-py/src/uuid.rs new file mode 100644 index 00000000..5490e69a --- /dev/null +++ b/rust/portablemc-py/src/uuid.rs @@ -0,0 +1,73 @@ +//! UUID type binding for 'uuid.UUID' in Python. + +use pyo3::exceptions::PyValueError; +use pyo3::exceptions::PyTypeError; +use pyo3::types::IntoPyDict; +use pyo3::types::PyBytes; +use pyo3::FromPyObject; +use pyo3::prelude::*; +use pyo3::intern; + + +use uuid::Uuid; + + +/// A binding for the standard library `uuid.UUID` Python type. +#[derive(Debug)] +#[repr(transparent)] +pub struct PyUuid(pub Uuid); + +impl From for PyUuid { + fn from(value: Uuid) -> Self { + Self(value) + } +} + +impl From for Uuid { + fn from(value: PyUuid) -> Self { + value.0 + } +} + +impl<'py> FromPyObject<'py> for PyUuid { + + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + + let mod_uuid = PyModule::import(ob.py(), intern!(ob.py(), "uuid"))?; + let ty_uuid = mod_uuid.getattr(intern!(ob.py(), "UUID"))?; + + if !ob.is_instance(&ty_uuid)? { + return Err(PyTypeError::new_err("expected uuid.UUID")); + } + + let bytes = ob.getattr(intern!(ob.py(), "bytes"))? + .downcast_into::()?; + + match Uuid::from_slice(bytes.as_bytes()) { + Ok(uuid) => Ok(Self(uuid)), + Err(_err) => Err(PyValueError::new_err("given uuid.UUID has invalid bytes")), + } + + } + +} + +impl<'py> IntoPyObject<'py> for PyUuid { + + type Target = PyAny; + type Output = Bound<'py, PyAny>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + + let mod_uuid = PyModule::import(py, intern!(py, "uuid"))?; + let ty_uuid = mod_uuid.getattr(intern!(py, "UUID"))?; + + let bytes = PyBytes::new(py, self.0.as_bytes()); + let kwargs = [("bytes", bytes)].into_py_dict(py)?; + + ty_uuid.call((), Some(&kwargs)) + + } + +} diff --git a/rust/portablemc/Cargo.toml b/rust/portablemc/Cargo.toml new file mode 100644 index 00000000..84300253 --- /dev/null +++ b/rust/portablemc/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "portablemc" +description = "Developer-oriented crate for launching Minecraft quickly and reliably with included support for Mojang versions and popular mod loaders. See portablemc-cli for the reference implementation." +categories = ["games"] +edition.workspace = true +version.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = true + +[dependencies] +thiserror.workspace = true + +serde.workspace = true +serde_path_to_error.workspace = true +serde_json.workspace = true + +uuid = { workspace = true, features = ["v5"] } +chrono.workspace = true +xmlparser.workspace = true + +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "fs", "time", "net", "macros"] } +reqwest = { workspace = true, features = ["json"] } + +indexmap.workspace = true +elsa.workspace = true + +regex.workspace = true + +once_cell.workspace = true + +gethostname.workspace = true +dunce.workspace = true +os_info.workspace = true +dirs.workspace = true +windows-registry.workspace = true + +sha1.workspace = true +md5.workspace = true +jsonwebtoken.workspace = true + +zip.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/rust/portablemc/src/base/mod.rs b/rust/portablemc/src/base/mod.rs new file mode 100644 index 00000000..a2c9d2a3 --- /dev/null +++ b/rust/portablemc/src/base/mod.rs @@ -0,0 +1,2579 @@ +//! The base installation procedure. + +pub(crate) mod serde; + +use std::io::{self, BufReader, BufWriter, Seek, SeekFrom}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::fmt::{self, Write as _}; +use std::path::{Path, PathBuf}; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::sync::LazyLock; +use std::time::Duration; +use std::{env, thread}; +use std::ffi::OsStr; + +use indexmap::IndexSet; + +use zip::ZipArchive; + +use sha1::{Digest, Sha1}; +use uuid::{uuid, Uuid}; + +use crate::path::{PathExt, PathBufExt}; +use crate::download::{self, Batch}; +use crate::maven::Gav; + + +/// Base URL for downloading game's assets. +pub(crate) const RESOURCES_URL: &str = "https://resources.download.minecraft.net/"; + +/// The URL to meta manifest for Mojang-provided JVMs. +pub(crate) const JVM_META_MANIFEST_URL: &str = "https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json"; + +/// Base URL for libraries. +pub(crate) const LIBRARIES_URL: &str = "https://libraries.minecraft.net/"; + +/// The UUID namespace of PMC, used in various places. +pub(crate) const UUID_NAMESPACE: Uuid = uuid!("8df5a464-38de-11ec-aa66-3fd636ee2ed7"); + +/// The default JVM arguments used if no one are presents, such as for old versions. +pub(crate) const LEGACY_JVM_ARGS: &[&str] = &[ + "-Djava.library.path=${natives_directory}", + "-Dminecraft.launcher.brand=${launcher_name}", + "-Dminecraft.launcher.version=${launcher_version}", + "-cp", + "${classpath}", +]; + +/// The installer that supports the minimal basic format for version metadata with +/// support for libraries, assets and loggers automatic installation. By defaults, it +/// also supports finding a suitable JVM for running the game and installs one provided +/// by Mojang as a fallback. +/// +/// Note that this installer doesn't provide any fetching of missing versions, enables +/// no feature by default and provides no fixes for legacy things. This installer just +/// implements the basics of how Minecraft versions are specified, this is mostly from +/// reverse engineering. **Most of the time, you don't want to use this directly, instead +/// you can use the [`mojang::Installer`](crate::mojang::Installer), that provides support +/// for fetching missing Mojang versions, various fixes and authentication support.** +#[derive(Debug, Clone)] +pub struct Installer { + version: String, + versions_dir: PathBuf, + libraries_dir: PathBuf, + assets_dir: PathBuf, + jvm_dir: PathBuf, + bin_dir: PathBuf, + mc_dir: PathBuf, + strict_assets_check: bool, + strict_libraries_check: bool, + strict_jvm_check: bool, + jvm_policy: JvmPolicy, + launcher_name: Option, + launcher_version: Option, +} + +impl Installer { + + /// Create a new installer with default configuration and the given root version. + /// + /// If the various directories to be configured are not configured then they will be + /// derived from the default main directory. + pub fn new(version: impl Into) -> Self { + + let mc_dir = default_main_dir().unwrap_or_else(|| Path::new("")); + + Self { + version: version.into(), + versions_dir: mc_dir.join("versions"), + libraries_dir: mc_dir.join("libraries"), + assets_dir: mc_dir.join("assets"), + jvm_dir: mc_dir.join("jvm"), + bin_dir: mc_dir.join("bin"), + mc_dir: mc_dir.to_path_buf(), + strict_assets_check: false, + strict_libraries_check: false, + strict_jvm_check: false, + jvm_policy: JvmPolicy::SystemThenMojang, + launcher_name: None, + launcher_version: None, + } + + } + + /// Get the root version to load with its hierarchy and install. + #[inline] + pub fn version(&self) -> &str { + &self.version + } + + /// Set the root version to load with its hierarchy and install. + #[inline] + pub fn set_version(&mut self, version: impl Into) -> &mut Self { + self.version = version.into(); + self + } + + /// The directory where versions are stored. + #[inline] + pub fn versions_dir(&self) -> &Path { + &self.versions_dir + } + + /// See [`Self::versions_dir`]. + #[inline] + pub fn set_versions_dir(&mut self, dir: impl Into) -> &mut Self { + self.versions_dir = dir.into(); + self + } + + /// The directory where libraries are stored, organized like a maven repository. + #[inline] + pub fn libraries_dir(&self) -> &Path { + &self.libraries_dir + } + + /// See [`Self::libraries_dir`]. + #[inline] + pub fn set_libraries_dir(&mut self, dir: impl Into) -> &mut Self { + self.libraries_dir = dir.into(); + self + } + + /// The directory where assets, assets index, cached skins and logs config are stored. + /// Note that this directory stores caches player skins, so this is the only + /// directory where the client will need to write, and so it needs the permission + /// to do so. + #[inline] + pub fn assets_dir(&self) -> &Path { + &self.assets_dir + } + + /// See [`Self::assets_dir`]. + #[inline] + pub fn set_assets_dir(&mut self, dir: impl Into) -> &mut Self { + self.assets_dir = dir.into(); + self + } + + /// The directory where Mojang-provided JVM has been installed. + #[inline] + pub fn jvm_dir(&self) -> &Path { + &self.jvm_dir + } + + /// See [`Self::jvm_dir`]. + #[inline] + pub fn set_jvm_dir(&mut self, dir: impl Into) -> &mut Self { + self.jvm_dir = dir.into(); + self + } + + /// The directory used to extract natives into (.dll, .so) before startup, in modern + /// versions the launcher no longer extract natives itself, instead LWJGL is auto + /// extracting its own needed natives into that directory. The user launching the + /// game should have read/write permissions to this directory. + /// + /// Note that a sub-directory will be created with a name that is kind of a hash of + /// class files and natives files paths. This directory is considered temporary, not + /// really heavy and so can be removed after all instances of the game have been + /// terminated, it can also be set to something like `/tmp/pmc` on Linux for example. + #[inline] + pub fn bin_dir(&self) -> &Path { + &self.bin_dir + } + + /// See [`Self::bin_dir`]. + #[inline] + pub fn set_bin_dir(&mut self, dir: impl Into) -> &mut Self { + self.bin_dir = dir.into(); + self + } + + /// The directory where the process' working directory is set and all user stuff is + /// saved (saves, resource packs, options and more). The user launching the + /// game should have read/write permissions to this directory. + #[inline] + pub fn mc_dir(&self) -> &Path { + &self.mc_dir + } + + /// See [`Self::mc_dir`]. + #[inline] + pub fn set_mc_dir(&mut self, dir: impl Into) -> &mut Self { + self.mc_dir = dir.into(); + self + } + + /// Shortcut for defining the various main directories of the game, by deriving + /// the given path, the directories `versions`, `assets`, `libraries` and `jvm` + /// are defined. + /// + /// **Note that on Windows**, long NT UNC paths are very likely to be unsupported and + /// you'll get unsound errors with the JVM or the game itself. + #[inline] + pub fn set_main_dir(&mut self, dir: impl Into) -> &mut Self { + let mc_dir = dir.into(); + self.versions_dir = mc_dir.join("versions"); + self.assets_dir = mc_dir.join("assets"); + self.libraries_dir = mc_dir.join("libraries"); + self.jvm_dir = mc_dir.join("jvm"); + self.bin_dir = mc_dir.join("bin"); + self.mc_dir = mc_dir; + self + } + + /// When enabled, all assets are strictly checked against their expected SHA-1, + /// this is disabled by default because it's heavy on CPU. + #[inline] + pub fn strict_assets_check(&self) -> bool { + self.strict_assets_check + } + + /// See [`Self::strict_assets_check`]. + #[inline] + pub fn set_strict_assets_check(&mut self, strict: bool) -> &mut Self { + self.strict_assets_check = strict; + self + } + + /// When enabled, all libraries are strictly checked against their expected SHA-1, + /// this is disabled by default because it's heavy on CPU. + #[inline] + pub fn strict_libraries_check(&self) -> bool { + self.strict_libraries_check + } + + /// See [`Self::strict_libraries_check`]. + #[inline] + pub fn set_strict_libraries_check(&mut self, strict: bool) -> &mut Self { + self.strict_libraries_check = strict; + self + } + + /// When enabled, all files from Mojang-provided JVMs are strictly checked against + /// their expected SHA-1, this is disabled by default because it's heavy on CPU. + #[inline] + pub fn strict_jvm_check(&self) -> bool { + self.strict_jvm_check + } + + /// See [`Self::set_strict_jvm_check`]. + #[inline] + pub fn set_strict_jvm_check(&mut self, strict: bool) -> &mut Self { + self.strict_jvm_check = strict; + self + } + + /// The policy for finding a JVM to run the game on. + #[inline] + pub fn jvm_policy(&self) -> &JvmPolicy { + &self.jvm_policy + } + + /// See [`Self::jvm_policy`]. + #[inline] + pub fn set_jvm_policy(&mut self, policy: JvmPolicy) -> &mut Self { + self.jvm_policy = policy; + self + } + + /// A specific launcher name to put on the command line, defaults to "portablemc". + pub fn launcher_name(&self) -> &str { + self.launcher_name.as_deref().unwrap_or(env!("CARGO_PKG_NAME")) + } + + /// See [`Self::launcher_name`]. + #[inline] + pub fn set_launcher_name(&mut self, name: impl Into) -> &mut Self { + self.launcher_name = Some(name.into()); + self + } + + /// A specific launcher version to put on the command line, defaults to PMC version. + pub fn launcher_version(&self) -> &str { + self.launcher_version.as_deref().unwrap_or(env!("CARGO_PKG_VERSION")) + } + + /// See [`Self::launcher_version`]. + #[inline] + pub fn set_launcher_version(&mut self, version: impl Into) -> &mut Self { + self.launcher_version = Some(version.into()); + self + } + + /// Ensure that a the given version, from its id, is fully installed and return + /// a game instance that can be used to run launch it. + #[inline] + pub fn install(&mut self, mut handler: impl Handler) -> Result { + self.install_dyn(&mut handler) + } + + /// Inner install function to force dyn dispatch. + #[inline(never)] + fn install_dyn(&mut self, handler: &mut dyn Handler) -> Result { + + // Start by setting up features. + let mut features = HashSet::new(); + handler.filter_features(&mut features); + handler.loaded_features(&features); + + // Then we have a sequence of steps that may add entries to the download batch. + let mut batch = Batch::new(); + let hierarchy = self.load_hierarchy(&mut *handler, &self.version)?; + let mut lib_files = self.load_libraries(&mut *handler, &hierarchy, &features, &mut batch)?; + let logger_config = self.load_logger(&mut *handler, &hierarchy, &mut batch)?; + let assets = self.load_assets(&mut *handler, &hierarchy, &mut batch)?; + let jvm = self.load_jvm(&mut *handler, &hierarchy, &mut batch)?; + + // If we don't find the main class it is impossible to launch. + let main_class = hierarchy.iter() + .find_map(|v| v.metadata.main_class.as_ref()) + .cloned() + .ok_or(Error::MainClassNotFound { })?; + + // Only trigger download events if the batch is not empty. Note that in this + // module and generally in this crate we transform handlers to a dynamic download + // handler '&mut dyn download::Handler' to avoid large polymorphism duplications. + if !batch.is_empty() { + + if !handler.download_resources() { + return Err(Error::DownloadResourcesCancelled { }); + } + + batch.download(&mut *handler) + .map_err(|e| Error::new_reqwest(e, "download resources"))? + .into_result()?; + + handler.downloaded_resources(); + + } + + // Finalization of libraries to create a unique bin dir and extract them into. + let bin_dir = self.finalize_libraries(&mut *handler, &mut lib_files)?; + + // Final installation step is to finalize assets if virtual or resource mapping. + if let Some(assets) = &assets { + self.finalize_assets(assets)?; + } + + // Finalization of JVM is needed to ensure executable and linked files. + self.finalize_jvm(&jvm)?; + + // Resolve arguments from the hierarchy of versions. + let mut jvm_args = Vec::new(); + let mut game_args = Vec::new(); + + for version in &hierarchy { + if let Some(version_args) = &version.metadata.arguments { + self.check_args(&mut jvm_args, &version_args.jvm, &features, None); + self.check_args(&mut game_args, &version_args.game, &features, None); + } else if let Some(version_legacy_args) = &version.metadata.legacy_arguments { + // Legacy args are overwriting everything and abort child version. + jvm_args = LEGACY_JVM_ARGS.iter().copied().map(str::to_string).collect::>(); + game_args = version_legacy_args.split_whitespace().map(str::to_string).collect::>(); + break; + } + } + + // The logger configuration is an additional JVM argument. + if let Some(logger_config) = &logger_config { + let logger_file = canonicalize_file(&logger_config.file)?; + jvm_args.push(logger_config.argument.replace("${path}", &logger_file.to_string_lossy())); + } + + // We also canonicalize paths that will probably be used by args replacements... + let bin_dir = canonicalize_file(&bin_dir)?; + let mc_dir = canonicalize_file(&self.mc_dir)?; + let libraries_dir = canonicalize_file(&self.libraries_dir)?; + let assets_dir = canonicalize_file(&self.assets_dir)?; + let jvm_file = canonicalize_file(&jvm.file)?; + let assets_virtual_dir = match &assets { + Some(Assets { mapping: Some(mapping), .. }) => Some(canonicalize_file(&mapping.virtual_dir)?), + _ => None, + }; + + // Closure to replace arguments in both JVM and game args. + let repl_arg = |arg: &str| { + Some(match arg { + // This is used by some mod loaders... + #[cfg(windows)] "classpath_separator" => ";".to_string(), + #[cfg(not(windows))] "classpath_separator" => ":".to_string(), + "classpath" => env::join_paths(lib_files.class_files.iter()) + .unwrap() + .to_string_lossy() + .into_owned(), + "natives_directory" => bin_dir.display().to_string(), + // Information about launcher anv launched version. + "launcher_name" => self.launcher_name().to_string(), + "launcher_version" => self.launcher_version().to_string(), + "version_name" => hierarchy[0].name.clone(), + "version_type" => return hierarchy.iter() // First occurrence of 'type'. + .filter_map(|v| v.metadata.r#type.as_ref()) + .map(|t| t.as_str().to_string()) + .next(), + // Same as the mc dir for simplification of the abstraction. + "game_directory" => mc_dir.display().to_string(), + // Has been observed in some custom versions... + "library_directory" => libraries_dir.display().to_string(), + // Modern objects-based assets... + "assets_root" => assets_dir.display().to_string(), + "assets_index_name" => return assets.as_ref() + .map(|assets| assets.id.clone()), + // Legacy assets... + "game_assets" => return assets_virtual_dir.as_ref() + .map(|dir| dir.display().to_string()), + _ => return None + }) + }; + + replace_strings_args(&mut jvm_args, repl_arg); + replace_strings_args(&mut game_args, repl_arg); + + Ok(Game { + jvm_file, + mc_dir, + main_class, + jvm_args, + game_args, + }) + + } + + /// Internal function that loads the version hierarchy from their JSON metadata files. + fn load_hierarchy(&self, + handler: &mut dyn Handler, + root_version: &str + ) -> Result> { + + // This happen if a temporary empty root id has been used. + if root_version.is_empty() { + return Err(Error::VersionNotFound { version: String::new() }); + } + + handler.load_hierarchy(root_version); + + let mut hierarchy = Vec::new(); + let mut current_name = Some(root_version.to_string()); + let mut unique_names = HashSet::new(); + + while let Some(version_name) = current_name.take() { + + if !unique_names.insert(version_name.clone()) { + return Err(Error::HierarchyLoop { version: version_name }); + } + + let version = self.load_version(handler, version_name)?; + if let Some(next_name) = &version.metadata.inherits_from { + current_name = Some(next_name.clone()); + } + hierarchy.push(version); + + } + + handler.loaded_hierarchy(&hierarchy); + + Ok(hierarchy) + + } + + /// Internal function that loads a version from its JSON metadata file. + fn load_version(&self, + handler: &mut dyn Handler, + version: String, + ) -> Result { + + if version.is_empty() { + return Err(Error::VersionNotFound { version: String::new() }); + } + + let dir = self.versions_dir.join(&version); + let file = dir.join_with_extension(&version, "json"); + + handler.load_version(&version, &file); + + // Try a second time if retry is requested... + for _ in 0..2 { + + let reader = match File::open(&file) { + Ok(reader) => BufReader::new(reader), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + if handler.need_version(&version, &file) { + continue; + } else { + break; + } + } + Err(e) => return Err(Error::new_io_file(e, &file)) + }; + + let mut deserializer = serde_json::Deserializer::from_reader(reader); + let metadata = serde_path_to_error::deserialize::<_, Box>(&mut deserializer) + .map_err(|e| Error::new_json_file(e, &file))?; + + handler.loaded_version(&version, &file); + + return Ok(LoadedVersion { + name: version, + dir, + metadata, + }); + + } + + // If not retried, we return a version not found error. + Err(Error::VersionNotFound { version }) + + } + + /// Load the entry point version JAR file. + fn load_client(&self, + handler: &mut dyn Handler, + hierarchy: &[LoadedVersion], + batch: &mut Batch, + ) -> Result { + + let root_version = &hierarchy[0]; + let file = root_version.dir.join_with_extension(&root_version.name, "jar"); + + handler.load_client(); + + let dl = hierarchy.iter() + .filter_map(|version| version.metadata.downloads.get("client")) + .next(); + + if let Some(dl) = dl { + let check_client_sha1 = dl.sha1.as_deref().filter(|_| self.strict_libraries_check); + if !check_file(&file, dl.size, check_client_sha1)? { + batch.push(dl.url.clone(), file.clone()) + .set_expected_size(dl.size) + .set_expected_sha1(dl.sha1.as_deref().copied()); + } + } else if !file.is_file() { + return Err(Error::ClientNotFound { }); + } + + handler.loaded_client(&file); + + Ok(file) + + } + + /// Load libraries required to run the game. + fn load_libraries(&self, + handler: &mut dyn Handler, + hierarchy: &[LoadedVersion], + features: &HashSet, + batch: &mut Batch, + ) -> Result { + + let client_file = self.load_client(&mut *handler, &hierarchy, &mut *batch)?; + + handler.load_libraries(); + + // Tracking libraries that are already defined and should not be overridden. + let mut libraries_set = HashSet::new(); + let mut libraries = Vec::new(); + + for version in hierarchy { + + for lib in &version.metadata.libraries { + + let mut lib_gav = lib.name.clone(); + + if let Some(lib_natives) = &lib.natives { + + // Same reason as below. + let (Some(os_name), Some(os_bits)) = (os_name(), os_bits()) else { + continue; + }; + + // If natives object is present, the classifier associated to the + // OS overrides the library specifier classifier. If not existing, + // we just skip this library because natives are missing. + let Some(classifier) = lib_natives.get(os_name) else { + continue; + }; + + // If we find a arch replacement pattern, we must replace it with + // the target architecture bit-ness (32, 64). + const ARCH_REPLACEMENT_PATTERN: &str = "${arch}"; + if let Some(pattern_idx) = classifier.find(ARCH_REPLACEMENT_PATTERN) { + let mut classifier = classifier.clone(); + classifier.replace_range(pattern_idx..pattern_idx + ARCH_REPLACEMENT_PATTERN.len(), os_bits); + lib_gav.set_classifier(Some(&classifier)); + } else { + lib_gav.set_classifier(Some(&classifier)); + } + + } + + // Start by applying rules before the actual parsing. Important, we do + // that after checking natives, so this will override the lib state if + // rejected, and we still benefit from classifier resolution. + if let Some(lib_rules) = &lib.rules { + if !self.check_rules(lib_rules, features, None) { + continue; + } + } + + // Clone the spec with wildcard for version because we shouldn't override + // if any of the group/artifact/classifier/extension are matching. + let mut lib_gav_wildcard = lib_gav.clone(); + lib_gav_wildcard.set_version("*"); + if !libraries_set.insert(lib_gav_wildcard) { + continue; + } + + libraries.push(LoadedLibrary { + gav: lib_gav, + path: None, + download: None, + natives: lib.natives.is_some(), + }); + + let lib_obj = libraries.last_mut().unwrap(); + + let lib_dl; + if lib_obj.natives { + // Unwrap because as seen above, if there are native with define a + // classifier on the GAV. + lib_dl = lib.downloads.classifiers.get(lib_obj.gav.classifier().unwrap()); + } else { + lib_dl = lib.downloads.artifact.as_ref(); + } + + if let Some(lib_dl) = lib_dl { + lib_obj.path = lib_dl.path.as_ref().map(PathBuf::from); + lib_obj.download = Some(LibraryDownload { + url: lib_dl.download.url.to_string(), + size: lib_dl.download.size, + sha1: lib_dl.download.sha1.as_deref().copied(), + }); + } else if let Some(repo_url) = &lib.url { + + // If we don't have any download information, it's possible to use + // the 'url', which is the base URL of a maven repository, that we + // can derive with the library name to find a URL. + + let mut url = repo_url.clone(); + if !url.ends_with('/') { + url.push('/'); + } + write!(url, "{}", lib_obj.gav.url()).unwrap(); + + lib_obj.download = Some(LibraryDownload { + url, + size: None, + sha1: None, + }); + + } + + // Additional check because libraries with empty URLs have been seen in + // the wild, so we remove the source if its URL is empty. + if let Some(lib_source) = &lib_obj.download { + if lib_source.url.is_empty() { + lib_obj.download = None; + } + } + + } + + } + + handler.filter_libraries(&mut libraries); + handler.loaded_libraries(&libraries); + + // Old versions seems to prefer having the main class first in class path, so by + // default here we put it first, but it may be modified by later versions. + let mut lib_files = LibrariesFiles::default(); + lib_files.class_files.push(client_file); + + // After possible filtering by event handler, verify libraries and download + // missing ones. + for lib in libraries { + + // Construct the library path depending on its presence. + let lib_file = if let Some(lib_rel_path) = lib.path.as_deref() { + // NOTE: Unsafe path joining. + self.libraries_dir.join(lib_rel_path) + } else { + lib.gav.file(&self.libraries_dir) + }; + + // If no repository URL is given, no more download method is available, + // so if the JAR file isn't installed, the game cannot be launched. + // + // Note: In the past, we used to default the url to Mojang's maven + // repository, but this was a bad habit because most libraries could + // not be downloaded from their repository, and this was confusing to + // get a download error for such libraries. + if let Some(download) = lib.download { + // Only check SHA-1 if strict checking is enabled. + let check_source_sha1 = download.sha1.as_ref().filter(|_| self.strict_libraries_check); + if !check_file(&lib_file, download.size, check_source_sha1)? { + batch.push(download.url, lib_file.clone()) + .set_expected_size(download.size) + .set_expected_sha1(download.sha1); + } + } else if !lib_file.is_file() { + return Err(Error::LibraryNotFound { gav: lib.gav }) + } + + (if lib.natives { + &mut lib_files.natives_files + } else { + &mut lib_files.class_files + }).push(lib_file); + + } + + handler.filter_libraries_files(&mut lib_files.class_files, &mut lib_files.natives_files); + handler.loaded_libraries_files(&lib_files.class_files, &lib_files.natives_files); + + Ok(lib_files) + + } + + /// Finalize libraries after download by making every path canonicalized, then + /// computing the unique UUID of all the lib files (just by hashing their file + /// names) in order to construct a bin (natives) directory unique to these files. + /// All natives files are then extracted or copied into this binary directory + /// and it is returned by this function. + fn finalize_libraries(&self, + handler: &mut dyn Handler, + lib_files: &mut LibrariesFiles + ) -> Result { + + let mut hash_buf = Vec::new(); + + // We know that everything has been downloaded and so we canonicalize in place. + for file in &mut lib_files.class_files { + *file = canonicalize_file(file)?; + hash_buf.extend_from_slice(file.as_os_str().as_encoded_bytes()); + } + + for file in &mut lib_files.natives_files { + *file = canonicalize_file(file)?; + hash_buf.extend_from_slice(file.as_os_str().as_encoded_bytes()); + } + + // We place the root id as prefix for clarity, even if we can theoretically + // have multiple bin dir for the same version, if libraries change. + let bin_uuid = Uuid::new_v5(&UUID_NAMESPACE, &hash_buf); + let bin_dir = self.bin_dir.join(&self.version) + .appended(format!("-{}", bin_uuid.hyphenated())); + + // Create the directory and then canonicalize it. + fs::create_dir_all(&bin_dir) + .map_err(|e| Error::new_io(e, format!("create dir: {}", bin_dir.display())))?; + + // Now we extract all binaries. + for src_file in &lib_files.natives_files { + + let ext = src_file.extension() + .map(OsStr::as_encoded_bytes) + .unwrap_or_default(); + + match ext { + b"zip" | b"jar" => { + + let src_reader = File::open(src_file) + .map_err(|e| Error::new_io_file(e, src_file)) + .map(BufReader::new)?; + + let mut archive = ZipArchive::new(src_reader) + .map_err(|e| Error::new_zip_file(e, src_file))?; + + for i in 0..archive.len() { + + let mut file = archive.by_index(i).unwrap(); + let Some(file_path) = file.enclosed_name() else { + continue; + }; + let Some(file_ext) = file_path.extension() else { + continue; + }; + + if !matches!(file_ext.as_encoded_bytes(), b"so" | b"dll" | b"dylib") { + continue; + } + + // Unwrapping because file should have a name if it has extension. + let file_name = file_path.file_name().unwrap(); + let dst_file = bin_dir.join(file_name); + + let mut dst_writer = File::create(&dst_file) + .map_err(|e| Error::new_io_file(e, &dst_file))?; + + io::copy(&mut file, &mut dst_writer) + .map_err(|e| Error::new_io(e, format!("extract: {}, from: {}, to: {}", + file.name(), + src_file.display(), + dst_file.display())))?; + + } + + } + _ => { + + // Here we just copy the file, if it happens to be a .so file we + // elide the version number (.so.1.2.3). + + let Some(mut file_name) = src_file.file_name() else { + continue; + }; + + // Right find a 'so' extension... + let file_name_bytes = file_name.as_encoded_bytes(); + let mut file_name_new_len = file_name_bytes.len(); + for part in file_name_bytes.rsplit(|&n| n == b'.') { + + // The remaining length can't be zero initially. + debug_assert_ne!(file_name_new_len, 0); + file_name_new_len -= part.len(); + if file_name_new_len == 0 { + continue; // This is equivalent to break. + } + + if part == b"so" { + // SAFETY: We matched an ASCII extension 'so' after the dot, + // so it's a valid bound where we can cut off the OS string. + file_name = unsafe { + OsStr::from_encoded_bytes_unchecked(&file_name_bytes[..file_name_new_len + 2]) + }; + break; + } + + file_name_new_len -= 1; // For the dot. + + } + + // Note that 'src_file' has been canonicalized and therefore we have + // no issue of relative linking. + let dst_file = bin_dir.join(file_name); + symlink_or_copy_file(&src_file, &dst_file)?; + + } + } + + } + + handler.extracted_binaries(&bin_dir); + + Ok(bin_dir) + + } + + /// Load libraries required to run the game. + fn load_logger(&self, + handler: &mut dyn Handler, + hierarchy: &[LoadedVersion], + batch: &mut Batch, + ) -> Result> { + + let config = hierarchy.iter() + .filter_map(|version| version.metadata.logging.get("client")) + .next(); + + let Some(config) = config else { + handler.no_logger(); + return Ok(None); + }; + + handler.load_logger(&config.file.id); + + let file = self.assets_dir + .join("log_configs") + .joined(config.file.id.as_str()); + + if !check_file(&file, config.file.download.size, config.file.download.sha1.as_deref())? { + batch.push(config.file.download.url.clone(), file.clone()) + .set_expected_size(config.file.download.size) + .set_expected_sha1(config.file.download.sha1.as_deref().copied()); + } + + handler.loaded_logger(&config.file.id); + + Ok(Some(LoggerConfig { + kind: config.r#type, + argument: config.argument.clone(), + file, + })) + + } + + /// Load and verify all assets of the game. + fn load_assets(&self, + handler: &mut dyn Handler, + hierarchy: &[LoadedVersion], + batch: &mut Batch, + ) -> Result> { + + /// Internal description of asset information first found in hierarchy. + #[derive(Debug)] + struct IndexInfo<'a> { + download: Option<&'a serde::Download>, + id: &'a str, + } + + // We search the first version that provides asset informations, we also support + // the legacy 'assets' that doesn't have download information. + let index_info = hierarchy.iter() + .find_map(|version| { + if let Some(asset_index) = &version.metadata.asset_index { + Some(IndexInfo { + download: Some(&asset_index.download), + id: &asset_index.id, + }) + } else if let Some(asset_id) = &version.metadata.assets { + Some(IndexInfo { + download: None, + id: &asset_id, + }) + } else { + None + } + }); + + let Some(index_info) = index_info else { + handler.no_assets(); + return Ok(None); + }; + + handler.load_assets(index_info.id); + + // Resolve all used directories and files... + let indexes_dir = self.assets_dir.join("indexes"); + let index_file = indexes_dir.join_with_extension(index_info.id, "json"); + + // All modern version metadata have download information attached to the assets + // index identifier, we check the file against the download information and then + // download this single file. If the file has no download info + let mut index_downloaded = false; + if let Some(dl) = index_info.download { + if !check_file(&index_file, dl.size, dl.sha1.as_deref())? { + download::single(dl.url.clone(), index_file.clone()) + .set_expected_size(dl.size) + .set_expected_sha1(dl.sha1.as_deref().copied()) + .download(&mut *handler)?; + index_downloaded = true; + } + } + + // Scoped to release the reader. + let asset_index = { + + let reader = match File::open(&index_file) { + Ok(reader) => BufReader::new(reader), + Err(e) if !index_downloaded && e.kind() == io::ErrorKind::NotFound => + return Err(Error::AssetsNotFound { id: index_info.id.to_owned() }), + Err(e) => + return Err(Error::new_io_file(e, &index_file)) + }; + + let mut deserializer = serde_json::Deserializer::from_reader(reader); + serde_path_to_error::deserialize::<_, serde::AssetIndex>(&mut deserializer) + .map_err(|e| Error::new_json_file(e, &index_file))? + + }; + + handler.loaded_assets(index_info.id, asset_index.objects.len()); + + // Now we check assets that needs to be downloaded... + let objects_dir = self.assets_dir.join("objects"); + let mut asset_file_name = String::new(); + let mut unique_hashes = HashSet::new(); + + let mut assets = Assets { + id: index_info.id.to_string(), + mapping: None, + }; + + // If any mapping is needed we compute the virtual directory. + if asset_index.r#virtual || asset_index.map_to_resources { + assets.mapping = Some(AssetsMapping { + objects: Vec::new(), + virtual_dir: self.assets_dir + .join("virtual") + .joined(assets.id.as_str()) + .into_boxed_path(), + resources: asset_index.map_to_resources, + }); + } + + for (asset_rel_file, asset) in &asset_index.objects { + + asset_file_name.clear(); + for byte in *asset.hash { + write!(asset_file_name, "{byte:02x}").unwrap(); + } + + let asset_hash_prefix = &asset_file_name[0..2]; + let asset_hash_file = objects_dir + .join(asset_hash_prefix) + .joined(asset_file_name.as_str()); + + // Save the association of asset path to the actual hash file, only do + // that if we need it because of virtual or resource mapped assets. + if let Some(mapping) = &mut assets.mapping { + mapping.objects.push(AssetObject { + rel_file: PathBuf::from(asset_rel_file).into_boxed_path(), + object_file: asset_hash_file.clone().into_boxed_path(), + size: asset.size, + }); + } + + // Some assets are represented with multiple files, but we don't + // want to download a file multiple time so we abort here. + if !unique_hashes.insert(&*asset.hash) { + continue; + } + + // Only check SHA-1 if strict checking. + let check_asset_sha1 = self.strict_assets_check.then_some(&*asset.hash); + if !check_file(&asset_hash_file, Some(asset.size), check_asset_sha1)? { + batch.push(format!("{RESOURCES_URL}{asset_hash_prefix}/{asset_file_name}"), asset_hash_file) + .set_expected_size(Some(asset.size)) + .set_expected_sha1(Some(*asset.hash)); + } + + } + + handler.verified_assets(index_info.id, asset_index.objects.len()); + Ok(Some(assets)) + + } + + /// Finalize assets linking in case of virtual or resources mapping. + fn finalize_assets(&self, assets: &Assets) -> Result<()> { + + // If the mapping is resource or virtual then we start by copying assets to + // their virtual directory. We are using hard link because it's way cheaper + // than copying and save storage. + let Some(mapping) = &assets.mapping else { + return Ok(()); + }; + + // Important note: pre-1.6 versions (more exactly 13w23b and before) are altering + // the resources directory on their own, downloading resources that don't match + // the metadata returned by http://s3.amazonaws.com/MinecraftResources/ (this + // URL no longer works, but can be fixed using proxies). This means that: + // + // - We should copy the resources again and again before each launch and let the + // game modify them if needed, therefore no hard/sym link to the virtual dir. + // + // - Running the installer for a pre-1.6 version in the same work dir as another + // running pre-1.6 version will overwrite the modified resources and therefore + // the running version may read the wrong assets for a short time (until the + // installed version is run), and if the two versions are different then both + // versions will download different things. There is also a potential issue if + // the installer wants to overwrite a resource while it is also being modified + // at the same time by the running instance. + let resources_dir = mapping.resources + .then(|| self.mc_dir.join("resources")); + + // Hard link each asset into its virtual directory, note on non-unix systems we + // also do that to the resources directory. + for object in &mapping.objects { + + let virtual_file = mapping.virtual_dir.join(&object.rel_file); + if let Some(parent) = virtual_file.parent() { + fs::create_dir_all(parent) + .map_err(|e| Error::new_io(e, format!("create dir: {}", parent.display())))?; + } + hard_link_file(&object.object_file, &virtual_file)?; + + // We copy each resource, if not matching (size only). + if let Some(resources_dir) = &resources_dir { + + let resource_file = resources_dir.join(&object.rel_file); + if !check_file(&resource_file, Some(object.size), None)? { + + if let Some(parent) = resource_file.parent() { + fs::create_dir_all(parent) + .map_err(|e| Error::new_io(e, format!("create dir: {}", parent.display())))?; + } + + fs::copy(&object.object_file, &resource_file) + .map_err(|e| Error::new_io(e, format!("copy: {}, to: {}", + object.object_file.display(), + resource_file.display())))?; + + } + + } + + } + + Ok(()) + + } + + /// The goal of this step is to find a valid JVM to run the game on. + fn load_jvm(&self, + handler: &mut dyn Handler, + hierarchy: &[LoadedVersion], + batch: &mut Batch, + ) -> Result { + + let version = hierarchy.iter() + .find_map(|version| version.metadata.java_version.as_ref()); + + let major_version = version + .map(|v| v.major_version) + .unwrap_or(8); // Default to Java 8 if not specified. + + // If there is not distribution we try to use a well-known one. + let distribution = version + .and_then(|v| v.component.as_deref()) + .or_else(|| Some(match major_version { + 8 => "jre-legacy", + 16 => "java-runtime-alpha", + 17 => "java-runtime-gamma", + 21 => "java-runtime-delta", + _ => return None + })); + + handler.load_jvm(major_version); + + // We simplify the code with this condition and duplicated match, because in the + // 'else' case we can simplify any policy that contains Mojang and System to + // System, because we don't have instructions for finding Mojang version. + let jvm = if let Some(distribution) = distribution { + match self.jvm_policy { + JvmPolicy::Static(ref file) => + Some(self.load_static_jvm(handler, &file, major_version)?), + JvmPolicy::System => + self.load_system_jvm(handler, major_version)?, + JvmPolicy::Mojang => + self.load_mojang_jvm(handler, distribution, batch)?, + JvmPolicy::SystemThenMojang => { + let mut jvm = self.load_system_jvm(handler, major_version)?; + if jvm.is_none() { + jvm = self.load_mojang_jvm(handler, distribution, batch)?; + } + jvm + } + JvmPolicy::MojangThenSystem => { + let mut jvm = self.load_mojang_jvm(handler, distribution, batch)?; + if jvm.is_none() { + jvm = self.load_system_jvm(handler, major_version)?; + } + jvm + } + } + } else { + match self.jvm_policy { + JvmPolicy::Static(ref file) => + Some(self.load_static_jvm(handler, &file, major_version)?), + JvmPolicy::System | + JvmPolicy::SystemThenMojang | + JvmPolicy::MojangThenSystem => + self.load_system_jvm(handler, major_version)?, + JvmPolicy::Mojang => None, + } + }; + + let Some(jvm) = jvm else { + return Err(Error::JvmNotFound { major_version }); + }; + + let version = jvm.version.as_ref() + .map(|v| v.full.as_str()); + + let compatible = jvm.version.as_ref() + .map(|v| v.major_compatibility.is_some()) + .unwrap_or(false); + + handler.loaded_jvm(&jvm.file, version, compatible); + + Ok(jvm) + + } + + /// Load the JVM by checking its version, + fn load_static_jvm(&self, + _handler: &mut dyn Handler, + file: &Path, + major_version: u32, + ) -> Result { + + let mut jvm = Jvm { + file: file.to_path_buf(), + version: None, + mojang: None, + }; + + self.find_jvm_versions(std::slice::from_mut(&mut jvm), major_version); + Ok(jvm) + + } + + /// Try to find a JVM executable installed on the system in standard paths, depending + /// on the OS. + fn load_system_jvm(&self, + handler: &mut dyn Handler, + major_version: u32, + ) -> Result> { + + let mut candidates = IndexSet::new(); + let exec_name = jvm_exec_name(); + + // Check every JVM available in PATH. + if let Some(path) = env::var_os("PATH") { + for mut path in env::split_paths(&path) { + path.push(exec_name); + if path.is_file() { + candidates.insert(path); + } + } + } + + // On Linux distributions the different JVMs are in '/usr/lib/jvm/'. + #[cfg(target_os = "linux")] { + if let Ok(read_dir) = fs::read_dir("/usr/lib/jvm/") { + for entry in read_dir { + let Ok(entry) = entry else { continue }; + let path = entry.path() + .joined("bin") + .joined(exec_name); + if path.is_file() { + candidates.insert(path); + } + } + } + } + + // On windows we can search in registry. + #[cfg(windows)] { + + const REG_PATHS: [&str; 4] = [ + "SOFTWARE\\JavaSoft\\Java Development Kit", + "SOFTWARE\\JavaSoft\\Java Runtime Environment", + "SOFTWARE\\JavaSoft\\JDK", + "SOFTWARE\\JavaSoft\\JRE", + ]; + + // Here we silently ignore any error. + for path in REG_PATHS { + let Ok(key) = windows_registry::LOCAL_MACHINE.open(path) else { continue }; + let Ok(keys) = key.keys() else { continue }; + for sub_key in keys { + let Ok(sub_key) = key.open(&sub_key) else { continue }; + let Ok(java_home) = sub_key.get_string("JavaHome") else { continue }; + let path = PathBuf::from(java_home) + .joined("bin") + .joined(exec_name); + if path.is_file() { + candidates.insert(path); + } + } + } + + } + + // Convert unique file paths to JVM, to be fed to JVM versions. + let mut jvms = candidates.into_iter().map(|file| Jvm { + file, + version: None, + mojang: None, + }).collect::>(); + + self.find_jvm_versions(&mut jvms, major_version); + + let mut min_score_jvm = None; + for jvm in jvms { + + let Some(version) = &jvm.version else { continue }; + + let Some(score) = version.major_compatibility else { + handler.found_jvm_system_version(&jvm.file, version.full.as_str(), false); + continue; + }; + + handler.found_jvm_system_version(&jvm.file, version.full.as_str(), true); + + // Don't replace the min score JVM if we are greater or equal. + if let Some((_, min_score)) = min_score_jvm { + if min_score <= score { + continue; + } + } + + min_score_jvm = Some((jvm, score)); + + } + + Ok(min_score_jvm.map(|(jvm, _score)| jvm)) + + } + + fn load_mojang_jvm(&self, + handler: &mut dyn Handler, + distribution: &str, + batch: &mut Batch, + ) -> Result> { + + // On Linux, only glibc dynamic linkage is supported by Mojang-provided JVMs. + if cfg!(target_os = "linux") && cfg!(target_feature = "crt-static") { + handler.warn_jvm_unsupported_dynamic_crt(); + return Ok(None); + } + + // If we don't have JVM platform this means that we can't load Mojang JVM. + let Some(jvm_platform) = mojang_jvm_platform() else { + handler.warn_jvm_unsupported_platform(); + return Ok(None); + }; + + // Start by ensuring that we have a cached version of the JVM meta-manifest. + let meta_manifest = { + + let mut entry = download::single_cached(JVM_META_MANIFEST_URL) + .set_keep_open() + .download(&mut *handler)?; + + let reader = BufReader::new(entry.take_handle().unwrap()); + let mut deserializer = serde_json::Deserializer::from_reader(reader); + serde_path_to_error::deserialize::<_, serde::JvmMetaManifest>(&mut deserializer) + .map_err(|e| Error::new_json_file(e, entry.file()))? + + }; + + let Some(meta_platform) = meta_manifest.platforms.get(jvm_platform) else { + handler.warn_jvm_unsupported_platform(); + return Ok(None); + }; + + let Some(meta_distribution) = meta_platform.distributions.get(distribution) else { + handler.warn_jvm_missing_distribution(); + return Ok(None); + }; + + // We take the first variant for now. + let Some(meta_variant) = meta_distribution.variants.get(0) else { + handler.warn_jvm_missing_distribution(); + return Ok(None); + }; + + let dir = self.jvm_dir.join(distribution); + let manifest_file = self.jvm_dir.join_with_extension(distribution, "json"); + + // On macOS the JVM bundle structure is a bit different so different bin path. + let bin_file = if cfg!(target_os = "macos") { + dir.join("jre.bundle/Contents/Home/bin/java") + } else { + dir.join("bin").joined(jvm_exec_name()) + }; + + // Check the manifest, download it, read and parse it... + let manifest = { + + if !check_file(&manifest_file, meta_variant.manifest.size, meta_variant.manifest.sha1.as_deref())? { + download::single(meta_variant.manifest.url.clone(), manifest_file.clone()) + .set_expected_size(meta_variant.manifest.size) + .set_expected_sha1(meta_variant.manifest.sha1.as_deref().copied()) + .set_keep_open() + .download(&mut *handler)?; + } + + let reader = File::open(&manifest_file) + .map_err(|e| Error::new_io_file(e, &manifest_file)) + .map(BufReader::new)?; + + let mut deserializer = serde_json::Deserializer::from_reader(reader); + serde_path_to_error::deserialize::<_, serde::JvmManifest>(&mut deserializer) + .map_err(|e| Error::new_json_file(e, &manifest_file))? + + }; + + let mut mojang_jvm = MojangJvm::default(); + + // Here we only check files because it's too early to assert symlinks. + for (rel_file, manifest_file) in &manifest.files { + + // NOTE: We could optimize this repeated allocation, maybe. + let rel_file = Path::new(rel_file); + let file = dir.join(rel_file); + + match manifest_file { + serde::JvmManifestFile::Directory => { + fs::create_dir_all(&file) + .map_err(|e| Error::new_io(e, format!("create dir: {}", file.display())))?; + } + serde::JvmManifestFile::File { + executable, + downloads + } => { + + if *executable { + mojang_jvm.executables.push(file.clone().into_boxed_path()); + } + + let dl = &downloads.raw; + + // Only check SHA-1 if strict checking is enabled. + let check_dl_sha1 = dl.sha1.as_deref().filter(|_| self.strict_jvm_check); + if !check_file(&file, dl.size, check_dl_sha1)? { + batch.push(dl.url.clone(), file) + .set_expected_size(dl.size) + .set_expected_sha1(dl.sha1.as_deref().copied()); + } + + } + serde::JvmManifestFile::Link { + target + } => { + mojang_jvm.links.push(MojangJvmLink { + file: file.into_boxed_path(), + target_file: PathBuf::from(target).into_boxed_path(), + }); + } + } + + } + + Ok(Some(Jvm { + file: bin_file, + version: Some(JvmVersion { + full: meta_variant.version.name.clone(), + major_compatibility: Some(0), // Likely perfect compact + }), + mojang: Some(mojang_jvm), + })) + + } + + /// Given a slice of multiple JVMs, update their detected version when possible, by + /// executing '-version' flag command. + fn find_jvm_versions(&self, jvms: &mut [Jvm], major_version: u32) { + + // We put the resulting JVM inside this vector so that we have the same + // ordering as the given exec files. + let mut children = Vec::new(); + let mut remaining = 0usize; + + // The standard doc says that -version outputs version on stderr. This + // argument -version is also practical because the version is given between + // double quotes. + for jvm in jvms.iter_mut() { + + let child = Command::new(&jvm.file) + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .ok(); + + if child.is_some() { + remaining += 1; + } + + children.push(child); + + } + + const TRIES_COUNT: usize = 30; // 3 second maximum. + const TRIES_SLEEP: Duration = Duration::from_millis(100); + + for _ in 0..TRIES_COUNT { + + for (child_idx, child_opt) in children.iter_mut().enumerate() { + + let Some(child) = child_opt else { continue }; + let Ok(status) = child.try_wait() else { + // If an error happens we just forget the child: don't check it again. + let _ = child.kill(); + *child_opt = None; + remaining -= 1; + continue; + }; + + // If child has terminated, we take child to not check it again. + let Some(status) = status else { continue }; + let child = child_opt.take().unwrap(); + remaining -= 1; + + // Not a success, just forget this child. + if !status.success() { + continue; + } + + // If successful, get the output (it should not error nor block)... + let output = child.wait_with_output().unwrap(); + let Ok(output) = String::from_utf8(output.stderr) else { + continue; // Ignore if stderr is not UTF-8. + }; + + jvms[child_idx].version = output.lines() + .filter_map(|line| line.split_once('"')) + .filter_map(|(_, line)| line.split_once('"')) + .map(|(version, _)| version) + .next() + .and_then(|version| { + + let actual_major_version = parse_jvm_major_version(version)?; + + Some(JvmVersion { + full: version.to_string(), + major_compatibility: calc_jvm_major_compatibility(major_version, actual_major_version), + }) + + }); + + } + + if remaining == 0 { + break; + } + + thread::sleep(TRIES_SLEEP); + + } + + } + + /// Finalize the setup of any Mojang-provided JVM, doing nothing if not Mojang. + fn finalize_jvm(&self, jvm: &Jvm) -> Result<()> { + + let Some(mojang_jvm) = &jvm.mojang else { + return Ok(()); + }; + + // This is only relevant on unix where we can set executable mode + #[cfg(unix)] + for exec_file in &mojang_jvm.executables { + + use std::os::unix::fs::PermissionsExt; + + let mut perm = exec_file.metadata() + .map_err(|e| Error::new_io_file(e, &exec_file))? + .permissions(); + + // Set executable permission for every owner/group/other with read access. + let mode = perm.mode(); + let new_mode = mode | ((mode & 0o444) >> 2); + if new_mode != mode { + + perm.set_mode(new_mode); + fs::set_permissions(exec_file, perm) + .map_err(|e| Error::new_io(e, format!("set permissions: {}", exec_file.display())))?; + + } + + } + + // On Unix we simply use a symlink, on other systems (Windows) we hard link, + // this act like a copy but is way cheaper. + for link in &mojang_jvm.links { + link_file(&link.target_file, &link.file)?; + } + + Ok(()) + + } + + /// Resolve metadata game arguments, checking for rules when needed. + fn check_args(&self, + dest: &mut Vec, + args: &[serde::VersionArgument], + features: &HashSet, + mut all_features: Option<&mut HashSet>, + ) { + + for arg in args { + + // If the argument is conditional then we check rule. + if let serde::VersionArgument::Conditional(cond) = arg { + if let Some(rules) = &cond.rules { + if !self.check_rules(rules, features, all_features.as_deref_mut()) { + continue; + } + } + } + + match arg { + serde::VersionArgument::Raw(val) => dest.push(val.clone()), + serde::VersionArgument::Conditional(cond) => + match &cond.value { + serde::SingleOrVec::Single(val) => dest.push(val.clone()), + serde::SingleOrVec::Vec(vals) => dest.extend_from_slice(&vals), + }, + } + + } + + } + + /// Resolve the given JSON array as rules and return true if allowed. + fn check_rules(&self, + rules: &[serde::Rule], + features: &HashSet, + mut all_features: Option<&mut HashSet>, + ) -> bool { + + // Initially disallowed... + let mut allowed = false; + + for rule in rules { + // NOTE: Diverge from what have been done in the Python module for long, we + // no longer early return on disallow. + match self.check_rule(rule, features, all_features.as_deref_mut()) { + Some(serde::RuleAction::Allow) => allowed = true, + Some(serde::RuleAction::Disallow) => allowed = false, + None => (), + } + } + + allowed + + } + + /// Resolve a single rule JSON object and return action if the rule passes. This + /// function accepts a set of all features that will be filled with all features + /// that are checked, accepted or not. + /// + /// This function may return unexpected schema error. + fn check_rule(&self, + rule: &serde::Rule, + features: &HashSet, + mut all_features: Option<&mut HashSet> + ) -> Option { + + if !self.check_rule_os(&rule.os) { + return None; + } + + for (feature, feature_expected) in &rule.features { + + // Only check if still valid... + if features.contains(feature) != *feature_expected { + return None; + } + + if let Some(all_features) = all_features.as_deref_mut() { + all_features.insert(feature.clone()); + } + + } + + Some(rule.action) + + } + + /// Resolve OS rules JSON object and return true if the OS is matching the rule. + /// + /// This function may return an unexpected schema error. + fn check_rule_os(&self, rule_os: &serde::RuleOs) -> bool { + + if let (Some(name), Some(os_name)) = (&rule_os.name, os_name()) { + if name != os_name { + return false; + } + } + + if let (Some(arch), Some(os_arch)) = (&rule_os.arch, os_arch()) { + if arch != os_arch { + return false; + } + } + + if let (Some(version), Some(os_version)) = (&rule_os.version, os_version()) { + if !version.is_match(os_version) { + return false; + } + } + + true + + } + +} + +crate::trait_event_handler! { + /// Handler for events happening when installing. + pub trait Handler: download::Handler { + + /// Filter the features that will be later used to filter rules using them. + fn filter_features(features: &mut HashSet); + /// Notification of all features that have been selected after filtering. + fn loaded_features(features: &HashSet); + + /// The version hierarchy will be loaded, starting from the given root version. + fn load_hierarchy(root_version: &str); + /// The given version hierarchy has been successfully loaded. + fn loaded_hierarchy(hierarchy: &[LoadedVersion]); + + /// A version will be loaded, at this point you can check the file for its + /// validity, and delete it if relevant, in this case [`Self::need_version`] + /// is called just after to possibly install the version metadata. + fn load_version(version: &str, file: &Path); + /// This event is called if the given version is missing a metadata file, in this + /// case its path is given and this handler has the possibility of installing it + /// before retrying. If the handler actually wants the loading to be retried after + /// it as handled it, it should return true. + fn need_version(version: &str, file: &Path) -> bool = false; + /// The given version in the hierarchy has been successfully loaded, the metadata + /// file path is also given. + fn loaded_version(version: &str, file: &Path); + + /// The client JAR file will be loaded. + fn load_client(); + /// The client JAR file has been loaded successfully at the given path. + fn loaded_client(file: &Path); + + /// The game required libraries are going to be loaded. + fn load_libraries(); + /// Filter versions before their verification. + fn filter_libraries(libraries: &mut Vec); + /// Libraries have been loaded. After that, the libraries will be verified and + /// added to the downloads list if missing. + fn loaded_libraries(libraries: &[LoadedLibrary]); + /// Libraries have been verified, the class files includes the client JAR file as + /// first path in the vector. Note that all paths will be canonicalized, + /// relatively to the current process' working dir, before being added to the + /// command line, so the files must exists. + fn filter_libraries_files(class_files: &mut Vec, natives_files: &mut Vec); + /// The final version of class and natives files has been loaded. + fn loaded_libraries_files(class_files: &[PathBuf], natives_files: &[PathBuf]); + + /// No logger configuration will be loaded because version doesn't specify any. + fn no_logger(); + /// The logger configuration will be loaded. + fn load_logger(id: &str); + /// Logger configuration has been loaded successfully. + fn loaded_logger(id: &str); + + /// Assets will not be loaded because version doesn't specify any. + fn no_assets(); + /// Assets will be loaded. + fn load_assets(id: &str); + /// Assets have been loaded, and are going to be verified in order to add missing + /// ones to the download batch. + fn loaded_assets(id: &str, count: usize); + /// Assets have been verified and missing assets have been added to the download + /// batch. + fn verified_assets(id: &str, count: usize); + + /// The JVM will be loaded, depending on the policy configured in the installer. + /// The major version that is required is given, when not specified by any + /// version metadata it defaults to Java 8, because most older versions didn't + /// specify it. + fn load_jvm(major_version: u32); + /// When searching for JVMs in the system standard paths, this event trigger for + /// each detected JVM executable, and indicates if this version is compatible and + /// therefore is a potential candidate for being used as the JVM. + fn found_jvm_system_version(file: &Path, version: &str, compatible: bool); + /// The system runs on Linux and has C runtime not dynamically linked (static, + /// musl for example), suggesting that your system doesn't provide dynamic C + /// runtime (glibc), and such JVM are not provided by Mojang. + fn warn_jvm_unsupported_dynamic_crt(); + /// When trying to find a Mojang JVM to install, your operating system and + /// architecture are not supported. + fn warn_jvm_unsupported_platform(); + /// When trying to find a Mojang JVM to install, your operating system and + /// architecture are supported but the distribution (the java version packaged and + /// distributed by Mojang) is not found. + fn warn_jvm_missing_distribution(); + /// The JVM has been loaded, if the version is known. The compatible flag + /// indicates if this JVM is **likely** compatible with the game version, + /// when false it indicates that it will likely be incompatible. + fn loaded_jvm(file: &Path, version: Option<&str>, compatible: bool); + + /// Resources will be downloaded. This function returns a boolean that indicates + /// if the download should proceed, this can be used to abort + fn download_resources() -> bool = true; + /// Resources have been successfully downloaded. + fn downloaded_resources(); + + /// All binaries has been successfully extracted to the given binary directory. + fn extracted_binaries(dir: &Path); + + } +} + +/// The base installer could not proceed to the installation of a version. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum Error { + /// The given version appears twice in the hierarchy, implying an infinite recursion. + #[error("hierarchy loop: {version}")] + HierarchyLoop { + version: String, + }, + /// The given version is not found when trying to fetch it. + #[error("version not found: {version}")] + VersionNotFound { + version: String, + }, + /// The given version is not found and no download information is provided. + #[error("assets not found: {id}")] + AssetsNotFound { + id: String, + }, + /// The version JAR file that is required has no download information and is not + /// already existing, is is mandatory to build the class path. + #[error("client not found")] + ClientNotFound { }, + /// A library has no download information and is missing the libraries directory. + #[error("library not found: {gav}")] + LibraryNotFound { + gav: Gav, + }, + /// No JVM was found when installing the version, this depends on installer policy. + #[error("jvm not found")] + JvmNotFound { + major_version: u32, + }, + #[error("main class not found")] + MainClassNotFound { }, + /// Returned if the [`Handler::download_resources`] returned false, the installation + /// procedure can't continue because it needs resources to be downloaded. + #[error("download resources cancelled")] + DownloadResourcesCancelled { }, + /// There are some errors in the given download batch. + #[error("download: {} errors over {} entries", batch.errors_count(), batch.len())] + Download { + batch: download::BatchResult, + }, + /// A generic error that originates from internal or third-party dependencies. The + /// goal of this is to provide a backward-compatible error variant that can be + /// dynamically checked and downcast if needed, the actual error types are not + /// guaranteed to be present in future versions. It's associated to an origin + /// string that helps knowing the location of the issue. + /// + /// Currently these are the error types that can be produced by PortableMC: + /// + /// - [`std::io::Error`] for any unexpected I/O error type. + /// + /// - [`serde_json::Error`] (or inside a [`serde_path_to_error::Error`]) for any + /// unexpected parsing error. + /// + /// - [`zip::result::ZipError`] for errors related to ZIP extractions. + /// + /// - [`reqwest::Error`] for errors related to HTTP requests. + #[error("internal: {error} @ {origin}")] + Internal { + #[source] + error: Box, + origin: Box, + }, +} + +impl From for Error { + fn from(batch: download::BatchResult) -> Self { + Self::Download { batch } + } +} + +impl From for Error { + fn from(value: download::EntryError) -> Self { + Self::Download { batch: download::BatchResult::from(value) } + } +} + +/// Type alias for a result with the base error type. +pub type Result = std::result::Result; + +impl Error { + + #[inline] + pub(crate) fn new_io(error: io::Error, origin: impl Into>) -> Self { + Self::Internal { error: Box::new(error), origin: origin.into() } + } + + #[inline] + pub(crate) fn new_json(error: serde_path_to_error::Error, origin: impl Into>) -> Self { + Self::Internal { error: Box::new(error), origin: origin.into() } + } + + #[inline] + pub(crate) fn new_zip(error: zip::result::ZipError, origin: impl Into>) -> Self { + Self::Internal { error: Box::new(error), origin: origin.into() } + } + + #[inline] + pub(crate) fn new_reqwest(error: reqwest::Error, origin: impl Into>) -> Self { + Self::Internal { error: Box::new(error), origin: origin.into() } + } + + #[inline] + pub(crate) fn new_io_file(error: io::Error, file: impl AsRef) -> Self { + Self::new_io(error, file.as_ref().display().to_string()) + } + + #[inline] + pub(crate) fn new_json_file(error: serde_path_to_error::Error, file: impl AsRef) -> Self { + Self::new_json(error, file.as_ref().display().to_string()) + } + + #[inline] + pub(crate) fn new_zip_file(error: zip::result::ZipError, file: impl AsRef) -> Self { + Self::new_zip(error, file.as_ref().display().to_string()) + } + +} + +/// The policy for finding or installing the JVM executable to be used for launching +/// the game. +#[derive(Debug, Clone)] +pub enum JvmPolicy { + /// The path to the JVM executable is given and will be used. + Static(PathBuf), + /// The installer will try to find a suitable JVM executable in the path, searching + /// a `java` (or `javaw.exe` on Windows) executable. On operating systems where it's + /// supported, this will also check for known directories (on Arch for example). + /// If the version needs a specific JVM major version, each candidate executable is + /// checked and a warning is triggered to notify that the version is not suited. + /// Invalid versions are not kept, and if no valid version is found at the end then + /// a [`Error::JvmNotFound`] error is returned. + System, + /// The installer will try to find a suitable JVM to install from Mojang-provided + /// distributions, if no JVM is available for the platform (`jvm_platform` on the + /// installer) and for the required distribution then a [`Error::JvmNotFound`] error + /// is returned. + Mojang, + /// The installer search system and then mojang as a fallback. + SystemThenMojang, + /// The installer search Mojang and then system as a fallback. + MojangThenSystem, +} + +/// Represent a loaded version. +#[derive(Clone)] +pub struct LoadedVersion { + /// Name of this version. + name: String, + /// Directory of that version, where metadata is stored with the JAR file. + dir: PathBuf, + /// The loaded metadata of the version. + metadata: Box, +} + +impl LoadedVersion { + + /// Get the version name. + /// + /// Most game resources call this the "version id", but for consistency and clarity + /// we decided to go with `name` everywhere in the public interface of the library. + /// When it's clear, we just call it "version". + pub fn name(&self) -> &str { + &self.name + } + + /// Get the version directory, where the metadata and client JAR is stored, this + /// directory is named after this version's name. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Return the release channel for this version, if specified in the metadata. + pub fn channel(&self) -> Option { + self.metadata.r#type.map(VersionChannel::from) + } + +} + +impl fmt::Debug for LoadedVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadedVersion") + .field("name", &self.name) + .field("dir", &self.dir) + .finish() + } +} + +/// The different release channels for versions. Most of the game versions calls this +/// the "version type", but for keyword reservation issues with `type` we call this +/// channel on the public interface, and this is also a good indicator +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum VersionChannel { + Release, + Snapshot, + Beta, + Alpha, +} + +/// Internal conversion from the serde equivalent of this to the public interface enum. +/// Both have the same discriminant values so this should be optimized to just a copy. +impl From for VersionChannel { + fn from(value: serde::VersionType) -> Self { + match value { + serde::VersionType::Release => Self::Release, + serde::VersionType::Snapshot => Self::Snapshot, + serde::VersionType::OldBeta => Self::Beta, + serde::VersionType::OldAlpha => Self::Alpha, + } + } +} + +/// Represent a loaded library. +#[derive(Debug, Clone)] +pub struct LoadedLibrary { + /// GAV for this library. + pub gav: Gav, + /// The path to install the library at, relative to the libraries directory, if not + /// specified, it will be derived from the library specifier. + pub path: Option, + /// An optional download information for this library if it is missing. + pub download: Option, + /// True if this contains natives that should be extracted into the binaries + /// directory before launching the game, instead of being added to the class path. + pub natives: bool, +} + +/// Represent how a library will be downloaded if needed. +#[derive(Debug, Clone)] +pub struct LibraryDownload { + pub url: String, + pub size: Option, + pub sha1: Option<[u8; 20]>, +} + +/// Description of all installed resources needed for running an installed game version. +/// The arguments may contain replacement patterns that will be used when starting the +/// game. +/// +/// **Important note:** paths in this structure are all relative to the directories +/// configured in the installer, they are all made absolute before launching the game. +#[derive(Debug, Clone)] +pub struct Game { + /// Path to the JVM executable file. + pub jvm_file: PathBuf, + /// Working directory where the JVM process should be running. + pub mc_dir: PathBuf, + /// The main class that contains the JVM entrypoint. + pub main_class: String, + /// List of JVM arguments (before the main class in the command line). + pub jvm_args: Vec, + /// List of game arguments (after the main class in the command line). + pub game_args: Vec, +} + +impl Game { + + /// Modify the arguments and check for unresolved variables. + /// + /// Currently internal to the crate, mostly unused. + pub(crate) fn replace_args(&mut self, mut func: F) + where + F: FnMut(&str) -> Option, + { + replace_strings_args(&mut self.jvm_args, &mut func); + replace_strings_args(&mut self.game_args, &mut func); + } + + /// Create a command to launch the process, this command can be modified if you wish. + pub fn command(&self) -> Command { + let mut command = Command::new(&self.jvm_file); + command + .current_dir(&self.mc_dir) + .args(&self.jvm_args) + .arg(&self.main_class) + .args(&self.game_args); + command + } + + /// Create a command to launch the process and directly spawn the process. + pub fn spawn(&self) -> io::Result { + self.command().spawn() + } + + /// Spawn the process and wait for it to finish. + pub fn spawn_and_wait(&self) -> io::Result { + self.spawn()?.wait() + } + +} + +// ========================== // +// Following code is internal // +// ========================== // + +/// Internal resolved libraries file paths. +#[derive(Debug, Default)] +struct LibrariesFiles { + class_files: Vec, + natives_files: Vec, +} + +/// Internal resolved logger configuration. +#[derive(Debug)] +struct LoggerConfig { + #[allow(unused)] + kind: serde::VersionLoggingType, + argument: String, + file: PathBuf, +} + +/// Internal resolved assets associating the virtual file path to its hash file path. +#[derive(Debug)] +struct Assets { + id: String, + mapping: Option, +} + +/// In case of virtual or resources mapped assets, the launcher needs to hard link all +/// asset object files to their virtual relative path inside the assets index's virtual +/// directory. +/// +/// - Virtual assets has been used between 13w23b (pre 1.6, excluded) and 13w48b (1.7.2). +/// - Resource mapped assets has been used for versions 13w23b (pre 1.6) and before. +#[derive(Debug)] +struct AssetsMapping { + /// List of objects to link to virtual dir. + objects: Vec, + /// Path to the virtual directory for the assets id. + virtual_dir: Box, + /// True if a resources directory should link game's working directory to the + /// assets index' virtual directory. + resources: bool, +} + +/// A single asset object mapping from its relative (virtual) path to the object path. +#[derive(Debug)] +struct AssetObject { + rel_file: Box, + object_file: Box, + size: u32, +} + +/// Internal resolved JVM. +#[derive(Debug)] +struct Jvm { + /// The 'java' or 'javaw' executable file. + file: PathBuf, + /// The JVM version, if known. + version: Option, + /// If this JVM originate from a mojang JVM, it contains the post-installation infos. + mojang: Option, +} + +/// When a JVM version is known, this contains the information and compatibility score of +/// that JVM. +#[derive(Debug)] +struct JvmVersion { + /// The full version string. + full: String, + /// A compatibility score for the major version of that JVM with the required major + /// version, none when the version is **likely** incompatible. The lower the score is, + /// the better the JVM is compatible, zero being the **most likely** compatible JVM. + major_compatibility: Option, +} + +/// Internal optional to the resolve JVM in case of Mojang-provided JVM where files +/// needs to be made executable and links added. +#[derive(Debug, Default)] +struct MojangJvm { + /// List of full paths to files that should be executable (relevant under Linux). + executables: Vec>, + /// List of links to add given `(link_file, target_file)`. + links: Vec, +} + +#[derive(Debug)] +struct MojangJvmLink { + file: Box, + target_file: Box, +} + +/// Check if a file at a given path has the corresponding properties (size and/or SHA-1), +/// returning true if it is valid, so false is returned anyway if the file doesn't exists. +pub(crate) fn check_file(file: &Path, size: Option, sha1: Option<&[u8; 20]>) -> Result { + check_file_advanced(file, size, sha1, false) +} + +/// Check if a file at a given path has the corresponding properties (size and/or SHA-1), +/// returning true if it is valid, you can choose if a file not found is considered valid +/// or not. +pub(crate) fn check_file_advanced(file: &Path, size: Option, sha1: Option<&[u8; 20]>, not_found_valid: bool) -> Result { + + fn inner(file: &Path, size: Option, sha1: Option<&[u8; 20]>, not_found_valid: bool) -> io::Result { + + if let Some(sha1) = sha1 { + // If we want to check SHA-1 we need to open the file and compute it... + match File::open(file) { + Ok(mut reader) => { + + // If relevant, start by checking the actual size of the file. + if let Some(size) = size { + let actual_size = reader.seek(SeekFrom::End(0))?; + if size as u64 != actual_size { + return Ok(false); + } + reader.seek(SeekFrom::Start(0))?; + } + + // Only after we compute hash... + let mut digest = Sha1::new(); + io::copy(&mut reader, &mut digest)?; + if digest.finalize().as_slice() != sha1 { + return Ok(false); + } + + Ok(true) + + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(not_found_valid), + Err(e) => return Err(e), + } + } else { + match (file.metadata(), size) { + // File is existing and we want to check size... + (Ok(metadata), Some(size)) => Ok(metadata.len() == size as u64), + // File is existing but we don't have size to check, no need to download. + (Ok(_metadata), None) => Ok(true), + (Err(e), _) if e.kind() == io::ErrorKind::NotFound => Ok(not_found_valid), + (Err(e), _) => return Err(e), + } + } + + } + + inner(file, size, sha1, not_found_valid) + .map_err(|e| Error::new_io(e, format!("check file: {}", file.display()))) + +} + +/// Apply arguments replacement for each string, explained in [`replace_string_args`]. +fn replace_strings_args<'input, F>(ss: &mut [String], mut func: F) +where + F: FnMut(&str) -> Option, +{ + for s in ss { + replace_string_args(s, &mut func); + } +} + +/// Given a string buffer, search for each argument of the form `${arg}`, give its name +/// to the given closure and if some value is returned, replace it by this value. +fn replace_string_args(s: &mut String, mut func: F) +where + F: FnMut(&str) -> Option, +{ + + // Our cursor means that everything before this index has been already checked. + let mut cursor = 0; + + while let Some(open_idx) = s[cursor..].find("${") { + + let open_idx = cursor + open_idx; + let Some(close_idx) = s[open_idx + 2..].find('}') else { break }; + let close_idx = open_idx + 2 + close_idx + 1; + cursor = close_idx; + + if let Some(value) = func(&s[open_idx + 2..close_idx - 1]) { + + s.replace_range(open_idx..close_idx, &value); + + let repl_len = close_idx - open_idx; + let repl_diff = value.len() as isize - repl_len as isize; + cursor = cursor.checked_add_signed(repl_diff).unwrap(); + + } + + } + +} + +/// Parse a JVM major version, this supports pre-v9 versions. +fn parse_jvm_major_version(version: &str) -> Option { + + // Special case for parsing versions such as '8u51'. + if !version.contains('.') { + if let Some((major, _patch)) = version.split_once('u') { + return major.parse::().ok(); + } + } + + let mut comp = version.split('.'); + let mut major = comp.next()?.parse::().ok()?; + if major == 1 { + major = comp.next()?.parse::().ok()?; + } + Some(major) + +} + +/// This function compute the compatibility between a given JVM version and the expected +/// one, returning None if the versions are fully incompatible. If versions are compatible +/// then a score is returned, the less this score is, the higher if the compatibility, +/// a score of zero means that the versions are fully compatible. +fn calc_jvm_major_compatibility(expected_version: u32, version: u32) -> Option { + if expected_version <= 8 { + // Because of huge breakings in the internal APIs between Java 8 and 9 (and onward), + // we require strict equality for Java 8 and before. + (expected_version == version).then_some(0) + } else { + // After Java 8, we allow any greater JVM version to run, the score is computed + // to privilege versions that are close to another, thus reducing potential + // breakings between version, even if it shouldn't happen. + if version >= expected_version { + Some(version - expected_version) + } else { + None + } + } +} + +/// Internal shortcut to canonicalize a file or directory and map error into an +/// installer error. +#[inline] +pub(crate) fn canonicalize_file(file: &Path) -> Result { + dunce::canonicalize(file) + .map_err(|e| Error::new_io(e, format!("canonicalize: {}", file.display()))) +} + +/// Internal shortcut to creating a link file that points to another one, this function +/// tries to create a symlink on unix systems and make a hard link on other systems. +/// **Not made for directories linking!** +/// +/// This function accepts relative path, in case of relative path is refers to the +/// directory the link resides in, no security check is performed. +/// +/// This function ignores if the links already exists. +#[inline] +pub(crate) fn link_file(original: &Path, link: &Path) -> Result<()> { + + let err; + let action; + + #[cfg(unix)] { + // We just give the relative link with '..' which will be resolved + // relative to the link's location by the filesystem. + err = std::os::unix::fs::symlink(original, link); + action = "symlink"; + } + + #[cfg(not(unix))] { + let parent_dir = link.parent().unwrap(); + let file = parent_dir.join(&original); + err = fs::hard_link(original, &file); + action = "hard link"; + } + + match err { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(Error::new_io(e, format!("{action}: {}, to: {}", original.display(), link.display()))), + } + +} + +#[inline] +pub(crate) fn symlink_or_copy_file(original: &Path, link: &Path) -> Result<()> { + + let err; + let action; + + #[cfg(unix)] { + // We just give the relative link with '..' which will be resolved + // relative to the link's location by the filesystem. + err = match std::os::unix::fs::symlink(original, link) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(e), + }; + action = "symlink"; + } + + #[cfg(not(unix))] { + err = fs::copy(original, link).map(|_| ()); + action = "copy"; + } + + err.map_err(|e| Error::new_io(e, format!("{action}: {}, to: {}", original.display(), link.display()))) + +} + +/// Internal shortcut to hard link files, this can also be used for hard linking +/// directories, if the link already exists the error is ignored. +#[inline] +pub(crate) fn hard_link_file(original: &Path, link: &Path) -> Result<()> { + match fs::hard_link(original, link) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(Error::new_io(e, format!("hard link: {}, to: {}", original.display(), link.display()))), + } +} + +/// A utility function not used in this module, but used for Fabric and Forge mod loader +/// installers, which needs to manually write the metadata. This function creates any +/// parent directory if missing. +pub(crate) fn write_version_metadata(file: &Path, metadata: &serde::VersionMetadata) -> Result<()> { + + // We unwrap because any version metadata file should be located insane version dir. + let dir = file.parent().unwrap(); + fs::create_dir_all(dir) + .map_err(|e| Error::new_io_file(e, dir))?; + + let writer = File::create(file) + .map_err(|e| Error::new_io_file(e, file)) + .map(BufWriter::new)?; + + let mut serializer = serde_json::Serializer::new(writer); + serde_path_to_error::serialize(&metadata, &mut serializer) + .map_err(|e| Error::new_json_file(e, file))?; + + Ok(()) + +} + +/// Return the default main directory for Minecraft, so called ".minecraft". +pub fn default_main_dir() -> Option<&'static Path> { + + static MAIN_DIR: LazyLock> = LazyLock::new(|| { + // TODO: Maybe change the main dir to something more standard under Linux. + if cfg!(target_os = "windows") { + dirs::data_dir().map(|dir| dir.joined(".minecraft")) + } else if cfg!(target_os = "macos") { + dirs::data_dir().map(|dir| dir.joined("minecraft")) + } else { + dirs::home_dir().map(|dir| dir.joined(".minecraft")) + } + }); + + MAIN_DIR.as_deref() + +} + +/// Return the default OS name for rules. +/// Returning none if the OS is not known. +/// +/// This is currently not dynamic, so this will return the OS name the binary +/// has been compiled for. +#[inline] +fn os_name() -> Option<&'static str> { + Some(match env::consts::OS { + "windows" => "windows", + "linux" => "linux", + "macos" => "osx", + "freebsd" => "freebsd", + "openbsd" => "openbsd", + "netbsd" => "netbsd", + _ => return None + }) +} + +/// Return the default OS system architecture name for rules. +/// +/// This is currently not dynamic, so this will return the OS architecture the binary +/// has been compiled for. +#[inline] +fn os_arch() -> Option<&'static str> { + Some(match env::consts::ARCH { + "x86" => "x86", + "x86_64" => "x86_64", + "arm" => "arm32", + "aarch64" => "arm64", + _ => return None + }) +} + +/// Return the default OS version name for rules. +#[inline] +fn os_bits() -> Option<&'static str> { + Some(match env::consts::ARCH { + "x86" | "arm" => "32", + "x86_64" | "aarch64" => "64", + _ => return None + }) +} + +/// Return the default OS version name for rules. +#[inline] +fn os_version() -> Option<&'static str> { + + static VERSION: LazyLock> = LazyLock::new(|| { + use os_info::Version; + match os_info::get().version() { + Version::Unknown => None, + version => Some(version.to_string()) + } + }); + + VERSION.as_deref() + +} + +/// Return the JVM exec file name. +#[inline] +fn jvm_exec_name() -> &'static str { + if cfg!(windows) { "javaw.exe" } else { "java" } +} + +#[inline] +fn mojang_jvm_platform() -> Option<&'static str> { + Some(match (env::consts::OS, env::consts::ARCH) { + ("macos", "x86_64") => "mac-os", + ("macos", "aarch64") => "mac-os-arm64", + ("linux", "x86") => "linux-i386", + ("linux", "x86_64") => "linux", + ("windows", "x86") => "windows-x86", + ("windows", "x86_64") => "windows-x64", + ("windows", "aarch64") => "windows-arm64", + _ => return None + }) +} + +#[cfg(test)] +mod tests { + + #[test] + fn replace_string_args() { + + use super::replace_string_args; + + let mut buf = "${begin}foo${middle}bar${end}".to_string(); + replace_string_args(&mut buf, |_arg| None); + assert_eq!(buf, "${begin}foo${middle}bar${end}"); + replace_string_args(&mut buf, |arg| if arg == "middle" { Some(".:.".to_string()) } else { None }); + assert_eq!(buf, "${begin}foo.:.bar${end}"); + replace_string_args(&mut buf, |arg| Some(format!("[ {arg} ]"))); + assert_eq!(buf, "[ begin ]foo.:.bar[ end ]"); + + } + + #[test] + fn parse_jvm_major_version() { + + use super::parse_jvm_major_version; + + assert_eq!(parse_jvm_major_version("7u80"), Some(7)); + assert_eq!(parse_jvm_major_version("8u51"), Some(8)); + assert_eq!(parse_jvm_major_version("17"), Some(17)); + assert_eq!(parse_jvm_major_version("17.0"), Some(17)); + assert_eq!(parse_jvm_major_version("17.0.2"), Some(17)); + assert_eq!(parse_jvm_major_version("1.8.0_111"), Some(8)); + assert_eq!(parse_jvm_major_version("10.0.2"), Some(10)); + + // Corner cases + assert_eq!(parse_jvm_major_version("10.foo"), Some(10)); + assert_eq!(parse_jvm_major_version("1.foo"), None); + assert_eq!(parse_jvm_major_version("foou51"), None); + assert_eq!(parse_jvm_major_version("8ufoo"), Some(8)); + + } + + #[test] + fn calc_jvm_major_compatibility() { + + use super::calc_jvm_major_compatibility; + + assert_eq!(calc_jvm_major_compatibility(7, 7), Some(0)); + assert_eq!(calc_jvm_major_compatibility(8, 8), Some(0)); + assert_eq!(calc_jvm_major_compatibility(8, 7), None); + + assert_eq!(calc_jvm_major_compatibility(9, 8), None); + assert_eq!(calc_jvm_major_compatibility(9, 9), Some(0)); + assert_eq!(calc_jvm_major_compatibility(9, 11), Some(2)); + assert_eq!(calc_jvm_major_compatibility(9, 17), Some(8)); + assert_eq!(calc_jvm_major_compatibility(17, 17), Some(0)); + assert_eq!(calc_jvm_major_compatibility(17, 11), None); + + } + +} diff --git a/rust/portablemc/src/base/serde.rs b/rust/portablemc/src/base/serde.rs new file mode 100644 index 00000000..26e594e5 --- /dev/null +++ b/rust/portablemc/src/base/serde.rs @@ -0,0 +1,430 @@ +//! JSON schemas structures for serde deserialization. +//! +//! This module is internal to the crate and should not be exposed because the format +//! might change with increasing version and bug fixes. + +use std::collections::HashMap; +use std::fmt; + +use chrono::{DateTime, FixedOffset}; + +use crate::serde::{HexString, RegexString}; +use crate::maven::Gav; + + +// ================== // +// VERSION METADATA // +// ================== // + +/// A version metadata JSON schema. +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionMetadata { + /// The version id, should be the same as the directory the metadata is in. + pub id: String, + /// The version type, such as 'release' or 'snapshot'. + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + /// The last time this version has been updated. + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + /// The first release time of this version. + #[serde(skip_serializing_if = "Option::is_none")] + pub release_time: Option, + /// If present, this is the name of another version to resolve after this one and + /// where fallback values will be taken. + #[serde(skip_serializing_if = "Option::is_none")] + pub inherits_from: Option, + /// Used by official launcher, optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum_launcher_version: Option, + /// Describe the Java version to use, optional. + #[serde(skip_serializing_if = "Option::is_none")] + pub java_version: Option, + /// The asset index to use when launching the game, it also has download information. + #[serde(skip_serializing_if = "Option::is_none")] + pub asset_index: Option, + /// Legacy asset index id without download information. + #[serde(skip_serializing_if = "Option::is_none")] + pub assets: Option, + /// Unknown, used by official launcher. + #[serde(skip_serializing_if = "Option::is_none")] + pub compliance_level: Option, + /// A mapping of downloads for entry point JAR files, such as for client or for + /// server. This sometime also defines a server executable for old versions. + #[serde(default)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub downloads: HashMap, + /// The sequence of JAR libraries to include in the class path when running the + /// version, the order of libraries should be respected in the class path (for + /// some corner cases with mod loaders). When a library is defined, it can't be + /// overridden by inherited versions. + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub libraries: Vec, + /// The full class name to run as the main JVM class. + #[serde(skip_serializing_if = "Option::is_none")] + pub main_class: Option, + /// Legacy arguments command line. + #[serde(rename = "minecraftArguments")] + #[serde(skip_serializing_if = "Option::is_none")] + pub legacy_arguments: Option, + /// Modern arguments for game and/or jvm. + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, + /// Logging configuration. + #[serde(default)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub logging: HashMap, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum VersionType { + Release, + Snapshot, + OldBeta, + OldAlpha, +} + +impl VersionType { + + pub fn as_str(&self) -> &'static str { + match self { + VersionType::Release => "release", + VersionType::Snapshot => "snapshot", + VersionType::OldBeta => "old_beta", + VersionType::OldAlpha => "old_alpha", + } + } + +} + +/// Object describing the Mojang-provided Java version to use to launch the game. +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionJavaVersion { + pub component: Option, + pub major_version: u32, +} + +/// Describe the asset index to use and how to download it when missing. +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionAssetIndex { + pub id: String, + pub total_size: u32, + #[serde(flatten)] + pub download: Download, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionLibrary { + pub name: Gav, + #[serde(default)] + #[serde(skip_serializing_if = "VersionLibraryDownloads::is_empty")] + pub downloads: VersionLibraryDownloads, + #[serde(skip_serializing_if = "Option::is_none")] + pub natives: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub rules: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionLibraryDownloads { + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact: Option, + #[serde(default)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub classifiers: HashMap, +} + +impl VersionLibraryDownloads { + fn is_empty(&self) -> bool { + self.artifact.is_none() && self.classifiers.is_empty() + } +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionLibraryDownload { + pub path: Option, + #[serde(flatten)] + pub download: Download, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionArguments { + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub game: Vec, + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub jvm: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(untagged)] +pub enum VersionArgument { + Raw(String), + Conditional(VersionConditionalArgument), +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionConditionalArgument { + pub value: SingleOrVec, + pub rules: Option>, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionLogging { + #[serde(default)] + pub r#type: VersionLoggingType, + pub argument: String, + pub file: VersionLoggingFile, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VersionLoggingType { + #[default] + #[serde(rename = "log4j2-xml")] + Log4j2Xml, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VersionLoggingFile { + pub id: String, + #[serde(flatten)] + pub download: Download, +} + + +// ================== // +// ASSET INDEX // +// ================== // + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct AssetIndex { + /// For version <= 13w23b (1.6.1). + #[serde(default)] + pub map_to_resources: bool, + /// For 13w23b (1.6.1) < version <= 13w48b (1.7.2). + #[serde(default)] + pub r#virtual: bool, + /// Mapping of assets from their real path to their download information. + pub objects: HashMap, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct AssetObject { + pub size: u32, + pub hash: HexString<20>, +} + +// ================== // +// JVM MANIFESTS // +// ================== // + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(transparent)] +pub struct JvmMetaManifest { + pub platforms: HashMap, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(transparent)] +pub struct JvmMetaManifestPlatform { + pub distributions: HashMap, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(transparent)] +pub struct JvmMetaManifestDistribution { + pub variants: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct JvmMetaManifestVariant { + pub availability: JvmMetaManifestAvailability, + pub manifest: Download, + pub version: JvmMetaManifestVersion, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct JvmMetaManifestAvailability { + pub group: u32, + pub progress: u8, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct JvmMetaManifestVersion { + pub name: String, + pub released: DateTime, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct JvmManifest { + pub files: HashMap, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "lowercase", tag = "type")] // Internally tagged. +pub enum JvmManifestFile { + Directory, + File { + #[serde(default)] + executable: bool, + downloads: JvmManifestFileDownloads, + }, + Link { + target: String, + }, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct JvmManifestFileDownloads { + pub raw: Download, + pub lzma: Option, +} + +// ================== // +// COMMON // +// ================== // + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Rule { + pub action: RuleAction, + #[serde(default)] + pub os: RuleOs, + #[serde(default)] + pub features: HashMap, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RuleOs { + pub name: Option, + pub arch: Option, + /// Only known value to use regex. + pub version: Option, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuleAction { + Allow, + Disallow, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct Download { + pub url: String, + pub size: Option, + pub sha1: Option>, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(untagged)] +pub enum SingleOrVec { + Single(T), + Vec(Vec) +} + +/// Internal serde structure for RFC3339 date time parsing, specifically for +/// [`VersionMetadata`] because it appears that some metadata might contain malformed +/// date time that we don't want to error. +/// +/// This as been observed with NeoForge installer embedded version, an example of +/// malformed time is "2024-12-09T23:22:49.408008176", where the timezone is missing. +/// +/// On old Forge versions there is also a missing ':' in the timezone offset between +/// hours and minutes. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DateTimeChill(pub DateTime); + +impl<'de> serde::Deserialize<'de> for DateTimeChill { + + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + + use chrono::format::ParseErrorKind; + + struct Visitor; + impl serde::de::Visitor<'_> for Visitor { + + type Value = DateTimeChill; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an RFC 3339 formatted date and time string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + + let err; + let mut buf; + + match DateTime::parse_from_rfc3339(v) { + Ok(date) => return Ok(DateTimeChill(date)), + Err(e) if e.kind() == ParseErrorKind::TooShort => { + // Try adding a 'Z' at the end, we don't know if this was the issue + // so we retry. + err = e; + buf = v.to_string(); + buf.push('Z'); + } + Err(e) if e.kind() == ParseErrorKind::Invalid => { + if let Some(index) = v.rfind(&['+', '-']) { + // This order avoids overflows. + if v.len() - index == 5 && v[v.len() - 4..].is_ascii() { + err = e; + buf = v.to_string(); + buf.insert(v.len() - 2, ':'); + } else { + return Err(E::custom(e)); + } + } else { + return Err(E::custom(e)); + } + } + Err(e) => return Err(E::custom(e)), + }; + + match DateTime::parse_from_rfc3339(&buf) { + Ok(date) => Ok(DateTimeChill(date)), + Err(_) => Err(E::custom(err)), // Return the original error!! + } + + } + + } + + deserializer.deserialize_str(Visitor) + + } + +} + +impl serde::Serialize for DateTimeChill { + + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } + +} diff --git a/rust/portablemc/src/download.rs b/rust/portablemc/src/download.rs new file mode 100644 index 00000000..22df298e --- /dev/null +++ b/rust/portablemc/src/download.rs @@ -0,0 +1,1027 @@ +//! Parallel batch HTTP(S) download implementation. +//! +//! Partially inspired by: +//! + +use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write}; +use std::iter::FusedIterator; +use std::cmp::Ordering; +use std::path::Path; +use std::{env, mem}; +use std::sync::Arc; + +use sha1::{Digest, Sha1}; + +use reqwest::{header, Client, StatusCode}; + +use tokio::io::{AsyncSeekExt, AsyncWriteExt}; +use tokio::fs::{self, File}; +use tokio::task::JoinSet; +use tokio::sync::mpsc; + +use crate::path::PathBufExt; + + +/// Download a single entry from the given URL to the given file. +pub fn single(url: impl Into>, file: impl Into>) -> Single { + Single(Entry::new(url.into(), file.into())) +} + +/// Download a single cached entry. +pub fn single_cached(url: impl Into>) -> Single { + Single(Entry::new_cached(url.into())) +} + +#[derive(Debug)] +pub struct Single(Entry); + +impl Single { + + #[inline] + pub fn url(&self) -> &str { + self.0.url() + } + + #[inline] + pub fn file(&self) -> &Path { + self.0.file() + } + + #[inline] + pub fn set_expected_size(&mut self, size: Option) -> &mut Self { + self.0.set_expected_size(size); + self + } + + #[inline] + pub fn set_expected_sha1(&mut self, sha1: Option<[u8; 20]>) -> &mut Self { + self.0.set_expected_sha1(sha1); + self + } + + #[inline] + pub fn set_keep_open(&mut self) -> &mut Self { + self.0.set_keep_open(); + self + } + + #[inline] + pub fn set_use_cache(&mut self) -> &mut Self { + self.0.set_use_cache(); + self + } + + /// Download this singe entry, returning success or error entry depending on the + /// result. + /// + /// This is internally starting an asynchronous Tokio runtime and block on it, so + /// this function will just panic if launched inside another runtime! + #[must_use] + pub fn download(&mut self, mut handler: impl Handler) -> Result { + + let client = crate::http::client() + .map_err(|e| EntryError { + core: self.0.core.clone(), + kind: EntryErrorKind::new_reqwest(e), + })?; + + crate::tokio::sync(download_single(client, &mut handler, &self.0)) + + } + +} + +/// A list of pending download that can be all downloaded at once. +#[derive(Debug)] +pub struct Batch { + /// All entries to be downloaded. + entries: Vec, +} + +impl Batch { + + /// Create a new empty download list. + #[inline] + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + /// Return the total number of entries pushed into this download batch. + #[inline] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Return true if this batch has no entry. + #[inline] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Insert a new entry to be downloaded in this download batch. + pub fn push(&mut self, url: impl Into>, file: impl Into>) -> &mut Entry { + self.entries.push(Entry::new(url.into(), file.into())); + self.entries.last_mut().unwrap() + } + + /// Insert a new entry to be downloaded in this download batch, this entry don't + /// need a file because it is purely cached and so the file is derived from the URL. + /// It is constructed from a standard cache directory called `portablemc-cache` + /// located in a standard user cache directory (or system tmp as a fallback), + /// the file name in that directory is the hash of the URL. + pub fn push_cached(&mut self, url: impl Into>) -> &mut Entry { + self.entries.push(Entry::new_cached(url.into())); + self.entries.last_mut().unwrap() + } + + pub fn entry(&self, index: usize) -> &Entry { + &self.entries[index] + } + + pub fn entry_mut(&mut self, index: usize) -> &mut Entry { + &mut self.entries[index] + } + + /// Download this whole batch, the batch is cleared if returning ok. It's left + /// untouched if it returns an error and no file is downloaded. + /// + /// This is internally starting an asynchronous Tokio runtime and block on it, so + /// this function will just panic if launched inside another runtime! + pub fn download(&mut self, mut handler: impl Handler) -> reqwest::Result { + let client = crate::http::client()?; + let entries = mem::take(&mut self.entries); + Ok(crate::tokio::sync(download_many(client, &mut handler, 40, entries))) + } + +} + +/// Represent the core information of an entry, its URL and the path where it's +/// downloaded. We put this in its own structure to ensure that these values are always +/// contiguous and this improves the copy of this structure when actually copied (when +/// moved at assembly level). +#[derive(Debug, Clone)] +struct EntryCore { + /// The URL to download the file from. + url: Box, + /// The file where the downloaded content is written. + file: Box, +} + +#[derive(Debug)] +pub struct Entry { + /// Core information. + core: EntryCore, + /// Optional expected size of the file. + expected_size: Option, + /// Optional expected SHA-1 of the file. + expected_sha1: Option<[u8; 20]>, + /// Use a file next to the entry file to keep track of the last-modified and entity + /// tag HTTP informations, that will be used in next downloads to actually download + /// the data only if needed. This means that the entry will not always be downloaded, + /// and its optional size and SHA-1 will be only checked when actually downloaded. + /// Also, this implies that if the program has no internet access then it will use + /// the cached version if existing. + use_cache: bool, + /// True to keep the file open after it has been downloaded, and store the handle + /// in the completed entry. + keep_open: bool, +} + +impl Entry { + + fn new(url: Box, file: Box) -> Self { + Self { + core: EntryCore { + url, + file, + }, + expected_size: None, + expected_sha1: None, + use_cache: false, + keep_open: false, + } + } + + fn new_cached(url: Box) -> Self { + + let url_digest = { + let mut sha1 = Sha1::new(); + sha1.update(&*url); + format!("{:x}", sha1.finalize()) + }; + + // Fallback to the tmp directory. + let mut file = dirs::cache_dir() + .unwrap_or(env::temp_dir()); + + file.push("portablemc-cache"); + file.push(url_digest); + + let mut ret = Self::new(url, file.into_boxed_path()); + ret.set_use_cache(); + ret + + } + + #[inline] + pub fn url(&self) -> &str { + &self.core.url + } + + #[inline] + pub fn file(&self) -> &Path { + &self.core.file + } + + #[inline] + pub fn expected_size(&self) -> Option { + self.expected_size + } + + #[inline] + pub fn set_expected_size(&mut self, size: Option) -> &mut Self { + self.expected_size = size; + self + } + + #[inline] + pub fn expected_sha1(&self) -> Option<&[u8; 20]> { + self.expected_sha1.as_ref() + } + + #[inline] + pub fn set_expected_sha1(&mut self, sha1: Option<[u8; 20]>) -> &mut Self { + self.expected_sha1 = sha1; + self + } + + /// After the file has been successfully downloaded, keep the handle opened so it + /// can be retrieved via [`EntrySuccess::handle`] related methods. The file's + /// cursor is rewind to the start. + #[inline] + pub fn set_keep_open(&mut self) -> &mut Self { + self.keep_open = true; + self + } + + /// Use a file next to the entry file to keep track of the last-modified and entity + /// tag HTTP informations, that will be used in next downloads to actually download + /// the data only if needed. This means that the entry will not always be downloaded, + /// and its optional size and SHA-1 will be only checked when actually downloaded. + /// Also, this implies that if the program has no internet access then it will use + /// the cached version if existing. + /// + /// This is usually not needed to call this function, prefer [`Batch::push_cached`]. + #[inline] + pub fn set_use_cache(&mut self) -> &mut Self { + self.use_cache = true; + self + } + +} + +/// When a download batch has been downloaded, this returned completed batch contains, +/// for each entry, it's success or not. +#[derive(Debug)] +pub struct BatchResult { + /// Each entry's result. + entries: Box<[Result]>, + /// The index of each entry that has an error. + errors: Box<[usize]>, +} + +impl BatchResult { + + /// Return the total number of entries pushed into this download batch. + #[inline] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Return true if this batch has no entry. + #[inline] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + #[inline] + pub fn entry(&self, index: usize) -> Result<&EntrySuccess, &EntryError> { + self.entries[index].as_ref() + } + + #[inline] + pub fn entry_mut(&mut self, index: usize) -> Result<&mut EntrySuccess, &mut EntryError> { + self.entries[index].as_mut() + } + + #[inline] + pub fn has_errors(&self) -> bool { + !self.errors.is_empty() + } + + #[inline] + pub fn successes_count(&self) -> usize { + self.entries.len() - self.errors.len() + } + + #[inline] + pub fn errors_count(&self) -> usize { + self.errors.len() + } + + pub fn iter_successes(&self) -> BatchResultSuccessesIter<'_> { + BatchResultSuccessesIter { + entries: self.entries.iter(), + count: self.successes_count(), + } + } + + pub fn iter_errors(&self) -> BatchResultErrorsIter<'_> { + BatchResultErrorsIter { + errors: self.errors.iter(), + entries: &self.entries, + } + } + + /// Make this batch result into a result which will be an error if at least one entry + /// has an error. + pub fn into_result(self) -> Result { + if self.has_errors() { + Err(self) + } else { + Ok(self) + } + } + +} + +/// To allow creation of a batch result from a single download. +impl From> for BatchResult { + fn from(value: Result) -> Self { + Self { + errors: if value.is_err() { Box::new([0]) } else { Box::new([]) }, + entries: Box::new([value]), + } + } +} + +impl From for BatchResult { + fn from(value: EntrySuccess) -> Self { + Self { + entries: Box::new([Ok(value)]), + errors: Box::new([]), + } + } +} + +impl From for BatchResult { + fn from(value: EntryError) -> Self { + Self { + entries: Box::new([Err(value)]), + errors: Box::new([0]), + } + } +} + +/// Iterator for successful +#[derive(Debug)] +pub struct BatchResultSuccessesIter<'a> { + entries: std::slice::Iter<'a, Result>, + count: usize, +} + +impl FusedIterator for BatchResultSuccessesIter<'_> { } +impl ExactSizeIterator for BatchResultSuccessesIter<'_> { } +impl<'a> Iterator for BatchResultSuccessesIter<'a> { + + type Item = &'a EntrySuccess; + + fn next(&mut self) -> Option { + loop { + if let Ok(success) = self.entries.next()? { + self.count -= 1; + return Some(success); + } + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.count, Some(self.count)) + } + +} + +/// Iterator for successful +#[derive(Debug)] +pub struct BatchResultErrorsIter<'a> { + errors: std::slice::Iter<'a, usize>, + entries: &'a [Result], +} + +impl FusedIterator for BatchResultErrorsIter<'_> { } +impl ExactSizeIterator for BatchResultErrorsIter<'_> { } +impl<'a> Iterator for BatchResultErrorsIter<'a> { + + type Item = &'a EntryError; + + fn next(&mut self) -> Option { + let index = *self.errors.next()?; + Some(self.entries[index].as_ref().unwrap_err()) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.errors.size_hint() + } + +} + +/// State of a successfully downloaded entry. +#[derive(Debug)] +pub struct EntrySuccess { + core: EntryCore, + inner: EntrySuccessInner, +} + +#[derive(Debug)] +struct EntrySuccessInner { + /// The final size of the downloaded entry. + size: u32, + /// The final SHA-1 of the downloaded entry. + sha1: [u8; 20], + /// Optional handle to the opened file, in case `keep_open` option was enabled. + handle: Option, +} + +impl EntrySuccess { + + #[inline] + pub fn url(&self) -> &str { + &self.core.url + } + + #[inline] + pub fn file(&self) -> &Path { + &self.core.file + } + + #[inline] + pub fn size(&self) -> u32 { + self.inner.size + } + + #[inline] + pub fn sha1(&self) -> &[u8; 20] { + &self.inner.sha1 + } + + /// If the entry was configured with `keep_open` option then it should return some + /// file handle. + #[inline] + pub fn handle(&self) -> Option<&std::fs::File> { + self.inner.handle.as_ref() + } + + /// If the entry was configured with `keep_open` option then it should return some + /// file handle through mutable ref. + #[inline] + pub fn handle_mut(&mut self) -> Option<&mut std::fs::File> { + self.inner.handle.as_mut() + } + + /// If the entry was configured with `keep_open` option then it should return some + /// file handle, once, and after this any `handle_` method will return none. + #[inline] + pub fn take_handle(&mut self) -> Option { + self.inner.handle.take() + } + + /// Take the internal handle if the entry was configured with `keep_open` option, and + /// read the entire file to a string. + /// + /// For now internal because it's being tested... + pub(crate) fn read_handle_to_string(&mut self) -> Option> { + let mut handle = self.take_handle()?; + let mut buf = String::new(); + match handle.read_to_string(&mut buf) { + Ok(_) => Some(Ok(buf)), + Err(e) => Some(Err(e)), + } + } + +} + +/// State of an entry that failed to download, it also acts as a standard error type. +#[derive(thiserror::Error, Debug)] +#[error("{core:?}: {kind}")] +pub struct EntryError { + core: EntryCore, + kind: EntryErrorKind, +} + +/// An error for a single entry. +#[derive(thiserror::Error, Debug)] +pub enum EntryErrorKind { + /// Invalid size of the fully downloaded entry compared to the expected size. + /// Implies that [`Entry::set_expected_size`] is not none. + #[error("invalid size")] + InvalidSize, + /// Invalid SHA-1 of the fully downloaded entry compared to the expected SHA-1. + /// Implies that [`Entry::set_expected_sha1`] is not none. + #[error("invalid sha1")] + InvalidSha1, + /// Invalid HTTP status code while requesting the entry. + #[error("invalid status: {0}")] + InvalidStatus(u16), + /// A generic error type for internal and third-party errors that may change depending + /// on the actual implementation. + /// + /// The current implementation yields the following error types: + /// + /// - [`std::io::Error`] for any I/O error related to opening and writing local files. + /// + /// - [`reqwest::Error`] for any error related to HTTP requests. + #[error("internal: {0}")] + Internal(#[source] Box), +} + +impl EntryErrorKind { + + #[inline] + fn new_io(e: io::Error) -> Self { + Self::Internal(Box::new(e)) + } + + #[inline] + fn new_reqwest(e: reqwest::Error) -> Self { + Self::Internal(Box::new(e)) + } + +} + +impl EntryError { + + #[inline] + pub fn url(&self) -> &str { + &self.core.url + } + + #[inline] + pub fn file(&self) -> &Path { + &self.core.file + } + + #[inline] + pub fn kind(&self) -> &EntryErrorKind { + &self.kind + } + +} + +crate::trait_event_handler! { + /// A handle for watching a batch download progress. + pub trait Handler { + /// Notification of a download progress, the download should be considered done when + /// 'count' is equal to 'total_count'. This is called anyway at the beginning and at + /// the end of the download. Note that the final given 'size' may be greater than + /// 'total_size' in case of unknown expected size, which 'total_size' is the sum. + fn progress(count: u32, total_count: u32, size: u32, total_size: u32); + } +} + +/// Internal split of the download_impl function without reqwest initialization error. +#[inline] +async fn download_many( + client: Client, + handler: &mut dyn Handler, + concurrent_count: usize, + entries: Vec, +) -> BatchResult { + + // Make it constant and sharable between all tasks. + let entries = Arc::new(entries); + + // Collect the index of each pending entry, we also keep the expected size for + // sorting and total size. We do this to avoid loosing the original entries order. + let mut indices = (0..entries.len()).collect::>(); + + // Sort our entries in order to download big files first, this is allowing better + // parallelization at start and avoid too much blocking at the end. Because our + // indices vector will pop the first index from the end, we put big files at the + // end, and so sort by ascending size. + indices.sort_by(|&a_index, &b_index| { + match (entries[a_index].expected_size, entries[b_index].expected_size) { + (Some(a), Some(b)) => Ord::cmp(&a, &b), + _ => Ordering::Equal, + } + }); + + // Current downloaded size and total size. + let mut size = 0; + let total_size = indices.iter() + .map(|&index| entries[index].expected_size.unwrap_or(0)) + .sum::(); + + // Send a progress update for each 1000 parts of the download. + let progress_size_interval = total_size / 1000; + let mut last_size = 0u32; + + handler.progress(0, entries.len() as u32, size, total_size); + + let mut completed = 0; + let mut futures = JoinSet::new(); + + let ( + progress_tx, + mut progress_rx, + ) = mpsc::channel(concurrent_count * 2); + + let mut results = (0..entries.len()).map(|_| None).collect::>(); + + // If we have theoretically completed all downloads, we still wait for joining all + // remaining futures in the join set. + while completed < entries.len() || !futures.is_empty() { + + while futures.len() < concurrent_count && !indices.is_empty() { + futures.spawn(download_many_entry( + client.clone(), + Arc::clone(&entries), + indices.pop().unwrap(), // Safe because not empty. + progress_tx.clone())); + } + + let mut force_progress = false; + + tokio::select! { + Some(res) = futures.join_next() => { + let (index, res) = res.expect("task should not be cancelled nor panicking"); + completed += 1; + force_progress = true; + let prev_res = results[index].replace(res); + debug_assert!(prev_res.is_none()); + } + Some(progress) = progress_rx.recv() => { + size += progress as u32; + } + else => { + // Just ignore, because it's invalid state, in case of join_next we + // ignore if JoinSet is empty because we rely mostly 'completed'. + // For the queue receive, we know that the other end will never be fully + // closed because we locally own both 'tx' and 'rx'. + continue; + } + }; + + if force_progress || size - last_size >= progress_size_interval { + handler.progress(completed as u32, entries.len() as u32, size, total_size); + last_size = size; + } + + } + + // Ensure that all tasks are aborted, this allows us to take back ownership of the + // underlying vector of entries. + assert!(futures.is_empty()); + + // Now that every task has terminated we should be able to take back the entries. + let entries = Arc::into_inner(entries).unwrap(); + let mut ret_entries = Vec::with_capacity(entries.len()); + let mut ret_errors = Vec::new(); + + for (entry, res) in entries.into_iter().zip(results) { + let res = res.expect("all entries should have a result"); + if res.is_err() { + ret_errors.push(ret_entries.len()); + } + ret_entries.push(match res { + Ok(inner) => Ok(EntrySuccess { core: entry.core, inner }), + Err(kind) => Err(EntryError { core: entry.core, kind }), + }); + } + + BatchResult { + entries: ret_entries.into_boxed_slice(), + errors: ret_errors.into_boxed_slice(), + } + +} + +/// Download entrypoint for a download, this is a wrapper around core download +/// function in order to easily catch the result and send it as an event. +async fn download_many_entry( + client: Client, + entries: Arc>, + index: usize, + progress_sender: mpsc::Sender, +) -> (usize, Result) { + + let progress_sender = ChannelEntryProgressSender { + sender: progress_sender, + }; + + (index, download_entry(client, &entries[index], progress_sender).await) + +} + +async fn download_single( + client: Client, + handler: &mut dyn Handler, + entry: &Entry, +) -> Result { + + let mut size = 0u32; + let total_size = entry.expected_size.unwrap_or(0); + + handler.progress(0, 1, 0, total_size); + + let progress_sender = DirectEntryProgressSender { + handler: &mut *handler, + size: &mut size, + total_size, + }; + + let res = download_entry(client, entry, progress_sender).await; + + handler.progress(1, 1, size, total_size); + + match res { + Ok(inner) => Ok(EntrySuccess { core: entry.core.clone(), inner }), + Err(kind) => Err(EntryError { core: entry.core.clone(), kind }), + } + +} + +/// Internal function to download a single download entry, returning a result with an +/// optional handle to the std file, if keep open parameter is enabled on the entry. +async fn download_entry( + client: Client, + entry: &Entry, + progress_sender: impl EntryProgressSender, +) -> Result { + + let mut progress_sender = progress_sender; + + let mut req = client.get(&*entry.core.url); + + // If we are in cache mode, then we derive the file name. + let cache_file = entry.use_cache.then(|| { + entry.core.file.to_path_buf().appended(".cache") + }); + + // If we are in cache mode, try checking the file, if the file is locally valid. + let mut cache = None; + if let Some(cache_file) = cache_file.as_deref() { + cache = check_download_cache(&entry.core.file, cache_file).await + .map_err(EntryErrorKind::new_io)?; + } + + // Then we add corresponding request headers for cache control. + if let Some((_, cache_meta)) = &cache { + if let Some(etag) = cache_meta.etag.as_deref() { + req = req.header(header::IF_NONE_MATCH, etag); + } + if let Some(last_modified) = cache_meta.last_modified.as_deref() { + req = req.header(header::IF_MODIFIED_SINCE, last_modified); + } + } + + // If it's a connection error just use the cached copy. + let mut res = match req.send().await { + Ok(res) => res, + Err(e) if cache.is_some() && (e.is_timeout() || e.is_request() || e.is_connect()) => { + // Using cache in case of network error. + let (handle, cache_meta) = cache.unwrap(); + return Ok(EntrySuccessInner { + size: cache_meta.size, + sha1: cache_meta.sha1.0, + handle: entry.keep_open.then_some(handle), + }); + } + Err(e) => { + // Other unhandled errors are returned and will be present in errored entries. + return Err(EntryErrorKind::new_reqwest(e)); + } + }; + + // Checking if the status is not OK, if this is a NOT_MODIFIED then we returned the + // file as-is, with the handle if keep open is requested. + if res.status() == StatusCode::NOT_MODIFIED && cache.is_some() { + let (handle, cache_meta) = cache.unwrap(); + return Ok(EntrySuccessInner { + size: cache_meta.size, + sha1: cache_meta.sha1.0, + handle: entry.keep_open.then_some(handle), + }); + } else if res.status() != StatusCode::OK { + return Err(EntryErrorKind::InvalidStatus(res.status().as_u16())); + } + + // Close the possible cached file because we'll need to create it just below. + drop(cache); + + // Create any parent directory so that we can create the file. + if let Some(parent) = entry.core.file.parent() { + tokio::fs::create_dir_all(parent).await.map_err(EntryErrorKind::new_io)?; + } + + // Only add read capability if the handle needs to be kept. + let mut dst = File::options() + .write(true) + .create(true) + .truncate(true) + .read(entry.keep_open) + .open(&*entry.core.file).await + .map_err(EntryErrorKind::new_io)?; + + let mut size = 0usize; + let mut sha1 = Sha1::new(); + + while let Some(chunk) = res.chunk().await.map_err(EntryErrorKind::new_reqwest)? { + + let delta = chunk.len(); + size += delta; + + AsyncWriteExt::write_all(&mut dst, &chunk).await.map_err(EntryErrorKind::new_io)?; + Write::write_all(&mut sha1, &chunk).map_err(EntryErrorKind::new_io)?; + + progress_sender.send(delta as u32).await; + + } + + // Ensure the file is fully written. + dst.flush().await.map_err(EntryErrorKind::new_io)?; + + // Now check required size and SHA-1. + let size = u32::try_from(size).map_err(|_| EntryErrorKind::InvalidSize)?; + let sha1 = sha1.finalize(); + + if let Some(expected_size) = entry.expected_size { + if expected_size != size { + return Err(EntryErrorKind::InvalidSize); + } + } + + if let Some(expected_sha1) = &entry.expected_sha1 { + if expected_sha1 != sha1.as_slice() { + return Err(EntryErrorKind::InvalidSha1); + } + } + + // If we have a cache file, write it. + if let Some(cache_file) = cache_file.as_deref() { + + let etag = res.headers().get(header::ETAG) + .and_then(|h| h.to_str().ok().map(str::to_string)); + + let last_modified = res.headers().get(header::LAST_MODIFIED) + .and_then(|h| h.to_str().ok().map(str::to_string)); + + // Only write the cache file if relevant! + if etag.is_some() || last_modified.is_some() { + + let cache_meta_writer = File::create(cache_file).await.map_err(EntryErrorKind::new_io)?; + let cache_meta_writer = BufWriter::new(cache_meta_writer.into_std().await); + + let res = serde_json::to_writer(cache_meta_writer, &serde::CacheMeta { + url: entry.core.url.to_string(), + size, + sha1: crate::serde::HexString(sha1.into()), + etag, + last_modified, + }); + + // Silently ignore errors by we remove the file if it happens. + if res.is_err() { + let _ = fs::remove_file(cache_file).await; + } + + } + + } + + let handle; + if entry.keep_open { + let mut file = dst.into_std().await; + file.rewind().map_err(EntryErrorKind::new_io)?; + handle = Some(file); + } else { + handle = None; + } + + Ok(EntrySuccessInner { + size, + sha1: sha1.into(), + handle, + }) + +} + +/// Given a file and its cache file, return the cache metadata only if the file is +/// existing and the file has not been modified (size and SHA-1). +/// +/// The opened file handle is also returned with the metadata, this avoids running into +/// race conditions by closing and reopening the file. The returned file handle is +/// writeable and its position is set to 0. +async fn check_download_cache(file: &Path, cache_file: &Path) -> io::Result> { + + // Start by reading the cache metadata associated to this file. + let cache = match File::open(cache_file).await { + Ok(file) => serde_json::from_reader::<_, serde::CacheMeta>(file.into_std().await).ok(), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + + let Some(cache) = cache else { + return Ok(None); + }; + + // NOTE: We open the file with write permission so that it can be used when returned. + let mut reader = match File::open(file).await { + Ok(reader) => reader, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + + // Start by checking size... + let actual_size = reader.seek(SeekFrom::End(0)).await?; + if cache.size as u64 != actual_size { + return Ok(None); + } + + reader.rewind().await?; + + // Then we check SHA-1... + let mut reader = reader.into_std().await; + let mut digest = Sha1::new(); + io::copy(&mut reader, &mut digest)?; + if cache.sha1.0 != digest.finalize().as_slice() { + return Ok(None); + } + + reader.rewind()?; + + Ok(Some((reader, cache))) + +} + +/// Internal abstract progress sender that support sending the progress into either a +/// channel or directly to a handler. +/// +/// NOTE: We tried to make this trait into an enum dispatch instead, but it cause issues +/// because the enum can't directly hold a `&mut dyn Handler` because it's not [`Send`], +/// therefore we needed to make the handler dynamic and when using the 'Channel' variant +/// we would use an explicit type annotation `&mut (dyn Handler + Send)` that was not +/// actually used, but this still caused the actual enum type to be different, and the +/// monomorphization didn't seem to work, increasing the binary size by 40 kB. +trait EntryProgressSender { + async fn send(&mut self, delta: u32); +} + +/// Implementation of the progress sender for the `download_many` function with channel. +struct ChannelEntryProgressSender { + sender: mpsc::Sender, +} + +impl EntryProgressSender for ChannelEntryProgressSender { + async fn send(&mut self, delta: u32) { + self.sender.send(delta).await.unwrap(); + } +} + +/// A progress sender specialized when downloading a single progress, we can therefore +/// directly send any progress directly to the handler! +struct DirectEntryProgressSender<'a> { + handler: &'a mut dyn Handler, + size: &'a mut u32, + total_size: u32, +} + +impl EntryProgressSender for DirectEntryProgressSender<'_> { + async fn send(&mut self, delta: u32) { + *self.size += delta; + self.handler.progress(0, 1, *self.size, self.total_size); + } +} + +/// Internal module for serde of cache metadata file. +mod serde { + + use crate::serde::HexString; + + #[derive(Debug, serde::Deserialize, serde::Serialize)] + pub struct CacheMeta { + /// The full URL of the cached resource, just for information. + pub url: String, + /// Size of the cached file, used to verify its validity. + pub size: u32, + /// SHA-1 hash of the cached file, used to verify its validity. + pub sha1: HexString<20>, + /// The ETag if present. + pub etag: Option, + /// Last modified data if present. + pub last_modified: Option, + } + +} diff --git a/rust/portablemc/src/fabric/mod.rs b/rust/portablemc/src/fabric/mod.rs new file mode 100644 index 00000000..60a50e34 --- /dev/null +++ b/rust/portablemc/src/fabric/mod.rs @@ -0,0 +1,593 @@ +//! Extension to the Mojang installer to support fetching and installation of +//! Fabric-related mod loader versions. + +mod serde; + +use std::path::Path; + +use reqwest::StatusCode; + +use crate::base::{self, Game}; +use crate::download; +use crate::mojang; + + +/// An installer for supporting mod loaders that are Fabric or like it (Quilt, +/// LegacyFabric, Babric). The generic parameter is used to specify the API to use. +#[derive(Debug, Clone)] +pub struct Installer { + /// The underlying Mojang installer logic. + mojang: mojang::Installer, + loader: Loader, + game_version: GameVersion, + loader_version: LoaderVersion, +} + +impl Installer { + + /// Create a new installer with default configuration. + pub fn new(loader: Loader, game_version: impl Into, loader_version: impl Into) -> Self { + Self { + mojang: mojang::Installer::new(String::new()), + loader, + game_version: game_version.into(), + loader_version: loader_version.into(), + } + } + + /// Same as [`Self::new`] but use the latest stable game and loader versions. + pub fn new_with_stable(loader: Loader) -> Self { + Self::new(loader, GameVersion::Stable, LoaderVersion::Stable) + } + + /// Get the underlying mojang installer. + #[inline] + pub fn mojang(&self) -> &mojang::Installer { + &self.mojang + } + + /// Get the underlying mojang installer through mutable reference. + /// + /// *Note that the `version` and `fetch` properties will be overwritten when + /// installing.* + #[inline] + pub fn mojang_mut(&mut self) -> &mut mojang::Installer { + &mut self.mojang + } + + /// Get the kind of loader that will be installed. + #[inline] + pub fn loader(&self) -> Loader { + self.loader + } + + /// Set the kind of loader that will be installed. + #[inline] + pub fn set_loader(&mut self, loader: Loader) -> &mut Self { + self.loader = loader; + self + } + + /// Get the game version the loader will be installed for. + #[inline] + pub fn game_version(&self) -> &GameVersion { + &self.game_version + } + + /// Set the game version the loader will be installed for. + #[inline] + pub fn set_game_version(&mut self, version: impl Into) { + self.game_version = version.into(); + } + + /// Get the loader version to install. + #[inline] + pub fn loader_version(&self) -> &LoaderVersion { + &self.loader_version + } + + /// Set the loader version to install. + #[inline] + pub fn set_loader_version(&mut self, version: impl Into) { + self.loader_version = version.into(); + } + + /// Install the currently configured Fabric loader with the given handler. + #[inline] + pub fn install(&mut self, mut handler: impl Handler) -> Result { + self.install_dyn(&mut handler) + } + + #[inline(never)] + fn install_dyn(&mut self, handler: &mut dyn Handler) -> Result { + + let Self { + ref mut mojang, + loader, + ref game_version, + ref loader_version, + } = *self; + + let api = Api::new(loader); + + let game_version = match game_version { + GameVersion::Stable | + GameVersion::Unstable => { + + let stable = matches!(game_version, GameVersion::Stable); + let versions = api.request_game_versions() + .map_err(|e| base::Error::new_reqwest(e, "request fabric game versions"))?; + + match versions.find_latest(stable) { + Some(v) => v.name().to_string(), + None => return Err(Error::LatestVersionNotFound { + game_version: None, + stable, + }), + } + + } + GameVersion::Name(name) => name.clone(), + }; + + let loader_version = match loader_version { + LoaderVersion::Stable | + LoaderVersion::Unstable => { + + let stable = matches!(loader_version, LoaderVersion::Stable); + let versions = api.request_loader_versions(Some(&game_version)) + .map_err(|e| base::Error::new_reqwest(e, "request fabric loader versions"))?; + + match versions.find_latest(stable) { + Some(v) => v.name().to_string(), + None => return Err(Error::LatestVersionNotFound { + game_version: Some(game_version), + stable, + }), + } + + } + LoaderVersion::Name(name) => name.clone(), + }; + + // Set the root version for underlying Mojang installer, equal to the name that + // we'll give to the version. + let prefix = loader.default_prefix(); + let root_version = format!("{prefix}-{game_version}-{loader_version}"); + mojang.set_version(root_version.clone()); + + // NOTE: We don't need to fetch exclude that version because the handler below + // already take care of that! 'mojang.add_fetch_exclude(...)' + + // Scoping the temporary internal handler. + let game = { + + let mut handler = InternalHandler { + inner: &mut *handler, + error: Ok(()), + api, + root_version: &root_version, + game_version: &game_version, + loader_version: &loader_version, + }; + + // Same as above, we are giving a &mut dyn ref to avoid huge monomorphization. + let res = mojang.install(&mut handler); + handler.error?; + res? + + }; + + Ok(game) + + } + +} + +crate::trait_event_handler! { + pub trait Handler: mojang::Handler { + fn fetch_loader_version(game_version: &str, loader_version: &str); + fn fetched_loader_version(game_version: &str, loader_version: &str); + } +} + +/// The base installer could not proceed to the installation of a version. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum Error { + /// Error from the mojang installer. + #[error("mojang: {0}")] + Mojang(#[source] mojang::Error), + /// An alias version, `Stable` or `Unstable` has not been found because the no version + /// is matching this criteria. This is used for both game version and loader version, + /// when game version is specified it means that the given . + #[error("latest version not found (stable: {stable})")] + LatestVersionNotFound { + game_version: Option, + stable: bool, + }, + /// The given game version as requested to launch Fabric with is not supported by the + /// selected API. + #[error("game version not found: {game_version}")] + GameVersionNotFound { + game_version: String, + }, + /// The given loader version as requested to launch Fabric with is not supported by + /// the selected API for the requested game version (which is supported). + #[error("loader version not found: {game_version}/{loader_version}")] + LoaderVersionNotFound { + game_version: String, + loader_version: String, + }, +} + +impl> From for Error { + fn from(value: T) -> Self { + Self::Mojang(value.into()) + } +} + +/// Type alias for a result with the fabric error type. +pub type Result = std::result::Result; + +/// Represent the different kind of loaders to install or fetch for versions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Loader { + /// This is the original and official Fabric API. + Fabric, + /// This is the API for the Quilt mod loader, which is a fork of Fabric. + Quilt, + /// This is the API for the LegacyFabric project which aims to backport the Fabric loader + /// to older versions, up to 1.14 snapshots. + LegacyFabric, + /// This is the API for the Babric project, which aims to support the Fabric loader + /// for Minecraft beta 1.7.3 in particular. + Babric, +} + +impl Loader { + + fn default_prefix(self) -> &'static str { + match self { + Loader::Fabric => "fabric", + Loader::Quilt => "quilt", + Loader::LegacyFabric => "legacyfabric", + Loader::Babric => "babric", + } + } + +} + +/// Specify the fabric game version to start the loader version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GameVersion { + /// Use the latest stable game version, this is usually equivalent to the 'Release' + /// version with Mojang, but is up to each fabric-like API to decide. + Stable, + /// Use the latest unstable game version, this is usually equivalent to the 'Snapshot' + /// version with Mojang, but is up to each fabric-like API to decide. + /// + /// Note that if the most recent version is stable, it will also be selected as the + /// most recent unstable one, much like Mojang, when a stable release is just + /// published, it is also the latest snapshot (usually not for a long time). + Unstable, + /// Use the specific version. + Name(String), +} + +impl> From for GameVersion { + fn from(value: T) -> Self { + Self::Name(value.into()) + } +} + +/// Specify the fabric loader version to start, see [`GameVersion`] for more explanation, +/// both are almost the same. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoaderVersion { + /// Use the latest stable loader version for the root version. + Stable, + /// Use the latest unstable loader version for the root version, see + /// [`GameVersion::Unstable`] for more explanation, the two are the same. + Unstable, + /// Use the specific version. + Name(String), +} + +/// An impl so that we can give string-like objects to the builder. +impl> From for LoaderVersion { + fn from(value: T) -> Self { + Self::Name(value.into()) + } +} + +/// A fabric-compatible API, this can be used to list and retrieve loader versions that +/// can be given to the installer for installation. +#[derive(Debug)] +pub struct Api { + /// Base URL for that API, not ending with a '/'. This API must support the following + /// endpoints supporting the same API as official Fabric API: + /// - `/versions/game` + /// - `/versions/loader` + /// - `/versions/loader/` + /// - `/versions/loader//` (returning status 400) + /// - `/versions/loader///profile/json` + base_url: &'static str, +} + +impl Api { + + /// Initialize the handle to + pub fn new(loader: Loader) -> Self { + Self { + base_url: match loader { + Loader::Fabric => "https://meta.fabricmc.net/v2", + Loader::Quilt => "https://meta.quiltmc.org/v3", + Loader::LegacyFabric => "https://meta.legacyfabric.net/v2", + Loader::Babric => "https://meta.babric.glass-launcher.net/v2", + } + } + } + + /// Request supported game versions. + pub fn request_game_versions(&self) -> reqwest::Result> { + self.raw_request_game_versions().map(|versions| ApiGameVersions { + _api: self, + versions, + }) + } + + fn raw_request_game_versions(&self) -> reqwest::Result> { + crate::tokio::sync(async move { + crate::http::client()? + .get(format!("{}/versions/game", self.base_url)) + .header(reqwest::header::ACCEPT, "application/json") + .send().await? + .error_for_status()? + .json().await + }) + } + + /// Request supported loader versions. + pub fn request_loader_versions(&self, game_version: Option<&str>) -> reqwest::Result> { + if let Some(game_version) = game_version { + self.raw_request_game_loader_versions(game_version).map(|versions| ApiLoaderVersions { + _api: self, + versions: versions.into_iter().map(|v| v.loader).collect(), + }) + } else { + self.raw_request_loader_versions().map(|versions| ApiLoaderVersions { + _api: self, + versions, + }) + } + } + + fn raw_request_loader_versions(&self) -> reqwest::Result> { + crate::tokio::sync(async move { + crate::http::client()? + .get(format!("{}/versions/loader", self.base_url)) + .header(reqwest::header::ACCEPT, "application/json") + .send().await? + .error_for_status()? + .json().await + }) + } + + /// Request supported loader versions for the given game version. + fn raw_request_game_loader_versions(&self, game_version: &str) -> reqwest::Result> { + crate::tokio::sync(async move { + crate::http::client()? + .get(format!("{}/versions/loader/{game_version}", self.base_url)) + .header(reqwest::header::ACCEPT, "application/json") + .send().await? + .error_for_status()? + .json().await + }) + } + + /// Return true if the given game version has any loader versions supported. + fn raw_request_has_game_loader_versions(&self, game_version: &str) -> reqwest::Result { + crate::tokio::sync(async move { + crate::http::client()? + .get(format!("{}/versions/loader/{game_version}", self.base_url)) + .header(reqwest::header::ACCEPT, "application/json") + .send().await? + .error_for_status()? + .bytes().await + .map(|bytes| &*bytes == b"[]") // This avoids parsing JSON + }) + } + + /// Request the prebuilt version metadata for the given game and loader versions. + fn raw_request_game_loader_version_metadata(&self, game_version: &str, loader_version: &str) -> reqwest::Result { + crate::tokio::sync(async move { + crate::http::client()? + .get(format!("{}/versions/loader/{game_version}/{loader_version}/profile/json", self.base_url)) + .header(reqwest::header::ACCEPT, "application/json") + .send().await? + .error_for_status()? + .json().await + }) + } + +} + +#[derive(Debug)] +pub struct ApiGameVersions<'a> { + _api: &'a Api, + versions: Vec, +} + +impl ApiGameVersions<'_> { + + /// Create an iterator over all game versions. + pub fn iter(&self) -> impl Iterator> + use<'_> { + self.versions.iter().map(|inner| ApiGameVersion { inner }) + } + + /// Get the latest supported version, stable or unstable. + pub fn find_latest(&self, stable: bool) -> Option> { + self.iter().find(|v| !stable || v.is_stable()) + } + +} + +#[derive(Debug)] +pub struct ApiGameVersion<'d> { + inner: &'d serde::Game, +} + +impl<'d> ApiGameVersion<'d> { + + #[inline] + pub fn name(&self) -> &'d str { + &self.inner.version + } + + #[inline] + pub fn is_stable(&self) -> bool { + self.inner.stable + } + +} + +#[derive(Debug)] +pub struct ApiLoaderVersions<'a> { + _api: &'a Api, + versions: Vec, +} + +impl ApiLoaderVersions<'_> { + + /// Create an iterator over all loader versions. + pub fn iter(&self) -> impl Iterator> + use<'_> { + self.versions.iter().map(|inner| ApiLoaderVersion { inner }) + } + + /// Get the latest supported version, stable or unstable. + pub fn find_latest(&self, stable: bool) -> Option> { + self.iter().find(|v| !stable || v.is_stable()) + } + +} + +#[derive(Debug)] +pub struct ApiLoaderVersion<'d> { + inner: &'d serde::Loader, +} + +impl<'d> ApiLoaderVersion<'d> { + + #[inline] + pub fn name(&self) -> &'d str { + &self.inner.version + } + + #[inline] + pub fn is_stable(&self) -> bool { + self.inner.stable.unwrap_or_else(|| { + !self.inner.version.contains("-beta") && !self.inner.version.contains("-pre") + }) + } + +} + +// ========================== // +// Following code is internal // +// ========================== // + +/// Internal handler given to the mojang installer. +struct InternalHandler<'a> { + /// Inner handler. + inner: &'a mut dyn Handler, + /// If there is an error in the handler. + error: Result<()>, + /// The real version is, as defined + api: Api, + root_version: &'a str, + game_version: &'a str, + loader_version: &'a str, +} + +impl download::Handler for InternalHandler<'_> { + + fn __internal_fallback(&mut self, _token: crate::sealed::Token) -> Option<&mut dyn download::Handler> { + Some(&mut self.inner) + } + +} + +impl base::Handler for InternalHandler<'_> { + + fn __internal_fallback(&mut self, _token: crate::sealed::Token) -> Option<&mut dyn base::Handler> { + Some(&mut self.inner) + } + + fn need_version(&mut self, version: &str, file: &Path) -> bool { + match self.inner_need_version(version, file) { + Ok(true) => return true, + Ok(false) => (), + Err(e) => self.error = Err(e), + } + self.inner.need_version(version, file) + } + +} + +impl mojang::Handler for InternalHandler<'_> { + + fn __internal_fallback(&mut self, _token: crate::sealed::Token) -> Option<&mut dyn mojang::Handler> { + Some(&mut self.inner) + } + +} + +impl InternalHandler<'_> { + + fn inner_need_version(&mut self, version: &str, file: &Path) -> Result { + + if version != self.root_version { + return Ok(false); + } + + self.inner.fetch_loader_version(self.game_version, self.loader_version); + + // At this point we've not yet checked if either game or loader versions + // are known by the API, we just wanted to allow the user to input any + // version if he will. But now that we need to request the prebuilt + // version metadata, in case of error we'll try to understand what's the + // issue: unknown game version or unknown loader version? + let mut metadata = match self.api.raw_request_game_loader_version_metadata(self.game_version, self.loader_version) { + Ok(metadata) => metadata, + Err(e) if e.status() == Some(StatusCode::NOT_FOUND) => { + + let has_versions = self.api.raw_request_has_game_loader_versions(self.game_version) + .map_err(|e| base::Error::new_reqwest(e, "request fabric has game loader versions"))?; + + if has_versions { + return Err(Error::LoaderVersionNotFound { + game_version: self.game_version.to_string(), + loader_version: self.loader_version.to_string(), + }); + } else { + return Err(Error::GameVersionNotFound { + game_version: self.game_version.to_string(), + }); + } + + } + Err(e) => return Err(base::Error::new_reqwest(e, "request fabric game loader version metadata").into()), + }; + + // Force the version id, the prebuilt one might not be exact. + metadata.id = version.to_string(); + base::write_version_metadata(file, &metadata)?; + + self.inner.fetched_loader_version(self.game_version, self.loader_version); + + Ok(true) + + } + +} diff --git a/rust/portablemc/src/fabric/serde.rs b/rust/portablemc/src/fabric/serde.rs new file mode 100644 index 00000000..7188123c --- /dev/null +++ b/rust/portablemc/src/fabric/serde.rs @@ -0,0 +1,34 @@ +//! Internal module for deserialization of the Fabric-like APIs. + +#![allow(unused)] + +use crate::maven::Gav; + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct Game { + pub version: String, + pub stable: bool, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct Loader { + pub separator: String, + pub build: u32, + pub maven: Gav, + pub version: String, + pub stable: Option, // Absent for some APIs (quilt) +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct Intermediary { + pub maven: Gav, + pub version: String, + pub stable: Option, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct GameLoader { + pub loader: Loader, + pub intermediary: Intermediary, + // missing: launcherMeta, +} \ No newline at end of file diff --git a/rust/portablemc/src/forge/mod.rs b/rust/portablemc/src/forge/mod.rs new file mode 100644 index 00000000..82988d05 --- /dev/null +++ b/rust/portablemc/src/forge/mod.rs @@ -0,0 +1,1260 @@ +//! Extension to the Mojang installer to support fetching and installation of +//! Forge and NeoForge mod loader versions. + +mod serde; + +use std::io::{self, BufRead, BufReader, BufWriter, Read, Seek}; +use std::process::{Command, Output}; +use std::path::{Path, PathBuf}; +use std::collections::HashMap; +use std::iter::FusedIterator; +use std::fmt::Write; +use std::{env, fs}; +use std::fs::File; + +use crate::download::{self, Batch, EntryErrorKind}; +use crate::base::{self, Game, LIBRARIES_URL}; +use crate::mojang::{self, FetchExclude}; +use crate::maven::{Gav, MetadataParser}; +use crate::path::{PathBufExt, PathExt}; + +use zip::ZipArchive; + +use elsa::sync::FrozenMap; + + +/// An installer that supports Forge and NeoForge mod loaders. +#[derive(Debug, Clone)] +pub struct Installer { + /// The underlying Mojang installer logic. + mojang: mojang::Installer, + /// The forge loader to install. + loader: Loader, + /// The forge installer version description. + version: Version, +} + +impl Installer { + + /// Create a new installer with default configuration. + pub fn new(loader: Loader, version: impl Into) -> Self { + Self { + // Empty version by default, will be set at install. + mojang: mojang::Installer::new(String::new()), + loader, + version: version.into(), + } + } + + /// Get the underlying mojang installer. + #[inline] + pub fn mojang(&self) -> &mojang::Installer { + &self.mojang + } + + /// Get the underlying mojang installer through mutable reference. + /// + /// *Note that the `version` and `fetch` properties will be overwritten when + /// installing.* + #[inline] + pub fn mojang_mut(&mut self) -> &mut mojang::Installer { + &mut self.mojang + } + + /// Get the kind of loader that will be installed. + #[inline] + pub fn loader(&self) -> Loader { + self.loader + } + + /// Set the kind of loader that will be installed. + #[inline] + pub fn set_loader(&mut self, loader: Loader) -> &mut Self { + self.loader = loader; + self + } + + /// Get the loader version that will be installed. + #[inline] + pub fn version(&self) -> &Version { + &self.version + } + + /// Change the loader version that will be installed. + #[inline] + pub fn set_version(&mut self, version: impl Into) -> &mut Self { + self.version = version.into(); + self + } + + /// Install the currently configured Forge/NeoForge loader with the given handler. + #[inline] + pub fn install(&mut self, mut handler: impl Handler) -> Result { + self.install_dyn(&mut handler) + } + + #[inline(never)] + fn install_dyn(&mut self, handler: &mut dyn Handler) -> Result { + + let Self { + ref mut mojang, + loader, + ref version, + } = *self; + + // Request the repository if needed! + let version = match version { + Version::Name(name) => name.clone(), + Version::Stable(game_version) | + Version::Unstable(game_version) => { + let stable = matches!(version, Version::Stable(_)); + match Repo::request(loader)?.find_latest(&game_version, stable) { + Some(v) => v.name().to_string(), + None => return Err(Error::LatestVersionNotFound { + game_version: game_version.clone(), + stable, + }), + } + } + }; + + let config = match loader { + Loader::Forge => InstallConfig::new_forge(&version), + Loader::NeoForge => InstallConfig::new_neoforge(&version), + }; + + // Shortcut because the version name is invalid and there will be no installer or + // that installer is not supported. + let Some(config) = config else { + return Err(Error::InstallerNotFound { version }); + }; + + // Construct the root version id. + let prefix = config.default_prefix; + let root_version = format!("{prefix}-{version}"); + + // Adding it to fetch exclude, we don't want to try to fetch it from Mojang's + // manifest: it's pointless and it avoids trying to fetch the manifest. + mojang.add_fetch_exclude(FetchExclude::Exact(root_version.clone())); + + // The goal is to run the installer a first time, check potential errors to + // know if the error is related to the loader, or not. + mojang.set_version(root_version.clone()); + let reason = match mojang.install(&mut *handler) { + Ok(game) => { + + if !config.check_libraries { + return Ok(game); + } + + // Using this outer loop to break when some reason to install is met. + loop { + + fn check_exists(file: &Path) -> bool { + fs::exists(file).unwrap_or_default() + } + + let libs_dir = mojang.base().libraries_dir(); + + // Start by checking patched client and universal client. + if !check_exists(&config.gav.with_classifier(Some("client")).file(libs_dir)) { + break InstallReason::MissingPatchedClient; + } + + if !check_exists(&config.gav.with_classifier(Some("universal")).file(libs_dir)) { + break InstallReason::MissingUniversalClient; + } + + // We analyze game argument to try find which libraries are absolutely + // required for the game to run, there has been so many way of launching + // the game in the Forge/NeoForge history that it's complicated to ensure + // that we can accurately determine if the mod loader is properly + // installed. + let mut mcp_version = None; + let mut args_iter = game.game_args.iter(); + while let Some(arg) = args_iter.next() { + match arg.as_str() { + "--fml.neoFormVersion" | + "--fml.mcpVersion" => { + let Some(version) = args_iter.next() else { continue }; + mcp_version = Some(version.as_str()); + } + _ => {} + } + } + + // If there is a MCP version to check, we go check if client extra, slim + // and srg files are present, or not, they are loaded dynamically by the + // mod loader. + if let Some(mcp_version) = mcp_version { + + let mcp_artifact = libs_dir + .join("net") + .joined("minecraft") + .joined("client") + .joined(&config.game_version) + .appended("-") + .appended(mcp_version) + .joined("client") + .appended("-") + .appended(&config.game_version) + .appended("-") + .appended(mcp_version) + .appended("-"); + + if !check_exists(&mcp_artifact.append("srg.jar")) { + break InstallReason::MissingClientSrg; + } + + if config.extra_in_mcp { + if !check_exists(&mcp_artifact.append("extra.jar")) { + break InstallReason::MissingClientExtra; + } + } else { + + let mc_artifact = libs_dir + .join("net") + .joined("minecraft") + .joined("client") + .joined(&config.game_version) + .joined("client") + .appended("-") + .appended(&config.game_version) + .appended("-"); + + if !check_exists(&mc_artifact.append("extra.jar")) + && !check_exists(&mc_artifact.append("extra-stable.jar")) { + break InstallReason::MissingClientExtra; + } + + } + + } + + // No reason to reinstall, we return the game as-is. + return Ok(game); + + } + + } + Err(mojang::Error::Base(base::Error::VersionNotFound { version })) + if version == root_version => { + InstallReason::MissingVersionMetadata + } + Err(mojang::Error::Base(base::Error::LibraryNotFound { gav })) + if gav.group() == "net.minecraftforge" && gav.artifact() == "forge" => { + InstallReason::MissingCoreLibrary + } + Err(e) => return Err(Error::Mojang(e)) + }; + + try_install(&mut *handler, &mut *mojang, &config, &root_version, serde::InstallSide::Client, reason)?; + + // Retrying launch! + mojang.set_version(root_version); + let game = mojang.install(&mut *handler)?; + Ok(game) + + } + +} + +crate::trait_event_handler! { + /// Handler for events happening when installing. + pub trait Handler: mojang::Handler { + /// The loader version failed to start, so this installer will (re)try to install + /// the mod loader. + fn installing(tmp_dir: &Path, reason: InstallReason); + /// The loader installer will be fetched. + fn fetch_installer(version: &str); + /// The loader installer has been successfully fetched. + fn fetched_installer(version: &str); + /// Notify that the game will be installed manually before running the installer, + /// because the installer needs it. + fn installing_game(); + /// The loader installer libraries will be fetched, either from being download, + /// or being extracted from the installer archive. + fn fetch_installer_libraries(); + /// The loader installer libraries has been successfully fetched or extracted. + fn fetched_installer_libraries(); + /// An installer processor will be run. + fn run_installer_processor(name: &Gav, task: Option<&str>); + /// The mod loader has been apparently successfully installed, it will be run a + /// second time to try... + fn installed(); + } +} + +/// The Forge installer could not proceed to the installation of a version. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum Error { + /// Error from the Mojang installer. + #[error("mojang: {0}")] + Mojang(#[source] mojang::Error), + /// If the latest stable or unstable version is requested but doesn't exists. + #[error("latest version not found for {game_version} (stable: {stable})")] + LatestVersionNotFound { + game_version: String, + stable: bool, + }, + /// The given loader version as requested to launch Forge with has not supported + /// installer. + #[error("installer not found: {version}")] + InstallerNotFound { + version: String, + }, + /// The 'maven-metadata.xml' file requested only is + #[error("maven metadata is malformed")] + MavenMetadataMalformed { }, + /// The 'install_profile.json' installer file was not found. + #[error("installer profile not found")] + InstallerProfileNotFound { }, + /// The 'install_profile.json' installer file is present but its versions are + /// incoherent with the expected loader and game versions that should've been + /// downloaded. + #[error("installer profile incoherent")] + InstallerProfileIncoherent { }, + /// The 'version.json' installer file was not found, it contains the version metadata + /// to be installed. + #[error("installer version metadata not found")] + InstallerVersionMetadataNotFound { }, + /// A file needed to be extracted from the installer but was not found. + #[error("installer file to extract not found")] + InstallerFileNotFound { + entry: String, + }, + /// Failed to execute so process. + #[error("installer invalid processor")] + InstallerInvalidProcessor { + name: Gav, + }, + /// A processor has failed while running, the process output is linked. + #[error("installer processor failed")] + InstallerProcessorFailed { + name: Gav, + output: Box, + }, + #[error("installer processor invalid output")] + InstallerProcessorInvalidOutput { + name: Gav, + file: Box, + expected_sha1: Box<[u8; 20]>, + } +} + +impl> From for Error { + fn from(value: T) -> Self { + Self::Mojang(value.into()) + } +} + +/// Type alias for a result with the Forge error type. +pub type Result = std::result::Result; + +/// The reason for (re)installing the mod loader. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallReason { + /// The root version metadata is missing, the load was probably not installed before. + MissingVersionMetadata, + /// The core library is missing, this exists on some loader versions and should've + /// been extracted from the installer. Reinstalling. + MissingCoreLibrary, + /// The client extra artifact is missing. + MissingClientExtra, + /// The client srg artifact is missing. + MissingClientSrg, + /// The patched client is missing. + MissingPatchedClient, + /// The universal client is missing. + MissingUniversalClient, +} + +/// Represent the different kind of loaders to install or fetch for versions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Loader { + /// Targets the original Forge project. + Forge, + /// Targets the NeoForge project. + NeoForge, +} + +/// The version to install. +#[derive(Debug, Clone)] +pub enum Version { + /// Launch the latest stable version for the given game version. + Stable(String), + /// Launch the latest stable or unstable version for the given game version. + Unstable(String), + /// Raw forge loader version. + Name(String), +} + +impl> From for Version { + fn from(value: T) -> Self { + Self::Name(value.into()) + } +} + +/// The version repository for Forge and NeoForge. +#[derive(Debug)] +pub struct Repo { + /// The main metadata XML data. + main_xml: String, + /// The legacy metadata XML data, it's basically used only + legacy_xml: Option, + /// Special boolean specifying if the repository is the one of NeoForge, this affects + /// how various things are resolved. + neoforge: bool, + /// Major versions temporary string map, used for NeoForge. + major_versions: FrozenMap<[u16; 2], String>, +} + +impl Repo { + + /// Request the repository for a given loader. + pub fn request(loader: Loader) -> Result { + match loader { + Loader::Forge => Self::request_forge(), + Loader::NeoForge => Self::request_neoforge(), + } + } + + /// Request the online Forge repository. + fn request_forge() -> Result { + + // This entry doesn't really support caching, but we use this so we can access + // the resource while being offline. + let mut main_entry = download::single_cached("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml") + .set_keep_open() + .download(())?; + + let main_xml = main_entry.read_handle_to_string().unwrap() + .map_err(|e| base::Error::new_io_file(e, main_entry.file()))?; + + Ok(Self { + main_xml, + legacy_xml: None, + neoforge: false, + major_versions: FrozenMap::new(), + }) + + } + + /// Request the online NeoForge repository. + fn request_neoforge() -> Result { + + // See comment above about caching. + let mut batch = download::Batch::new(); + batch.push_cached("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml").set_keep_open(); + batch.push_cached("https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml").set_keep_open(); + + let mut result = batch.download(()) + .map_err(|e| base::Error::new_reqwest(e, "request neoforge repo"))? + .into_result()?; + + let main_entry = result.entry_mut(0).unwrap(); + let main_xml = main_entry.read_handle_to_string().unwrap() + .map_err(|e| base::Error::new_io_file(e, main_entry.file()))?; + + let legacy_entry = result.entry_mut(1).unwrap(); + let legacy_xml = legacy_entry.read_handle_to_string().unwrap() + .map_err(|e| base::Error::new_io_file(e, legacy_entry.file()))?; + + Ok(Self { + main_xml, + legacy_xml: Some(legacy_xml), + neoforge: true, + major_versions: FrozenMap::new(), + }) + + } + + /// Return an iterator over all loaders in the repository, the iteration order is not + /// consistent between Forge and NeoForge. + pub fn iter(&self) -> RepoIter<'_> { + RepoIter { + main: MetadataParser::new(&self.main_xml), + legacy: self.legacy_xml.as_deref().map(MetadataParser::new), + repo: self, + } + } + + /// Return the repository version that has this exact name. + pub fn find_by_name(&self, name: &str) -> Option> { + self.iter().find(|v| v.name() == name) + } + + /// Find the latest loader version given, optionally with a specified game version + /// and stable or not. Note that the latest stable version is also the latest unstable + /// one if no version is unstable before it. + pub fn find_latest(&self, game_version: &str, stable: bool) -> Option> { + + // Parse the game version to build a prefix to match versions against. + let [major, minor] = parse_game_version(game_version)?; + let prefix = if self.neoforge { + if major == 20 && minor == 1 { + format!("1.20.1-") + } else { + format!("{major}.{minor}.") + } + } else { + if game_version == "1.7.10-pre4" { + format!("1.7.10_pre4-") + } else { + format!("{game_version}-") + } + }; + + let mut it = self.iter() + .filter(|v| v.name().starts_with(&prefix)) + .filter(|v| !stable || v.is_stable()); + + // NeoForge has latest versions last. + if self.neoforge { + it.last() + } else { + it.next() + } + + } + +} + +/// An iterator over all loader versions in this repository. +#[derive(Debug)] +pub struct RepoIter<'a> { + main: MetadataParser<'a>, + legacy: Option>, + repo: &'a Repo, +} + +impl<'a> Iterator for RepoIter<'a> { + + type Item = RepoVersion<'a>; + + fn next(&mut self) -> Option { + + let version = match self.main.next() { + Some(v) => v, + None => self.legacy.as_mut()?.next()?, + }; + + Some(RepoVersion { + repo: self.repo, + version, + }) + + } + +} + +// Because 'MetadataParser' also implement this. +impl FusedIterator for RepoIter<'_> { } + +/// Reference to a version owned by the requested repository. +#[derive(Debug)] +pub struct RepoVersion<'a> { + version: &'a str, + repo: &'a Repo, +} + +impl<'a> RepoVersion<'a> { + + /// Return the full name of this loader, containing both game and loader versions. + /// Note that this naming is inconsistent. + pub fn name(&self) -> &'a str { + self.version + } + + /// Get the game version from this version, the returned value might be allocated if + /// the game version needs to be reconstructed. + pub fn game_version(&self) -> &'a str { + if self.repo.neoforge { + // Special case from the legacy NeoForge repository where '1.20.1' is missing. + if self.version == "47.1.82" || self.version.starts_with("1.20.1-") { + "1.20.1" + } else if let Some([major, minor]) = parse_generic_version::<2, 2>(self.version) { + self.repo.major_versions.insert_with([major, minor], || { + if minor == 0 { + format!("1.{major}") + } else { + format!("1.{major}.{minor}") + } + }) + } else { + "" // Should not happen + } + } else { + match self.version.split_once('-') { + // Special case with forge, this is the only pre-release supported. + Some(("1.7.10_pre4", _)) => "1.7.10-pre4", + Some((game_version, _)) => game_version, + None => "" // Should not happen + } + } + } + + /// Return true if this version is stable. + pub fn is_stable(&self) -> bool { + if self.repo.neoforge { + !self.version.ends_with("-beta") + } else { + true // Forge is always stable + } + } + +} + +// ========================== // +// Following code is internal // +// ========================== // + +/// Represent an abstract version that can be provided to the common Forge installer. +#[derive(Debug, Clone)] +struct InstallConfig { + /// Default prefix for the full root version id of the format + /// '--. + default_prefix: &'static str, + /// The full name of this version. + gav: Gav, + /// The main maven repository URL where the installer artifact can be downloaded. + /// Should not have a leading slash. + repo_url: &'static str, + /// The game version this loader version is patching. + game_version: String, + /// If the [`base::Installer`] runs successfully, this bool is used to determine + /// if some important libraries should be checked anyway, if those libraries are + /// absent the installer tries to reinstall. + check_libraries: bool, + /// If `check_libraries` is true, and this is true, the given ladder version is known + /// to put its "extra" generated artifact inside the MCP-versioned game version + /// inside `net.minecraft:client`. + extra_in_mcp: bool, + /// Set to true if this loader is expected to have a legacy install profile. + legacy_install_profile: bool, + /// Set to true when the installer processors should be checked, this exists because + /// some old versions systematically generate wrong SHA-1 and we prefer allowing + /// these versions to be installed even if files might be invalid. + check_processor_outputs: bool, +} + +impl InstallConfig { + + /// Create a new Forge version from its raw name. + /// + /// This constructor will parse the version to internally change the installer + /// behavior. + fn new_forge(name: &str) -> Option { + + let (game_version, loader_version) = name.split_once('-')?; + let (loader_version, _) = loader_version.split_once('-').unwrap_or((loader_version, "")); + let loader_version = parse_generic_version::<4, 2>(loader_version); + + Some(Self { + default_prefix: "forge", + gav: Gav::new("net.minecraftforge", "forge", name, None, None), + repo_url: "https://maven.minecraftforge.net", + game_version: if game_version == "1.7.10_pre4" { + "1.7.10-pre4".to_string() // The only pre-release supported. + } else { + game_version.to_string() + }, + // The first version to actually use processors was 1.13.2-25.0.9, therefore + // we only check libraries for this version after onward. + check_libraries: loader_version.map(|v| v >= [25, 0, 0, 0]).unwrap_or(false), + // The 'extra' classifier is stored in different directories depending on version: + // v >= 1.16.1-32.0.20: inside '-' + // v <= 1.16.1-32.0.19: inside '' + extra_in_mcp: loader_version.map(|v| v >= [32, 0, 20, 0]).unwrap_or(false), + // The install profiles comes in multiples forms: + // >= 1.12.2-14.23.5.2851: There are two files, 'install_profile.json' which + // contains processors and shared data, and `version.json` which is the raw + // version meta to be fetched. + // <= 1.12.2-14.23.5.2847: There is only an 'install_profile.json' with the + // version meta stored in 'versionInfo' object. Each library have two keys + // 'serverreq' and 'clientreq' that should be removed when the profile is + // returned. + legacy_install_profile: loader_version.map(|v| v <= [14, 23, 5, 2847]).unwrap_or(false), + // v >= 1.14.4-28.1.16: hashes are valid + // 1.13 <= v <= 1.14.4-28.1.15: hashes are invalid + // 1.12.2-14.23.5.2851 <= v < 1.13: no processor therefore no hash to check + // v <= 1.12.2-14.23.5.2847: legacy installer, no processor + check_processor_outputs: loader_version.map(|v| v >= [28, 1, 16, 0]).unwrap_or(false), + }) + + } + + /// Create a new NeoForge version from its name. + /// + /// This constructor will parse the version to internally change the installer + /// behavior. + fn new_neoforge(name: &str) -> Option { + + let gav; + let game_version; + + if name == "47.1.82" || name.starts_with("1.20.1-") { + gav = Gav::new("net.neoforged", "forge", name, None, None); + game_version = "1.20.1".to_string(); + } else { + gav = Gav::new("net.neoforged", "neoforge", name, None, None); + game_version = match parse_generic_version::<2, 2>(name)? { + [major, 0] => format!("1.{major}"), + [major, minor] => format!("1.{major}.{minor}"), + }; + }; + + Some(Self { + default_prefix: "neoforge", + gav, + repo_url: "https://maven.neoforged.net/releases", + game_version, + check_libraries: true, + extra_in_mcp: true, + legacy_install_profile: false, + check_processor_outputs: true, + }) + + } + +} + +/// Try installing the mod loader. +fn try_install( + handler: &mut dyn Handler, + mojang: &mut mojang::Installer, + config: &InstallConfig, + root_version: &str, + side: serde::InstallSide, + reason: InstallReason, +) -> Result<()> { + + let tmp_dir = env::temp_dir().joined(root_version); + handler.installing(&tmp_dir, reason); + + // The first thing we do is fetching the installer, so it ends early if there is + // simply no installer for this version! + handler.fetch_installer(config.gav.version()); + + let installer_gav = config.gav.with_classifier(Some("installer")); + let installer_url = format!("{}/{}", config.repo_url, installer_gav.url()); + + // Download and check result in case installer is just not found. + let entry = download::single(installer_url, tmp_dir.join("installer.jar")) + .set_keep_open() + .download(&mut *handler); + + let mut entry = match entry { + Ok(entry) => entry, + Err(e) => { + if let EntryErrorKind::InvalidStatus(404) = e.kind() { + return Err(Error::InstallerNotFound { + version: config.gav.version().to_string(), + }); + } else { + return Err(e.into()); + } + } + }; + + let installer_reader = BufReader::new(entry.take_handle().unwrap()); + let installer_file = entry.file(); + let mut installer_zip = ZipArchive::new(installer_reader) + .map_err(|e| base::Error::new_zip_file(e, installer_file))?; + + handler.fetched_installer(config.gav.version()); + + // We need to ensure that the underlying game version is fully installed. Here we + // just forward the handler as-is, and we check for version not found to warn + // about an non-existing game version. We keep the installed, or found, JVM exec + // for later execution of installer processors. Note that the JVM exec path should + // be already canonicalized. + handler.installing_game(); + mojang.set_version(config.game_version.clone()); + let jvm_file = match mojang.install(&mut *handler) { + Err(e) => return Err(Error::Mojang(e)), + Ok(game) => game.jvm_file, + }; + + const PROFILE_ENTRY: &str = "install_profile.json"; + let profile = match installer_zip.by_name(PROFILE_ENTRY) { + Ok(reader) => { + + let mut deserializer = serde_json::Deserializer::from_reader(reader); + let res = if config.legacy_install_profile { + serde_path_to_error::deserialize::<_, serde::LegacyInstallProfile>(&mut deserializer) + .map(InstallProfileKind::Legacy) + } else { + serde_path_to_error::deserialize::<_, serde::ModernInstallProfile>(&mut deserializer) + .map(InstallProfileKind::Modern) + }; + + res.map_err(|e| base::Error::new_json(e, format!("entry: {}, from: {}", + PROFILE_ENTRY, + installer_file.display())))? + + } + Err(_) => return Err(Error::InstallerProfileNotFound { }) + }; + + // The installer directly installs libraries to these directories. + // We canonicalize the libs path here, this avoids doing it after each join. + let libraries_dir = base::canonicalize_file(mojang.base().libraries_dir())?; + let game_version_dir = mojang.base().versions_dir().join(&config.game_version); + let game_client_file = game_version_dir.join_with_extension(&config.game_version, "jar"); + let root_version_dir = mojang.base().versions_dir().join(&root_version); + let metadata_file = root_version_dir.join_with_extension(&root_version, "json"); + let mut metadata; + + match profile { + InstallProfileKind::Modern(profile) => { + + if profile.minecraft != config.game_version { + return Err(Error::InstallerProfileIncoherent { }); + } + + // Immediately try, and keep the version metadata, this avoid launching this + // error at the end after all the processing happened. + let metadata_entry = profile.json.strip_prefix('/').unwrap_or(&profile.json); + metadata = match installer_zip.by_name(metadata_entry) { + Ok(reader) => { + let mut deserializer = serde_json::Deserializer::from_reader(reader); + serde_path_to_error::deserialize::<_, Box>(&mut deserializer) + .map_err(|e| base::Error::new_json(e, format!("entry: {}, from: {}", + metadata_entry, + installer_file.display())))? + } + Err(_) => return Err(Error::InstallerVersionMetadataNotFound { }) + }; + + handler.fetch_installer_libraries(); + + // Some early (still modern) installers (<= 1.16.5) embed the forge universal + // JAR, we need to extract it given its path. It also appears that more modern + // versions have this property back... + if let Some(name) = &profile.path { + let lib_file = name.file(&libraries_dir); + extract_installer_maven_artifact(installer_file, &mut installer_zip, name, &lib_file)?; + } + + // We keep as map of libraries to their file path, this is also used because + // some NeoForge installers have been seen to have duplicated library. + let mut libraries = HashMap::new(); + let mut batch = Batch::new(); + + for lib in &profile.libraries { + + // Ignore duplicated libs, see above. + if libraries.contains_key(&lib.name) { + continue + } + + let lib_dl = &lib.downloads.artifact; + + let lib_file = if let Some(lib_path) = &lib_dl.path { + // NOTE: Unsafe joining! + libraries_dir.join(lib_path) + } else { + lib.name.file(&libraries_dir) + }; + + libraries.insert(&lib.name, lib_file.clone()); + + if !lib_dl.download.url.is_empty() { + batch.push(lib_dl.download.url.to_string(), lib_file) + .set_expected_size(lib_dl.download.size) + .set_expected_sha1(lib_dl.download.sha1.as_deref().copied()); + } else { + extract_installer_maven_artifact(installer_file, &mut installer_zip, &lib.name, &lib_file)?; + } + + } + + // Download all libraries just before running post processors. + if !batch.is_empty() { + batch.download(&mut *handler) + .map_err(|e| base::Error::new_reqwest(e, "download forge libraries"))? + .into_result()?; + } + + handler.fetched_installer_libraries(); + + // Parse data entries... + let mut data = HashMap::with_capacity(profile.data.len()); + for (name, entry) in &profile.data { + let entry = entry.get(side); + let kind = match entry.as_bytes() { + [b'[', .., b']'] => { + if let Ok(gav) = entry[1..entry.len() - 1].parse::() { + InstallDataTypedEntry::Library(gav) + } else { + // Gently ignore the error as it should never happen. + continue; + } + } + [b'\'', .., b'\''] => { + InstallDataTypedEntry::Literal(entry[1..entry.len() - 1].to_string()) + } + _ => { + // This is a file that we should extract to the temp directory. + // NOTE: Unsafe joining. + let entry = entry.strip_prefix('/').unwrap_or(entry); + let tmp_file = tmp_dir.join(entry); + extract_installer_file(installer_file, &mut installer_zip, entry, &tmp_file)?; + InstallDataTypedEntry::File(tmp_file) + } + }; + data.insert(name.clone(), kind); + } + + // Builtin entries. + data.insert("SIDE".to_string(), InstallDataTypedEntry::Literal(side.as_str().to_string())); + data.insert("MINECRAFT_JAR".to_string(), InstallDataTypedEntry::File(game_client_file)); + data.insert("MINECRAFT_VERSION".to_string(), InstallDataTypedEntry::Literal(config.game_version.to_string())); + // Currently no support for ROOT because it's apparently used only for server... + // data.insert("ROOT".to_string(), InstallDataTypedEntry::File(mojang.standard().)); + data.insert("INSTALLER".to_string(), InstallDataTypedEntry::File(installer_file.to_path_buf())); + data.insert("LIBRARY_DIR".to_string(), InstallDataTypedEntry::File(libraries_dir.to_path_buf())); + + // Now we process each post-processor in order, each processor will refer to + // one of the library installed earlier. + for processor in &profile.processors { + + if let Some(processor_sides) = &processor.sides { + if !processor_sides.iter().copied().any(|processor_side| processor_side == side) { + continue + } + } + + let Some(jar_file) = libraries.get(&processor.jar) else { + return Err(Error::InstallerInvalidProcessor { + name: processor.jar.clone(), + }); + }; + + let Some(main_class) = find_jar_main_class(&jar_file)? else { + return Err(Error::InstallerInvalidProcessor { + name: processor.jar.clone(), + }); + }; + + let mut classes = vec![jar_file.as_path()]; + for dep_name in &processor.classpath { + if let Some(dep_path) = libraries.get(dep_name) { + classes.push(dep_path.as_path()); + } else { + return Err(Error::InstallerInvalidProcessor { + name: processor.jar.clone(), + }); + } + } + + let class_path = env::join_paths(classes).unwrap(); + + // Find a debug-purpose processor task name... + let task = if processor.args.len() >= 2 && processor.args[0] == "--task" { + Some(processor.args[1].as_str()) + } else { + None + }; + + handler.run_installer_processor(&processor.jar, task); + + // Construct the command to run the processor. + let mut command = Command::new(&jvm_file); + command + .arg("-cp") + .arg(class_path) + .arg(&main_class); + + for arg in &processor.args { + if let Some(arg) = format_processor_arg(&arg, &libraries_dir, &data) { + command.arg(arg); + } else { + // Ignore malformed arguments for now. + command.arg(arg); + } + } + + let output = command.output() + .map_err(|e| base::Error::new_io(e, format!("spawn: {}", jvm_file.display())))?; + + if !output.status.success() { + return Err(Error::InstallerProcessorFailed { + name: processor.jar.clone(), + output: Box::new(output), + }); + } + + // If process SHA-1 check is enabled... + if config.check_processor_outputs { + for (file, sha1) in &processor.outputs { + let Some(file) = format_processor_arg(&file, &libraries_dir, &data) else { continue }; + let Some(sha1) = format_processor_arg(&sha1, &libraries_dir, &data) else { continue }; + let Some(sha1) = crate::serde::parse_hex_bytes::<20>(&sha1) else { continue }; + let file = Path::new(&file); + if !base::check_file(file, None, Some(&sha1))? { + return Err(Error::InstallerProcessorInvalidOutput { + name: processor.jar.clone(), + file: file.to_path_buf().into_boxed_path(), + expected_sha1: Box::new(sha1), + }); + } + } + } + + } + + } + InstallProfileKind::Legacy(profile) => { + + metadata = profile.version_info; + + // Older versions used to require libraries that are no longer installed + // by parent versions, therefore it's required to add url if not + // provided, pointing to maven central repository, for downloading. + for lib in &mut metadata.libraries { + if lib.url.is_none() { + lib.url = Some(LIBRARIES_URL.to_string()); + } + } + + // Old version (<= 1.6.4) of forge are broken, even on official launcher. + // So we fix them by manually adding the correct inherited version. + if metadata.inherits_from.is_none() { + metadata.inherits_from = Some(config.game_version.clone()); + } + + // Extract the universal JAR file of the mod loader. + let jar_file = profile.install.path.file(libraries_dir); + let jar_entry = &profile.install.file_path[..]; + extract_installer_file(installer_file, &mut installer_zip, &jar_entry, &jar_file)?; + + } + } + + metadata.id = root_version.to_string(); + base::write_version_metadata(&metadata_file, &metadata)?; + + handler.installed(); + + Ok(()) + +} + +#[derive(Debug)] +enum InstallProfileKind { + Modern(serde::ModernInstallProfile), + Legacy(serde::LegacyInstallProfile), +} + +/// Internal install data. +#[derive(Debug)] +enum InstallDataTypedEntry { + /// The data is referencing a library. + Library(Gav), + /// The value is a literal value. + Literal(String), + /// The value is a file. + File(PathBuf), +} + +/// Format a processor argument, NOTE THAT it is directly implemented, especially from +/// `net.minecraftforge.installer.json.Util.replaceToken` class inside the installer. +fn format_processor_arg( + input: &str, + libraries_dir: &Path, + data: &HashMap +) -> Option { + + if matches!(input.as_bytes(), [b'[', .., b']']) { + let gav = input[1..input.len() - 1].parse::().ok()?; + return Some(format!("{}", gav.file(libraries_dir).display())); + } + + #[derive(Debug)] + enum TokenKind { + Data, + Literal, + } + + let mut global_buf = String::new(); + let mut token_buf = String::new(); + let mut token = None; + let mut escape = false; + + for (index, ch) in input.char_indices() { + match ch { + '\\' if !escape => { + if index == input.len() - 1 { + return None; + } + escape = true; + } + '{' if !escape && token.is_none() => { + token = Some(TokenKind::Data); + } + '}' if !escape && matches!(token, Some(TokenKind::Data)) => { + match data.get(&token_buf)? { + InstallDataTypedEntry::Library(gav) => { + write!(global_buf, "{}", gav.file(libraries_dir).display()).unwrap(); + } + InstallDataTypedEntry::Literal(lit) => { + global_buf.push_str(lit); + } + InstallDataTypedEntry::File(path_buf) => { + write!(global_buf, "{}", path_buf.display()).unwrap(); + } + } + token_buf.clear(); + token = None; + } + '\'' if !escape && token.is_none() => { + token = Some(TokenKind::Literal); + } + '\'' if !escape && matches!(token, Some(TokenKind::Literal)) => { + global_buf.push_str(&token_buf); + token_buf.clear(); + token = None; + } + _ => { + if token.is_none() { + global_buf.push(ch); + } else { + token_buf.push(ch); + } + escape = false; + } + } + } + + Some(global_buf) + +} + + +/// For the modern installer, extract from its archive the given artifact to the library +/// directory. +fn extract_installer_maven_artifact( + installer_file: &Path, + installer_zip: &mut ZipArchive, + src_name: &Gav, + dst_file: &Path, +) -> Result<()> { + let src_entry = format!("maven/{}", src_name.url()); + extract_installer_file(installer_file, installer_zip, &src_entry, dst_file) +} + +/// Extract an installer file from its archive. +fn extract_installer_file( + installer_file: &Path, + installer_zip: &mut ZipArchive, + src_entry: &str, + dst_file: &Path, +) -> Result<()> { + + let mut reader = installer_zip.by_name(&src_entry) + .map_err(|_| Error::InstallerFileNotFound { + entry: src_entry.to_string(), + })?; + + let parent_dir = dst_file.parent().unwrap(); + fs::create_dir_all(parent_dir) + .map_err(|e| base::Error::new_io_file(e, parent_dir))?; + + let mut writer = File::create(dst_file) + .map_err(|e| base::Error::new_io_file(e, dst_file)) + .map(BufWriter::new)?; + + io::copy(&mut reader, &mut writer) + .map_err(|e| base::Error::new_io(e, format!("extract: {}, from: {}", + src_entry, + installer_file.display())))?; + + Ok(()) + +} + +/// From a JAR file path, open it and try to find the main class path from the manifest. +fn find_jar_main_class(jar_file: &Path) -> Result> { + + let jar_reader = File::open(jar_file) + .map_err(|e| base::Error::new_io_file(e, jar_file)) + .map(BufReader::new)?; + + let mut jar_zip = ZipArchive::new(jar_reader) + .map_err(|e| base::Error::new_zip_file(e, jar_file))?; + + let Ok(mut manifest_reader) = jar_zip.by_name("META-INF/MANIFEST.MF") + .map(BufReader::new) else { + // The manifest was not found, is should NEVER happen, we ignore this. + return Ok(None); + }; + + const MAIN_CLASS_KEY: &str = "Main-Class: "; + + let mut line = String::new(); + while manifest_reader.read_line(&mut line).unwrap_or(0) != 0 { + if line.starts_with(MAIN_CLASS_KEY) { + if let Some(last_non_whitespace) = line.rfind(|c: char| !c.is_whitespace()) { + line.truncate(last_non_whitespace + 1); + line.drain(0..MAIN_CLASS_KEY.len()); + return Ok(Some(line)) + } else { + // The main class is empty? + return Ok(None); + } + } + line.clear(); + } + + Ok(None) + +} + +/// Generic version parsing with dot separator and default value to zero. +fn parse_generic_version(version: &str) -> Option<[u16; MAX]> { + let mut it = version.split('.'); + let mut ret = [0; MAX]; + for i in 0..MAX { + ret[i] = match it.next() { + Some(raw) => raw.parse::().ok()?, + None if i < MIN => return None, + None => 0, + }; + } + Some(ret) +} + +/// Internal function that parses the game version major and minor version numbers, if +/// the version starts with "1.", returning 0 for minor version is not present. +fn parse_game_version(version: &str) -> Option<[u16; 2]> { + let version = version.strip_prefix("1.")?; + let (version, _rest) = version.split_once("-pre").unwrap_or((version, "")); + parse_generic_version::<2, 1>(version) +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn parse_version() { + + assert_eq!(parse_generic_version::<4, 2>("1"), None); + assert_eq!(parse_generic_version::<4, 2>("1.2"), Some([1, 2, 0, 0])); + assert_eq!(parse_generic_version::<4, 2>("1.2.3"), Some([1, 2, 3, 0])); + assert_eq!(parse_generic_version::<4, 2>("1.2.3.4"), Some([1, 2, 3, 4])); + assert_eq!(parse_generic_version::<4, 2>("1.2.3.4.5"), Some([1, 2, 3, 4])); + + assert_eq!(parse_game_version("1"), None); + assert_eq!(parse_game_version("1.2"), Some([2, 0])); + assert_eq!(parse_game_version("1.2-pre3"), Some([2, 0])); + assert_eq!(parse_game_version("1.2.5"), Some([2, 5])); + assert_eq!(parse_game_version("1.2.5-pre3"), Some([2, 5])); + + } + +} diff --git a/rust/portablemc/src/forge/serde.rs b/rust/portablemc/src/forge/serde.rs new file mode 100644 index 00000000..0cfa044b --- /dev/null +++ b/rust/portablemc/src/forge/serde.rs @@ -0,0 +1,111 @@ +//! JSON schemas structures for serde deserialization. +//! +//! This module is internal to the module because it might be modified sooner or later +//! to fix issues with Forge installers. + +use std::collections::HashMap; + +use crate::maven::Gav; + +use crate::base; + + +/// For loader >= 1.12.2-14.23.5.2851 +#[derive(serde::Deserialize, Debug, Clone)] +pub struct ModernInstallProfile { + // /// The loader version. + // pub version: String, + /// The minecraft version. + pub minecraft: String, + /// The installing forge GAV, for early installers, no longer used in modern ones. + pub path: Option, + /// Path to the 'version.json' file containing the full version metadata. + pub json: String, + /// Libraries for the installation. + #[serde(default)] + pub libraries: Vec, + /// Post-processors used to generate the final client. + #[serde(default)] + pub processors: Vec, + /// Constant data used for replacement in post-processor arguments. + #[serde(deserialize_with = "crate::serde::deserialize_or_empty_seq")] + pub data: HashMap, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct InstallLibrary { + pub name: Gav, + pub downloads: InstallLibraryDownloads, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct InstallLibraryDownloads { + pub artifact: base::serde::VersionLibraryDownload, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct InstallProcessor { + pub jar: Gav, + #[serde(default)] + pub sides: Option>, + #[serde(default)] + pub classpath: Vec, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub outputs: HashMap, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct InstallDataEntry { + pub client: String, + pub server: String, +} + +impl InstallDataEntry { + + pub fn get(&self, side: InstallSide) -> &str { + match side { + InstallSide::Client => &self.client, + InstallSide::Server => &self.server, + } + } + +} + +/// For loader <= 1.12.2-14.23.5.2847 +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LegacyInstallProfile { + pub install: LegacyInstall, + pub version_info: Box, +} + +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LegacyInstall { + // /// The game version. + // pub minecraft: String, + pub path: Gav, + /// The path, within the installer archive, where the universal JAR is located and + /// can be extracted from. + pub file_path: String, +} + +#[derive(serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum InstallSide { + Client, + Server, +} + +impl InstallSide { + + pub fn as_str(self) -> &'static str { + match self { + InstallSide::Client => "client", + InstallSide::Server => "server", + } + } + +} diff --git a/rust/portablemc/src/http.rs b/rust/portablemc/src/http.rs new file mode 100644 index 00000000..f3543fb4 --- /dev/null +++ b/rust/portablemc/src/http.rs @@ -0,0 +1,23 @@ +//! This module provides various HTTP(S) request utilities, everything is based on +//! async reqwest with tokio. + +use once_cell::sync::OnceCell; +use reqwest::{Client, ClientBuilder}; + + +/// The user agent to be used on each HTTP request. +pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +/// Get a new client builder for async HTTP(S) requests. +pub fn builder() -> ClientBuilder { + Client::builder().user_agent(USER_AGENT) +} + +/// Return the singleton instance for the HTTP client to be used internally by PMC. +pub fn client() -> reqwest::Result { + static INSTANCE: OnceCell = OnceCell::new(); + let inst = INSTANCE.get_or_try_init(|| { + builder().build() + })?; + Ok(inst.clone()) +} diff --git a/rust/portablemc/src/lib.rs b/rust/portablemc/src/lib.rs new file mode 100644 index 00000000..efe7480c --- /dev/null +++ b/rust/portablemc/src/lib.rs @@ -0,0 +1,107 @@ +//! PortableMC is a library and CLI for programmatically launching Minecraft. +#![deny(unsafe_op_in_unsafe_fn)] + +mod path; +mod http; +mod tokio; +mod serde; + +pub mod download; + +pub mod maven; + +pub mod msa; + +pub mod base; +pub mod mojang; +pub mod fabric; +pub mod forge; + + +/// Internal module used for sealing traits and their methods with a sealed token. +#[allow(unused)] +mod sealed { + + /// Internal sealed trait that be extended from by traits to be sealed. + pub trait Sealed { } + + /// A token type that can be added as a parameter on a function on a non-sealed trait + /// to make this particular function sealed and not callable nor implementable by + /// external crates. + pub struct Token; + +} + + +/// This macro help defining an event handler trait, this macro automatically implements +/// the trait for any `&mut impl Self`, every function has a default empty body so that +/// any addition of method is backward compatible and valid for minor version increment. +macro_rules! trait_event_handler { + ( + $( #[ $meta:meta ] )* + $vis:vis trait $name:ident $( : $( $super:path ),+ $(,)? )? { + $( + $( #[ $func_meta:meta ] )* + fn $func:ident ( $( $arg:ident : $arg_ty:ty ),* $(,)? ) + $( -> $ret_ty:ty = $ret_default:expr )?; + )* + } + ) => { + + $( #[ $meta ] )* + $vis trait $name $( : $( $super ),+ )? { + + /// This special handler function can be used to provide a fallback for every + /// function that is not implemented by the implementor. + /// + /// This function is exposed in the public API but it's unsure if it will be + /// implemented as-is in the future, so it cannot be implemented nor called + /// by external crates thanks to a "sealed" token type. + #[doc(hidden)] + #[inline(always)] + fn __internal_fallback(&mut self, _token: $crate::sealed::Token) -> Option<&mut dyn $name> { + None + } + + $( + $( #[ $func_meta ] )* + fn $func ( &mut self $( , $arg : $arg_ty )* ) $( -> $ret_ty )? { + // We expect the fallback call to be inlined every time because the + // default functions are statically known, and for the dynamic + // dispatch implementation with '&mut dyn H' (below) all functions + // are defined to just forward the call, so the fallback function is + // never used. + if let Some(fallback) = $name::__internal_fallback(self, $crate::sealed::Token) { + $name::$func( fallback $(, $arg)* ) + } else { + $( $ret_default )? + } + } + )* + + } + + impl $name for () { } + + impl $name for &'_ mut H { + $( + fn $func ( &mut self $( , $arg : $arg_ty )* ) $( -> $ret_ty )? { + $name::$func( &mut **self $(, $arg)* ) + } + )* + } + + // Implementation for tuples, calling both handlers each time. + impl $name for (H0, H1) { + $( + fn $func ( &mut self $( , $arg : $arg_ty )* ) $( -> $ret_ty )? { + $name::$func( &mut self.0 $(, $arg)* ); + $name::$func( &mut self.1 $(, $arg)* ) // We only keep last value. + } + )* + } + + }; +} + +pub(crate) use trait_event_handler; diff --git a/rust/portablemc/src/maven.rs b/rust/portablemc/src/maven.rs new file mode 100644 index 00000000..8f3c6f4f --- /dev/null +++ b/rust/portablemc/src/maven.rs @@ -0,0 +1,585 @@ +//! Maven related utilities, such as GAV and 'maven-metadata.xml' parsing. + +use std::path::{Path, PathBuf}; +use std::iter::FusedIterator; +use std::num::NonZeroU16; +use std::str::FromStr; +use std::borrow::Cow; +use std::ops::Range; +use std::fmt; + + +/// A maven-style library specifier, known as GAV, for Group, Artifact, Version, but it +/// also contains an optional classifier and extension for the pointed file. The memory +/// footprint of this structure is optimized to contain only one string, its format is the +/// the following: `group:artifact:version[:classifier][@extension]`. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Gav { + /// Internal buffer. + raw: String, + /// Length of the group part in the specifier. + group_len: NonZeroU16, + /// Length of the artifact part in the specifier. + artifact_len: NonZeroU16, + /// Length of the version part in the specifier. + version_len: NonZeroU16, + /// Length of the classifier part in the specifier, if relevant. + classifier_len: Option, + /// Length of the extension part in the specifier, if relevant. + extension_len: Option, +} + +impl Gav { + + /// Create a new library specifier with the given components. + /// Each component, if given, should not be empty. + pub fn new(group: &str, artifact: &str, version: &str, classifier: Option<&str>, extension: Option<&str>) -> Self { + + let mut raw = format!("{group}:{artifact}:{version}"); + + if let Some(classifier) = classifier { + raw.push(':'); + raw.push_str(classifier); + } + + if let Some(extension) = extension { + raw.push('@'); + raw.push_str(extension); + } + + Self { + raw, + group_len: NonZeroU16::new(group.len().try_into().expect("group too long")).expect("group empty"), + artifact_len: NonZeroU16::new(artifact.len().try_into().expect("artifact too long")).expect("artifact empty"), + version_len: NonZeroU16::new(version.len().try_into().expect("version too long")).expect("version empty"), + classifier_len: classifier.map(|classifier| NonZeroU16::new(classifier.len().try_into().expect("classifier too long")).expect("classifier empty")), + extension_len: extension.map(|extension| NonZeroU16::new(extension.len().try_into().expect("extension too long")).expect("extension empty")), + } + + } + + /// Internal method to parse + fn _from_str(raw: Cow) -> Option { + + // Early check that raw string is not longer than u16 max because we cast using + // 'as' and we don't want the cast to overflow, checking the size of the full + // string is a guarantee that any of its piece will be less than u16 max long. + if raw.len() > u16::MAX as usize { + return None; + } + + let mut split = raw.split('@'); + let raw0 = split.next()?; + let extension_len = match split.next() { + Some(s) => Some(NonZeroU16::new(s.len() as _)?), + None => None, + }; + + if split.next().is_some() { + return None; + } + + let mut split = raw0.split(':'); + let group_len = NonZeroU16::new(split.next()?.len() as _)?; + let artifact_len = NonZeroU16::new(split.next()?.len() as _)?; + let version_len = NonZeroU16::new(split.next()?.len() as _)?; + let classifier_len = match split.next() { + Some(s) => Some(NonZeroU16::new(s.len() as _)?), + None => None, + }; + + if split.next().is_some() { + return None; + } + + Some(Self { + raw: raw.into_owned(), + group_len, + artifact_len, + version_len, + classifier_len, + extension_len, + }) + + } + + #[inline] + fn group_range(&self) -> Range { + 0..self.group_len.get() as usize + } + + #[inline] + fn artifact_range(&self) -> Range { + let prev = self.group_range(); + prev.end + 1..prev.end + 1 + self.artifact_len.get() as usize + } + + #[inline] + fn version_range(&self) -> Range { + let prev = self.artifact_range(); + prev.end + 1..prev.end + 1 + self.version_len.get() as usize + } + + #[inline] + fn classifier_range(&self) -> Range { + let prev = self.version_range(); + match self.classifier_len { + Some(classifier_len) => prev.end + 1..prev.end + 1 + classifier_len.get() as usize, + None => prev.end..prev.end + } + } + + #[inline] + fn extension_range(&self) -> Range { + let prev = self.classifier_range(); + match self.extension_len { + Some(extension_len) => prev.end + 1..prev.end + 1 + extension_len.get() as usize, + None => prev.end..prev.end + } + } + + /// Return the group name of the library, never empty. + #[inline] + pub fn group(&self) -> &str { + &self.raw[self.group_range()] + } + + /// Change the group of the library, should not be empty. + pub fn set_group(&mut self, group: &str) { + let range = self.group_range(); + self.group_len = NonZeroU16::new(group.len().try_into().expect("group too long")).expect("group empty"); + self.raw.replace_range(range, group); + } + + /// Return the artifact name of the library, never empty. + #[inline] + pub fn artifact(&self) -> &str { + &self.raw[self.artifact_range()] + } + + /// Change the artifact of the library, should not be empty. + pub fn set_artifact(&mut self, artifact: &str) { + let range = self.artifact_range(); + self.artifact_len = NonZeroU16::new(artifact.len().try_into().expect("artifact too long")).expect("artifact empty"); + self.raw.replace_range(range, artifact); + } + + /// Return the version of the library, never empty. + #[inline] + pub fn version(&self) -> &str { + &self.raw[self.version_range()] + } + + /// Change the version of the library, should not be empty. + pub fn set_version(&mut self, version: &str) { + let range = self.version_range(); + self.version_len = NonZeroU16::new(version.len().try_into().expect("version too long")).expect("version empty"); + self.raw.replace_range(range, version); + } + + pub fn with_version(&self, version: &str) -> Self { + Self::new(self.group(), self.artifact(), version, self.classifier(), self.extension()) + } + + /// Return the classifier of the library, none if no classifier. + #[inline] + pub fn classifier(&self) -> Option<&str> { + let range = self.classifier_range(); + if range.is_empty() { + None + } else { + Some(&self.raw[self.classifier_range()]) + } + } + + /// Change the classifier of the library, should not be empty. + pub fn set_classifier(&mut self, classifier: Option<&str>) { + let range = self.classifier_range(); + if let Some(classifier) = classifier { + self.classifier_len = Some(NonZeroU16::new(classifier.len().try_into().expect("classifier too long")).expect("classifier empty")); + self.raw.replace_range(range.clone(), classifier); + if range.is_empty() { + self.raw.insert(range.start, ':'); + } + } else if !range.is_empty() { + self.classifier_len = None; + self.raw.replace_range(range.start - 1..range.end, ""); + } + } + + pub fn with_classifier(&self, classifier: Option<&str>) -> Self { + Self::new(self.group(), self.artifact(), self.version(), classifier, self.extension()) + } + + /// Return the extension of the library, none if the default extension should be used, + /// like "jar". + #[inline] + pub fn extension(&self) -> Option<&str> { + let range = self.extension_range(); + if range.is_empty() { + None + } else { + Some(&self.raw[range]) + } + } + + /// Return the extension of the library, "jar" if default extension should be used. + #[inline] + pub fn extension_or_default(&self) -> &str { + self.extension().unwrap_or("jar") + } + + /// Change the extension of the library, should not be empty. + pub fn set_extension(&mut self, extension: Option<&str>) { + let range = self.extension_range(); + if let Some(extension) = extension { + self.extension_len = Some(NonZeroU16::new(extension.len().try_into().expect("extension too long")).expect("extension empty")); + self.raw.replace_range(range.clone(), extension); + if range.is_empty() { + self.raw.insert(range.start, '@'); + } + } else if !range.is_empty() { + self.extension_len = None; + self.raw.replace_range(range.start - 1..range.end, ""); + } + } + + /// Get the representation of the GAV as a string. + #[inline] + pub fn as_str(&self) -> &str { + &self.raw + } + + /// Get a URL formatter for this GAV, this can be appended to any full URL. + /// + /// For example, + /// `net.minecraft:client:1.21.1` will transform into + /// `net/minecraft/client/1.21.1/client-1.21.1.jar`. + #[inline] + pub fn url(&self) -> GavUrl<'_> { + GavUrl(self) + } + + /// Create a file path of this GAV from a base directory. + /// + /// NOTE: Unsafe path joining if any component as a '..'! + pub fn file>(&self, dir: P) -> PathBuf { + + let mut buf = dir.as_ref().to_path_buf(); + for group_part in self.group().split('.') { + buf.push(group_part); + } + + let artifact = self.artifact(); + let version = self.version(); + buf.push(artifact); + buf.push(version); + + // Build the terminal file name. + buf.push(artifact); + buf.as_mut_os_string().push("-"); + buf.as_mut_os_string().push(version); + if let Some(classifier) = self.classifier() { + buf.as_mut_os_string().push("-"); + buf.as_mut_os_string().push(classifier); + } + buf.as_mut_os_string().push("."); + buf.as_mut_os_string().push(self.extension_or_default()); + + buf + + } + +} + +impl FromStr for Gav { + + type Err = (); + + fn from_str(s: &str) -> Result { + Self::_from_str(Cow::Borrowed(s)).ok_or(()) + } + +} + +impl fmt::Display for Gav { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Debug for Gav { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Gav").field(&self.raw).finish() + } +} + +impl<'de> serde::Deserialize<'de> for Gav { + + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + + type Value = Gav; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a string gav (group:artifact:version[:classifier][@extension])") + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + Gav::_from_str(Cow::Owned(v)) + .ok_or_else(|| E::custom("invalid string gav (group:artifact:version[:classifier][@extension])")) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Gav::_from_str(Cow::Borrowed(v)) + .ok_or_else(|| E::custom("invalid string gav (group:artifact:version[:classifier][@extension])")) + } + + } + + deserializer.deserialize_string(Visitor) + + } + +} + +impl serde::Serialize for Gav { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer + { + serializer.serialize_str(self.as_str()) + } +} + +/// URL formatter for a gav. +pub struct GavUrl<'a>(&'a Gav); + +impl fmt::Display for GavUrl<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + + for group_part in self.0.group().split('.') { + f.write_str(group_part)?; + f.write_str("/")?; + } + + let artifact = self.0.artifact(); + let version = self.0.version(); + + f.write_str(artifact)?; + f.write_str("/")?; + f.write_str(version)?; + f.write_str("/")?; + f.write_str(artifact)?; + f.write_str("-")?; + f.write_str(version)?; + + if let Some(classifier) = self.0.classifier() { + f.write_str("-")?; + f.write_str(classifier)?; + } + + f.write_str(".")?; + f.write_str(self.0.extension_or_default()) + + } +} + +/// A streaming parser for a 'maven-metadata.xml' file, this is an iterator that return +/// each versions. +#[derive(Debug)] +pub(crate) struct MetadataParser<'a> { + tokenizer: Option>, +} + +impl<'a> MetadataParser<'a> { + + pub fn new(buffer: &'a str) -> Self { + Self { + tokenizer: Some(xmlparser::Tokenizer::from(buffer)), + } + } + +} + +impl<'a> Iterator for MetadataParser<'a> { + + type Item = &'a str; + + fn next(&mut self) -> Option { + + use xmlparser::{Token, ElementEnd}; + + let tokenizer = self.tokenizer.as_mut()?; + let mut version = false; + + while let Ok(token) = tokenizer.next()? { + + match token { + Token::ElementStart { prefix, local, .. } => { + if prefix.is_empty() && local == "version" { + if !version { + version = true; + } else { + break; // return none + } + } else if version { + break; // return none + } + } + Token::ElementEnd { end: ElementEnd::Close(prefix, local), .. } => { + if version { + if prefix.is_empty() && local == "version" { + version = false; + } else { + break; // return none + } + } + } + Token::ElementEnd { end: ElementEnd::Empty, .. } => { + if version { + break; // return none + } + } + Token::Text { text } => { + if version { + return Some(text.as_str()); + } + } + _ => continue, + } + + } + + // Tokenizer doesn't implement FusedIterator yet so we nullify if none's returned. + // If any error we nullify tokenizer so we always return none. + self.tokenizer = None; + None + + } + +} + +/// Valid to implement because we return none forever after any error. +impl FusedIterator for MetadataParser<'_> { } + + +#[cfg(test)] +mod tests { + + use std::str::FromStr; + use super::Gav; + + #[test] + #[should_panic] + fn empty_group() { + Gav::new("", "baz", "0.1.2-beta", None, None); + } + + #[test] + #[should_panic] + fn empty_artifact() { + Gav::new("foo.bar", "", "0.1.2-beta", None, None); + } + + #[test] + #[should_panic] + fn empty_version() { + Gav::new("foo.bar", "baz", "", None, None); + } + + #[test] + #[should_panic] + fn empty_classifier() { + Gav::new("foo.bar", "baz", "0.1.2-beta", Some(""), None); + } + + #[test] + #[should_panic] + fn empty_extension() { + Gav::new("foo.bar", "baz", "0.1.2-beta", None, Some("")); + } + + #[test] + fn as_str_correct() { + assert_eq!(Gav::new("foo.bar", "baz", "0.1.2-beta", None, None).as_str(), "foo.bar:baz:0.1.2-beta"); + assert_eq!(Gav::new("foo.bar", "baz", "0.1.2-beta", Some("natives"), None).as_str(), "foo.bar:baz:0.1.2-beta:natives"); + assert_eq!(Gav::new("foo.bar", "baz", "0.1.2-beta", None, Some("jar")).as_str(), "foo.bar:baz:0.1.2-beta@jar"); + assert_eq!(Gav::new("foo.bar", "baz", "0.1.2-beta", Some("natives"), Some("jar")).as_str(), "foo.bar:baz:0.1.2-beta:natives@jar"); + } + + #[test] + fn from_str_correct() { + + const WRONG_CASES: &'static [&'static str] = &[ + "", ":", "::", + "foo.bar::", ":baz:", "::0.1.2-beta", + "foo.bar:baz:", "foo.bar::0.1.2-beta", ":baz:0.1.2-beta", + "foo.bar:baz:0.1.2-beta:", + "foo.bar:baz:0.1.2-beta@", + ]; + + for case in WRONG_CASES { + assert_eq!(Gav::from_str(case), Err(())); + } + + let gav = Gav::from_str("foo.bar:baz:0.1.2-beta").unwrap(); + assert_eq!(gav.group(), "foo.bar"); + assert_eq!(gav.artifact(), "baz"); + assert_eq!(gav.version(), "0.1.2-beta"); + assert_eq!(gav.classifier(), None); + assert_eq!(gav.extension(), None); + + let gav = Gav::from_str("foo.bar:baz:0.1.2-beta:natives@txt").unwrap(); + assert_eq!(gav.group(), "foo.bar"); + assert_eq!(gav.artifact(), "baz"); + assert_eq!(gav.version(), "0.1.2-beta"); + assert_eq!(gav.classifier(), Some("natives")); + assert_eq!(gav.extension(), Some("txt")); + + } + + #[test] + fn modify() { + + let mut gav = Gav::from_str("foo.bar:baz:0.1.2-beta").unwrap(); + + gav.set_group("foo.bar.00"); + assert_eq!(gav.as_str(), "foo.bar.00:baz:0.1.2-beta"); + gav.set_group("foo.bar"); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.2-beta"); + + gav.set_artifact("baz00"); + assert_eq!(gav.as_str(), "foo.bar:baz00:0.1.2-beta"); + gav.set_artifact("baz"); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.2-beta"); + + gav.set_version("0.1.3-alpha"); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.3-alpha"); + gav.set_version("0.1.2-beta"); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.2-beta"); + + gav.set_classifier(Some("natives")); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.2-beta:natives"); + gav.set_classifier(None); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.2-beta"); + + gav.set_extension(Some("txt")); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.2-beta@txt"); + gav.set_extension(None); + assert_eq!(gav.as_str(), "foo.bar:baz:0.1.2-beta"); + + } + +} diff --git a/rust/portablemc/src/mojang/mod.rs b/rust/portablemc/src/mojang/mod.rs new file mode 100644 index 00000000..5acd4201 --- /dev/null +++ b/rust/portablemc/src/mojang/mod.rs @@ -0,0 +1,1121 @@ +//! Extension to the base installer with verification and installation of missing +//! Mojang versions, it also provides support for common arguments and fixes on legacy +//! versions. + +pub(crate) mod serde; + +use std::io::{Write as _, BufReader}; +use std::path::{Path, PathBuf}; +use std::collections::HashSet; +use std::env; +use std::fs; + +use chrono::{DateTime, FixedOffset}; +use regex::Regex; +use uuid::Uuid; + +use crate::base::{self, + check_file_advanced, Game, LibraryDownload, LoadedLibrary, VersionChannel, LIBRARIES_URL}; +use crate::maven::Gav; +use crate::download; +use crate::msa; + + +/// Static URL to the version manifest provided by Mojang. +pub(crate) const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + +/// An installer for supporting Mojang-provided versions. It provides support for various +/// standard arguments such as demo mode, window resolution and quick play, it also +/// provides various fixes for known issues of old versions. +/// +/// By default, this installer tries to handle every version that is not found in the +/// hierarchy, if a version with that name exists in the Mojang's versions manifest, then +/// it is fetched. This means that every missing version will try to fetch the manifest +/// (using the cached version if relevant). This behavior can be changed by excluding +/// +/// Notes about various versions: +/// - 1.19.3 metadata adds no parameter to specify extract directory for LWJGL (version +/// 3.3.1-build-7), therefore natives are extracted to +/// '/tmp/lwjgl<username>/<version>'. +#[derive(Debug, Clone)] +pub struct Installer { + /// The underlying base installer logic. + base: base::Installer, + /// Inner installer data, put in a sub struct to fix borrow issue. + inner: InstallerInner, +} + +/// Internal installer data. +#[derive(Debug, Clone)] +struct InstallerInner { + version: Version, + fetch_excludes: Vec, + demo: bool, + quick_play: Option, + resolution: Option<(u16, u16)>, + disable_multiplayer: bool, + disable_chat: bool, + auth_type: String, // Empty to trigger default auth. + auth_uuid: Uuid, + auth_username: String, + auth_token: String, + auth_xuid: String, // Apparently used for Minecraft Telemetry + client_id: String, // Apparently used for Minecraft Telemetry + fix_legacy_quick_play: bool, + fix_legacy_proxy: bool, + fix_legacy_merge_sort: bool, + fix_legacy_resolution: bool, + fix_broken_authlib: bool, + fix_lwjgl: Option, +} + +impl Installer { + + /// Create a new installer with default configuration, using defaults directories. + /// + /// This Mojang installer has all fixes enabled except LWJGL and missing version + /// fetching is enabled. + pub fn new(version: impl Into) -> Self { + Self { + base: base::Installer::new(String::new()), + inner: InstallerInner { + version: version.into(), + fetch_excludes: Vec::new(), // No exclude by default. + demo: false, + quick_play: None, + resolution: None, + disable_multiplayer: false, + disable_chat: false, + auth_type: String::new(), + auth_uuid: Uuid::nil(), + auth_username: String::new(), + auth_token: String::new(), + auth_xuid: String::new(), + client_id: String::new(), + fix_legacy_quick_play: true, + fix_legacy_proxy: true, + fix_legacy_merge_sort: true, + fix_legacy_resolution: true, + fix_broken_authlib: true, + fix_lwjgl: None, + } + } + } + + /// Same as [`Self::new`] but targets latest release version by default. + pub fn new_with_release() -> Self { + Self::new(Version::Release) + } + + /// Get the underlying base installer. + #[inline] + pub fn base(&self) -> &base::Installer { + &self.base + } + + /// Get the underlying base installer through mutable reference. + /// + /// *Note that the `version` property will be overwritten when installing.* + #[inline] + pub fn base_mut(&mut self) -> &mut base::Installer { + &mut self.base + } + + /// Get the mojang version to install. + #[inline] + pub fn version(&self) -> &Version { + &self.inner.version + } + + /// Set the mojang version to install. + #[inline] + pub fn set_version(&mut self, version: impl Into) -> &mut Self { + self.inner.version = version.into(); + self + } + + /// Return the list of filters + #[inline] + pub fn fetch_excludes(&self) -> &[FetchExclude] { + &self.inner.fetch_excludes + } + + /// Clear all fetch exclude filters. See [`Self::fetch_excludes`] and + /// [`Self::add_fetch_exclude`]. **This is the default state when constructed.** + pub fn clear_fetch_exclude(&mut self) -> &mut Self { + self.inner.fetch_excludes.clear(); + self + } + + /// Append the given filter to the fetch exclude list. + pub fn add_fetch_exclude(&mut self, exclude: FetchExclude) -> &mut Self { + self.inner.fetch_excludes.push(exclude); + self + } + + /// Get if the demo mode is enabled for the game. + #[inline] + pub fn demo(&self) -> bool { + self.inner.demo + } + + /// Set if the demo mode should be enabled for the game. + #[inline] + pub fn set_demo(&mut self, demo: bool) -> &mut Self { + self.inner.demo = demo; + self + } + + /// If enabled, get the Quick Play configuration when launching the game. + #[inline] + pub fn quick_play(&self) -> Option<&QuickPlay> { + self.inner.quick_play.as_ref() + } + + /// Enables Quick Play when launching the game, from 1.20 (23w14a). + #[inline] + pub fn set_quick_play(&mut self, quick_play: QuickPlay) -> &mut Self { + self.inner.quick_play = Some(quick_play); + self + } + + /// Remove Quick Play when launching, this is the default. + #[inline] + pub fn remove_quick_play(&mut self) -> &mut Self { + self.inner.quick_play = None; + self + } + + /// If enabled, get the initial game's window resolution. + #[inline] + pub fn resolution(&self) -> Option<(u16, u16)> { + self.inner.resolution + } + + /// Set an initial resolution for the game's window. + #[inline] + pub fn set_resolution(&mut self, width: u16, height: u16) -> &mut Self { + self.inner.resolution = Some((width, height)); + self + } + + /// Remove initial resolution for the game's window, this is the default. + #[inline] + pub fn remove_resolution(&mut self) -> &mut Self { + self.inner.resolution = None; + self + } + + /// Get if multiplayer should be disabled when launching the game. + #[inline] + pub fn disable_multiplayer(&self) -> bool { + self.inner.disable_multiplayer + } + + /// Disable or not the multiplayer when launching the game. + #[inline] + pub fn set_disable_multiplayer(&mut self, disable_multiplayer: bool) -> &mut Self { + self.inner.disable_multiplayer = disable_multiplayer; + self + } + + /// Get if the chat should be disabled when launching the game. + #[inline] + pub fn disable_chat(&self) -> bool { + self.inner.disable_chat + } + + /// Disable or not the chat when launching the game. + #[inline] + pub fn set_disable_chat(&mut self, disable_chat: bool) -> &mut Self { + self.inner.disable_chat = disable_chat; + self + } + + /// Get the currently configured authentication UUID, may be nil (zero-filled) if not + /// configured. + /// + /// Note that when installing, if this is nil then it will be defined using + /// [`Self::set_auth_offline_hostname`]. + #[inline] + pub fn auth_uuid(&self) -> Uuid { + self.inner.auth_uuid + } + + /// Get the currently configured authentication UUID, may be empty if not configured. + /// + /// Note that when installing, if this is nil then it will be defined using + /// [`Self::set_auth_offline_hostname`]. + #[inline] + pub fn auth_username(&self) -> &str { + &self.inner.auth_username + } + + /// Internal function to reset to zero-length all online-related auth variables. + fn reset_auth_online(&mut self) -> &mut Self { + self.inner.auth_type = String::new(); + self.inner.auth_token = String::new(); + self.inner.auth_xuid = String::new(); + self + } + + /// Use offline session with the given UUID and username, note that the username will + /// be truncated 16 bytes at most (this function will panic if the truncation is not + /// on a valid UTF-8 character boundary). + pub fn set_auth_offline(&mut self, uuid: Uuid, username: impl Into) -> &mut Self { + self.inner.auth_uuid = uuid; + self.inner.auth_username = username.into(); + self.inner.auth_username.truncate(16); + self.reset_auth_online() + } + + /// Use offline session with the given UUID, the username is derived from the first + /// 8 characters of the rendered UUID. + pub fn set_auth_offline_uuid(&mut self, uuid: Uuid) -> &mut Self { + self.inner.auth_uuid = uuid; + self.inner.auth_username = uuid.to_string(); + self.inner.auth_username.truncate(8); + self.reset_auth_online() + } + + /// Use offline session with the given username (initially truncated to 16 chars), + /// the UUID is then derived from this username using the same derivation used by + /// most Mojang clients (versions to be defined), this produces a MD5 (v3) UUID + /// with `OfflinePlayer:{username}` as the hashed string. + /// + /// The advantage of this method is to produce the same UUID as the one that will + /// be produced by Mojang's authlib when connecting to an offline-mode multiplayer + /// server. + pub fn set_auth_offline_username(&mut self, username: impl Into) -> &mut Self { + + self.inner.auth_username = username.into(); + self.inner.auth_username.truncate(16); + + let mut context = md5::Context::new(); + context.write_fmt(format_args!("OfflinePlayer:{}", self.inner.auth_username)).unwrap(); + + self.inner.auth_uuid = uuid::Builder::from_bytes(context.compute().0) + .with_variant(uuid::Variant::RFC4122) + .with_version(uuid::Version::Md5) + .into_uuid(); + + self.reset_auth_online() + + } + + /// Use offline session with the given username (initially truncated to 16 chars), + /// the UUID is then derived from this username using a PMC-specific derivation of + /// the username and the PMC namespace with SHA-1 (UUID v5). + /// + /// Note that the produced UUID will not be used when playing on multiplayer servers + /// (the server must also be in offline-mode), in this case the server gives you an + /// arbitrary UUID that is not the one your game has been launched with. Most servers + /// uses the UUID derivation embedded in Mojang's authlib, deriving the UUID from the + /// username, if you want the UUID to be coherent with this derivation, you can use + /// [`Self::set_auth_offline_username`] instead. + pub fn set_auth_offline_username_legacy(&mut self, username: impl Into) -> &mut Self { + self.inner.auth_username = username.into(); + self.inner.auth_username.truncate(16); + self.inner.auth_uuid = Uuid::new_v5(&base::UUID_NAMESPACE, self.inner.auth_username.as_bytes()); + self.reset_auth_online() + } + + /// Use offline session with a deterministic UUID, derived from this machine's + /// hostname, the username is then derived from the UUID following the same logic + /// as for [`Self::set_auth_offline_uuid`]. + /// + /// **This is the default UUID/username used if no auth is specified, so you don't + /// need to call this function, except if you want to override previous auth.** + pub fn set_auth_offline_hostname(&mut self) -> &mut Self { + self.set_auth_offline_uuid(Uuid::new_v5(&base::UUID_NAMESPACE, gethostname::gethostname().as_encoded_bytes())) + } + + /// Use online authentication with the given Microsoft Account. + pub fn set_auth_msa(&mut self, account: &msa::Account) -> &mut Self { + self.inner.auth_uuid = account.uuid(); + self.inner.auth_username = account.username().to_string(); + self.inner.auth_token = account.access_token().to_string(); + self.inner.auth_type = "msa".to_string(); + self.inner.auth_xuid = account.xuid().to_string(); + self + } + + /// Get the client ID used for telemetry of the game, the default client id is empty + /// and therefore the telemetry can't use it. + #[inline] + pub fn client_id(&self) -> &str { + &self.inner.client_id + } + + /// See [`Self::client_id`]. + #[inline] + pub fn set_client_id(&mut self, client_id: impl Into) -> &mut Self { + self.inner.client_id = client_id.into(); + self + } + + /// When starting versions older than 1.20 (23w14a) where Quick Play was not supported + /// by the client, this fix tries to use legacy arguments instead, such as --server + /// and --port, this is enabled by default. + #[inline] + pub fn fix_legacy_quick_play(&self) -> bool { + self.inner.fix_legacy_quick_play + } + + /// See [`Self::fix_legacy_quick_play`]. + #[inline] + pub fn set_fix_legacy_quick_play(&mut self, fix: bool) -> &mut Self { + self.inner.fix_legacy_quick_play = fix; + self + } + + /// When starting older alpha, beta and release up to 1.5, this allows legacy online + /// resources such as skins to be properly requested. The implementation is currently + /// using `betacraft.uk` proxies, this is enabled by default. + #[inline] + pub fn fix_legacy_proxy(&self) -> bool { + self.inner.fix_legacy_proxy + } + + /// See [`Self::fix_legacy_proxy`]. + #[inline] + pub fn set_fix_legacy_proxy(&mut self, fix: bool) -> &mut Self { + self.inner.fix_legacy_proxy = fix; + self + } + + /// When starting older alpha and beta versions, this adds a JVM argument to use the + /// legacy merge sort `java.util.Arrays.useLegacyMergeSort=true`, this is required on + /// some old versions to avoid crashes, this is enabled by default. + #[inline] + pub fn fix_legacy_merge_sort(&self) -> bool { + self.inner.fix_legacy_merge_sort + } + + /// See [`Self::fix_legacy_merge_sort`]. + #[inline] + pub fn set_fix_legacy_merge_sort(&mut self, fix: bool) -> &mut Self { + self.inner.fix_legacy_merge_sort = fix; + self + } + + /// When starting older versions that don't support modern resolution arguments, this + /// fix will add arguments to force resolution of the initial window, this is enabled + /// by default. + #[inline] + pub fn fix_legacy_resolution(&self) -> bool { + self.inner.fix_legacy_resolution + } + + /// See [`Self::fix_legacy_resolution`]. + #[inline] + pub fn set_fix_legacy_resolution(&mut self, fix: bool) -> &mut Self { + self.inner.fix_legacy_resolution = fix; + self + } + + /// Versions 1.16.4 and 1.16.5 uses authlib:2.1.28 which cause multiplayer button + /// (and probably in-game chat) to be disabled, this can be fixed by switching to + /// version 2.2.30 of authlib, this is enabled by default. + #[inline] + pub fn fix_broken_authlib(&self) -> bool { + self.inner.fix_broken_authlib + } + + /// See [`Self::fix_broken_authlib`]. + #[inline] + pub fn set_fix_broken_authlib(&mut self, fix: bool) -> &mut Self { + self.inner.fix_broken_authlib = fix; + self + } + + /// See [`Self::set_fix_lwjgl`]. + #[inline] + pub fn fix_lwjgl(&self) -> Option<&str> { + self.inner.fix_lwjgl.as_deref() + } + + /// Changing the version of LWJGL, this support versions greater or equal to 3.2.3, + /// and also provides ARM support when the LWJGL version supports it. It's not + /// guaranteed to work with every version of Minecraft, and downgrading LWJGL version + /// is not recommended. + /// + /// If the given version is less than 3.2.3 this will do nothing. + #[inline] + pub fn set_fix_lwjgl(&mut self, lwjgl_version: impl Into) -> &mut Self { + self.inner.fix_lwjgl = Some(lwjgl_version.into()); + self + } + + /// Don't fix LWJGL version, see [`Self::set_fix_lwjgl`]. + #[inline] + pub fn remove_fix_lwjgl(&mut self) -> &mut Self { + self.inner.fix_lwjgl = None; + self + } + + /// Install the given Mojang version from its identifier. This also supports alias + /// identifiers such as "release" and "snapshot" that will be resolved, note that + /// these identifiers are just those presents in the "latest" mapping of the + /// Mojang versions manifest. + /// + /// If the given version is not found in the manifest then it's silently ignored and + /// the version metadata must already exists. + #[inline] + pub fn install(&mut self, mut handler: impl Handler) -> Result { + self.install_dyn(&mut handler) + } + + #[inline(never)] + fn install_dyn(&mut self, handler: &mut dyn Handler) -> Result { + + // Apply default offline auth, derived from hostname. + if self.inner.auth_uuid.is_nil() || self.inner.auth_username.is_empty() { + self.set_auth_offline_hostname(); + } + + let Self { + ref mut base, + ref inner, + } = self; + + let manifest = match self.inner.version { + Version::Release | + Version::Snapshot => Some(Manifest::request(&mut *handler)?), + _ => None + }; + + let version = match &self.inner.version { + Version::Release => manifest.as_ref().unwrap().latest_release_name(), + Version::Snapshot => manifest.as_ref().unwrap().latest_snapshot_name(), + Version::Name(name) => name.as_str(), + }; + + base.set_version(version); + + // Let the handler find the "leaf" version. + let mut leaf_version = String::new(); + + // Scoping the temporary internal handler. + let mut game = { + + let mut handler = InternalHandler { + inner: &mut *handler, + installer: &inner, + error: Ok(()), + manifest, + leaf_version: &mut leaf_version, + }; + + // Same as above, we are giving a &mut dyn ref to avoid huge monomorphization. + let res = base.install(&mut handler); + handler.error?; + res? + + }; + + // Apply auth parameters. + game.replace_args(|arg| { + Some(match arg { + "auth_player_name" => inner.auth_username.clone(), + "auth_uuid" => inner.auth_uuid.as_simple().to_string(), + "auth_access_token" => inner.auth_token.clone(), + "auth_xuid" => inner.auth_xuid.clone(), + // Legacy parameter + "auth_session" if !inner.auth_token.is_empty() => + format!("token:{}:{}", inner.auth_token, inner.auth_uuid.as_simple()), + "auth_session" => String::new(), + "user_type" => inner.auth_type.clone(), + "user_properties" => format!("{{}}"), + "clientid" => inner.client_id.clone(), + _ => return None + }) + }); + + // If Quick Play is enabled, we know that the feature has been enabled by the + // handler, and if the feature is actually present (1.20 and after), if not + // present we can try to use legacy arguments for supported quick play types. + if let Some(quick_play) = &inner.quick_play { + + let quick_play_arg = match quick_play { + QuickPlay::Path { .. } => "quickPlayPath", + QuickPlay::Singleplayer { .. } => "quickPlaySingleplayer", + QuickPlay::Multiplayer { .. } => "quickPlayMultiplayer", + QuickPlay::Realms { .. } => "quickPlayRealms", + }; + + let mut quick_play_supported = false; + game.replace_args(|arg| { + if arg == quick_play_arg { + quick_play_supported = true; + Some(match quick_play { + QuickPlay::Path { path } => path.display().to_string(), + QuickPlay::Singleplayer { name } => name.clone(), + QuickPlay::Multiplayer { host, port } => format!("{host}:{port}"), + QuickPlay::Realms { id } => id.clone(), + }) + } else { + None + } + }); + + if !quick_play_supported && inner.fix_legacy_quick_play { + if let QuickPlay::Multiplayer { host, port } = quick_play { + + game.game_args.extend([ + "--server".to_string(), host.clone(), + "--port".to_string(), port.to_string(), + ]); + + quick_play_supported = true; + handler.fixed_legacy_quick_play(); + + } + } + + if !quick_play_supported { + handler.warn_unsupported_quick_play(); + } + + } + + if inner.fix_legacy_proxy { + + // Checking as bytes because it's ASCII and we simply matching. + let proxy_port = match leaf_version.as_bytes() { + [b'1', b'.', b'0' | b'1' | b'3' | b'4' | b'5'] | + [b'1', b'.', b'2' | b'3' | b'4' | b'5', b'.', ..] | + b"13w16a" | b"13w16b" => Some(11707), + id if id.starts_with(b"a1.0.") => Some(80), + id if id.starts_with(b"a1.1.") => Some(11702), + id if id.starts_with(b"a1.") => Some(11705), + id if id.starts_with(b"b1.") => Some(11705), + _ => None, + }; + + if let Some(proxy_port) = proxy_port { + game.jvm_args.push(format!("-Dhttp.proxyHost=betacraft.uk")); + game.jvm_args.push(format!("-Dhttp.proxyPort={proxy_port}")); + handler.fixed_legacy_proxy("betacraft.uk", proxy_port); + } + + } + + if inner.fix_legacy_merge_sort && (leaf_version.starts_with("a1.") || leaf_version.starts_with("b1.")) { + game.jvm_args.push("-Djava.util.Arrays.useLegacyMergeSort=true".to_string()); + handler.fixed_legacy_merge_sort(); + } + + if let Some((width, height)) = inner.resolution { + + let mut resolution_supported = false; + game.replace_args(|arg| { + let repl = match arg { + "resolution_width" => width.to_string(), + "resolution_height" => height.to_string(), + _ => return None + }; + resolution_supported = true; + Some(repl) + }); + + if !resolution_supported && inner.fix_legacy_resolution { + + game.game_args.extend([ + "--width".to_string(), width.to_string(), + "--height".to_string(), height.to_string(), + ]); + + resolution_supported = true; + handler.fixed_legacy_resolution(); + + } + + if !resolution_supported { + handler.warn_unsupported_resolution(); + } + + } + + if inner.disable_multiplayer { + game.game_args.push("--disableMultiplayer".to_string()); + } + + if inner.disable_chat { + game.game_args.push("--disableChat".to_string()); + } + + Ok(game) + + } + +} + +crate::trait_event_handler! { + /// Handler for events happening when installing. + pub trait Handler: base::Handler { + + /// When the given version is being loaded but the file has an invalid size, + /// SHA-1, or any other invalidating reason, it has been removed in order to + /// download an up-to-date version. + fn invalidated_version(version: &str); + /// The required version metadata is missing and so will be fetched. + fn fetch_version(version: &str); + /// The version has been fetched. + fn fetched_version(version: &str); + + /// Quick play has been fixed. + fn fixed_legacy_quick_play(); + /// Legacy proxy has been defined to fix legacy versions. + fn fixed_legacy_proxy(host: &str, port: u16); + /// Legacy merge sort has been fixed. + fn fixed_legacy_merge_sort(); + /// Legacy resolution arguments have been added. + fn fixed_legacy_resolution(); + /// Notification of a fix of authlib:2.1.28 has happened. + fn fixed_broken_authlib(); + + /// A quick play mode is requested by is not supported by this version, or the fix + /// has been disabled. This is just a warning. + fn warn_unsupported_quick_play(); + /// A specific initial window resolution has been requested but it's not supported + /// by the current version and the fix is disabled. This is just a warning. + fn warn_unsupported_resolution(); + + } +} + +/// The Mojang installer could not proceed to the installation of a version. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum Error { + /// Error from the base installer. + #[error("base: {0}")] + Base(#[source] base::Error), + /// The LWJGL fix is enabled with a version that is not supported, maybe because + /// it is too old (< 3.2.3) or because of your system not being supported. + #[error("lwjgl fix not found: {version}")] + LwjglFixNotFound { + version: String, + }, +} + +impl> From for Error { + fn from(value: T) -> Self { + Error::Base(value.into()) + } +} + +/// Type alias for a result with the Mojang error type. +pub type Result = std::result::Result; + +/// The version to install. +#[derive(Debug, Clone)] +pub enum Version { + /// Install the latest Mojang release. + Release, + /// Install the latest Mojang snapshot. + Snapshot, + /// Install this specific game version, if not a Mojang-provided version, it should + /// be already installed in the versions directory. + Name(String), +} + +/// An impl so that we can give string-like objects to the builder. +impl> From for Version { + fn from(value: T) -> Self { + Self::Name(value.into()) + } +} + +/// This represent the optional Quick Play mode when launching the game. This is usually +/// not supported on versions older than 1.20 (23w14a), however a fix exists for +/// supporting multiplayer Quick Play on older versions, other modes are unsupported. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuickPlay { + /// Launch the game and follow instruction for Quick Play in the given path, relative + /// to the working directory. + Path { + path: PathBuf, + }, + /// Launch the game and directly join the world given its name. + Singleplayer { + name: String, + }, + /// Launch the game and directly join the specified server address. + Multiplayer { + host: String, + port: u16, + }, + /// Launch the game and directly join the realm given its id. + Realms { + id: String, + }, +} + +/// The different kind of patterns for filtering which versions are fetched or not. +#[derive(Debug, Clone)] +pub enum FetchExclude { + /// Exclude all versions from being fetched from Mojang's manifest. It overrides + /// all other excludes. + All, + /// Exclude a specific version name. + Exact(String), + /// Exclude a version's name that matches the given regex. + Regex(Regex), +} + +/// A handle to the Mojang versions manifest. +#[derive(Debug)] +pub struct Manifest { + inner: Box, +} + +impl Manifest { + + /// Request the Mojang versions' manifest. It takes a download handler because this + /// it will download it in cache and reuse any previous one that is still valid. + pub fn request(handler: impl download::Handler) -> Result { + + let mut entry = download::single_cached(VERSION_MANIFEST_URL) + .set_keep_open() + .download(handler)?; + + let reader = BufReader::new(entry.take_handle().unwrap()); + let mut deserializer = serde_json::Deserializer::from_reader(reader); + let manifest = serde_path_to_error::deserialize::<_, Box>(&mut deserializer) + .map_err(|e| base::Error::new_json_file(e, entry.file()))?; + + Ok(Self { inner: manifest }) + + } + + /// Iterator over all versions in the manifest. + /// + /// This method currently returns an abstract iterator because the API is not + /// stabilized yet. + pub fn iter(&self) -> impl Iterator> + use<'_> { + self.inner.versions.iter() + .map(ManifestVersion) + } + + /// Return the latest release version name. + #[inline] + pub fn latest_release_name(&self) -> &str { + &self.inner.latest.release + } + + /// Return the latest snapshot version name. + #[inline] + pub fn latest_snapshot_name(&self) -> &str { + &self.inner.latest.release + } + + /// Find the index of a version given its name. + pub fn find_index_of_name(&self, name: &str) -> Option { + self.inner.versions.iter().position(|v| v.id == name) + } + + /// Get a version from its index within the manifest. + pub fn find_by_index(&self, index: usize) -> Option> { + self.inner.versions.get(index).map(ManifestVersion) + } + + /// Get a handle to a version information from its name. + pub fn find_by_name(&self, name: &str) -> Option> { + self.inner.versions.iter() + .find(|v| v.id == name) + .map(ManifestVersion) + } + +} + +/// A handle to a version in the Mojang versions manifest. +#[derive(Debug)] +pub struct ManifestVersion<'a>(&'a serde::MojangManifestVersion); + +impl<'a> ManifestVersion<'a> { + + /// The name of this version. + /// + /// See [`base::LoadedVersion::name`] for more information on the naming. + pub fn name(&self) -> &'a str { + &self.0.id + } + + /// The release channel of this version. + pub fn channel(&self) -> VersionChannel { + VersionChannel::from(self.0.r#type) + } + + /// The last update time for this version. + pub fn time(&self) -> &'a DateTime { + &self.0.time + } + + /// The release time for this version. + pub fn release_time(&self) -> &'a DateTime { + &self.0.release_time + } + + /// Return the download URL to this version metadata. + pub fn url(&self) -> &'a str { + &self.0.download.url + } + + /// Return the expected size of this version metadata, if any. + pub fn size(&self) -> Option { + self.0.download.size + } + + /// Return the expected SHA-1 of this version metadata, if any. + pub fn sha1(&self) -> Option<&'a [u8; 20]> { + self.0.download.sha1.as_deref() + } + +} + +// ========================== // +// Following code is internal // +// ========================== // + +/// Internal handler given to the standard installer. +struct InternalHandler<'a> { + /// Inner handler. + inner: &'a mut dyn Handler, + /// Back-reference to the installer to know its configuration. + installer: &'a InstallerInner, + /// If there is an error in the handler. + error: Result<()>, + /// If fetching is enabled, then this contains the manifest to use. + manifest: Option, + /// Id of the "leaf" version, the last version without inherited version. + leaf_version: &'a mut String, +} + +impl download::Handler for InternalHandler<'_> { + + fn __internal_fallback(&mut self, _token: crate::sealed::Token) -> Option<&mut dyn download::Handler> { + Some(&mut self.inner) + } + +} + +impl base::Handler for InternalHandler<'_> { + + fn __internal_fallback(&mut self, _token: crate::sealed::Token) -> Option<&mut dyn base::Handler> { + Some(&mut self.inner) + } + + fn filter_features(&mut self, features: &mut HashSet) { + + if self.installer.demo { + features.insert("is_demo_user".to_string()); + } + + if self.installer.resolution.is_some() { + features.insert("has_custom_resolution".to_string()); + } + + if let Some(quick_play) = &self.installer.quick_play { + features.insert(match quick_play { + QuickPlay::Path { .. } => "has_quick_plays_support", + QuickPlay::Singleplayer { .. } => "is_quick_play_singleplayer", + QuickPlay::Multiplayer { .. } => "is_quick_play_multiplayer", + QuickPlay::Realms { .. } => "is_quick_play_realms", + }.to_string()); + } + + } + + fn loaded_hierarchy(&mut self, hierarchy: &[base::LoadedVersion]) { + *self.leaf_version = hierarchy.last().unwrap().name().to_string(); + self.inner.loaded_hierarchy(hierarchy); + } + + fn load_version(&mut self, version: &str, file: &Path) { + self.inner.load_version(version, file); + match self.inner_load_version(version, file) { + Ok(()) => (), + Err(e) => self.error = Err(e), + } + } + + fn need_version(&mut self, version: &str, file: &Path) -> bool { + match self.inner_need_version(version, file) { + Ok(true) => return true, + Ok(false) => (), + Err(e) => self.error = Err(e), + } + self.inner.need_version(version, file) + } + + fn filter_libraries(&mut self, libraries: &mut Vec) { + + if self.installer.fix_broken_authlib { + self.apply_fix_broken_authlib(&mut *libraries); + } + + if let Some(lwjgl_version) = self.installer.fix_lwjgl.as_deref() { + match self.apply_fix_lwjgl(&mut *libraries, lwjgl_version) { + Ok(()) => (), + Err(e) => self.error = Err(e), + } + } + + } + +} + +impl InternalHandler<'_> { + + fn inner_load_version(&mut self, version: &str, file: &Path) -> Result<()> { + + // If any pattern matches, return Ok. + for pattern in &self.installer.fetch_excludes { + match pattern { + FetchExclude::All => + return Ok(()), + FetchExclude::Exact(name) if name == version => + return Ok(()), + FetchExclude::Regex(regex) if regex.is_match(version) => + return Ok(()), + _ => (), + } + } + + // Only ensure that the manifest is loaded after checking fetch exclude. + let manifest = match self.manifest { + Some(ref manifest) => manifest, + None => self.manifest.insert(Manifest::request(&mut *self.inner)?) + }; + + // Unwrap because we checked the manifest in the condition. + let Some(version) = manifest.find_by_name(version) else { + return Ok(()); + }; + + if !check_file_advanced(file, version.size(), version.sha1(), true)? { + + fs::remove_file(file) + .map_err(|e| base::Error::new_io_file(e, file))?; + + self.inner.invalidated_version(version.name()); + + } + + Ok(()) + + } + + fn inner_need_version(&mut self, version: &str, file: &Path) -> Result { + + let Some(manifest) = self.manifest.as_ref() else { + return Ok(false); + }; + + let Some(version) = manifest.find_by_name(version) else { + return Ok(false); + }; + + self.inner.fetch_version(version.name()); + + download::single(version.url(), file) + .set_expected_size(version.size()) + .set_expected_sha1(version.sha1().copied()) + .download(&mut *self.inner)?; + + self.inner.fetched_version(version.name()); + + Ok(true) + + } + + fn apply_fix_broken_authlib(&mut self, libraries: &mut Vec) { + + let target_gav = Gav::new("com.mojang", "authlib", "2.1.28", None, None); + let pos = libraries.iter().position(|lib| lib.gav == target_gav); + + if let Some(pos) = pos { + + libraries[pos].path = None; // Ensure that the path is recomputed. + libraries[pos].gav.set_version("2.2.30"); + libraries[pos].download = Some(LibraryDownload { + url: format!("{LIBRARIES_URL}com/mojang/authlib/2.2.30/authlib-2.2.30.jar"), + size: Some(87497), + sha1: Some([0xd6, 0xe6, 0x77, 0x19, 0x9a, 0xa6, 0xb1, 0x9c, 0x4a, 0x9a, 0x2e, 0x72, 0x50, 0x34, 0x14, 0x9e, 0xb3, 0xe7, 0x46, 0xf8]), + }); + + self.inner.fixed_broken_authlib(); + + } + + } + + fn apply_fix_lwjgl(&mut self, libraries: &mut Vec, version: &str) -> Result<()> { + + if version != "3.2.3" && !version.starts_with("3.3.") { + return Err(Error::LwjglFixNotFound { + version: version.to_string(), + }); + } + + let classifier = match (env::consts::OS, env::consts::ARCH) { + ("windows", "x86") => "natives-windows-x86", + ("windows", "x86_64") => "natives-windows", + ("windows", "aarch64") if version != "3.2.3" => "natives-windows-arm64", + ("linux", "x86" | "x86_64") => "natives-linux", + ("linux", "arm") => "natives-linux-arm32", + ("linux", "aarch64") => "natives-linux-arm64", + ("macos", "x86_64") => "natives-macos", + ("macos", "aarch64") if version != "3.2.3" => "natives-macos-arm64", + _ => return Err(Error::LwjglFixNotFound { + version: version.to_string(), + }) + }; + + // Contains to-be-expected unique LWJGL libraries, without classifier. + let mut lwjgl_libs = Vec::new(); + + // Start by not retaining libraries with classifiers (natives). + libraries.retain_mut(|lib| { + if let ("org.lwjgl", "jar") = (lib.gav.group(), lib.gav.extension_or_default()) { + if lib.gav.classifier().is_none() { + lib.path = None; + lib.download = None; // Will be updated afterward. + lib.gav.set_version(version); + lwjgl_libs.push(lib.gav.clone()); + true + } else { + // Libraries with classifiers are not retained. + false + } + } else { + true + } + }); + + // Now we add the classifiers for each LWJGL lib. + libraries.extend(lwjgl_libs.into_iter().map(|mut gav| { + gav.set_classifier(Some(classifier)); + LoadedLibrary { + gav, + path: None, + download: None, // Will be set in the loop just after. + natives: false, + } + })); + + // Finally we update the download source. + for lib in libraries { + if let ("org.lwjgl", "jar") = (lib.gav.group(), lib.gav.extension_or_default()) { + let url = format!("https://repo1.maven.org/maven2/{}", lib.gav.url()); + lib.download = Some(LibraryDownload { url, size: None, sha1: None }); + } + } + + Ok(()) + + } + +} diff --git a/rust/portablemc/src/mojang/serde.rs b/rust/portablemc/src/mojang/serde.rs new file mode 100644 index 00000000..11d7ce36 --- /dev/null +++ b/rust/portablemc/src/mojang/serde.rs @@ -0,0 +1,34 @@ +//! JSON schemas structures for serde deserialization. + +use chrono::{DateTime, FixedOffset}; + +use crate::base; + + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MojangManifest { + /// A map associated the latest versions. + pub latest: MojangManifestLatest, + /// List of all versions. + pub versions: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct MojangManifestLatest { + pub release: String, + pub snapshot: String, +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MojangManifestVersion { + pub id: String, + pub r#type: base::serde::VersionType, + pub time: DateTime, + pub release_time: DateTime, + #[serde(flatten)] + pub download: base::serde::Download, + /// Unknown, used by official launcher. + pub compliance_level: Option, +} diff --git a/rust/portablemc/src/msa.rs b/rust/portablemc/src/msa.rs new file mode 100644 index 00000000..96bed346 --- /dev/null +++ b/rust/portablemc/src/msa.rs @@ -0,0 +1,915 @@ +//! Microsoft Account authentication for Minecraft accounts. + +use std::io::{self, BufReader, BufWriter, Read, Seek}; +use std::iter::FusedIterator; +use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::fmt::Debug; +use std::sync::Arc; +use std::fs::File; + +use reqwest::{Client, StatusCode}; +use serde_json::json; +use uuid::Uuid; + +use jsonwebtoken::{DecodingKey, TokenData, Validation}; + + +/// Microsoft Account authenticator. +/// +/// See . Shout out to wiki.vg which no +/// longer exists: +#[derive(Debug, Clone)] +pub struct Auth { + app_id: Arc, + language_code: Option, +} + +impl Auth { + + /// Create a new authenticator with the given application (client) id. + pub fn new(app_id: &str) -> Self { + Self { + app_id: Arc::from(app_id), + language_code: None, + } + } + + #[inline] + pub fn app_id(&self) -> &str { + &self.app_id + } + + #[inline] + pub fn language_code(&self) -> Option<&str> { + self.language_code.as_deref() + } + + /// Define a specific language code to use for localized messages. + /// + /// See + #[inline] + pub fn set_language_code(&mut self, code: impl Into) -> &mut Self { + self.language_code = Some(code.into()); + self + } + + /// Request a device code and if successful, returns the device code auth flow that + /// contains the user code and the verification URI for that, this flow should be + /// waited in order to get access to a minecraft authenticator that will ultimately + /// produce the desired username, UUID and its auth token(s). + /// + /// You can opt-in to also request the account's primary email via OpenID MSA scope. + pub fn request_device_code(&self) -> Result { + + crate::tokio::sync(async move { + + // We request the 'XboxLive.signin' and 'offline_access' scopes that are + // mandatory for the Minecraft authentication. + // We could also request email with "openid email" scopes. + let req = MsDeviceAuthRequest { + client_id: &self.app_id, + scope: "XboxLive.signin offline_access", + mkt: self.language_code.as_deref(), + }; + + let client = crate::http::builder().build() + .map_err(AuthError::new_reqwest)?; + + let res = client + .post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode") + .form(&req) + .send().await + .map_err(AuthError::new_reqwest)?; + + if res.status() != StatusCode::OK { + return Err(AuthError::InvalidStatus(res.status().as_u16())); + } + + let res = res + .json::().await + .map_err(AuthError::new_reqwest)?; + + Ok(DeviceCodeFlow { + client, + app_id: Arc::clone(&self.app_id), + res, + }) + + }) + + } + +} + +/// Microsoft Account device code flow authenticator. +#[derive(Debug, Clone)] +pub struct DeviceCodeFlow { + client: Client, + app_id: Arc, + res: MsDeviceAuthSuccess, +} + +impl DeviceCodeFlow { + + #[inline] + pub fn app_id(&self) -> &str { + &self.app_id + } + + #[inline] + pub fn user_code(&self) -> &str { + &self.res.user_code + } + + #[inline] + pub fn verification_uri(&self) -> &str { + &self.res.verification_uri + } + + #[inline] + pub fn message(&self) -> &str { + &self.res.message + } + + /// Wait for the user to authorize via the given user code and verification URI. + /// If successful the authentication continues and the account is authenticated, if + /// possible. + /// + /// After a successful answer, this flow object should not be used again! + pub fn wait(&self) -> Result { + + crate::tokio::sync(async move { + + let req = MsTokenRequest::DeviceCode { + client_id: &self.app_id, + device_code: &self.res.device_code, + }; + + let interval = Duration::from_secs(self.res.interval as u64); + + loop { + + tokio::time::sleep(interval).await; + match request_ms_token(&self.client, &req, "XboxLive.signin").await? { + Ok(res) => { + + let mut account = request_minecraft_account(&self.client, &res.access_token).await?; + account.app_id = self.app_id.to_string(); + account.refresh_token = res.refresh_token; + + break Ok(account); + + } + Err(res) => { + match res.error.as_str() { + "authorization_pending" => + continue, + "authorization_declined" => + break Err(AuthError::Declined), + "expired_token" => + break Err(AuthError::TimedOut), + "bad_verification_code" | _ => + break Err(AuthError::Unknown(res.error_description)), + } + } + } + + } + + }) + + } + +} + +/// An authenticated and validated Minecraft account. +#[derive(Debug, Clone)] +pub struct Account { + app_id: String, + refresh_token: String, + access_token: String, + uuid: Uuid, + username: String, + xuid: String, +} + +impl Account { + + /// The ID of the application that account was authorized for. + #[inline] + pub fn app_id(&self) -> &str { + &self.app_id + } + + /// The access token to give to Minecraft's AuthLib when starting the game. + #[inline] + pub fn access_token(&self) -> &str { + &self.access_token + } + + /// The player's UUID. + #[inline] + pub fn uuid(&self) -> Uuid { + self.uuid + } + + /// The player's username. + #[inline] + pub fn username(&self) -> &str { + &self.username + } + + /// The Xbox XUID. + #[inline] + pub fn xuid(&self) -> &str { + &self.xuid + } + + /// Make a request of this account's profile, this function take self by mutable + /// reference because it may update the username if it has been modified since last + /// request. If this function returns an error, it may be necessary to refresh the + /// account. + /// + /// It's not required to run that on newly authenticated or refreshed accounts. + pub fn request_profile(&mut self) -> Result<(), AuthError> { + + let client = crate::http::builder().build() + .map_err(AuthError::new_reqwest)?; + + let profile = crate::tokio::sync(request_minecraft_profile(&client, &self.access_token))?; + self.username = profile.name; + Ok(()) + + } + + /// Request a token refresh of this account, this will use the internal refresh token, + /// this will also update the username, uuid and access token. + pub fn request_refresh(&mut self) -> Result<(), AuthError> { + + crate::tokio::sync(async move { + + let client = crate::http::builder().build() + .map_err(AuthError::new_reqwest)?; + + let req = MsTokenRequest::RefreshToken { + client_id: &self.app_id, + scope: Some("XboxLive.signin offline_access"), + refresh_token: &self.refresh_token, + client_secret: None, + }; + + let res = match request_ms_token(&client, &req, "XboxLive.signin").await? { + Ok(res) => res, + Err(res) => { + return Err(AuthError::Unknown(res.error_description)); + } + }; + + let account = request_minecraft_account(&client, &res.access_token).await?; + self.refresh_token = res.refresh_token; + self.access_token = account.access_token; + self.uuid = account.uuid; + self.username = account.username; + + Ok(()) + + }) + + } + +} + +/// Request a Minecraft Account token from the given request. +async fn request_ms_token( + client: &Client, + req: &MsTokenRequest<'_>, + expected_scope: &str, +) -> Result, AuthError> { + + let res = client + .post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token") + .form(req) + .send().await + .map_err(AuthError::new_reqwest)?; + + match res.status() { + StatusCode::OK => { + + let res = res.json::().await + .map_err(AuthError::new_reqwest)?; + + if res.token_type != "Bearer" { + return Err(AuthError::Unknown(format!("Unexpected token type: {}", res.token_type))); + } else if res.scope != expected_scope { + return Err(AuthError::Unknown(format!("Unexpected scope: {}", res.scope))); + } + + Ok(Ok(res)) + + } + StatusCode::BAD_REQUEST => { + Ok(Err(res.json::().await.map_err(AuthError::new_reqwest)?)) + } + status => Err(AuthError::InvalidStatus(status.as_u16())), + } + +} + +/// Full procedure to gain access to a real Minecraft account from a given MSA token. +/// The returned account has no client id, no refresh token and no email. +async fn request_minecraft_account( + client: &Client, + ms_auth_token: &str, +) -> Result { + + // XBL authentication and authorization... + let user_res = request_xbl_user(&client, ms_auth_token).await?; + let xsts_res = request_xbl_xsts(&client, &user_res.token).await?; + + // Now checking coherency... + if user_res.display_claims.xui.is_empty() + || user_res.display_claims.xui != xsts_res.display_claims.xui { + return Err(AuthError::Unknown(format!("Invalid or incoherent display claims."))) + } + + let user_hash = xsts_res.display_claims.xui[0].uhs.as_str(); + let xsts_token = xsts_res.token.as_str(); + + // Minecraft with XBL... + let mc_res = request_minecraft_with_xbl(&client, user_hash, xsts_token).await?; + let mc_res_token = decode_jwt_without_validation::(&mc_res.access_token) + .map_err(AuthError::new_jwt)?; + // Minecraft profile... + let profile_res = request_minecraft_profile(&client, &mc_res.access_token).await?; + + Ok(Account { + app_id: String::new(), + refresh_token: String::new(), + access_token: mc_res.access_token, + uuid: profile_res.id, + username: profile_res.name, + xuid: mc_res_token.claims.xuid, + }) + +} + +async fn request_xbl_user( + client: &Client, + ms_auth_token: &str, +) -> Result { + + let req = json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": format!("d={ms_auth_token}"), + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + }); + + let res = client + .post("https://user.auth.xboxlive.com/user/authenticate") + .json(&req) + .send().await + .map_err(AuthError::new_reqwest)?; + + match res.status() { + StatusCode::OK => Ok(res.json::().await.map_err(AuthError::new_reqwest)?), + status => return Err(AuthError::InvalidStatus(status.as_u16())), + } + +} + +async fn request_xbl_xsts( + client: &Client, + xbl_user_token: &str, +) -> Result { + + let req = json!({ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [xbl_user_token] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + }); + + let res = client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .json(&req) + .send().await + .map_err(AuthError::new_reqwest)?; + + match res.status() { + StatusCode::OK => Ok(res.json::().await.map_err(AuthError::new_reqwest)?), + StatusCode::UNAUTHORIZED => { + let res = res.json::().await.map_err(AuthError::new_reqwest)?; + return Err(AuthError::Unknown(res.message)); + } + status => return Err(AuthError::InvalidStatus(status.as_u16())), + } + +} + +async fn request_minecraft_with_xbl( + client: &Client, + user_hash: &str, + xsts_token: &str, +) -> Result { + + let req = json!({ + "identityToken": format!("XBL3.0 x={user_hash};{xsts_token}"), + }); + + let res = client + .post("https://api.minecraftservices.com/authentication/login_with_xbox") + .json(&req) + .send().await + .map_err(AuthError::new_reqwest)?; + + let mc_res = match res.status() { + StatusCode::OK => res.json::().await.map_err(AuthError::new_reqwest)?, + status => return Err(AuthError::InvalidStatus(status.as_u16())), + }; + + if mc_res.token_type != "Bearer" { + return Err(AuthError::Unknown(format!("Unexpected token type: {}", mc_res.token_type))); + } + + Ok(mc_res) + +} + +async fn request_minecraft_profile( + client: &Client, + access_token: &str, +) -> Result { + + let res = client + .get("https://api.minecraftservices.com/minecraft/profile") + .bearer_auth(access_token) + .send().await + .map_err(AuthError::new_reqwest)?; + + match res.status() { + StatusCode::OK => Ok(res.json::().await.map_err(AuthError::new_reqwest)?), + StatusCode::FORBIDDEN => return Err(AuthError::Unknown(format!("Forbidden access to api.minecraftservices.com, likely because the application lacks approval from Mojang, see https://minecraft.wiki/w/Microsoft_authentication."))), + StatusCode::UNAUTHORIZED => return Err(AuthError::OutdatedToken), + StatusCode::NOT_FOUND => return Err(AuthError::DoesNotOwnGame), + status => return Err(AuthError::InvalidStatus(status.as_u16())), + } + +} + +fn decode_jwt_without_validation(token: &str) -> jsonwebtoken::errors::Result> +where + T: serde::de::DeserializeOwned, +{ + // We don't want to validate the token, just decode its data. + // See https://github.com/Keats/jsonwebtoken/issues/277. + let key = DecodingKey::from_secret(&[]); + let mut validation = Validation::default(); + validation.insecure_disable_signature_validation(); + validation.validate_aud = false; + jsonwebtoken::decode(token, &key, &validation) +} + +/// The error type containing one error for each failed entry in a download batch. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum AuthError { + /// Authorization declined by the user. + #[error("declined")] + Declined, + /// Time out of the authentication flow. + #[error("timed out")] + TimedOut, + /// When refreshing the Minecraft profile, this tells that the token is outdated, but + /// the caller can still try to refresh it. + #[error("outdated token")] + OutdatedToken, + #[error("does not own the game")] + DoesNotOwnGame, + /// An unknown HTTP status has been received. + #[error("invalid status: {0}")] + InvalidStatus(u16), + /// An unknown, unhandled error happened. + #[error("unknown: {0}")] + Unknown(String), + /// A generic error type for internal and third-party errors that may change depending + /// on the actual implementation. + /// + /// The current implementation yields the following error types: + /// + /// - [`reqwest::Error`] for any error related to HTTP requests. + /// + /// - [`jsonwebtoken::errors::Error`] for any error related to decoding JWTs. + #[error("internal: {0}")] + Internal(#[source] Box), +} + +impl AuthError { + + #[inline] + fn new_reqwest(e: reqwest::Error) -> Self { + Self::Internal(Box::new(e)) + } + + #[inline] + fn new_jwt(e: jsonwebtoken::errors::Error) -> Self { + Self::Internal(Box::new(e)) + } + +} + +/// (URL encoded) +#[derive(Debug, Clone, serde::Serialize)] +struct MsDeviceAuthRequest<'a> { + client_id: &'a str, + scope: &'a str, + mkt: Option<&'a str>, +} + +/// (JSON) +#[derive(Debug, Clone, serde::Deserialize)] +struct MsDeviceAuthSuccess { + device_code: String, + user_code: String, + verification_uri: String, + #[allow(unused)] + expires_in: u32, + interval: u32, + message: String, +} + +/// (URL encoded) +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "grant_type")] +enum MsTokenRequest<'a> { + #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")] + DeviceCode { + client_id: &'a str, + device_code: &'a str, + }, + #[serde(rename = "refresh_token")] + RefreshToken { + client_id: &'a str, + scope: Option<&'a str>, + refresh_token: &'a str, + client_secret: Option<&'a str>, + }, +} + +/// (JSON) +#[derive(Debug, Clone, serde::Deserialize)] +struct MsTokenSuccess { + /// Always "Bearer" + token_type: String, + scope: String, + #[allow(unused)] + expires_in: u32, + access_token: String, + /// Issued if the original scope parameter included the openid scope + #[allow(unused)] + id_token: Option, + /// Issued if the original scope parameter included offline_access. + refresh_token: String, +} + +/// (JSON) Generic authentication error returned by the API. +#[derive(Debug, Clone, serde::Deserialize)] +struct MsAuthError { + error: String, + error_description: String, + #[allow(unused)] + trace_id: String, + #[allow(unused)] + correlation_id: String, + #[allow(unused)] + error_uri: Option, +} + +/// (JSON) +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "PascalCase")] +struct XblSuccess { + display_claims: XblDisplayClaims, + #[allow(unused)] + issue_instant: String, + #[allow(unused)] + not_after: String, + token: String, +} + +/// (JSON) +#[derive(Debug, Clone, serde::Deserialize)] +struct XblDisplayClaims { + xui: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +struct XblXui { + uhs: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "PascalCase")] +#[allow(unused)] +struct XblError { + identity: String, + x_err: u32, + message: String, + redirect: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct MinecraftWithXblSuccess { + /// Some UUID, not the account's player UUID. + #[allow(unused)] + username: String, + /// The actual Minecraft access token to use to launch the game. + access_token: String, + token_type: String, + #[allow(unused)] + expires_in: u32, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct MinecraftProfileSuccess { + /// The real UUID of the Minecraft account. + #[serde(with = "uuid::serde::simple")] + id: Uuid, + /// The username of the Minecraft account. + name: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[allow(unused)] +struct OpenIdToken { + nonce: Option, + email: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct MinecraftToken { + xuid: String, +} + +/// A file-backed database for storing accounts. It allows storing and retrieving +/// accounts atomically (using shared read and exclusive write property of the underlying +/// filesystem). +#[derive(Debug)] +pub struct Database { + file: PathBuf, +} + +impl Database { + + /// Create a new database at the given location, the parent directory may not exists. + /// This will not actually load the database contents, but it will + pub fn new>(file: P) -> Self { + Self { + file: file.into(), + } + } + + /// Get the file path. + pub fn file(&self) -> &Path { + &self.file + } + + /// Internal function to load the database data. + fn load(&self) -> Result, DatabaseError> { + + let reader = match File::open(&self.file) { + Ok(reader) => reader, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + + let data = serde_json::from_reader::<_, DatabaseData>(BufReader::new(reader)) + .map_err(|e| DatabaseError::Corrupted.map_json_io(e))?; + + Ok(Some(data)) + + } + + /// Internal function to load the database data + fn load_and_store(&self, func: F) -> Result + where + F: for<'a> FnOnce(&'a mut DatabaseData, &'a mut bool) -> T, + { + + let mut rw = File::options() + .write(true) + .read(true) + .create(true) + .open(&self.file)?; + + let mut data; + + // If the file is empty, don't try to decode it but create a new empty database! + if rw.read(&mut [0; 1])? == 0 { + data = DatabaseData { + accounts: Vec::new(), + }; + } else { + + // Rewind to re-read it from start! + rw.rewind()?; + + data = serde_json::from_reader::<_, DatabaseData>(BufReader::new(&mut rw)) + .map_err(|e| DatabaseError::Corrupted.map_json_io(e))?; + + } + + let mut save = false; + let ret = func(&mut data, &mut save); + + if save { + + rw.rewind()?; + rw.set_len(0)?; + + serde_json::to_writer(BufWriter::new(rw), &data) + .map_err(|_| DatabaseError::WriteFailed)?; + + } + + Ok(ret) + + } + + /// Load every account in this database and return an iterator over all of them. + pub fn load_iter(&self) -> Result { + self.load().map(|data| { + DatabaseIter { + raw: data.map(|data| data.accounts) + .unwrap_or_default() + .into_iter(), + } + }) + } + + /// Load an account from its UUID. + pub fn load_from_uuid(&self, uuid: Uuid) -> Result, DatabaseError> { + self.load().map(|data| data.and_then(|data| { + data.accounts.into_iter() + .find(|acc| acc.uuid == uuid) + .map(Account::from) + })) + } + + /// Load an account from its username, because a username it not guaranteed to be + /// unique, in case of non-freshed sessions that keep old . + pub fn load_from_username(&self, username: &str) -> Result, DatabaseError> { + self.load().map(|data| data.and_then(|data| { + data.accounts.into_iter() + .find(|acc| acc.username == username) + .map(Account::from) + })) + } + + /// Remove the given account from its UUID, if existing, and save the database without + /// it. + /// + /// If the account doesn't exist, the database is not touch, only read. + pub fn remove_from_uuid(&self, uuid: Uuid) -> Result, DatabaseError> { + self.load_and_store(|data, save| { + let index = data.accounts.iter().position(|acc| acc.uuid == uuid)?; + *save = true; + Some(data.accounts.remove(index).into()) + }) + } + + /// Remove the given account from its username, if existing, and save the database + /// without it. Note that a username is not guaranteed to be unique, so only the first + /// matching account is removed. + /// + /// If the account doesn't exist, the database is not touch, only read. + pub fn remove_from_username(&self, username: &str) -> Result, DatabaseError> { + self.load_and_store(|data, save| { + let index = data.accounts.iter().position(|acc| acc.username == username)?; + *save = true; + Some(data.accounts.remove(index).into()) + }) + } + + /// Store the given account in this database, overwrite any previously stored account + /// with the same UUID. + pub fn store(&self, account: Account) -> Result<(), DatabaseError> { + self.load_and_store(|data, save| { + *save = true; + if let Some(index) = data.accounts.iter().position(|acc| acc.uuid == account.uuid) { + data.accounts[index] = account.into(); + } else { + data.accounts.push(account.into()); + } + }) + } + +} + +/// An iterator over all loader accounts in the database. +pub struct DatabaseIter { + raw: std::vec::IntoIter, +} + +impl FusedIterator for DatabaseIter { } +impl ExactSizeIterator for DatabaseIter { } +impl Iterator for DatabaseIter { + + type Item = Account; + + #[inline] + fn next(&mut self) -> Option { + self.raw.next().map(Account::from) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.raw.size_hint() + } + +} + +impl DoubleEndedIterator for DatabaseIter { + + #[inline] + fn next_back(&mut self) -> Option { + self.raw.next_back().map(Account::from) + } + +} + +/// The error type containing one error for each failed entry in a download batch. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum DatabaseError { + /// An underlying I/O error when opening the database file. + #[error("io: {0}")] + Io(#[from] io::Error), + /// The database is corrupted and nothing can be done about it automatically, you + /// can move the file to a backup location before retrying. + #[error("corrupted")] + Corrupted, + #[error("write failed")] + WriteFailed, +} + +impl DatabaseError { + + /// Internal function to map this error type and replace it by [`Self::Io`] whenever + /// the given serde error has an underlying I/O error. + fn map_json_io(self, value: serde_json::Error) -> Self { + if let Some(kind) = value.io_error_kind() { + Self::Io(kind.into()) + } else { + self + } + } + +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct DatabaseData { + accounts: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct DatabaseDataAccount { + app_id: String, + refresh_token: String, + access_token: String, + uuid: Uuid, + username: String, + xuid: String, +} + +impl From for Account { + fn from(value: DatabaseDataAccount) -> Self { + Self { + app_id: value.app_id, + refresh_token: value.refresh_token, + access_token: value.access_token, + uuid: value.uuid, + username: value.username, + xuid: value.xuid, + } + } +} + +impl From for DatabaseDataAccount { + fn from(value: Account) -> Self { + Self { + app_id: value.app_id, + refresh_token: value.refresh_token, + access_token: value.access_token, + uuid: value.uuid, + username: value.username, + xuid: value.xuid, + } + } +} diff --git a/rust/portablemc/src/path.rs b/rust/portablemc/src/path.rs new file mode 100644 index 00000000..33f04242 --- /dev/null +++ b/rust/portablemc/src/path.rs @@ -0,0 +1,80 @@ +//! Various uncategorized utilities. + +use std::path::{Path, PathBuf}; +use std::ffi::OsStr; + + +/// Extension to the standard [`Path`]. +pub trait PathExt { + + /// A shortcut method to join a file name with its extension to the current path. + /// This shortcut avoids a temporary allocation of a formatted string when joining. + fn join_with_extension, S: AsRef>(&self, name: P, extension: S) -> PathBuf; + + fn append>(&self, s: S) -> PathBuf; + +} + +impl PathExt for Path { + + #[inline] + fn join_with_extension, S: AsRef>(&self, name: P, extension: S) -> PathBuf { + self.join(name).appended(".").appended(extension) + } + + #[inline] + fn append>(&self, s: S) -> PathBuf { + self.to_path_buf().appended(s) + } + +} + + +/// Extension to the standard [`PathBuf`], mainly to ease joining and raw appending. In +/// this launcher we do a lot of path joining so we don't want to allocate each time. +pub trait PathBufExt { + + /// Return this path joined with another one, this is different from [`Path::join`] + /// in that is doesn't reallocate a new path on each join. + fn joined>(self, path: P) -> Self; + + /// Return this path appended with another string, this doesn't add any path separator. + fn appended>(self, s: S) -> Self; + +} + +impl PathBufExt for PathBuf { + + #[inline] + fn joined>(mut self, path: P) -> Self { + self.push(path); + self + } + + #[inline] + fn appended>(mut self, s: S) -> Self { + self.as_mut_os_string().push(s); + self + } + +} + + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn paths() { + + const SEP: &str = std::path::MAIN_SEPARATOR_STR; + + let path = Path::new("foo"); + assert_eq!(path.join_with_extension("bar", "json"), PathBuf::from(format!("foo{SEP}bar.json"))); + assert_eq!(path.append(SEP).appended("bar.json"), PathBuf::from(format!("foo{SEP}bar.json"))); + assert_eq!(path.join("bar").joined("baz"), PathBuf::from(format!("foo{SEP}bar{SEP}baz"))); + + } + +} diff --git a/rust/portablemc/src/serde.rs b/rust/portablemc/src/serde.rs new file mode 100644 index 00000000..2b13154c --- /dev/null +++ b/rust/portablemc/src/serde.rs @@ -0,0 +1,180 @@ +//! Common serde extensions and custom types. + +use std::ops::{Deref, DerefMut}; +use std::fmt::Write; + +use regex::Regex; + + +/// A regular expression serialized and deserialized to/from its string representation. +#[derive(Debug, Clone)] +pub struct RegexString(pub Regex); + +impl Deref for RegexString { + type Target = Regex; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for RegexString { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl serde::Serialize for RegexString { + + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer + { + serializer.serialize_str(self.0.as_str()) + } + +} + +impl<'de> serde::Deserialize<'de> for RegexString { + + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + + type Value = RegexString; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a string regex") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Regex::new(v) + .map(RegexString) + .map_err(|e| E::custom(e)) + } + + } + + deserializer.deserialize_str(Visitor) + + } + +} + + +/// A hexadecimal, lower case, formatted bytes string. +#[derive(Debug, Clone)] +pub struct HexString(pub [u8; N]); + +impl Deref for HexString { + type Target = [u8; N]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for HexString { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl serde::Serialize for HexString { + + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer + { + let mut buf = String::new(); + for b in self.0 { + write!(buf, "{b:02x}").unwrap(); + } + serializer.serialize_str(&buf) + } + +} + +impl<'de, const N: usize> serde::Deserialize<'de> for HexString { + + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + + struct Visitor; + impl<'de, const N: usize> serde::de::Visitor<'de> for Visitor { + + type Value = HexString; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a bytes string ({} hex characters)", N * 2) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + parse_hex_bytes::(v) + .map(HexString) + .ok_or_else(|| E::custom(format_args!("invalid bytes string ({} hex characters)", N * 2))) + } + + } + + deserializer.deserialize_str(Visitor) + + } + +} + +/// Parse the given hex bytes string into the given destination slice, returning none if +/// the input string cannot be parsed, is too short or too long. +pub fn parse_hex_bytes(mut string: &str) -> Option<[u8; LEN]> { + + let mut dst = [0; LEN]; + for dst in &mut dst { + if string.is_char_boundary(2) { + + let (num, rem) = string.split_at(2); + string = rem; + + *dst = u8::from_str_radix(num, 16).ok()?; + + } else { + return None; + } + } + + // Only successful if no string remains. + string.is_empty().then_some(dst) + +} + +pub fn deserialize_or_empty_seq<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, + T: Default, +{ + + use serde::Deserialize; + + #[derive(serde::Deserialize, Debug, Clone)] + #[serde(untagged)] + enum SomeOrSeq { + Some(T), + Seq([(); 0]), + } + + match SomeOrSeq::deserialize(deserializer)? { + SomeOrSeq::Some(val) => Ok(val), + SomeOrSeq::Seq([]) => Ok(T::default()), + } + +} diff --git a/rust/portablemc/src/tokio.rs b/rust/portablemc/src/tokio.rs new file mode 100644 index 00000000..52c33201 --- /dev/null +++ b/rust/portablemc/src/tokio.rs @@ -0,0 +1,17 @@ +//! Async utilities around Tokio runtime. + +use std::future::Future; + + +/// Block on the given future with the Tokio runtime with time and I/O enabled. +pub fn sync(future: F) -> F::Output { + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .enable_io() + .build() + .unwrap(); + + rt.block_on(future) + +} diff --git a/rust/portablemc/tests/event.rs b/rust/portablemc/tests/event.rs new file mode 100644 index 00000000..10a2565f --- /dev/null +++ b/rust/portablemc/tests/event.rs @@ -0,0 +1,307 @@ +//! Automated installation tests with verification of the events ordering for various +//! specific versions metadata. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::{env, fs, io}; + +use regex::Regex; + +use portablemc::base::{self, JvmPolicy, LoadedLibrary, LoadedVersion}; +use portablemc::download; +use portablemc::mojang; + + +macro_rules! def_checks { + ( $fn_name:ident, $( $rem:tt )* ) => { + #[test] + #[cfg_attr(miri, ignore)] + fn $fn_name () { + check( stringify!($fn_name) ); + } + def_checks!( $($rem)* ); + }; + () => {}; +} + +def_checks![ + recurse, + client_not_found, + libraries, +]; + +/// Common function to check a predefined version, placed in the "data" directory, and +/// the triggering order of its events. +fn check(version: &str) { + + let data_dir = { + let mut buf = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + buf.push("tests"); + buf.push("event"); + buf + }; + + let metadata_file = data_dir.join(format!("{version}.json")); + + let expected_log = { + match fs::read_to_string(data_dir.join(format!("{version}.{}.log", env::consts::OS))) { + Ok(log) => log, + Err(e) if e.kind() == io::ErrorKind::NotFound => + fs::read_to_string(data_dir.join(format!("{version}.log"))).unwrap(), + Err(e) => Err(e).unwrap(), + } + }; + let expected_logs = expected_log.lines().map(str::to_string).collect::>(); + drop(expected_log); + + fs::create_dir_all(env!("CARGO_TARGET_TMPDIR")).unwrap(); + let tmp_main_dir = tempfile::Builder::new() + .prefix("") + .suffix(".event") + .tempdir_in(env!("CARGO_TARGET_TMPDIR")) + .unwrap() + .into_path(); + + let tmp_versions_dir = tmp_main_dir.join("versions"); + let tmp_version_dir = tmp_versions_dir.join(version); + let tmp_metadata_file = tmp_version_dir.join(format!("{version}.json")); + + fs::create_dir_all(&tmp_version_dir).unwrap(); + fs::copy(&metadata_file, &tmp_metadata_file).unwrap(); + + // Now run the installer and store its actual logs... + let mut actual_logs = Vec::new(); + let mut inst = base::Installer::new(version); + inst.set_main_dir(tmp_main_dir.to_path_buf()); + inst.set_jvm_policy(JvmPolicy::Static(PathBuf::new())); + match inst.install(TestHandler { logs: &mut actual_logs }) { + Ok(_game) => {} + Err(base::Error::DownloadResourcesCancelled { }) => {} + Err(e) => { + actual_logs.push(format!("base::{e:?}")); + } + } + + assert_logs_eq(expected_logs, actual_logs, &tmp_main_dir); + + // Only remove it here so when the test did not panic. + fs::remove_dir_all(&tmp_main_dir).unwrap(); + +} + +/// Replace macro of the form `name!()` by giving the content to the closure +/// and replacing the whole macro by the returned content. +fn replace_macro(s: &mut String, name: &str, mut func: F) +where + F: FnMut(&str) -> String, +{ + + let open_pat = format!("${name}("); + let mut cursor = 0; + + while let Some(open_idx) = s[cursor..].find(&open_pat) { + + let open_idx = cursor + open_idx; + let Some(close_idx) = s[open_idx + open_pat.len()..].find(')') else { break }; + let close_idx = open_idx + open_pat.len() + close_idx + 1; + cursor = close_idx; + + let value = func(&s[open_idx + open_pat.len()..close_idx - 1]); + s.replace_range(open_idx..close_idx, &value); + + let repl_len = close_idx - open_idx; + let repl_diff = value.len() as isize - repl_len as isize; + cursor = cursor.checked_add_signed(repl_diff).unwrap(); + + } + +} + +/// Compare expected logs and actual logs, also checking for macros in expected string. +fn assert_logs_eq( + expected_logs: Vec, + actual_logs: Vec, + tmp_main_dir: &Path, +) { + + let mut expected_logs_it = expected_logs.into_iter().peekable(); + let mut actual_logs_it = actual_logs.into_iter().peekable(); + + // Check line by line. + let mut valid = true; + let mut regex_cache = None::; + + loop { + + let Some(expected_log) = expected_logs_it.peek_mut() else { + while let Some(actual_log) = actual_logs_it.next() { + eprintln!("== Expected less line"); + eprintln!("{actual_log}"); + valid = false; + } + break; + }; + + // Replace any unprocessed path macro. + replace_macro(&mut *expected_log, "os", |_| env::consts::OS.to_string()); + replace_macro(&mut *expected_log, "path", |path| { + let mut buf = tmp_main_dir.to_path_buf(); + buf.extend(path.split('/')); + format!("{buf:?}") + }); + + let Some(actual_log) = actual_logs_it.peek() else { + eprintln!("== Expected more lines"); + valid = false; + break; + }; + + let expected_log = &*expected_log; + let actual_log = &*actual_log; + + eprintln!("=="); + eprintln!(" {expected_log}"); + eprintln!(" {actual_log}"); + + if let Some(regex_str) = expected_log.strip_prefix("$ignore_many ") { + + let regex = match ®ex_cache { + Some(regex) if regex.as_str() == regex_str => regex, + _ => { + let regex = Regex::new(regex_str).expect("failed to compile regex for $ignore_many"); + regex_cache.insert(regex) + } + }; + + if regex.is_match(&actual_log) { + actual_logs_it.next(); + } else { + expected_logs_it.next(); + eprintln!("== Retrying..."); + } + + } else if let Some(regex_str) = expected_log.strip_prefix("$ignore_once ") { + + let regex = Regex::new(regex_str).expect("failed to compile regex for $ignore_once"); + + if regex.is_match(&actual_log) { + expected_logs_it.next(); + actual_logs_it.next(); + } else { + valid = false; + break; + } + + } else if expected_log != actual_log { + valid = false; + break; + } else { + expected_logs_it.next(); + actual_logs_it.next(); + } + + } + + if !valid { + panic!("Incoherent, read above!"); + } + +} + +/// The handler used to debug event when testing version installation. This handler stores +/// every method invocation as a debug string that can later be matched against an +/// expected trace. +#[derive(Debug)] +struct TestHandler<'a> { + logs: &'a mut Vec, +} + +macro_rules! impl_test_handler { + ( + $prefix:literal : + $( fn $func:ident ( $( $arg:ident : $arg_ty:ty ),* ) $( -> $ret_ty:ty = $ret_value:expr )?; )* + ) => { + $( + fn $func ( &mut self $(, $arg : $arg_ty )* ) $( -> $ret_ty )? { + self.logs.push(format!( + concat!($prefix, "::", stringify!($func), "(", $( "{", stringify!($arg), ":?}, ", )* ")") + $( , $arg = $arg )* + )); + $( $ret_value )? + } + )* + }; +} + +impl download::Handler for TestHandler<'_> { + impl_test_handler! { + "download": + fn progress(count: u32, total_count: u32, size: u32, total_size: u32); + } +} + +impl base::Handler for TestHandler<'_> { + + impl_test_handler! { + "base": + fn filter_features(features: &mut HashSet); + fn loaded_features(features: &HashSet); + fn load_hierarchy(root_version: &str); + fn loaded_hierarchy(hierarchy: &[LoadedVersion]); + fn load_version(version: &str, file: &Path); + fn need_version(version: &str, file: &Path) -> bool = false; + fn loaded_version(version: &str, file: &Path); + fn load_client(); + fn loaded_client(file: &Path); + fn load_libraries(); + fn filter_libraries(libraries: &mut Vec); + fn loaded_libraries(libraries: &[LoadedLibrary]); + fn filter_libraries_files(class_files: &mut Vec, natives_files: &mut Vec); + fn loaded_libraries_files(class_files: &[PathBuf], natives_files: &[PathBuf]); + fn no_logger(); + fn load_logger(id: &str); + fn loaded_logger(id: &str); + fn no_assets(); + fn load_assets(id: &str); + fn loaded_assets(id: &str, count: usize); + fn verified_assets(id: &str, count: usize); + fn load_jvm(major_version: u32); + fn found_jvm_system_version(file: &Path, version: &str, compatible: bool); + fn warn_jvm_unsupported_dynamic_crt(); + fn warn_jvm_unsupported_platform(); + fn warn_jvm_missing_distribution(); + fn loaded_jvm(file: &Path, version: Option<&str>, compatible: bool); + } + + fn download_resources(&mut self) -> bool { + // Just skip download resources. + false + } + + fn downloaded_resources(&mut self) { + // Ignore. + } + + fn extracted_binaries(&mut self, _dir: &Path) { + // Ignore. + } + +} + +impl mojang::Handler for TestHandler<'_> { + + impl_test_handler! { + "mojang": + fn invalidated_version(version: &str); + fn fetch_version(version: &str); + fn fetched_version(version: &str); + fn fixed_legacy_quick_play(); + fn fixed_legacy_proxy(host: &str, port: u16); + fn fixed_legacy_merge_sort(); + fn fixed_legacy_resolution(); + fn fixed_broken_authlib(); + fn warn_unsupported_quick_play(); + fn warn_unsupported_resolution(); + } + +} diff --git a/rust/portablemc/tests/event/client_not_found.json b/rust/portablemc/tests/event/client_not_found.json new file mode 100644 index 00000000..c65fbb08 --- /dev/null +++ b/rust/portablemc/tests/event/client_not_found.json @@ -0,0 +1,3 @@ +{ + "id": "jar_not_found" +} \ No newline at end of file diff --git a/rust/portablemc/tests/event/client_not_found.log b/rust/portablemc/tests/event/client_not_found.log new file mode 100644 index 00000000..c4cf709e --- /dev/null +++ b/rust/portablemc/tests/event/client_not_found.log @@ -0,0 +1,8 @@ +base::filter_features({}, ) +base::loaded_features({}, ) +base::load_hierarchy("client_not_found", ) +base::load_version("client_not_found", $path(versions/client_not_found/client_not_found.json), ) +base::loaded_version("client_not_found", $path(versions/client_not_found/client_not_found.json), ) +base::loaded_hierarchy([LoadedVersion { name: "client_not_found", dir: $path(versions/client_not_found) }], ) +base::load_client() +base::ClientNotFound diff --git a/rust/portablemc/tests/event/libraries.json b/rust/portablemc/tests/event/libraries.json new file mode 100644 index 00000000..83c536fc --- /dev/null +++ b/rust/portablemc/tests/event/libraries.json @@ -0,0 +1,61 @@ +{ + "id": "libraries", + "libraries": [ + { + "name": "mock:lib0:1.0.0", + "url": "https://mock.com/" + }, + { + "name": "mock:lib0:1.0.10", + "url": "https://mock.com/dir/" + }, + { + "name": "mock:lib1:1.0.0", + "url": "https://mock.com/", + "downloads": { + "artifact": { + "url": "https://mock.com/non-standard/mock/lib1/1.0.0/lib1-1.0.0.jar" + } + } + }, + { + "name": "mock:lib2:1.0.0", + "url": "https://mock.com/", + "downloads": { + "artifact": { + "path": "lib2-non-standard.jar", + "url": "https://mock.com/non-standard/mock/lib2/1.0.0/lib2-1.0.0.jar" + } + } + }, + { + "name": "mock:lib3:1.0.0", + "url": "https://mock.com/", + "rules": [] + }, + { + "name": "mock:lib3:1.0.0", + "url": "https://mock.com/", + "rules": [ + { + "action": "allow" + } + ] + }, + { + "name": "mock:lib4:1.0.0", + "url": "https://mock.com/", + "natives": { + "linux": "natives-linux", + "osx": "natives-macos", + "windows": "natives-windows" + } + } + ], + "mainClass": "net.minecraft.client.main.Main", + "downloads": { + "client": { + "url": "https://doesnotwork.theorozier.fr/client.jar" + } + } +} \ No newline at end of file diff --git a/rust/portablemc/tests/event/libraries.log b/rust/portablemc/tests/event/libraries.log new file mode 100644 index 00000000..f922d272 --- /dev/null +++ b/rust/portablemc/tests/event/libraries.log @@ -0,0 +1,17 @@ +base::filter_features({}, ) +base::loaded_features({}, ) +base::load_hierarchy("libraries", ) +base::load_version("libraries", $path(versions/libraries/libraries.json), ) +base::loaded_version("libraries", $path(versions/libraries/libraries.json), ) +base::loaded_hierarchy([LoadedVersion { name: "libraries", dir: $path(versions/libraries) }], ) +base::load_client() +base::loaded_client($path(versions/libraries/libraries.jar), ) +base::load_libraries() +$ignore_once ^base::filter_libraries +base::loaded_libraries([LoadedLibrary { gav: Gav("mock:lib0:1.0.0"), path: None, download: Some(LibraryDownload { url: "https://mock.com/mock/lib0/1.0.0/lib0-1.0.0.jar", size: None, sha1: None }), natives: false }, LoadedLibrary { gav: Gav("mock:lib1:1.0.0"), path: None, download: Some(LibraryDownload { url: "https://mock.com/non-standard/mock/lib1/1.0.0/lib1-1.0.0.jar", size: None, sha1: None }), natives: false }, LoadedLibrary { gav: Gav("mock:lib2:1.0.0"), path: Some("lib2-non-standard.jar"), download: Some(LibraryDownload { url: "https://mock.com/non-standard/mock/lib2/1.0.0/lib2-1.0.0.jar", size: None, sha1: None }), natives: false }, LoadedLibrary { gav: Gav("mock:lib3:1.0.0"), path: None, download: Some(LibraryDownload { url: "https://mock.com/mock/lib3/1.0.0/lib3-1.0.0.jar", size: None, sha1: None }), natives: false }, LoadedLibrary { gav: Gav("mock:lib4:1.0.0:natives-$os()"), path: None, download: Some(LibraryDownload { url: "https://mock.com/mock/lib4/1.0.0/lib4-1.0.0-natives-$os().jar", size: None, sha1: None }), natives: true }], ) +$ignore_once ^base::filter_libraries_files +base::loaded_libraries_files([$path(versions/libraries/libraries.jar), $path(libraries/mock/lib0/1.0.0/lib0-1.0.0.jar), $path(libraries/mock/lib1/1.0.0/lib1-1.0.0.jar), $path(libraries/lib2-non-standard.jar), $path(libraries/mock/lib3/1.0.0/lib3-1.0.0.jar)], [$path(libraries/mock/lib4/1.0.0/lib4-1.0.0-natives-$os().jar)], ) +base::no_logger() +base::no_assets() +base::load_jvm(8, ) +base::loaded_jvm("", None, false, ) \ No newline at end of file diff --git a/rust/portablemc/tests/event/recurse.json b/rust/portablemc/tests/event/recurse.json new file mode 100644 index 00000000..41f2d11a --- /dev/null +++ b/rust/portablemc/tests/event/recurse.json @@ -0,0 +1,4 @@ +{ + "id": "recurse", + "inheritsFrom": "recurse" +} \ No newline at end of file diff --git a/rust/portablemc/tests/event/recurse.log b/rust/portablemc/tests/event/recurse.log new file mode 100644 index 00000000..a6952074 --- /dev/null +++ b/rust/portablemc/tests/event/recurse.log @@ -0,0 +1,6 @@ +base::filter_features({}, ) +base::loaded_features({}, ) +base::load_hierarchy("recurse", ) +base::load_version("recurse", $path(versions/recurse/recurse.json), ) +base::loaded_version("recurse", $path(versions/recurse/recurse.json), ) +base::HierarchyLoop { version: "recurse" } diff --git a/rust/portablemc/tests/mojang.rs b/rust/portablemc/tests/mojang.rs new file mode 100644 index 00000000..534f64c5 --- /dev/null +++ b/rust/portablemc/tests/mojang.rs @@ -0,0 +1,60 @@ +//! Integration test to ensure that all versions can be installed, without their +//! resources. + +use std::fs; +use std::path::PathBuf; + +use portablemc::base::{self, JvmPolicy, VersionChannel}; +use portablemc::mojang::{self, Manifest}; +use portablemc::download; + + +/// This test tries to parse all versions (except snapshots). +#[test] +#[ignore = "long, use internet"] +fn all() { + + fs::create_dir_all(env!("CARGO_TARGET_TMPDIR")).unwrap(); + let tmp_main_dir = tempfile::Builder::new() + .prefix("") + .suffix(".all") + .tempdir_in(env!("CARGO_TARGET_TMPDIR")) + .unwrap() + .into_path(); + + let mut inst = mojang::Installer::new(mojang::Version::Release); + inst.base_mut().set_main_dir(tmp_main_dir.clone()); + inst.base_mut().set_jvm_policy(JvmPolicy::Static(PathBuf::new())); + + let manifest = Manifest::request(()).unwrap(); + for version in manifest.iter() { + + if let VersionChannel::Snapshot = version.channel() { + continue; + } + + inst.set_version(version.name()); + match inst.install(NoResourceHandler) { + Ok(_game) => {} + Err(mojang::Error::Base(base::Error::DownloadResourcesCancelled { })) => {} + Err(e) => Err(e).unwrap(), + } + + } + + // Only remove it here so when the test did not panic. + fs::remove_dir_all(&tmp_main_dir).unwrap(); + +} + + +struct NoResourceHandler; +impl download::Handler for NoResourceHandler { } +impl base::Handler for NoResourceHandler { + + fn download_resources(&mut self) -> bool { + false + } + +} +impl mojang::Handler for NoResourceHandler { } diff --git a/test/data/versions/1.21.json b/test/data/versions/1.21.json new file mode 100644 index 00000000..edcef2f3 --- /dev/null +++ b/test/data/versions/1.21.json @@ -0,0 +1,1686 @@ +{ + "arguments": { + "game": [ + "--username", + "${auth_player_name}", + "--version", + "${version_name}", + "--gameDir", + "${game_directory}", + "--assetsDir", + "${assets_root}", + "--assetIndex", + "${assets_index_name}", + "--uuid", + "${auth_uuid}", + "--accessToken", + "${auth_access_token}", + "--clientId", + "${clientid}", + "--xuid", + "${auth_xuid}", + "--userType", + "${user_type}", + "--versionType", + "${version_type}", + { + "rules": [ + { + "action": "allow", + "features": { + "is_demo_user": true + } + } + ], + "value": "--demo" + }, + { + "rules": [ + { + "action": "allow", + "features": { + "has_custom_resolution": true + } + } + ], + "value": [ + "--width", + "${resolution_width}", + "--height", + "${resolution_height}" + ] + }, + { + "rules": [ + { + "action": "allow", + "features": { + "has_quick_plays_support": true + } + } + ], + "value": [ + "--quickPlayPath", + "${quickPlayPath}" + ] + }, + { + "rules": [ + { + "action": "allow", + "features": { + "is_quick_play_singleplayer": true + } + } + ], + "value": [ + "--quickPlaySingleplayer", + "${quickPlaySingleplayer}" + ] + }, + { + "rules": [ + { + "action": "allow", + "features": { + "is_quick_play_multiplayer": true + } + } + ], + "value": [ + "--quickPlayMultiplayer", + "${quickPlayMultiplayer}" + ] + }, + { + "rules": [ + { + "action": "allow", + "features": { + "is_quick_play_realms": true + } + } + ], + "value": [ + "--quickPlayRealms", + "${quickPlayRealms}" + ] + } + ], + "jvm": [ + { + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ], + "value": [ + "-XstartOnFirstThread" + ] + }, + { + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ], + "value": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump" + }, + { + "rules": [ + { + "action": "allow", + "os": { + "arch": "x86" + } + } + ], + "value": "-Xss1M" + }, + "-Djava.library.path=${natives_directory}", + "-Djna.tmpdir=${natives_directory}", + "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}", + "-Dio.netty.native.workdir=${natives_directory}", + "-Dminecraft.launcher.brand=${launcher_name}", + "-Dminecraft.launcher.version=${launcher_version}", + "-cp", + "${classpath}" + ] + }, + "assetIndex": { + "id": "17", + "sha1": "fab15439bdef669e389e25e815eee8f1b2aa915e", + "size": 447033, + "totalSize": 799252591, + "url": "https://piston-meta.mojang.com/v1/packages/fab15439bdef669e389e25e815eee8f1b2aa915e/17.json" + }, + "assets": "17", + "complianceLevel": 1, + "downloads": { + "client": { + "sha1": "0e9a07b9bb3390602f977073aa12884a4ce12431", + "size": 26836080, + "url": "https://piston-data.mojang.com/v1/objects/0e9a07b9bb3390602f977073aa12884a4ce12431/client.jar" + }, + "client_mappings": { + "sha1": "0530a206839eb1e9b35ec86acbbe394b07a2d9fb", + "size": 9597156, + "url": "https://piston-data.mojang.com/v1/objects/0530a206839eb1e9b35ec86acbbe394b07a2d9fb/client.txt" + }, + "server": { + "sha1": "450698d1863ab5180c25d7c804ef0fe6369dd1ba", + "size": 51623779, + "url": "https://piston-data.mojang.com/v1/objects/450698d1863ab5180c25d7c804ef0fe6369dd1ba/server.jar" + }, + "server_mappings": { + "sha1": "31c77994d96f05ba25a870ada70f47f315330437", + "size": 7454609, + "url": "https://piston-data.mojang.com/v1/objects/31c77994d96f05ba25a870ada70f47f315330437/server.txt" + } + }, + "id": "1.21", + "javaVersion": { + "component": "java-runtime-delta", + "majorVersion": 21 + }, + "libraries": [ + { + "downloads": { + "artifact": { + "path": "ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar", + "sha1": "1227f9e0666314f9de41477e3ec277e542ed7f7b", + "size": 1330045, + "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar" + } + }, + "name": "ca.weblite:java-objc-bridge:1.1", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "com/github/oshi/oshi-core/6.4.10/oshi-core-6.4.10.jar", + "sha1": "b1d8ab82d11d92fd639b56d639f8f46f739dd5fa", + "size": 979212, + "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.4.10/oshi-core-6.4.10.jar" + } + }, + "name": "com.github.oshi:oshi-core:6.4.10" + }, + { + "downloads": { + "artifact": { + "path": "com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", + "sha1": "b3add478d4382b78ea20b1671390a858002feb6c", + "size": 283367, + "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar" + } + }, + "name": "com.google.code.gson:gson:2.10.1" + }, + { + "downloads": { + "artifact": { + "path": "com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar", + "sha1": "1dcf1de382a0bf95a3d8b0849546c88bac1292c9", + "size": 4617, + "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" + } + }, + "name": "com.google.guava:failureaccess:1.0.1" + }, + { + "downloads": { + "artifact": { + "path": "com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar", + "sha1": "5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318", + "size": 3041591, + "url": "https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar" + } + }, + "name": "com.google.guava:guava:32.1.2-jre" + }, + { + "downloads": { + "artifact": { + "path": "com/ibm/icu/icu4j/73.2/icu4j-73.2.jar", + "sha1": "61ad4ef7f9131fcf6d25c34b817f90d6da06c9e9", + "size": 14567819, + "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/73.2/icu4j-73.2.jar" + } + }, + "name": "com.ibm.icu:icu4j:73.2" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/authlib/6.0.54/authlib-6.0.54.jar", + "sha1": "de8bc95660e1b2fe8793fd427a7a10dcec5b3ea7", + "size": 115242, + "url": "https://libraries.minecraft.net/com/mojang/authlib/6.0.54/authlib-6.0.54.jar" + } + }, + "name": "com.mojang:authlib:6.0.54" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar", + "sha1": "5c685c5ffa94c4cd39496c7184c1d122e515ecef", + "size": 964, + "url": "https://libraries.minecraft.net/com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar" + } + }, + "name": "com.mojang:blocklist:1.0.10" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar", + "sha1": "73e324f2ee541493a5179abf367237faa782ed21", + "size": 79955, + "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar" + } + }, + "name": "com.mojang:brigadier:1.2.9" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/datafixerupper/8.0.16/datafixerupper-8.0.16.jar", + "sha1": "67d4de6d7f95d89bcf5862995fb854ebaec02a34", + "size": 724313, + "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/8.0.16/datafixerupper-8.0.16.jar" + } + }, + "name": "com.mojang:datafixerupper:8.0.16" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/logging/1.2.7/logging-1.2.7.jar", + "sha1": "24cb95ffb0e3433fd6e844c04e68009e504ca1c0", + "size": 15343, + "url": "https://libraries.minecraft.net/com/mojang/logging/1.2.7/logging-1.2.7.jar" + } + }, + "name": "com.mojang:logging:1.2.7" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/patchy/2.2.10/patchy-2.2.10.jar", + "sha1": "da05971b07cbb379d002cf7eaec6a2048211fefc", + "size": 4439, + "url": "https://libraries.minecraft.net/com/mojang/patchy/2.2.10/patchy-2.2.10.jar" + } + }, + "name": "com.mojang:patchy:2.2.10" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar", + "sha1": "3cad216e3a7f0c19b4b394388bc9ffc446f13b14", + "size": 12243, + "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar" + } + }, + "name": "com.mojang:text2speech:1.17.9" + }, + { + "downloads": { + "artifact": { + "path": "commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar", + "sha1": "4e3eb3d79888d76b54e28b350915b5dc3919c9de", + "size": 360738, + "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar" + } + }, + "name": "commons-codec:commons-codec:1.16.0" + }, + { + "downloads": { + "artifact": { + "path": "commons-io/commons-io/2.15.1/commons-io-2.15.1.jar", + "sha1": "f11560da189ab563a5c8e351941415430e9304ea", + "size": 501218, + "url": "https://libraries.minecraft.net/commons-io/commons-io/2.15.1/commons-io-2.15.1.jar" + } + }, + "name": "commons-io:commons-io:2.15.1" + }, + { + "downloads": { + "artifact": { + "path": "commons-logging/commons-logging/1.2/commons-logging-1.2.jar", + "sha1": "4bfc12adfe4842bf07b657f0369c4cb522955686", + "size": 61829, + "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar" + } + }, + "name": "commons-logging:commons-logging:1.2" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar", + "sha1": "f8f3d8644afa5e6e1a40a3a6aeb9d9aa970ecb4f", + "size": 306590, + "url": "https://libraries.minecraft.net/io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-buffer:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar", + "sha1": "384ba4d75670befbedb45c4d3b497a93639c206d", + "size": 345274, + "url": "https://libraries.minecraft.net/io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-codec:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar", + "sha1": "7cceacaf11df8dc63f23d0fb58e9d4640fc88404", + "size": 659930, + "url": "https://libraries.minecraft.net/io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-common:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar", + "sha1": "abb86c6906bf512bf2b797a41cd7d2e8d3cd7c36", + "size": 560040, + "url": "https://libraries.minecraft.net/io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-handler:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar", + "sha1": "cec8348108dc76c47cf87c669d514be52c922144", + "size": 37792, + "url": "https://libraries.minecraft.net/io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-resolver:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar", + "sha1": "795da37ded759e862457a82d9d92c4d39ce8ecee", + "size": 147139, + "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-transport-classes-epoll:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar", + "sha1": "5514744c588190ffda076b35a9b8c9f24946a960", + "size": 40427, + "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar" + } + }, + "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-aarch_64", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar", + "sha1": "54188f271e388e7f313aea995e82f58ce2cdb809", + "size": 38954, + "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar" + } + }, + "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-x86_64", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar", + "sha1": "d469d84265ab70095b01b40886cabdd433b6e664", + "size": 43897, + "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-transport-native-unix-common:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar", + "sha1": "f37380d23c9bb079bc702910833b2fd532c9abd0", + "size": 489624, + "url": "https://libraries.minecraft.net/io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar" + } + }, + "name": "io.netty:netty-transport:4.1.97.Final" + }, + { + "downloads": { + "artifact": { + "path": "it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar", + "sha1": "c24946d46824bd528054bface3231d2ecb7e95e8", + "size": 23326598, + "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar" + } + }, + "name": "it.unimi.dsi:fastutil:8.5.12" + }, + { + "downloads": { + "artifact": { + "path": "net/java/dev/jna/jna-platform/5.14.0/jna-platform-5.14.0.jar", + "sha1": "28934d48aed814f11e4c584da55c49fa7032b31b", + "size": 1369287, + "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.14.0/jna-platform-5.14.0.jar" + } + }, + "name": "net.java.dev.jna:jna-platform:5.14.0" + }, + { + "downloads": { + "artifact": { + "path": "net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar", + "sha1": "67bf3eaea4f0718cb376a181a629e5f88fa1c9dd", + "size": 1878533, + "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar" + } + }, + "name": "net.java.dev.jna:jna:5.14.0" + }, + { + "downloads": { + "artifact": { + "path": "net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar", + "sha1": "4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c", + "size": 78146, + "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar" + } + }, + "name": "net.sf.jopt-simple:jopt-simple:5.0.4" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/commons/commons-compress/1.26.0/commons-compress-1.26.0.jar", + "sha1": "659feffdd12280201c8aacb8f7be94f9a883c824", + "size": 1078328, + "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.26.0/commons-compress-1.26.0.jar" + } + }, + "name": "org.apache.commons:commons-compress:1.26.0" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar", + "sha1": "1ed471194b02f2c6cb734a0cd6f6f107c673afae", + "size": 657952, + "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar" + } + }, + "name": "org.apache.commons:commons-lang3:3.14.0" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", + "sha1": "e5f6cae5ca7ecaac1ec2827a9e2d65ae2869cada", + "size": 780321, + "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar" + } + }, + "name": "org.apache.httpcomponents:httpclient:4.5.13" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar", + "sha1": "51cf043c87253c9f58b539c9f7e44c8894223850", + "size": 327891, + "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar" + } + }, + "name": "org.apache.httpcomponents:httpcore:4.4.16" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar", + "sha1": "bea6fede6328fabafd7e68363161a7ea6605abd1", + "size": 335001, + "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar" + } + }, + "name": "org.apache.logging.log4j:log4j-api:2.22.1" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar", + "sha1": "7183a25510a02ad00cc6a95d3b3d2a7d3c5a8dc4", + "size": 1900022, + "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar" + } + }, + "name": "org.apache.logging.log4j:log4j-core:2.22.1" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar", + "sha1": "d7e6693c2606cb7e7335047d7bb96dec52db5665", + "size": 27364, + "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar" + } + }, + "name": "org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1" + }, + { + "downloads": { + "artifact": { + "path": "org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar", + "sha1": "8872d22b293e8f5d7d56ff92be966e6dc28ebdc6", + "size": 99701, + "url": "https://libraries.minecraft.net/org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar" + } + }, + "name": "org.jcraft:jorbis:0.0.17" + }, + { + "downloads": { + "artifact": { + "path": "org/joml/joml/1.10.5/joml-1.10.5.jar", + "sha1": "22566d58af70ad3d72308bab63b8339906deb649", + "size": 712082, + "url": "https://libraries.minecraft.net/org/joml/joml/1.10.5/joml-1.10.5.jar" + } + }, + "name": "org.joml:joml:1.10.5" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3.jar", + "sha1": "a0db6c84a8becc8ca05f9dbfa985edc348a824c7", + "size": 450896, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl-freetype:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-linux.jar", + "sha1": "149070a5480900347071b7074779531f25a6e3dc", + "size": 1245129, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-arm64.jar", + "sha1": "b0a8c9baa9d1f54ac61e1ab9640c7659e7fa700c", + "size": 1040981, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-patch.jar", + "sha1": "806d869f37ce0df388a24e17aaaf5ca0894d851b", + "size": 1071983, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-patch.jar" + } + }, + "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-patch", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows.jar", + "sha1": "81091b006dbb43fab04c8c638e9ac87c51b4096d", + "size": 1035586, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-arm64.jar", + "sha1": "82028265a0a2ff33523ca75137ada7dc176e5210", + "size": 886068, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-x86.jar", + "sha1": "15a8c1de7f51d07a92eae7ce1222557073a0c0c3", + "size": 877480, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar", + "sha1": "efa1eb78c5ccd840e9f329717109b5e892d72f8e", + "size": 135546, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl-glfw:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar", + "sha1": "a03684c5e4b1b1dbbe0d29dbbdc27b985b6840f2", + "size": 118478, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar", + "sha1": "a1bf400f6bc64e6195596cb1430dafda46090751", + "size": 140884, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar" + } + }, + "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-macos", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar", + "sha1": "ee8cc78d0a4a5b3b4600fade6d927c9fc320c858", + "size": 138288, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar", + "sha1": "e449e28b4891fc423c54c85fbc5bb0b9efece67a", + "size": 166368, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-arm64.jar", + "sha1": "f27018dc74f6289574502b46cce55d52817554e2", + "size": 141970, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar", + "sha1": "32334f3fd5270a59bad9939a93115acb6de36dcf", + "size": 157123, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar", + "sha1": "b543467b7ff3c6920539a88ee602d34098628be5", + "size": 43896, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar", + "sha1": "4f86728bf449b1dd61251c4e0ac01df1389cb51e", + "size": 206779, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar", + "sha1": "2906637657a57579847238c9c72d2c4bde7083f8", + "size": 153131, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar" + } + }, + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-macos", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar", + "sha1": "e9412c3ff8cb3a3bad1d3f52909ad74d8a5bdad1", + "size": 141418, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar", + "sha1": "426222fc027602a5f21b9c0fe79cde6a4c7a011f", + "size": 180344, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-arm64.jar", + "sha1": "ba1f3fed0ee4be0217eaa41c5bbfb4b9b1383c33", + "size": 154415, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar", + "sha1": "f6063b6e0f23be483c5c88d84ce51b39dc69126c", + "size": 148612, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar", + "sha1": "daada81ceb5fc0c291fbfdd4433cb8d9423577f2", + "size": 110586, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl-openal:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar", + "sha1": "3037360cc4595079bea240af250b6d1a527e0905", + "size": 573224, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar", + "sha1": "8df8338bfa77f2ebabef4e58964bd04d24805cbf", + "size": 519824, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar" + } + }, + "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-macos", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar", + "sha1": "0c78b078de2fb52f45aa55d04db889a560f3544f", + "size": 471012, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar", + "sha1": "cf83862ae95d98496b26915024c7e666d8ab1c8f", + "size": 698720, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-arm64.jar", + "sha1": "8e0615235116b9e4160dfe87bec90f5f6378bf72", + "size": 630410, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar", + "sha1": "87b8d5050e3adb46bb58fe1cb2669a4a48fce10d", + "size": 638424, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar", + "sha1": "02f6b0147078396a58979125a4c947664e98293a", + "size": 929192, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl-opengl:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar", + "sha1": "62c70a4b00ca5391882b0f4b787c1588d24f1c86", + "size": 80463, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar", + "sha1": "1bd45997551ae8a28469f3a2b678f4b7289e12c0", + "size": 41484, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar" + } + }, + "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-macos", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar", + "sha1": "d213ddef27637b1af87961ffa94d6b27036becc8", + "size": 42487, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar", + "sha1": "e6c1eec8be8a71951b830a4d69efc01c6531900c", + "size": 101535, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-arm64.jar", + "sha1": "65e956d3735a1abdc82eff4baec1b61174697d4b", + "size": 83095, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar", + "sha1": "0d32d833dcaa2f355a886eaf21f0408b5f03241d", + "size": 88612, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar", + "sha1": "25dd6161988d7e65f71d5065c99902402ee32746", + "size": 120283, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl-stb:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar", + "sha1": "fd1271ccd9d85eff2fa31f3fd543e02ccfaf5041", + "size": 231820, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar", + "sha1": "472792c98fb2c1557c060cb9da5fca6a9773621f", + "size": 216456, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar" + } + }, + "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-macos", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar", + "sha1": "51c6955571fbcdb7bb538c6aa589b953b584c6af", + "size": 183628, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar", + "sha1": "1d9facdf6541de114b0f963be33505b7679c78cb", + "size": 261297, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-arm64.jar", + "sha1": "a584ab44de569708871f0a79561f4d8c37487f2c", + "size": 219511, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar", + "sha1": "b5c874687b9aac1a936501d4ed2c49567fd1b575", + "size": 227800, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3.jar", + "sha1": "82d755ca94b102e9ca77283b9e2dc46d1b15fbe5", + "size": 13400, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-linux.jar", + "sha1": "d8d58daa0c3e5fd906fee96f5fddbcbc07cc308b", + "size": 44192, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos.jar", + "sha1": "6598081e346a03038a8be68eb2de614a1c2eac68", + "size": 45865, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos.jar" + } + }, + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-macos", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos-arm64.jar", + "sha1": "406feedb977372085a61eb0fee358183f4f4c67a", + "size": 42498, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows.jar", + "sha1": "a6697981b0449a5087c1d546fc08b4f73e8f98c9", + "size": 130253, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-arm64.jar", + "sha1": "a88c494f3006eb91a7433b12a3a55a9a6c20788b", + "size": 110867, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-x86.jar", + "sha1": "c336c84ee88cccb495c6ffa112395509e7378e8a", + "size": 111797, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "sha1": "29589b5f87ed335a6c7e7ee6a5775f81f97ecb84", + "size": 785029, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar" + } + }, + "name": "org.lwjgl:lwjgl:3.3.3" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar", + "sha1": "1713758e3660ba66e1e954396fd18126038b33c0", + "size": 114627, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar" + } + }, + "name": "org.lwjgl:lwjgl:3.3.3:natives-linux", + "rules": [ + { + "action": "allow", + "os": { + "name": "linux" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", + "sha1": "33a6efa288390490ce6eb6c3df47ac21ecf648cf", + "size": 60543, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar" + } + }, + "name": "org.lwjgl:lwjgl:3.3.3:natives-macos", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar", + "sha1": "226246e75f6bd8d4e1895bdce8638ef87808d114", + "size": 48620, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl:3.3.3:natives-macos-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar", + "sha1": "a5ed18a2b82fc91b81f40d717cb1f64c9dcb0540", + "size": 165442, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar" + } + }, + "name": "org.lwjgl:lwjgl:3.3.3:natives-windows", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-arm64.jar", + "sha1": "e9aca8c5479b520a2a7f0d542a118140e812c5e8", + "size": 133378, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-arm64.jar" + } + }, + "name": "org.lwjgl:lwjgl:3.3.3:natives-windows-arm64", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar", + "sha1": "9e670718e050aeaeea0c2d5b907cffb142f2e58f", + "size": 139653, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar" + } + }, + "name": "org.lwjgl:lwjgl:3.3.3:natives-windows-x86", + "rules": [ + { + "action": "allow", + "os": { + "name": "windows" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lz4/lz4-java/1.8.0/lz4-java-1.8.0.jar", + "sha1": "4b986a99445e49ea5fbf5d149c4b63f6ed6c6780", + "size": 682804, + "url": "https://libraries.minecraft.net/org/lz4/lz4-java/1.8.0/lz4-java-1.8.0.jar" + } + }, + "name": "org.lz4:lz4-java:1.8.0" + }, + { + "downloads": { + "artifact": { + "path": "org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar", + "sha1": "7cf2726fdcfbc8610f9a71fb3ed639871f315340", + "size": 64579, + "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar" + } + }, + "name": "org.slf4j:slf4j-api:2.0.9" + } + ], + "logging": { + "client": { + "argument": "-Dlog4j.configurationFile=${path}", + "file": { + "id": "client-1.12.xml", + "sha1": "bd65e7d2e3c237be76cfbef4c2405033d7f91521", + "size": 888, + "url": "https://piston-data.mojang.com/v1/objects/bd65e7d2e3c237be76cfbef4c2405033d7f91521/client-1.12.xml" + }, + "type": "log4j2-xml" + } + }, + "mainClass": "net.minecraft.client.main.Main", + "minimumLauncherVersion": 21, + "releaseTime": "2024-06-13T08:24:03+00:00", + "time": "2024-06-13T08:24:03+00:00", + "type": "release" +} \ No newline at end of file