diff --git a/Cargo.lock b/Cargo.lock index 2ee6be34d..b90fdb911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ "getrandom 0.2.15", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -481,6 +481,7 @@ version = "0.1.0" dependencies = [ "chrono", "either", + "lazy_static", "rand 0.8.5", "serde", "serde_bytes", @@ -615,7 +616,7 @@ dependencies = [ "futures-lite 2.3.0", "parking", "polling", - "rustix", + "rustix 0.38.37", "slab", "tracing", "windows-sys 0.59.0", @@ -647,7 +648,7 @@ dependencies = [ "cfg-if", "event-listener 5.3.1", "futures-lite 2.3.0", - "rustix", + "rustix 0.38.37", "tracing", ] @@ -674,7 +675,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 0.38.37", "signal-hook-registry", "slab", "windows-sys 0.59.0", @@ -761,6 +762,17 @@ dependencies = [ "webpki-roots 0.26.6", ] +[[package]] +name = "async-walkdir" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37672978ae0febce7516ae0a85b53e6185159a9a28787391eb63fc44ec36037d" +dependencies = [ + "async-fs", + "futures-lite 2.3.0", + "thiserror 2.0.7", +] + [[package]] name = "async_zip" version = "0.0.17" @@ -1809,6 +1821,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.13" @@ -2463,9 +2481,9 @@ dependencies = [ [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] @@ -2553,6 +2571,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "enumflags2" version = "0.7.10" @@ -2611,12 +2641,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2829,6 +2859,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.0.5", + "tokio", + "windows-sys 0.59.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3090,6 +3131,19 @@ dependencies = [ "x11", ] +[[package]] +name = "generator" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bd114ceda131d3b1d665eba35788690ad37f5916457286b32ab6fd3c438dd" +dependencies = [ + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.58.0", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -3107,7 +3161,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" dependencies = [ - "rustix", + "rustix 0.38.37", "windows-targets 0.52.6", ] @@ -3135,6 +3189,18 @@ dependencies = [ "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 = "gif" version = "0.13.1" @@ -3444,6 +3510,54 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d844af74f7b799e41c78221be863bade11c430d46042c3b49ca8ae0c6d27287" +dependencies = [ + "async-recursion", + "async-trait", + "cfg-if", + "critical-section", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 1.0.3", + "ipnet", + "once_cell", + "rand 0.9.0", + "ring 0.17.8", + "thiserror 2.0.7", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a128410b38d6f931fcc6ca5c107a3b02cabd6c05967841269a4ad65d23c44331" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.0", + "resolv-conf", + "smallvec", + "thiserror 2.0.7", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3917,24 +4031,23 @@ dependencies = [ [[package]] name = "idna" -version = "0.5.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "idna" -version = "1.0.2" +name = "idna_adapter" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd69211b9b519e98303c015e21a007e293db403b6c85b9b124e133d25e242cdd" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", - "smallvec", - "utf8_iter", ] [[package]] @@ -4045,7 +4158,7 @@ dependencies = [ "log", "num-format", "once_cell", - "quick-xml 0.37.2", + "quick-xml 0.37.4", "rgb", "str_stack", ] @@ -4088,6 +4201,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.10.0" @@ -4487,7 +4612,7 @@ dependencies = [ "futures-util", "hostname", "httpdate", - "idna 1.0.2", + "idna 1.0.3", "mime", "native-tls", "nom", @@ -4524,9 +4649,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.159" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libdbus-sys" @@ -4598,6 +4723,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "litemap" version = "0.7.3" @@ -4637,6 +4768,19 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -4914,6 +5058,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "moka" +version = "0.12.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "loom", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "thiserror 1.0.64", + "uuid 1.12.0", +] + [[package]] name = "muda" version = "0.15.1" @@ -5515,6 +5678,7 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" dependencies = [ + "critical-section", "portable-atomic", ] @@ -6055,7 +6219,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.4.0", "pin-project-lite", - "rustix", + "rustix 0.38.37", "tracing", "windows-sys 0.59.0", ] @@ -6093,7 +6257,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -6202,7 +6366,7 @@ dependencies = [ "hex", "lazy_static", "procfs-core", - "rustix", + "rustix 0.38.37", ] [[package]] @@ -6308,6 +6472,31 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quartz_nbt" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf389329ba2dad9c6d898b7955a64e58c89dd52d04f4e2753b9d86eb5f49821" +dependencies = [ + "anyhow", + "byteorder", + "cesu8", + "flate2", + "quartz_nbt_macros", + "serde", +] + +[[package]] +name = "quartz_nbt_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "289baa0c8a4d1f840d2de528a7f8c29e0e9af48b3018172b3edad4f716e8daed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -6353,11 +6542,12 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.2" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" dependencies = [ "memchr", + "tokio", ] [[package]] @@ -6423,6 +6613,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "r2d2" version = "0.8.10" @@ -6465,6 +6661,17 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "zerocopy 0.8.24", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -6485,6 +6692,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -6503,6 +6720,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -6769,6 +6995,15 @@ dependencies = [ "windows-registry 0.2.0", ] +[[package]] +name = "resolv-conf" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48375394603e3dd4b2d64371f7148fd8c7baa2680e28741f2cb8d23b59e3d4c4" +dependencies = [ + "hostname", +] + [[package]] name = "result" version = "1.0.0" @@ -7018,10 +7253,23 @@ dependencies = [ "bitflags 2.6.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", +] + [[package]] name = "rustls" version = "0.20.9" @@ -8486,6 +8734,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tao" version = "0.30.8" @@ -8954,7 +9208,7 @@ dependencies = [ "cfg-if", "fastrand 2.1.1", "once_cell", - "rustix", + "rustix 0.38.37", "windows-sys 0.59.0", ] @@ -8987,6 +9241,7 @@ dependencies = [ "ariadne", "async-recursion", "async-tungstenite", + "async-walkdir", "async_zip", "base64 0.22.1", "byteorder", @@ -8999,13 +9254,17 @@ dependencies = [ "dunce", "either", "flate2", + "fs4", "futures", + "hickory-resolver", "indicatif", "lazy_static", "notify", "notify-debouncer-mini", "p256", "paste", + "quartz_nbt", + "quick-xml 0.37.4", "rand 0.8.5", "regex", "reqwest 0.12.7", @@ -9021,6 +9280,7 @@ dependencies = [ "tempfile", "thiserror 1.0.64", "tokio", + "tokio-util", "tracing", "tracing-error", "tracing-subscriber", @@ -9039,6 +9299,7 @@ dependencies = [ "cocoa 0.25.0", "daedalus", "dashmap 6.1.0", + "either", "native-dialog", "objc", "opener", @@ -9063,6 +9324,7 @@ dependencies = [ "tracing", "tracing-error", "url", + "urlencoding", "uuid 1.12.0", "window-shadows", ] @@ -9314,9 +9576,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" dependencies = [ "bytes", "futures-core", @@ -9781,12 +10043,12 @@ dependencies = [ [[package]] name = "url" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna 0.5.0", + "idna 1.0.3", "percent-encoding", "serde", ] @@ -10000,6 +10262,15 @@ 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 = "wasite" version = "0.1.0" @@ -10094,7 +10365,7 @@ checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 0.38.37", "scoped-tls", "smallvec", "wayland-sys", @@ -10107,7 +10378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" dependencies = [ "bitflags 2.6.0", - "rustix", + "rustix 0.38.37", "wayland-backend", "wayland-scanner", ] @@ -10322,7 +10593,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.37", ] [[package]] @@ -10336,6 +10607,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + [[package]] name = "winapi" version = "0.3.9" @@ -10768,6 +11045,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "woothee" version = "0.13.0" @@ -10869,8 +11155,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" dependencies = [ "libc", - "linux-raw-sys", - "rustix", + "linux-raw-sys 0.4.14", + "rustix 0.38.37", ] [[package]] @@ -11085,7 +11371,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive 0.8.24", ] [[package]] @@ -11099,6 +11394,17 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "zerofrom" version = "0.1.4" diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index fe9390f97..7b0a32838 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -16,6 +16,7 @@ "@modrinth/ui": "workspace:*", "@modrinth/utils": "workspace:*", "@sentry/vue": "^8.27.0", + "@geometrically/minecraft-motd-parser": "^1.1.4", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-os": "^2.2.0", @@ -50,7 +51,8 @@ "tsconfig": "workspace:*", "typescript": "^5.5.4", "vite": "^5.4.6", - "vue-tsc": "^2.1.6" + "vue-tsc": "^2.1.6", + "@taijased/vue-render-tracker": "^1.0.7" }, "packageManager": "pnpm@9.4.0", "web-types": "../../web-types.json" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index a744566aa..85d252745 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -16,6 +16,7 @@ import { RestoreIcon, RightArrowIcon, SettingsIcon, + WorldIcon, XIcon, } from '@modrinth/assets' import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui' @@ -166,11 +167,17 @@ async function setupApp() { `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`, 'criticalAnnouncements', true, - ).then((res) => { - if (res && res.header && res.body) { - criticalErrorMessage.value = res - } - }) + ) + .then((res) => { + if (res && res.header && res.body) { + criticalErrorMessage.value = res + } + }) + .catch(() => { + console.log( + `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`, + ) + }) useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => { if (res && res.articles) { @@ -359,7 +366,7 @@ function handleAuxClick(e) { diff --git a/apps/app-frontend/src/components/ui/modal/InstanceModalTitlePrefix.vue b/apps/app-frontend/src/components/ui/modal/InstanceModalTitlePrefix.vue new file mode 100644 index 000000000..c28213cae --- /dev/null +++ b/apps/app-frontend/src/components/ui/modal/InstanceModalTitlePrefix.vue @@ -0,0 +1,20 @@ + + diff --git a/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue b/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue index 24d27feb6..ebd04c015 100644 --- a/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue @@ -7,7 +7,7 @@ import { get, set } from '@/helpers/settings' const themeStore = useTheming() const settings = ref(await get()) -const options = ref(['project_background', 'page_path']) +const options = ref(['project_background', 'page_path', 'worlds_tab']) function getStoreValue(key: string) { return themeStore.featureFlags[key] ?? false @@ -30,7 +30,7 @@ watch(

- {{ option }} + {{ option.replaceAll('_', ' ') }}

diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue new file mode 100644 index 000000000..90bbef67c --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue @@ -0,0 +1,220 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue new file mode 100644 index 000000000..9ad347fca --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue @@ -0,0 +1,275 @@ + + + diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue new file mode 100644 index 000000000..cb39b7e2d --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue @@ -0,0 +1,470 @@ + + + diff --git a/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue new file mode 100644 index 000000000..00fab96ec --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue @@ -0,0 +1,115 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue new file mode 100644 index 000000000..b93ca749c --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue @@ -0,0 +1,93 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue new file mode 100644 index 000000000..2fc39c317 --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue @@ -0,0 +1,112 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue new file mode 100644 index 000000000..64c82b27d --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue @@ -0,0 +1,86 @@ + + diff --git a/apps/app-frontend/src/helpers/events.js b/apps/app-frontend/src/helpers/events.js index 0ed288365..81a849b9e 100644 --- a/apps/app-frontend/src/helpers/events.js +++ b/apps/app-frontend/src/helpers/events.js @@ -62,7 +62,7 @@ export async function process_listener(callback) { ProfilePayload { uuid: unique identification of the process in the state (currently identified by path, but that will change) name: name of the profile - profile_path: relative path to profile (used for path identification) + profile_path: relative path toprofile_listener profile (used for path identification) path: path to profile (used for opening the profile in the OS file explorer) event: event type ("Created", "Added", "Edited", "Removed") } diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js index 569420101..89ebd52ba 100644 --- a/apps/app-frontend/src/helpers/utils.js +++ b/apps/app-frontend/src/helpers/utils.js @@ -37,6 +37,13 @@ export async function restartApp() { return await invoke('restart_app') } +/** + * @deprecated This method is no longer needed, and just returns its parameter + */ +export function sanitizePotentialFileUrl(url) { + return url +} + export const releaseColor = (releaseType) => { switch (releaseType) { case 'release': @@ -49,3 +56,7 @@ export const releaseColor = (releaseType) => { return '' } } + +export async function copyToClipboard(text) { + await navigator.clipboard.writeText(text) +} diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts new file mode 100644 index 000000000..13615cdf2 --- /dev/null +++ b/apps/app-frontend/src/helpers/worlds.ts @@ -0,0 +1,303 @@ +import { invoke } from '@tauri-apps/api/core' +import { get_full_path } from '@/helpers/profile' +import { openPath } from '@/helpers/utils' +import { autoToHTML } from '@geometrically/minecraft-motd-parser' +import dayjs from 'dayjs' +import type { GameVersion } from '@modrinth/ui' + +type BaseWorld = { + name: string + last_played?: string + icon?: string +} + +export type SingleplayerWorld = BaseWorld & { + type: 'singleplayer' + path: string + game_mode: SingleplayerGameMode + hardcore: boolean + locked: boolean +} + +export type ServerWorld = BaseWorld & { + type: 'server' + index: number + address: string + pack_status: ServerPackStatus +} + +export type World = SingleplayerWorld | ServerWorld + +export type WorldWithProfile = { + profile: string +} & World + +export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator' +export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt' + +export type ServerStatus = { + // https://minecraft.wiki/w/Text_component_format + description?: string | Chat + players?: { + max: number + online: number + sample: { name: string; id: string }[] + } + version?: { + name: string + protocol: number + } + favicon?: string + enforces_secure_chat: boolean + ping?: number +} + +export interface Chat { + text: string + bold: boolean + italic: boolean + underlined: boolean + strikethrough: boolean + obfuscated: boolean + color?: string + extra: Chat[] +} + +export type ServerData = { + refreshing: boolean + status?: ServerStatus + rawMotd?: string | Chat + renderedMotd?: string +} + +export async function get_recent_worlds(limit: number): Promise { + return await invoke('plugin:worlds|get_recent_worlds', { limit }) +} + +export async function get_profile_worlds(path: string): Promise { + return await invoke('plugin:worlds|get_profile_worlds', { path }) +} + +export async function get_singleplayer_world( + instance: string, + world: string, +): Promise { + return await invoke('plugin:worlds|get_singleplayer_world', { instance, world }) +} + +export async function rename_world( + instance: string, + world: string, + newName: string, +): Promise { + return await invoke('plugin:worlds|rename_world', { instance, world, newName }) +} + +export async function reset_world_icon(instance: string, world: string): Promise { + return await invoke('plugin:worlds|reset_world_icon', { instance, world }) +} + +export async function backup_world(instance: string, world: string): Promise { + return await invoke('plugin:worlds|backup_world', { instance, world }) +} + +export async function delete_world(instance: string, world: string): Promise { + return await invoke('plugin:worlds|delete_world', { instance, world }) +} + +export async function add_server_to_profile( + path: string, + name: string, + address: string, + packStatus: ServerPackStatus, +): Promise { + return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus }) +} + +export async function edit_server_in_profile( + path: string, + index: number, + name: string, + address: string, + packStatus: ServerPackStatus, +): Promise { + return await invoke('plugin:worlds|edit_server_in_profile', { + path, + index, + name, + address, + packStatus, + }) +} + +export async function remove_server_from_profile(path: string, index: number): Promise { + return await invoke('plugin:worlds|remove_server_from_profile', { path, index }) +} + +export async function get_profile_protocol_version(path: string): Promise { + return await invoke('plugin:worlds|get_profile_protocol_version', { path }) +} + +export async function get_server_status( + address: string, + protocolVersion: number | null = null, +): Promise { + return await invoke('plugin:worlds|get_server_status', { address, protocolVersion }) +} + +export async function start_join_singleplayer_world(path: string, world: string): Promise { + return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world }) +} + +export async function start_join_server(path: string, address: string): Promise { + return await invoke('plugin:worlds|start_join_server', { path, address }) +} + +export async function showWorldInFolder(instancePath: string, worldPath: string) { + const fullPath = await get_full_path(instancePath) + return await openPath(fullPath + '/saves/' + worldPath) +} + +export function getWorldIdentifier(world: World) { + return world.type === 'singleplayer' ? world.path : world.address +} + +export function sortWorlds(worlds: World[]) { + worlds.sort((a, b) => { + if (!a.last_played) { + return 1 + } + if (!b.last_played) { + return -1 + } + return dayjs(b.last_played).diff(dayjs(a.last_played)) + }) +} + +export function isSingleplayerWorld(world: World): world is SingleplayerWorld { + return world.type === 'singleplayer' +} + +export function isServerWorld(world: World): world is ServerWorld { + return world.type === 'server' +} + +export async function refreshServerData( + serverData: ServerData, + protocolVersion: number | null, + address: string, +): Promise { + serverData.refreshing = true + await get_server_status(address, protocolVersion) + .then((status) => { + serverData.status = status + if (status.description) { + serverData.rawMotd = status.description + serverData.renderedMotd = autoToHTML(status.description) + } + }) + .catch((err) => { + console.error(`Refreshing addr: ${address}`, err) + }) + .finally(() => { + serverData.refreshing = false + }) +} + +export async function refreshServers( + worlds: World[], + serverData: Record, + protocolVersion: number | null, +) { + const servers = worlds.filter(isServerWorld) + servers.forEach((server) => { + if (!serverData[server.address]) { + serverData[server.address] = { + refreshing: true, + } + } else { + serverData[server.address].refreshing = true + } + }) + + // noinspection ES6MissingAwait - handled with .then by refreshServerData already + Promise.all( + Object.keys(serverData).map((address) => + refreshServerData(serverData[address], protocolVersion, address), + ), + ) +} + +export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) { + const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath) + if (index !== -1) { + worlds[index] = await get_singleplayer_world(instancePath, worldPath) + sortWorlds(worlds) + } else { + console.error(`Error refreshing world, could not find world at path ${worldPath}.`) + } +} + +export async function handleDefaultProfileUpdateEvent( + worlds: World[], + instancePath: string, + e: ProfileEvent, +) { + if (e.event === 'world_updated') { + await refreshWorld(worlds, instancePath, e.world) + } + + if (e.event === 'server_joined') { + const world = worlds.find( + (w) => + w.type === 'server' && + (w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)), + ) + if (world) { + world.last_played = e.timestamp + sortWorlds(worlds) + } else { + console.error(`Could not find world for server join event: ${e.host}:${e.port}`) + } + } +} + +export async function refreshWorlds(instancePath: string): Promise { + const worlds = await get_profile_worlds(instancePath).catch((err) => { + console.error(`Error refreshing worlds for instance: ${instancePath}`, err) + }) + if (worlds) { + sortWorlds(worlds) + } + + return worlds ?? [] +} + +const FIRST_QUICK_PLAY_VERSION = '23w14a' + +export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) { + if (!gameVersions.length) { + return false + } + + const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion) + const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION) + + return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex +} + +export type ProfileEvent = { profile_path_id: string } & ( + | { + event: 'servers_updated' + } + | { + event: 'world_updated' + world: string + } + | { + event: 'server_joined' + host: string + port: number + timestamp: string + } +) diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 515e4e71a..4290c58b8 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -20,12 +20,57 @@ "app.settings.tabs.resource-management": { "message": "Resource management" }, + "instance.add-server.add-and-play": { + "message": "Add and play" + }, + "instance.add-server.add-server": { + "message": "Add server" + }, + "instance.add-server.resource-pack.disabled": { + "message": "Disabled" + }, + "instance.add-server.resource-pack.enabled": { + "message": "Enabled" + }, + "instance.add-server.resource-pack.prompt": { + "message": "Prompt" + }, + "instance.add-server.title": { + "message": "Add a server" + }, + "instance.edit-server.title": { + "message": "Edit server" + }, + "instance.edit-world.name": { + "message": "Name" + }, + "instance.edit-world.placeholder-name": { + "message": "Minecraft World" + }, + "instance.edit-world.reset-icon": { + "message": "Reset icon" + }, + "instance.edit-world.title": { + "message": "Edit world" + }, "instance.filter.disabled": { "message": "Disabled projects" }, "instance.filter.updates-available": { "message": "Updates available" }, + "instance.server-modal.address": { + "message": "Address" + }, + "instance.server-modal.name": { + "message": "Name" + }, + "instance.server-modal.placeholder-name": { + "message": "Minecraft Server" + }, + "instance.server-modal.resource-pack": { + "message": "Resource pack" + }, "instance.settings.tabs.general": { "message": "General" }, @@ -308,6 +353,42 @@ "instance.settings.title": { "message": "Settings" }, + "instance.worlds.a_minecraft_server": { + "message": "A Minecraft Server" + }, + "instance.worlds.cant_connect": { + "message": "Can't connect to server" + }, + "instance.worlds.copy_address": { + "message": "Copy address" + }, + "instance.worlds.filter.available": { + "message": "Available" + }, + "instance.worlds.game_already_open": { + "message": "Instance is already open" + }, + "instance.worlds.hardcore": { + "message": "Hardcore mode" + }, + "instance.worlds.no_quick_play": { + "message": "You can only jump straight into worlds on Minecraft 1.20+" + }, + "instance.worlds.play_anyway": { + "message": "Play anyway" + }, + "instance.worlds.type.server": { + "message": "Server" + }, + "instance.worlds.type.singleplayer": { + "message": "Singleplayer" + }, + "instance.worlds.view_instance": { + "message": "View instance" + }, + "instance.worlds.world_in_use": { + "message": "World is in use" + }, "search.filter.locked.instance": { "message": "Provided by the instance" }, diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js index ba6d3f49b..a37a7018f 100644 --- a/apps/app-frontend/src/main.js +++ b/apps/app-frontend/src/main.js @@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue' import 'floating-vue/dist/style.css' import { createPlugin } from '@vintl/vintl/plugin' import * as Sentry from '@sentry/vue' +import { VueScanPlugin } from '@taijased/vue-render-tracker' const VIntlPlugin = createPlugin({ controllerOpts: { @@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({ injectInto: [], }) +const vueScan = new VueScanPlugin({ + enabled: false, // Enable or disable the tracker + showOverlay: true, // Show overlay to visualize renders + log: false, // Log render events to the console + playSound: false, // Play sound on each render +}) + const pinia = createPinia() let app = createApp(App) @@ -35,6 +43,7 @@ Sentry.init({ tracesSampleRate: 0.1, }) +app.use(vueScan) app.use(router) app.use(pinia) app.use(FloatingVue, { diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index 9d9064eb5..e5c1e0689 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -1,4 +1,4 @@ - + diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js index 6c4866b46..82b0b3ec2 100644 --- a/apps/app-frontend/src/pages/index.js +++ b/apps/app-frontend/src/pages/index.js @@ -1,4 +1,5 @@ import Index from './Index.vue' import Browse from './Browse.vue' +import Worlds from './Worlds.vue' -export { Index, Browse } +export { Index, Browse, Worlds } diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index d2f3d52e0..65bfbf68f 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -1,152 +1,156 @@ diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue new file mode 100644 index 000000000..22eed8dbb --- /dev/null +++ b/apps/app-frontend/src/pages/instance/Worlds.vue @@ -0,0 +1,447 @@ + + diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js index e433570eb..fa77df524 100644 --- a/apps/app-frontend/src/pages/instance/index.js +++ b/apps/app-frontend/src/pages/instance/index.js @@ -1,5 +1,7 @@ import Index from './Index.vue' +import Overview from './Overview.vue' +import Worlds from './Worlds.vue' import Mods from './Mods.vue' import Logs from './Logs.vue' -export { Index, Mods, Logs } +export { Index, Overview, Worlds, Mods, Logs } diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 1f5082b13..74ed3fb6d 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -192,6 +192,11 @@ const [allLoaders, allGameVersions] = await Promise.all([ async function fetchProjectData() { const project = await get_project(route.params.id, 'must_revalidate').catch(handleError) + if (!project) { + handleError('Error loading project') + return + } + data.value = project ;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] = await Promise.all([ diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 49eae8461..6d5e4e372 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -18,6 +18,14 @@ export default new createRouter({ breadcrumb: [{ name: 'Home' }], }, }, + { + path: '/worlds', + name: 'Worlds', + component: Pages.Worlds, + meta: { + breadcrumb: [{ name: 'Worlds' }], + }, + }, { path: '/browse/:projectType', name: 'Discover content', @@ -106,13 +114,31 @@ export default new createRouter({ component: Instance.Index, props: true, children: [ + // { + // path: '', + // name: 'Overview', + // component: Instance.Overview, + // meta: { + // useRootContext: true, + // breadcrumb: [{ name: '?Instance' }], + // }, + // }, + { + path: 'worlds', + name: 'InstanceWorlds', + component: Instance.Worlds, + meta: { + useRootContext: true, + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }], + }, + }, { path: '', name: 'Mods', component: Instance.Mods, meta: { useRootContext: true, - breadcrumb: [{ name: '?Instance' }], + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }], }, }, { @@ -121,7 +147,7 @@ export default new createRouter({ component: Instance.Mods, meta: { useRootContext: true, - breadcrumb: [{ name: '?Instance' }], + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }], }, }, { diff --git a/apps/app-playground/src/main.rs b/apps/app-playground/src/main.rs index f8d943938..ac68a9627 100644 --- a/apps/app-playground/src/main.rs +++ b/apps/app-playground/src/main.rs @@ -3,9 +3,8 @@ windows_subsystem = "windows" )] -use std::time::Duration; use theseus::prelude::*; -use tokio::signal::ctrl_c; +use theseus::worlds::get_recent_worlds; // A simple Rust implementation of the authentication run // 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend) @@ -41,21 +40,16 @@ async fn main() -> theseus::Result<()> { // Initialize state State::init().await?; - loop { - if State::get().await?.friends_socket.is_connected().await { - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; + let worlds = get_recent_worlds(4).await?; + for world in worlds { + println!( + "World: {:?}/{:?} played at {:?}: {:#?}", + world.profile, + world.world.name, + world.world.last_played, + world.world.details + ); } - tracing::info!("Starting host"); - - let socket = State::get().await?.friends_socket.open_port(25565).await?; - tracing::info!("Running host on socket {}", socket.socket_id()); - - ctrl_c().await?; - tracing::info!("Stopping host"); - socket.shutdown().await?; - Ok(()) } diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index 06f38432d..4862a2bdd 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -30,8 +30,10 @@ tokio = { version = "1", features = ["full"] } thiserror = "1.0" daedalus = { path = "../../packages/daedalus" } chrono = "0.4.26" +either = "1.15" url = "2.2" +urlencoding = "2.1" uuid = { version = "1.1", features = ["serde", "v4"] } os_info = "3.7.0" diff --git a/apps/app/build.rs b/apps/app/build.rs index ae314cd9b..f04a80569 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -240,6 +240,29 @@ fn main() { .default_permission( DefaultPermissionRule::AllowAllCommands, ), + ) + .plugin( + "worlds", + InlinedPlugin::new() + .commands(&[ + "get_recent_worlds", + "get_profile_worlds", + "get_singleplayer_world", + "rename_world", + "reset_world_icon", + "backup_world", + "delete_world", + "add_server_to_profile", + "edit_server_in_profile", + "remove_server_from_profile", + "get_profile_protocol_version", + "get_server_status", + "start_join_singleplayer_world", + "start_join_server", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), ), ) .expect("Failed to run tauri-build"); diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index 1a93620cd..b9777b6d9 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -35,6 +35,7 @@ "tags:default", "utils:default", "ads:default", - "friends:default" + "friends:default", + "worlds:default" ] } diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 82c33888f..09d37e87a 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -19,6 +19,7 @@ pub mod utils; pub mod ads; pub mod cache; pub mod friends; +pub mod worlds; pub type Result = std::result::Result; diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs index 18e3831d3..db979be35 100644 --- a/apps/app/src/api/profile.rs +++ b/apps/app/src/api/profile.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use theseus::prelude::*; +use theseus::profile::QuickPlayType; pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("profile") @@ -250,7 +251,7 @@ pub async fn profile_get_pack_export_candidates( // invoke('plugin:profile|profile_run', path) #[tauri::command] pub async fn profile_run(path: &str) -> Result { - let process = profile::run(path).await?; + let process = profile::run(path, &QuickPlayType::None).await?; Ok(process) } @@ -264,7 +265,9 @@ pub async fn profile_run_credentials( path: &str, credentials: Credentials, ) -> Result { - let process = profile::run_credentials(path, &credentials).await?; + let process = + profile::run_credentials(path, &credentials, &QuickPlayType::None) + .await?; Ok(process) } @@ -347,6 +350,9 @@ pub async fn profile_edit(path: &str, edit_profile: EditProfile) -> Result<()> { prof.name = name; } if let Some(game_version) = edit_profile.game_version.clone() { + if game_version != prof.game_version { + prof.protocol_version = None; + } prof.game_version = game_version; } if let Some(loader) = edit_profile.loader { diff --git a/apps/app/src/api/utils.rs b/apps/app/src/api/utils.rs index 391ea50f4..2c906d124 100644 --- a/apps/app/src/api/utils.rs +++ b/apps/app/src/api/utils.rs @@ -4,9 +4,11 @@ use theseus::{ prelude::{CommandPayload, DirectoryInfo}, }; -use crate::api::Result; +use crate::api::{Result, TheseusSerializableError}; use dashmap::DashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use theseus::prelude::canonicalize; +use url::Url; pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("utils") @@ -140,3 +142,28 @@ pub async fn handle_command(command: String) -> Result<()> { tracing::info!("handle command: {command}"); Ok(theseus::handler::parse_and_emit_command(&command).await?) } + +// Remove when (and if) https://github.com/tauri-apps/tauri/issues/12022 is implemented +pub(crate) fn tauri_convert_file_src(path: &Path) -> Result { + #[cfg(any(windows, target_os = "android"))] + const BASE: &str = "http://asset.localhost/"; + #[cfg(not(any(windows, target_os = "android")))] + const BASE: &str = "asset://localhost/"; + + macro_rules! theseus_try { + ($test:expr) => { + match $test { + Ok(val) => val, + Err(e) => { + return Err(TheseusSerializableError::Theseus(e.into())) + } + } + }; + } + + let path = theseus_try!(canonicalize(path)); + let path = path.to_string_lossy(); + let encoded = urlencoding::encode(&path); + + Ok(theseus_try!(Url::parse(&format!("{BASE}{encoded}")))) +} diff --git a/apps/app/src/api/worlds.rs b/apps/app/src/api/worlds.rs new file mode 100644 index 000000000..82c003220 --- /dev/null +++ b/apps/app/src/api/worlds.rs @@ -0,0 +1,195 @@ +use crate::api::Result; +use either::Either; +use tauri::{AppHandle, Manager, Runtime}; +use theseus::prelude::ProcessMetadata; +use theseus::profile::{get_full_path, QuickPlayType}; +use theseus::worlds::{ + ServerPackStatus, ServerStatus, World, WorldWithProfile, +}; +use theseus::{profile, worlds}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("worlds") + .invoke_handler(tauri::generate_handler![ + get_recent_worlds, + get_profile_worlds, + get_singleplayer_world, + rename_world, + reset_world_icon, + backup_world, + delete_world, + add_server_to_profile, + edit_server_in_profile, + remove_server_from_profile, + get_profile_protocol_version, + get_server_status, + start_join_singleplayer_world, + start_join_server, + ]) + .build() +} + +#[tauri::command] +pub async fn get_recent_worlds( + app_handle: AppHandle, + limit: usize, +) -> Result> { + let mut result = worlds::get_recent_worlds(limit).await?; + for world in result.iter_mut() { + adapt_world_icon(&app_handle, &mut world.world); + } + Ok(result) +} + +#[tauri::command] +pub async fn get_profile_worlds( + app_handle: AppHandle, + path: &str, +) -> Result> { + let mut result = worlds::get_profile_worlds(path).await?; + for world in result.iter_mut() { + adapt_world_icon(&app_handle, world); + } + Ok(result) +} + +#[tauri::command] +pub async fn get_singleplayer_world( + app_handle: AppHandle, + instance: &str, + world: &str, +) -> Result { + let instance = get_full_path(instance).await?; + let mut world = worlds::get_singleplayer_world(&instance, world).await?; + adapt_world_icon(&app_handle, &mut world); + Ok(world) +} + +fn adapt_world_icon(app_handle: &AppHandle, world: &mut World) { + if let Some(Either::Left(icon_path)) = &world.icon { + let icon_path = icon_path.clone(); + if let Ok(new_url) = super::utils::tauri_convert_file_src(&icon_path) { + world.icon = Some(Either::Right(new_url)); + if let Err(e) = + app_handle.asset_protocol_scope().allow_file(&icon_path) + { + tracing::warn!( + "Failed to allow file access for icon {}: {}", + icon_path.display(), + e + ); + } + } else { + tracing::warn!( + "Encountered invalid icon path for world {}: {}", + world.name, + icon_path.display() + ); + world.icon = None; + } + } +} + +#[tauri::command] +pub async fn rename_world( + instance: &str, + world: &str, + new_name: &str, +) -> Result<()> { + let instance = get_full_path(instance).await?; + worlds::rename_world(&instance, world, new_name).await?; + Ok(()) +} + +#[tauri::command] +pub async fn reset_world_icon(instance: &str, world: &str) -> Result<()> { + let instance = get_full_path(instance).await?; + worlds::reset_world_icon(&instance, world).await?; + Ok(()) +} + +#[tauri::command] +pub async fn backup_world(instance: &str, world: &str) -> Result { + let instance = get_full_path(instance).await?; + Ok(worlds::backup_world(&instance, world).await?) +} + +#[tauri::command] +pub async fn delete_world(instance: &str, world: &str) -> Result<()> { + let instance = get_full_path(instance).await?; + worlds::delete_world(&instance, world).await?; + Ok(()) +} + +#[tauri::command] +pub async fn add_server_to_profile( + path: &str, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result { + let path = get_full_path(path).await?; + Ok( + worlds::add_server_to_profile(&path, name, address, pack_status) + .await?, + ) +} + +#[tauri::command] +pub async fn edit_server_in_profile( + path: &str, + index: usize, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result<()> { + let path = get_full_path(path).await?; + worlds::edit_server_in_profile(&path, index, name, address, pack_status) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn remove_server_from_profile( + path: &str, + index: usize, +) -> Result<()> { + let path = get_full_path(path).await?; + worlds::remove_server_from_profile(&path, index).await?; + Ok(()) +} + +#[tauri::command] +pub async fn get_profile_protocol_version(path: &str) -> Result> { + Ok(worlds::get_profile_protocol_version(path).await?) +} + +#[tauri::command] +pub async fn get_server_status( + address: &str, + protocol_version: Option, +) -> Result { + Ok(worlds::get_server_status(address, protocol_version).await?) +} + +#[tauri::command] +pub async fn start_join_singleplayer_world( + path: &str, + world: String, +) -> Result { + let process = + profile::run(path, &QuickPlayType::Singleplayer(world)).await?; + + Ok(process) +} + +#[tauri::command] +pub async fn start_join_server( + path: &str, + address: &str, +) -> Result { + let process = + profile::run(path, &QuickPlayType::Server(address.to_owned())).await?; + + Ok(process) +} diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index f313bb9a3..27bec5b0a 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -268,6 +268,7 @@ fn main() { .plugin(api::cache::init()) .plugin(api::ads::init()) .plugin(api::friends::init()) + .plugin(api::worlds::init()) .invoke_handler(tauri::generate_handler![ initialize_state, is_dev, diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index 7f3e31ac8..5c479f17c 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -76,7 +76,7 @@ ], "security": { "assetProtocol": { - "scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*"], + "scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*", "$APPDATA/profiles/*/saves/*/icon.png", "$APPCONFIG/profiles/*/saves/*/icon.png", "$CONFIG/profiles/*/saves/*/icon.png"], "enable": true }, "capabilities": ["ads", "core", "plugins"], diff --git a/apps/frontend/src/assets/styles/components.scss b/apps/frontend/src/assets/styles/components.scss index 0cc476281..a1ea3a1e9 100644 --- a/apps/frontend/src/assets/styles/components.scss +++ b/apps/frontend/src/assets/styles/components.scss @@ -930,7 +930,7 @@ button { color: var(--color-text); padding: 0.5rem 0 0.5rem 1rem; font-weight: var(--font-weight-medium); - min-height: 40px; + min-height: 36px; box-sizing: border-box; width: fit-content; align-items: center; diff --git a/apps/frontend/src/assets/styles/global.scss b/apps/frontend/src/assets/styles/global.scss index 06d8568c4..b0dcdc0ce 100644 --- a/apps/frontend/src/assets/styles/global.scss +++ b/apps/frontend/src/assets/styles/global.scss @@ -451,7 +451,7 @@ textarea { var(--shadow-inset-sm), 0 0 0 0 transparent; transition: box-shadow 0.1s ease-in-out; - min-height: 40px; + min-height: 36px; &:focus, &:focus-visible { diff --git a/apps/frontend/src/components/ui/servers/MOTDEditor.vue b/apps/frontend/src/components/ui/servers/MOTDEditor.vue deleted file mode 100644 index aa4c5d0e8..000000000 --- a/apps/frontend/src/components/ui/servers/MOTDEditor.vue +++ /dev/null @@ -1,660 +0,0 @@ - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/PoweredByPyro.vue b/apps/frontend/src/components/ui/servers/PoweredByPyro.vue deleted file mode 100644 index f6c54fa3c..000000000 --- a/apps/frontend/src/components/ui/servers/PoweredByPyro.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/apps/frontend/src/public/Monocraft.ttf b/apps/frontend/src/public/Monocraft.ttf deleted file mode 100644 index 4066b0a98..000000000 Binary files a/apps/frontend/src/public/Monocraft.ttf and /dev/null differ diff --git a/apps/labrinth/src/routes/internal/statuses.rs b/apps/labrinth/src/routes/internal/statuses.rs index 800bf681f..233134820 100644 --- a/apps/labrinth/src/routes/internal/statuses.rs +++ b/apps/labrinth/src/routes/internal/statuses.rs @@ -91,8 +91,7 @@ pub async fn ws_init( let friend_statuses = if !friends.is_empty() { let db = db.clone(); let redis = redis.clone(); - - let statuses = tokio_stream::iter(friends.iter()) + tokio_stream::iter(friends.iter()) .map(|x| { let db = db.clone(); let redis = redis.clone(); @@ -112,9 +111,10 @@ pub async fn ws_init( }) .buffer_unordered(16) .collect::>() - .await; - - statuses.into_iter().flatten().collect() + .await + .into_iter() + .flatten() + .collect() } else { Vec::new() }; diff --git a/packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json b/packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json new file mode 100644 index 000000000..052e614c9 --- /dev/null +++ b/packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11,\n $12, $13, $14,\n $15, $16,\n $17, jsonb($18), jsonb($19),\n $20, $21, $22, $23,\n $24, $25, $26,\n $27\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n\n created = $12,\n modified = $13,\n last_played = $14,\n\n submitted_time_played = $15,\n recent_time_played = $16,\n\n override_java_path = $17,\n override_extra_launch_args = jsonb($18),\n override_custom_env_vars = jsonb($19),\n override_mc_memory_max = $20,\n override_mc_force_fullscreen = $21,\n override_mc_game_resolution_x = $22,\n override_mc_game_resolution_y = $23,\n\n override_hook_pre_launch = $24,\n override_hook_wrapper = $25,\n override_hook_post_exit = $26,\n\n protocol_version = $27\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 27 + }, + "nullable": [] + }, + "hash": "06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3" +} diff --git a/packages/app-lib/.sqlx/query-4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1.json b/packages/app-lib/.sqlx/query-1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb.json similarity index 75% rename from packages/app-lib/.sqlx/query-4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1.json rename to packages/app-lib/.sqlx/query-1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb.json index a28eadf3b..7fce3d030 100644 --- a/packages/app-lib/.sqlx/query-4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1.json +++ b/packages/app-lib/.sqlx/query-1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", + "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", "describe": { "columns": [ { @@ -29,109 +29,114 @@ "type_info": "Text" }, { - "name": "mod_loader", + "name": "protocol_version", "ordinal": 5, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "mod_loader_version", + "name": "mod_loader", "ordinal": 6, "type_info": "Text" }, { - "name": "groups!: serde_json::Value", + "name": "mod_loader_version", "ordinal": 7, + "type_info": "Text" + }, + { + "name": "groups!: serde_json::Value", + "ordinal": 8, "type_info": "Null" }, { "name": "linked_project_id", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "linked_version_id", "ordinal": 9, "type_info": "Text" }, { - "name": "locked", + "name": "linked_version_id", "ordinal": 10, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "created", + "name": "locked", "ordinal": 11, "type_info": "Integer" }, { - "name": "modified", + "name": "created", "ordinal": 12, "type_info": "Integer" }, { - "name": "last_played", + "name": "modified", "ordinal": 13, "type_info": "Integer" }, { - "name": "submitted_time_played", + "name": "last_played", "ordinal": 14, "type_info": "Integer" }, { - "name": "recent_time_played", + "name": "submitted_time_played", "ordinal": 15, "type_info": "Integer" }, { - "name": "override_java_path", + "name": "recent_time_played", "ordinal": 16, + "type_info": "Integer" + }, + { + "name": "override_java_path", + "ordinal": 17, "type_info": "Text" }, { "name": "override_extra_launch_args!: serde_json::Value", - "ordinal": 17, - "type_info": "Null" - }, - { - "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 18, "type_info": "Null" }, { - "name": "override_mc_memory_max", + "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 19, - "type_info": "Integer" + "type_info": "Null" }, { - "name": "override_mc_force_fullscreen", + "name": "override_mc_memory_max", "ordinal": 20, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_x", + "name": "override_mc_force_fullscreen", "ordinal": 21, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_y", + "name": "override_mc_game_resolution_x", "ordinal": 22, "type_info": "Integer" }, { - "name": "override_hook_pre_launch", + "name": "override_mc_game_resolution_y", "ordinal": 23, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "override_hook_wrapper", + "name": "override_hook_pre_launch", "ordinal": 24, "type_info": "Text" }, { - "name": "override_hook_post_exit", + "name": "override_hook_wrapper", "ordinal": 25, "type_info": "Text" + }, + { + "name": "override_hook_post_exit", + "ordinal": 26, + "type_info": "Text" } ], "parameters": { @@ -143,6 +148,7 @@ false, true, false, + true, false, true, null, @@ -166,5 +172,5 @@ true ] }, - "hash": "4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1" + "hash": "1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb" } diff --git a/packages/app-lib/.sqlx/query-5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094.json b/packages/app-lib/.sqlx/query-30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d.json similarity index 76% rename from packages/app-lib/.sqlx/query-5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094.json rename to packages/app-lib/.sqlx/query-30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d.json index 279912e85..89040a6c9 100644 --- a/packages/app-lib/.sqlx/query-5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094.json +++ b/packages/app-lib/.sqlx/query-30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", + "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", "describe": { "columns": [ { @@ -29,109 +29,114 @@ "type_info": "Text" }, { - "name": "mod_loader", + "name": "protocol_version", "ordinal": 5, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "mod_loader_version", + "name": "mod_loader", "ordinal": 6, "type_info": "Text" }, { - "name": "groups!: serde_json::Value", + "name": "mod_loader_version", "ordinal": 7, + "type_info": "Text" + }, + { + "name": "groups!: serde_json::Value", + "ordinal": 8, "type_info": "Null" }, { "name": "linked_project_id", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "linked_version_id", "ordinal": 9, "type_info": "Text" }, { - "name": "locked", + "name": "linked_version_id", "ordinal": 10, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "created", + "name": "locked", "ordinal": 11, "type_info": "Integer" }, { - "name": "modified", + "name": "created", "ordinal": 12, "type_info": "Integer" }, { - "name": "last_played", + "name": "modified", "ordinal": 13, "type_info": "Integer" }, { - "name": "submitted_time_played", + "name": "last_played", "ordinal": 14, "type_info": "Integer" }, { - "name": "recent_time_played", + "name": "submitted_time_played", "ordinal": 15, "type_info": "Integer" }, { - "name": "override_java_path", + "name": "recent_time_played", "ordinal": 16, + "type_info": "Integer" + }, + { + "name": "override_java_path", + "ordinal": 17, "type_info": "Text" }, { "name": "override_extra_launch_args!: serde_json::Value", - "ordinal": 17, - "type_info": "Null" - }, - { - "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 18, "type_info": "Null" }, { - "name": "override_mc_memory_max", + "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 19, - "type_info": "Integer" + "type_info": "Null" }, { - "name": "override_mc_force_fullscreen", + "name": "override_mc_memory_max", "ordinal": 20, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_x", + "name": "override_mc_force_fullscreen", "ordinal": 21, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_y", + "name": "override_mc_game_resolution_x", "ordinal": 22, "type_info": "Integer" }, { - "name": "override_hook_pre_launch", + "name": "override_mc_game_resolution_y", "ordinal": 23, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "override_hook_wrapper", + "name": "override_hook_pre_launch", "ordinal": 24, "type_info": "Text" }, { - "name": "override_hook_post_exit", + "name": "override_hook_wrapper", "ordinal": 25, "type_info": "Text" + }, + { + "name": "override_hook_post_exit", + "ordinal": 26, + "type_info": "Text" } ], "parameters": { @@ -143,6 +148,7 @@ false, true, false, + true, false, true, null, @@ -166,5 +172,5 @@ true ] }, - "hash": "5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094" + "hash": "30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d" } diff --git a/packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json b/packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json new file mode 100644 index 000000000..aaea116c7 --- /dev/null +++ b/packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT profile_path, host, port, join_time\n FROM join_log\n WHERE profile_path = $1\n ", + "describe": { + "columns": [ + { + "name": "profile_path", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "host", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "join_time", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c" +} diff --git a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json index 9742cb7b4..22e39e75b 100644 --- a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json +++ b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json @@ -41,7 +41,7 @@ { "name": "display_claims!: serde_json::Value", "ordinal": 7, - "type_info": "Null" + "type_info": "Text" } ], "parameters": { diff --git a/packages/app-lib/.sqlx/query-db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json b/packages/app-lib/.sqlx/query-db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json deleted file mode 100644 index 4d503ee1d..000000000 --- a/packages/app-lib/.sqlx/query-db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11,\n $12, $13, $14,\n $15, $16,\n $17, jsonb($18), jsonb($19),\n $20, $21, $22, $23,\n $24, $25, $26\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n\n created = $12,\n modified = $13,\n last_played = $14,\n\n submitted_time_played = $15,\n recent_time_played = $16,\n\n override_java_path = $17,\n override_extra_launch_args = jsonb($18),\n override_custom_env_vars = jsonb($19),\n override_mc_memory_max = $20,\n override_mc_force_fullscreen = $21,\n override_mc_game_resolution_x = $22,\n override_mc_game_resolution_y = $23,\n\n override_hook_pre_launch = $24,\n override_hook_wrapper = $25,\n override_hook_post_exit = $26\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 26 - }, - "nullable": [] - }, - "hash": "db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58" -} diff --git a/packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json b/packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json new file mode 100644 index 000000000..017a85166 --- /dev/null +++ b/packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO join_log (profile_path, host, port, join_time)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (profile_path, host, port) DO UPDATE SET\n join_time = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641" +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 9278509c4..a8fa45bfb 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -11,13 +11,14 @@ serde_json = "1.0" serde_ini = "0.2.0" sha1_smol = { version = "1.0.0", features = ["std"] } sha2 = "0.10.8" -url = "2.2" +url = { version = "2.2", features = ["serde"] } uuid = { version = "1.1", features = ["serde", "v4"] } zip = "0.6.5" async_zip = { version = "0.0.17", features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] } flate2 = "1.0.28" tempfile = "3.5.0" dashmap = { version = "6.0.1", features = ["serde"] } +quick-xml = { version = "0.37", features = ["async-tokio"] } chrono = { version = "0.4.19", features = ["serde"] } daedalus = { path = "../../packages/daedalus" } @@ -42,7 +43,10 @@ async-tungstenite = { version = "0.27.0", features = ["tokio-runtime", "tokio-ru futures = "0.3" reqwest = { version = "0.12.3", features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls", "charset", "http2", "macos-system-configuration"], default-features = false } tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" async-recursion = "1.0.4" +fs4 = { version = "0.13", features = ["tokio"] } +async-walkdir = "2.1" notify = { version = "6.1.1", default-features = false } notify-debouncer-mini = { version = "0.4.1", default-features = false } @@ -61,6 +65,9 @@ base64 = "0.22.0" sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros" ] } +quartz_nbt = { version = "0.2", features = ["serde"] } +hickory-resolver = "0.25" + ariadne = { path = "../ariadne" } [target.'cfg(windows)'.dependencies] diff --git a/packages/app-lib/migrations/20250318160526_protocol-versions.sql b/packages/app-lib/migrations/20250318160526_protocol-versions.sql new file mode 100644 index 000000000..d067b3fe3 --- /dev/null +++ b/packages/app-lib/migrations/20250318160526_protocol-versions.sql @@ -0,0 +1 @@ +ALTER TABLE profiles ADD COLUMN protocol_version INTEGER NULL diff --git a/packages/app-lib/migrations/20250408181656_add-join-log.sql b/packages/app-lib/migrations/20250408181656_add-join-log.sql new file mode 100644 index 000000000..119891eed --- /dev/null +++ b/packages/app-lib/migrations/20250408181656_add-join-log.sql @@ -0,0 +1,10 @@ +CREATE TABLE join_log ( + profile_path TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + join_time INTEGER NOT NULL, + + PRIMARY KEY (profile_path, host, port), + FOREIGN KEY (profile_path) REFERENCES profiles(path) ON DELETE CASCADE +); +CREATE INDEX join_log_profile_path ON join_log(profile_path); diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index 8ba9108b1..cff82794f 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -298,7 +298,7 @@ pub async fn get_latest_log_cursor( profile_path: &str, cursor: u64, // 0 to start at beginning of file ) -> crate::Result { - get_generic_live_log_cursor(profile_path, "latest.log", cursor).await + get_generic_live_log_cursor(profile_path, "launcher_log.txt", cursor).await } #[tracing::instrument] diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 81e4c5e67..3c142edc1 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -12,6 +12,7 @@ pub mod process; pub mod profile; pub mod settings; pub mod tags; +pub mod worlds; pub mod data { pub use crate::state::{ diff --git a/packages/app-lib/src/api/profile/create.rs b/packages/app-lib/src/api/profile/create.rs index b32c95e5c..aa840a3ac 100644 --- a/packages/app-lib/src/api/profile/create.rs +++ b/packages/app-lib/src/api/profile/create.rs @@ -77,6 +77,7 @@ pub async fn profile_create( name, icon_path: None, game_version, + protocol_version: None, loader: modloader, loader_version: loader.map(|x| x.id), groups: Vec::new(), diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs index 54ee1a435..c44333bc0 100644 --- a/packages/app-lib/src/api/profile/mod.rs +++ b/packages/app-lib/src/api/profile/mod.rs @@ -36,6 +36,13 @@ use tokio::{fs::File, process::Command, sync::RwLock}; pub mod create; pub mod update; +#[derive(Debug, Clone)] +pub enum QuickPlayType { + None, + Singleplayer(String), + Server(String), +} + /// Remove a profile #[tracing::instrument] pub async fn remove(path: &str) -> crate::Result<()> { @@ -623,14 +630,17 @@ fn pack_get_relative_path( /// Run Minecraft using a profile and the default credentials, logged in credentials, /// failing with an error if no credentials are available #[tracing::instrument] -pub async fn run(path: &str) -> crate::Result { +pub async fn run( + path: &str, + quick_play_type: &QuickPlayType, +) -> crate::Result { let state = State::get().await?; let default_account = Credentials::get_default_credential(&state.pool) .await? .ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?; - run_credentials(path, &default_account).await + run_credentials(path, &default_account, quick_play_type).await } /// Run Minecraft using a profile, and credentials for authentication @@ -640,6 +650,7 @@ pub async fn run(path: &str) -> crate::Result { pub async fn run_credentials( path: &str, credentials: &Credentials, + quick_play_type: &QuickPlayType, ) -> crate::Result { let state = State::get().await?; let settings = Settings::get(&state.pool).await?; @@ -719,6 +730,7 @@ pub async fn run_credentials( credentials, post_exit_hook, &profile, + quick_play_type, ) .await } diff --git a/packages/app-lib/src/api/worlds.rs b/packages/app-lib/src/api/worlds.rs new file mode 100644 index 000000000..1868f75ae --- /dev/null +++ b/packages/app-lib/src/api/worlds.rs @@ -0,0 +1,830 @@ +use crate::data::ModLoader; +use crate::launcher::get_loader_version_from_profile; +use crate::profile::get_full_path; +use crate::state::{server_join_log, Profile, ProfileInstallStage}; +pub use crate::util::server_ping::{ + ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion, +}; +use crate::util::{io, server_ping}; +use crate::{launcher, Error, ErrorKind, Result, State}; +use async_walkdir::WalkDir; +use async_zip::{Compression, ZipEntryBuilder}; +use chrono::{DateTime, Local, TimeZone, Utc}; +use either::Either; +use fs4::tokio::AsyncFileExt; +use futures::StreamExt; +use lazy_static::lazy_static; +use quartz_nbt::{NbtCompound, NbtTag}; +use regex::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::cmp::Reverse; +use std::io::Cursor; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; +use tokio_util::compat::FuturesAsyncWriteCompatExt; +use url::Url; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct WorldWithProfile { + pub profile: String, + #[serde(flatten)] + pub world: World, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct World { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_played: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + with = "either::serde_untagged_optional" + )] + pub icon: Option>, + #[serde(flatten)] + pub details: WorldDetails, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WorldDetails { + Singleplayer { + path: String, + game_mode: SingleplayerGameMode, + hardcore: bool, + locked: bool, + }, + Server { + index: usize, + address: String, + pack_status: ServerPackStatus, + }, +} + +#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum SingleplayerGameMode { + #[default] + Survival, + Creative, + Adventure, + Spectator, +} + +#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum ServerPackStatus { + Enabled, + Disabled, + #[default] + Prompt, +} + +impl From> for ServerPackStatus { + fn from(value: Option) -> Self { + match value { + Some(true) => ServerPackStatus::Enabled, + Some(false) => ServerPackStatus::Disabled, + None => ServerPackStatus::Prompt, + } + } +} + +impl From for Option { + fn from(val: ServerPackStatus) -> Self { + match val { + ServerPackStatus::Enabled => Some(true), + ServerPackStatus::Disabled => Some(false), + ServerPackStatus::Prompt => None, + } + } +} + +pub async fn get_recent_worlds(limit: usize) -> Result> { + let state = State::get().await?; + let profiles_dir = state.directories.profiles_dir(); + + let mut profiles = Profile::get_all(&state.pool).await?; + profiles.sort_by_key(|x| Reverse(x.last_played)); + + let mut result = Vec::with_capacity(limit); + + let mut least_recent_time = None; + for profile in profiles { + if result.len() >= limit && profile.last_played < least_recent_time { + break; + } + let profile_path = &profile.path; + let profile_dir = profiles_dir.join(profile_path); + let profile_worlds = + get_all_worlds_in_profile(profile_path, &profile_dir).await; + if let Err(e) = profile_worlds { + tracing::error!( + "Failed to get worlds for profile {}: {}", + profile_path, + e + ); + continue; + } + for world in profile_worlds? { + let is_older = least_recent_time.is_none() + || world.last_played < least_recent_time; + if result.len() >= limit && is_older { + continue; + } + if is_older { + least_recent_time = world.last_played; + } + result.push(WorldWithProfile { + profile: profile_path.clone(), + world, + }); + } + if result.len() > limit { + result.sort_by_key(|x| Reverse(x.world.last_played)); + result.truncate(limit); + } + } + + if result.len() <= limit { + result.sort_by_key(|x| Reverse(x.world.last_played)); + } + Ok(result) +} + +pub async fn get_profile_worlds(profile_path: &str) -> Result> { + get_all_worlds_in_profile(profile_path, &get_full_path(profile_path).await?) + .await +} + +async fn get_all_worlds_in_profile( + profile_path: &str, + profile_dir: &Path, +) -> Result> { + let mut worlds = vec![]; + get_singleplayer_worlds_in_profile(profile_dir, &mut worlds).await?; + get_server_worlds_in_profile(profile_path, profile_dir, &mut worlds) + .await?; + Ok(worlds) +} + +async fn get_singleplayer_worlds_in_profile( + instance_dir: &Path, + worlds: &mut Vec, +) -> Result<()> { + let saves_dir = instance_dir.join("saves"); + if !saves_dir.exists() { + return Ok(()); + } + let mut saves_dir = io::read_dir(saves_dir).await?; + while let Some(world_dir) = saves_dir.next_entry().await? { + let world_path = world_dir.path(); + let level_dat_path = world_path.join("level.dat"); + if !level_dat_path.exists() { + continue; + } + if let Ok(world) = read_singleplayer_world(world_path).await { + worlds.push(world); + } + } + + Ok(()) +} + +pub async fn get_singleplayer_world( + profile_path: &Path, + world: &str, +) -> Result { + read_singleplayer_world(get_world_dir(profile_path, world)).await +} + +async fn read_singleplayer_world(world_path: PathBuf) -> Result { + if let Some(_lock) = try_get_world_session_lock(&world_path).await? { + read_singleplayer_world_maybe_locked(world_path, false).await + } else { + read_singleplayer_world_maybe_locked(world_path, true).await + } +} + +async fn read_singleplayer_world_maybe_locked( + world_path: PathBuf, + locked: bool, +) -> Result { + #[derive(Deserialize, Debug)] + #[serde(rename_all = "PascalCase")] + struct LevelDataRoot { + data: LevelData, + } + + #[derive(Deserialize, Debug)] + #[serde(rename_all = "PascalCase")] + struct LevelData { + #[serde(default)] + level_name: String, + #[serde(default)] + last_played: i64, + #[serde(default)] + game_type: i32, + #[serde(default, rename = "hardcore")] + hardcore: bool, + } + + let level_data = io::read(world_path.join("level.dat")).await?; + let level_data: LevelDataRoot = quartz_nbt::serde::deserialize( + &level_data, + quartz_nbt::io::Flavor::GzCompressed, + )? + .0; + let level_data = level_data.data; + + let icon = Some(world_path.join("icon.png")).filter(|i| i.exists()); + + let game_mode = match level_data.game_type { + 0 => SingleplayerGameMode::Survival, + 1 => SingleplayerGameMode::Creative, + 2 => SingleplayerGameMode::Adventure, + 3 => SingleplayerGameMode::Spectator, + _ => SingleplayerGameMode::Survival, + }; + + Ok(World { + name: level_data.level_name, + last_played: Utc.timestamp_millis_opt(level_data.last_played).single(), + icon: icon.map(Either::Left), + details: WorldDetails::Singleplayer { + path: world_path + .file_name() + .unwrap() + .to_string_lossy() + .to_string(), + game_mode, + hardcore: level_data.hardcore, + locked, + }, + }) +} + +async fn get_server_worlds_in_profile( + profile_path: &str, + instance_dir: &Path, + worlds: &mut Vec, +) -> Result<()> { + let servers = servers_data::read(instance_dir).await?; + if servers.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let join_log = server_join_log::get_joins(profile_path, &state.pool) + .await + .ok(); + + for (index, server) in servers.into_iter().enumerate() { + if server.hidden { + // TODO: Figure out whether we want to hide or show direct connect servers + continue; + } + let icon = server.icon.and_then(|icon| { + Url::parse(&format!("data:image/png;base64,{}", icon)).ok() + }); + let last_played = join_log + .as_ref() + .and_then(|log| { + let address = parse_server_address(&server.ip).ok()?; + log.get(&(address.0.to_owned(), address.1)) + }) + .copied(); + let world = World { + name: server.name, + last_played, + icon: icon.map(Either::Right), + details: WorldDetails::Server { + index, + address: server.ip, + pack_status: server.accept_textures.into(), + }, + }; + worlds.push(world); + } + + Ok(()) +} + +pub async fn rename_world( + instance: &Path, + world: &str, + new_name: &str, +) -> Result<()> { + let world = get_world_dir(instance, world); + let level_dat_path = world.join("level.dat"); + if !level_dat_path.exists() { + return Ok(()); + } + let _lock = get_world_session_lock(&world).await?; + + let level_data = io::read(&level_dat_path).await?; + let (mut root_data, _) = quartz_nbt::io::read_nbt( + &mut Cursor::new(level_data), + quartz_nbt::io::Flavor::GzCompressed, + )?; + let data = root_data.get_mut::<_, &mut NbtCompound>("Data")?; + + data.insert( + "LevelName", + NbtTag::String(new_name.trim_ascii().to_string()), + ); + + let mut level_data = vec![]; + quartz_nbt::io::write_nbt( + &mut level_data, + None, + &root_data, + quartz_nbt::io::Flavor::GzCompressed, + )?; + io::write(level_dat_path, level_data).await?; + Ok(()) +} + +pub async fn reset_world_icon(instance: &Path, world: &str) -> Result<()> { + let world = get_world_dir(instance, world); + let icon = world.join("icon.png"); + if let Some(_lock) = try_get_world_session_lock(&world).await? { + let _ = io::remove_file(icon).await; + } + Ok(()) +} + +pub async fn backup_world(instance: &Path, world: &str) -> Result { + let world_dir = get_world_dir(instance, world); + let _lock = get_world_session_lock(&world_dir).await?; + let backups_dir = instance.join("backups"); + + io::create_dir_all(&backups_dir).await?; + + let name_base = { + let now = Local::now(); + let formatted_time = now.format("%Y-%m-%d_%H-%M-%S"); + format!("{}_{}", formatted_time, world) + }; + let output_path = + backups_dir.join(find_available_name(&backups_dir, &name_base, ".zip")); + + let writer = tokio::fs::File::create(&output_path).await?; + let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(writer); + + let mut walker = WalkDir::new(&world_dir); + while let Some(entry) = walker.next().await { + let entry = entry.map_err(|e| io::IOError::IOPathError { + path: e.path().unwrap().to_string_lossy().to_string(), + source: e.into_io().unwrap(), + })?; + if !entry.file_type().await?.is_file() { + continue; + } + if entry.file_name() == "session.lock" { + continue; + } + let zip_filename = format!( + "{world}/{}", + entry + .path() + .strip_prefix(&world_dir)? + .display() + .to_string() + .replace('\\', "/") + ); + let mut stream = writer + .write_entry_stream( + ZipEntryBuilder::new(zip_filename.into(), Compression::Deflate) + .build(), + ) + .await? + .compat_write(); + let mut source = tokio::fs::File::open(entry.path()).await?; + tokio::io::copy(&mut source, &mut stream).await?; + stream.into_inner().close().await?; + } + + writer.close().await?; + Ok(io::metadata(output_path).await?.len()) +} + +fn find_available_name(dir: &Path, file_name: &str, extension: &str) -> String { + lazy_static! { + static ref RESERVED_WINDOWS_FILENAMES: Regex = RegexBuilder::new(r#"^.*\.|(?:COM|CLOCK\$|CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$"#) + .case_insensitive(true) + .build() + .unwrap(); + static ref COPY_COUNTER_PATTERN: Regex = RegexBuilder::new(r#"^(?.*) \((?\d*)\)$"#) + .case_insensitive(true) + .unicode(true) + .build() + .unwrap(); + } + + let mut file_name = file_name.replace( + [ + '/', '\n', '\r', '\t', '\0', '\x0c', '`', '?', '*', '\\', '<', '>', + '|', '"', ':', '.', '/', '"', + ], + "_", + ); + if RESERVED_WINDOWS_FILENAMES.is_match(&file_name) { + file_name.insert(0, '_'); + file_name.push('_'); + } + + let mut count = 0; + if let Some(find) = COPY_COUNTER_PATTERN.captures(&file_name) { + count = find + .name("count") + .unwrap() + .as_str() + .parse::() + .unwrap_or(0); + let end = find.name("name").unwrap().end(); + drop(find); + file_name.truncate(end); + } + + if file_name.len() > 255 - extension.len() { + file_name.truncate(255 - extension.len()); + } + + let mut current_attempt = file_name.clone(); + loop { + if count != 0 { + let with_count = format!(" ({count})"); + if file_name.len() > 255 - with_count.len() { + current_attempt.truncate(255 - with_count.len()); + } + current_attempt.push_str(&with_count); + } + + current_attempt.push_str(extension); + + let result = dir.join(¤t_attempt); + if !result.exists() { + return current_attempt; + } + + count += 1; + current_attempt.replace_range(..current_attempt.len(), &file_name); + } +} + +pub async fn delete_world(instance: &Path, world: &str) -> Result<()> { + let world = get_world_dir(instance, world); + let lock = get_world_session_lock(&world).await?; + let lock_path = world.join("session.lock"); + + let mut dir = io::read_dir(&world).await?; + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + if entry.file_type().await?.is_dir() { + io::remove_dir_all(path).await?; + continue; + } + if path != lock_path { + io::remove_file(path).await?; + } + } + + drop(lock); + io::remove_file(lock_path).await?; + io::remove_dir(world).await?; + + Ok(()) +} + +fn get_world_dir(instance: &Path, world: &str) -> PathBuf { + instance.join("saves").join(world) +} + +async fn get_world_session_lock(world: &Path) -> Result { + let lock_path = world.join("session.lock"); + let mut file = tokio::fs::File::options() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path) + .await?; + file.write_all("☃".as_bytes()).await?; + file.sync_all().await?; + let locked = file.try_lock_exclusive()?; + locked.then_some(file).ok_or_else(|| { + io::IOError::IOPathError { + source: std::io::Error::new( + std::io::ErrorKind::ResourceBusy, + "already locked by Minecraft", + ), + path: lock_path.to_string_lossy().into_owned(), + } + .into() + }) +} + +async fn try_get_world_session_lock( + world: &Path, +) -> Result> { + let file = tokio::fs::File::options() + .create(true) + .write(true) + .truncate(false) + .open(world.join("session.lock")) + .await?; + file.sync_all().await?; + let locked = file.try_lock_exclusive()?; + Ok(locked.then_some(file)) +} + +pub async fn add_server_to_profile( + profile_path: &Path, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result { + let mut servers = servers_data::read(profile_path).await?; + let insert_index = servers + .iter() + .position(|x| x.hidden) + .unwrap_or(servers.len()); + servers.insert( + insert_index, + servers_data::ServerData { + name, + ip: address, + accept_textures: pack_status.into(), + hidden: false, + icon: None, + }, + ); + servers_data::write(profile_path, &servers).await?; + Ok(insert_index) +} + +pub async fn edit_server_in_profile( + profile_path: &Path, + index: usize, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result<()> { + let mut servers = servers_data::read(profile_path).await?; + let server = + servers + .get_mut(index) + .filter(|x| !x.hidden) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No editable server at index {index}" + )) + .as_error() + })?; + server.name = name; + server.ip = address; + server.accept_textures = pack_status.into(); + servers_data::write(profile_path, &servers).await?; + Ok(()) +} + +pub async fn remove_server_from_profile( + profile_path: &Path, + index: usize, +) -> Result<()> { + let mut servers = servers_data::read(profile_path).await?; + if servers.get(index).filter(|x| !x.hidden).is_none() { + return Err(ErrorKind::InputError(format!( + "No removable server at index {index}" + )) + .into()); + } + servers.remove(index); + servers_data::write(profile_path, &servers).await?; + Ok(()) +} + +mod servers_data { + use crate::util::io; + use crate::Result; + use serde::{Deserialize, Serialize}; + use std::path::Path; + + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "camelCase")] + pub struct ServerData { + #[serde(default)] + pub hidden: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default)] + pub ip: String, + #[serde(default)] + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub accept_textures: Option, + } + + pub async fn read(instance_dir: &Path) -> Result> { + #[derive(Deserialize, Debug)] + struct ServersData { + #[serde(default)] + servers: Vec, + } + + let servers_dat_path = instance_dir.join("servers.dat"); + if !servers_dat_path.exists() { + return Ok(vec![]); + } + let servers_data = io::read(servers_dat_path).await?; + let servers_data: ServersData = quartz_nbt::serde::deserialize( + &servers_data, + quartz_nbt::io::Flavor::Uncompressed, + )? + .0; + Ok(servers_data.servers) + } + + pub async fn write( + instance_dir: &Path, + servers: &[ServerData], + ) -> Result<()> { + #[derive(Serialize, Debug)] + struct ServersData<'a> { + servers: &'a [ServerData], + } + + let servers_dat_path = instance_dir.join("servers.dat"); + let data = quartz_nbt::serde::serialize( + &ServersData { servers }, + None, + quartz_nbt::io::Flavor::Uncompressed, + )?; + io::write(servers_dat_path, data).await?; + Ok(()) + } +} + +pub async fn get_profile_protocol_version( + profile: &str, +) -> Result> { + let mut profile = super::profile::get(profile).await?.ok_or_else(|| { + ErrorKind::UnmanagedProfileError(format!( + "Could not find profile {}", + profile + )) + })?; + if profile.install_stage != ProfileInstallStage::Installed { + return Ok(None); + } + + if let Some(protocol_version) = profile.protocol_version { + return Ok(Some(protocol_version)); + } + + let minecraft = crate::api::metadata::get_minecraft_versions().await?; + let version_index = minecraft + .versions + .iter() + .position(|it| it.id == profile.game_version) + .ok_or(crate::ErrorKind::LauncherError(format!( + "Invalid game version: {}", + profile.game_version + )))?; + let version = &minecraft.versions[version_index]; + + let loader_version = get_loader_version_from_profile( + &profile.game_version, + profile.loader, + profile.loader_version.as_deref(), + ) + .await?; + if profile.loader != ModLoader::Vanilla && loader_version.is_none() { + return Ok(None); + } + + let version_jar = + loader_version.as_ref().map_or(version.id.clone(), |it| { + format!("{}-{}", version.id.clone(), it.id.clone()) + }); + + let state = State::get().await?; + let client_path = state + .directories + .version_dir(&version_jar) + .join(format!("{version_jar}.jar")); + + if !client_path.exists() { + return Ok(None); + } + + let version = launcher::read_protocol_version_from_jar(client_path).await?; + if version.is_some() { + profile.protocol_version = version; + profile.upsert(&state.pool).await?; + } + Ok(version) +} + +pub async fn get_server_status( + address: &str, + protocol_version: Option, +) -> Result { + let (original_host, original_port) = parse_server_address(address)?; + let (host, port) = + resolve_server_address(original_host, original_port).await?; + server_ping::get_server_status( + &(&host as &str, port), + (original_host, original_port), + protocol_version, + ) + .await +} + +pub fn parse_server_address(address: &str) -> Result<(&str, u16)> { + parse_server_address_inner(address) + .map_err(|e| Error::from(ErrorKind::InputError(e))) +} + +// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565 +fn parse_server_address_inner( + address: &str, +) -> std::result::Result<(&str, u16), String> { + let (host, port_str) = if address.starts_with("[") { + let colon_index = address.find(':'); + let close_bracket_index = address.rfind(']'); + if colon_index.is_none() || close_bracket_index.is_none() { + return Err(format!("Invalid bracketed host/port: {address}")); + } + let close_bracket_index = close_bracket_index.unwrap(); + + let host = &address[1..close_bracket_index]; + if close_bracket_index + 1 == address.len() { + (host, "") + } else { + if address.as_bytes().get(close_bracket_index).copied() + != Some(b':') + { + return Err(format!( + "Only a colon may follow a close bracket: {address}" + )); + } + let port_str = &address[close_bracket_index + 2..]; + for c in port_str.chars() { + if !c.is_ascii_digit() { + return Err(format!("Port must be numeric: {address}")); + } + } + (host, port_str) + } + } else { + let colon_pos = address.find(':'); + if let Some(colon_pos) = colon_pos { + (&address[..colon_pos], &address[colon_pos + 1..]) + } else { + (address, "") + } + }; + + let mut port = None; + if !port_str.is_empty() { + if port_str.starts_with('+') { + return Err(format!("Unparseable port number: {port_str}")); + } + port = port_str.parse::().ok(); + if port.is_none() { + return Err(format!("Unparseable port number: {port_str}")); + } + } + + Ok((host, port.unwrap_or(25565))) +} + +async fn resolve_server_address( + host: &str, + port: u16, +) -> Result<(String, u16)> { + if host.parse::().is_ok() || host.parse::().is_ok() { + return Ok((host.to_owned(), port)); + } + let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build(); + Ok(match resolver + .srv_lookup(format!("_minecraft._tcp.{}", host)) + .await + { + Err(e) + if e.proto() + .filter(|x| x.kind().is_no_records_found()) + .is_some() => + { + None + } + Err(e) => return Err(e.into()), + Ok(lookup) => lookup + .into_iter() + .next() + .map(|r| (r.target().to_string(), r.port())), + } + .unwrap_or_else(|| (host.to_owned(), port))) +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 9eabe33a7..587c9559a 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -13,6 +13,12 @@ pub enum ErrorKind { #[error("Serialization error (JSON): {0}")] JSONError(#[from] serde_json::Error), + #[error("Serialization error (NBT): {0}")] + NBTError(#[from] quartz_nbt::io::NbtIoError), + + #[error("NBT data structure error: {0}")] + NBTReprError(#[from] quartz_nbt::NbtReprError), + #[error("Serialization error (websocket): {0}")] WebsocketSerializationError( #[from] ariadne::networking::serialization::SerializationError, @@ -116,6 +122,9 @@ pub enum ErrorKind { #[error("Move directory error: {0}")] DirectoryMoveError(String), + + #[error("Error resolving DNS: {0}")] + DNSError(#[from] hickory_resolver::ResolveError), } #[derive(Debug)] diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index 0c0ac023e..0c2b22df8 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -1,5 +1,6 @@ //! Theseus state management system use ariadne::users::{UserId, UserStatus}; +use chrono::{DateTime, Utc}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use std::{path::PathBuf, sync::Arc}; @@ -234,13 +235,23 @@ pub enum ProcessPayloadType { #[derive(Serialize, Clone)] pub struct ProfilePayload { pub profile_path_id: String, + #[serde(flatten)] pub event: ProfilePayloadType, } #[derive(Serialize, Clone)] -#[serde(rename_all = "snake_case")] +#[serde(tag = "event", rename_all = "snake_case")] pub enum ProfilePayloadType { Created, Synced, + ServersUpdated, + WorldUpdated { + world: String, + }, + ServerJoined { + host: String, + port: u16, + timestamp: DateTime, + }, Edited, Removed, } diff --git a/packages/app-lib/src/launcher/args.rs b/packages/app-lib/src/launcher/args.rs index 919c33042..23583406f 100644 --- a/packages/app-lib/src/launcher/args.rs +++ b/packages/app-lib/src/launcher/args.rs @@ -1,5 +1,6 @@ //! Minecraft CLI argument logic use crate::launcher::parse_rules; +use crate::profile::QuickPlayType; use crate::state::Credentials; use crate::{ state::{MemorySettings, WindowSize}, @@ -31,7 +32,12 @@ pub fn get_class_paths( .iter() .filter_map(|library| { if let Some(rules) = &library.rules { - if !parse_rules(rules, java_arch, minecraft_updated) { + if !parse_rules( + rules, + java_arch, + &QuickPlayType::None, + minecraft_updated, + ) { return None; } } @@ -111,6 +117,7 @@ pub fn get_jvm_arguments( memory: MemorySettings, custom_args: Vec, java_arch: &str, + quick_play_type: &QuickPlayType, log_config: Option<&LoggingConfiguration>, ) -> crate::Result> { let mut parsed_arguments = Vec::new(); @@ -130,6 +137,7 @@ pub fn get_jvm_arguments( ) }, java_arch, + quick_play_type, )?; } else { parsed_arguments.push(format!( @@ -214,6 +222,7 @@ pub fn get_minecraft_arguments( version_type: &VersionType, resolution: WindowSize, java_arch: &str, + quick_play_type: &QuickPlayType, ) -> crate::Result> { if let Some(arguments) = arguments { let mut parsed_arguments = Vec::new(); @@ -233,9 +242,11 @@ pub fn get_minecraft_arguments( assets_directory, version_type, resolution, + quick_play_type, ) }, java_arch, + quick_play_type, )?; Ok(parsed_arguments) @@ -253,6 +264,7 @@ pub fn get_minecraft_arguments( assets_directory, version_type, resolution, + quick_play_type, )?); } Ok(parsed_arguments) @@ -273,6 +285,7 @@ fn parse_minecraft_argument( assets_directory: &Path, version_type: &VersionType, resolution: WindowSize, + quick_play_type: &QuickPlayType, ) -> crate::Result { Ok(argument .replace("${accessToken}", access_token) @@ -326,7 +339,21 @@ fn parse_minecraft_argument( ) .replace("${version_type}", version_type.as_str()) .replace("${resolution_width}", &resolution.0.to_string()) - .replace("${resolution_height}", &resolution.1.to_string())) + .replace("${resolution_height}", &resolution.1.to_string()) + .replace( + "${quickPlaySingleplayer}", + match quick_play_type { + QuickPlayType::Singleplayer(world) => world, + _ => "", + }, + ) + .replace( + "${quickPlayMultiplayer}", + match quick_play_type { + QuickPlayType::Server(address) => address, + _ => "", + }, + )) } fn parse_arguments( @@ -334,6 +361,7 @@ fn parse_arguments( parsed_arguments: &mut Vec, parse_function: F, java_arch: &str, + quick_play_type: &QuickPlayType, ) -> crate::Result<()> where F: Fn(&str) -> crate::Result, @@ -348,7 +376,7 @@ where } } Argument::Ruled { rules, value } => { - if parse_rules(rules, java_arch, true) { + if parse_rules(rules, java_arch, quick_play_type, true) { match value { ArgumentValue::Single(arg) => { parsed_arguments.push(parse_function( diff --git a/packages/app-lib/src/launcher/download.rs b/packages/app-lib/src/launcher/download.rs index fbd239c8d..047b3a2eb 100644 --- a/packages/app-lib/src/launcher/download.rs +++ b/packages/app-lib/src/launcher/download.rs @@ -1,6 +1,7 @@ //! Downloader for Minecraft data use crate::launcher::parse_rules; +use crate::profile::QuickPlayType; use crate::{ event::{ emit::{emit_loading, loading_try_for_each_concurrent}, @@ -295,7 +296,7 @@ pub async fn download_libraries( stream::iter(libraries.iter()) .map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move { if let Some(rules) = &library.rules { - if !parse_rules(rules, java_arch, minecraft_updated) { + if !parse_rules(rules, java_arch, &QuickPlayType::None, minecraft_updated) { tracing::trace!("Skipped library {}", &library.name); return Ok(()); } diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index ac9cd71a2..ca7c5cf4b 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -4,6 +4,7 @@ use crate::event::emit::{emit_loading, init_or_edit_loading}; use crate::event::{LoadingBarId, LoadingBarType}; use crate::launcher::download::download_log_config; use crate::launcher::io::IOError; +use crate::profile::QuickPlayType; use crate::state::{ Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage, }; @@ -13,8 +14,10 @@ use chrono::Utc; use daedalus as d; use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo}; use daedalus::modded::LoaderVersion; +use serde::Deserialize; use st::Profile; use std::collections::HashMap; +use std::path::PathBuf; use tokio::process::Command; mod args; @@ -28,11 +31,14 @@ pub mod download; pub fn parse_rules( rules: &[d::minecraft::Rule], java_version: &str, + quick_play_type: &QuickPlayType, minecraft_updated: bool, ) -> bool { let mut x = rules .iter() - .map(|x| parse_rule(x, java_version, minecraft_updated)) + .map(|x| { + parse_rule(x, java_version, quick_play_type, minecraft_updated) + }) .collect::>>(); if rules @@ -53,6 +59,7 @@ pub fn parse_rules( pub fn parse_rule( rule: &d::minecraft::Rule, java_version: &str, + quick_play_type: &QuickPlayType, minecraft_updated: bool, ) -> Option { use d::minecraft::{Rule, RuleAction}; @@ -70,9 +77,14 @@ pub fn parse_rule( !features.is_demo_user.unwrap_or(true) || features.has_custom_resolution.unwrap_or(false) || !features.has_quick_plays_support.unwrap_or(true) - || !features.is_quick_play_multiplayer.unwrap_or(true) + || (features.is_quick_play_singleplayer.unwrap_or(false) + && matches!( + quick_play_type, + QuickPlayType::Singleplayer(_) + )) + || (features.is_quick_play_multiplayer.unwrap_or(false) + && matches!(quick_play_type, QuickPlayType::Server(..))) || !features.is_quick_play_realms.unwrap_or(true) - || !features.is_quick_play_singleplayer.unwrap_or(true) } _ => return Some(true), }; @@ -305,12 +317,11 @@ pub async fn install_minecraft( ) .await?; + let client_path = state + .directories + .version_dir(&version_jar) + .join(format!("{version_jar}.jar")); if let Some(processors) = &version_info.processors { - let client_path = state - .directories - .version_dir(&version_jar) - .join(format!("{version_jar}.jar")); - let libraries_dir = state.directories.libraries_dir(); if let Some(ref mut data) = version_info.data { @@ -403,8 +414,11 @@ pub async fn install_minecraft( } } + let protocol_version = read_protocol_version_from_jar(client_path).await?; + crate::api::profile::edit(&profile.path, |prof| { prof.install_stage = ProfileInstallStage::Installed; + prof.protocol_version = protocol_version; async { Ok(()) } }) @@ -414,6 +428,34 @@ pub async fn install_minecraft( Ok(()) } +pub async fn read_protocol_version_from_jar( + path: PathBuf, +) -> crate::Result> { + let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?; + let Some(entry_index) = zip + .file() + .entries() + .iter() + .position(|x| matches!(x.filename().as_str(), Ok("version.json"))) + else { + return Ok(None); + }; + + #[derive(Deserialize, Debug)] + struct VersionData { + protocol_version: Option, + } + + let mut data = vec![]; + zip.reader_with_entry(entry_index) + .await? + .read_to_end_checked(&mut data) + .await?; + let data: VersionData = serde_json::from_slice(&data)?; + + Ok(data.protocol_version) +} + #[tracing::instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn launch_minecraft( @@ -426,6 +468,7 @@ pub async fn launch_minecraft( credentials: &Credentials, post_exit_hook: Option, profile: &Profile, + quick_play_type: &QuickPlayType, ) -> crate::Result { if profile.install_stage == ProfileInstallStage::PackInstalling || profile.install_stage == ProfileInstallStage::MinecraftInstalling @@ -581,6 +624,7 @@ pub async fn launch_minecraft( *memory, Vec::from(java_args), &java_version.architecture, + quick_play_type, version_info .logging .as_ref() @@ -603,6 +647,7 @@ pub async fn launch_minecraft( &version.type_, *resolution, &java_version.architecture, + quick_play_type, )? .into_iter() .collect::>(), @@ -708,6 +753,12 @@ pub async fn launch_minecraft( // This also spawns the process and prepares the subsequent processes state .process_manager - .insert_new_process(&profile.path, command, post_exit_hook) + .insert_new_process( + &profile.path, + command, + post_exit_hook, + state.directories.profile_logs_dir(&profile.path), + version_info.logging.is_some(), + ) .await } diff --git a/packages/app-lib/src/state/fs_watcher.rs b/packages/app-lib/src/state/fs_watcher.rs index f30e3a3cf..30e22c39a 100644 --- a/packages/app-lib/src/state/fs_watcher.rs +++ b/packages/app-lib/src/state/fs_watcher.rs @@ -37,9 +37,7 @@ pub async fn init_watcher() -> crate::Result { let mut found = false; for component in e.path.components() { if found { - profile_path = Some( - component.as_os_str().to_string_lossy(), - ); + profile_path = Some(component.as_os_str()); break; } @@ -51,26 +49,72 @@ pub async fn init_watcher() -> crate::Result { } if let Some(profile_path) = profile_path { - if e.path + let profile_path_str = + profile_path.to_string_lossy().to_string(); + let first_file_name = e + .path .components() - .any(|x| x.as_os_str() == "crash-reports") + .skip_while(|x| x.as_os_str() != profile_path) + .nth(1) + .map(|x| x.as_os_str()); + if first_file_name + .filter(|x| *x == "crash-reports") + .is_some() && e.path .extension() - .map(|x| x == "txt") - .unwrap_or(false) + .filter(|x| *x == "txt") + .is_some() { - crash_task(profile_path.to_string()); + crash_task(profile_path_str); } else if !visited_profiles.contains(&profile_path) { - let path = profile_path.to_string(); - tokio::spawn(async move { - let _ = emit_profile( - &path, - ProfilePayloadType::Synced, - ) - .await; - }); - visited_profiles.push(profile_path); + let event = if first_file_name + .filter(|x| *x == "servers.dat") + .is_some() + { + Some(ProfilePayloadType::ServersUpdated) + } else if first_file_name + .filter(|x| { + *x == "saves" + && e.path + .file_name() + .filter(|x| *x == "level.dat") + .is_some() + }) + .is_some() + { + tracing::info!( + "World updated: {}", + e.path.display() + ); + Some(ProfilePayloadType::WorldUpdated { + world: e + .path + .parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + .to_string(), + }) + } else if first_file_name + .filter(|x| *x == "saves") + .is_none() + { + Some(ProfilePayloadType::Synced) + } else { + None + }; + if let Some(event) = event { + tokio::spawn(async move { + let _ = emit_profile( + &profile_path_str, + event, + ) + .await; + }); + visited_profiles.push(profile_path); + } } } }); @@ -111,13 +155,14 @@ pub(crate) async fn watch_profile( let profile_path = dirs.profiles_dir().join(profile_path); if profile_path.exists() && profile_path.is_dir() { - for folder in ProjectType::iterator() - .map(|x| x.get_folder()) - .chain(["crash-reports"]) - { + for folder in ProjectType::iterator().map(|x| x.get_folder()).chain([ + "crash-reports", + "saves", + "servers.dat", + ]) { let path = profile_path.join(folder); - if !path.exists() && !path.is_symlink() { + if !path.exists() && !path.is_symlink() && !folder.contains(".") { if let Err(e) = crate::util::io::create_dir_all(&path).await { tracing::error!( "Failed to create directory for watcher {path:?}: {e}" diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index e0cb60c32..1ec52ea65 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -320,6 +320,7 @@ where name: profile.metadata.name, icon_path: profile.metadata.icon, game_version: profile.metadata.game_version, + protocol_version: None, loader: profile.metadata.loader.into(), loader_version: profile .metadata diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 75f73f689..5ca7ffbe2 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -45,6 +45,8 @@ pub use self::mr_auth::*; mod legacy_converter; +pub mod server_join_log; + // Global state // RwLock on state only has concurrent reads, except for config dir change which takes control of the State static LAUNCHER_STATE: OnceCell> = OnceCell::const_new(); diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index cfce1fd90..485457e95 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -1,15 +1,23 @@ -use crate::event::emit::emit_process; -use crate::event::ProcessPayloadType; +use crate::event::emit::{emit_process, emit_profile}; +use crate::event::{ProcessPayloadType, ProfilePayloadType}; use crate::profile; use crate::util::io::IOError; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use dashmap::DashMap; +use quick_xml::events::Event; +use quick_xml::Reader; use serde::Deserialize; use serde::Serialize; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::ExitStatus; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; use uuid::Uuid; +const LAUNCHER_LOG_PATH: &str = "launcher_log.txt"; + pub struct ProcessManager { processes: DashMap, } @@ -32,8 +40,16 @@ impl ProcessManager { profile_path: &str, mut mc_command: Command, post_exit_command: Option, + logs_folder: PathBuf, + xml_logging: bool, ) -> crate::Result { - let mc_proc = mc_command.spawn().map_err(IOError::from)?; + mc_command.stdout(std::process::Stdio::piped()); + mc_command.stderr(std::process::Stdio::piped()); + + let mut mc_proc = mc_command.spawn().map_err(IOError::from)?; + + let stdout = mc_proc.stdout.take(); + let stderr = mc_proc.stderr.take(); let process = Process { metadata: ProcessMetadata { @@ -46,6 +62,65 @@ impl ProcessManager { let metadata = process.metadata.clone(); + if !logs_folder.exists() { + tokio::fs::create_dir_all(&logs_folder) + .await + .map_err(|e| IOError::with_path(e, &logs_folder))?; + } + + let log_path = logs_folder.join(LAUNCHER_LOG_PATH); + + { + let mut log_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&log_path) + .map_err(|e| IOError::with_path(e, &log_path))?; + + // Initialize with timestamp header + let now = chrono::Local::now(); + writeln!( + log_file, + "# Minecraft launcher log started at {}", + now.format("%Y-%m-%d %H:%M:%S") + ) + .map_err(|e| IOError::with_path(e, &log_path))?; + writeln!(log_file, "# Profile: {} \n", profile_path) + .map_err(|e| IOError::with_path(e, &log_path))?; + writeln!(log_file).map_err(|e| IOError::with_path(e, &log_path))?; + } + + if let Some(stdout) = stdout { + let log_path_clone = log_path.clone(); + + let profile_path = metadata.profile_path.clone(); + tokio::spawn(async move { + Process::process_output( + &profile_path, + stdout, + log_path_clone, + xml_logging, + ) + .await; + }); + } + + if let Some(stderr) = stderr { + let log_path_clone = log_path.clone(); + + let profile_path = metadata.profile_path.clone(); + tokio::spawn(async move { + Process::process_output( + &profile_path, + stderr, + log_path_clone, + xml_logging, + ) + .await; + }); + } + tokio::spawn(Process::sequential_process_manager( profile_path.to_string(), post_exit_command, @@ -120,7 +195,381 @@ struct Process { child: Child, } +#[derive(Debug, Default)] +struct Log4jEvent { + timestamp: Option, + logger: Option, + level: Option, + thread: Option, + message: Option, +} + impl Process { + async fn process_output( + profile_path: &str, + reader: R, + log_path: impl AsRef, + xml_logging: bool, + ) where + R: tokio::io::AsyncRead + Unpin, + { + let mut buf_reader = BufReader::new(reader); + + if xml_logging { + let mut reader = Reader::from_reader(buf_reader); + reader.config_mut().enable_all_checks(false); + + let mut buf = Vec::new(); + let mut current_event = Log4jEvent::default(); + let mut in_event = false; + let mut in_message = false; + let mut in_throwable = false; + let mut current_content = String::new(); + + loop { + match reader.read_event_into_async(&mut buf).await { + Err(e) => { + tracing::error!( + "Error at position {}: {:?}", + reader.buffer_position(), + e + ); + break; + } + // exits the loop when reaching end of file + Ok(Event::Eof) => break, + + Ok(Event::Start(e)) => { + match e.name().as_ref() { + b"log4j:Event" => { + // Reset for new event + current_event = Log4jEvent::default(); + in_event = true; + + // Extract attributes + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy( + attr.key.into_inner(), + ) + .to_string(); + let value = + String::from_utf8_lossy(&attr.value) + .to_string(); + + match key.as_str() { + "logger" => { + current_event.logger = Some(value) + } + "level" => { + current_event.level = Some(value) + } + "thread" => { + current_event.thread = Some(value) + } + "timestamp" => { + current_event.timestamp = + Some(value) + } + _ => {} + } + } + } + b"log4j:Message" => { + in_message = true; + current_content = String::new(); + } + b"log4j:Throwable" => { + in_throwable = true; + current_content = String::new(); + } + _ => {} + } + } + Ok(Event::End(e)) => { + match e.name().as_ref() { + b"log4j:Message" => { + in_message = false; + current_event.message = + Some(current_content.clone()); + } + b"log4j:Throwable" => { + in_throwable = false; + // Process and write the log entry + let thread = current_event + .thread + .as_deref() + .unwrap_or(""); + let level = current_event + .level + .as_deref() + .unwrap_or(""); + let logger = current_event + .logger + .as_deref() + .unwrap_or(""); + + if let Some(message) = ¤t_event.message { + let formatted_time = + Process::format_timestamp( + current_event.timestamp.as_deref(), + ); + let formatted_log = format!( + "{} [{}] [{}{}]: {}\n", + formatted_time, + thread, + if !logger.is_empty() { + format!("{}/", logger) + } else { + String::new() + }, + level, + message.trim() + ); + + // Write the log message + if let Err(e) = Process::append_to_log_file( + &log_path, + &formatted_log, + ) { + tracing::error!( + "Failed to write to log file: {}", + e + ); + } + + // Write the throwable if present + if !current_content.is_empty() { + if let Err(e) = + Process::append_to_log_file( + &log_path, + ¤t_content, + ) + { + tracing::error!("Failed to write throwable to log file: {}", e); + } + } + } + } + b"log4j:Event" => { + in_event = false; + // If no throwable was present, write the log entry at the end of the event + if current_event.message.is_some() + && !in_throwable + { + let thread = current_event + .thread + .as_deref() + .unwrap_or(""); + let level = current_event + .level + .as_deref() + .unwrap_or(""); + let logger = current_event + .logger + .as_deref() + .unwrap_or(""); + let message = current_event + .message + .as_deref() + .unwrap_or("") + .trim(); + + let formatted_time = + Process::format_timestamp( + current_event.timestamp.as_deref(), + ); + let formatted_log = format!( + "{} [{}] [{}{}]: {}\n", + formatted_time, + thread, + if !logger.is_empty() { + format!("{}/", logger) + } else { + String::new() + }, + level, + message + ); + + // Write the log message + if let Err(e) = Process::append_to_log_file( + &log_path, + &formatted_log, + ) { + tracing::error!( + "Failed to write to log file: {}", + e + ); + } + + if let Some(timestamp) = + current_event.timestamp.as_deref() + { + if let Err(e) = Self::maybe_handle_server_join_logging( + profile_path, + timestamp, + message + ).await { + tracing::error!("Failed to handle server join logging: {e}"); + } + } + } + } + _ => {} + } + } + Ok(Event::Text(mut e)) => { + if in_message || in_throwable { + if let Ok(text) = e.unescape() { + current_content.push_str(&text); + } + } else if !in_event + && !e.inplace_trim_end() + && !e.inplace_trim_start() + { + if let Ok(text) = e.unescape() { + if let Err(e) = Process::append_to_log_file( + &log_path, + &format!("{text}\n"), + ) { + tracing::error!( + "Failed to write to log file: {}", + e + ); + } + } + } + } + Ok(Event::CData(e)) => { + if in_message || in_throwable { + if let Ok(text) = e + .escape() + .map_err(|x| x.into()) + .and_then(|x| x.unescape()) + { + current_content.push_str(&text); + } + } + } + _ => (), + } + + buf.clear(); + } + } else { + let mut line = String::new(); + + while let Ok(bytes_read) = buf_reader.read_line(&mut line).await { + if bytes_read == 0 { + break; // End of stream + } + + if !line.is_empty() { + if let Err(e) = Self::append_to_log_file(&log_path, &line) { + tracing::warn!("Failed to write to log file: {}", e); + } + } + + line.clear(); + } + } + } + + fn format_timestamp(timestamp: Option<&str>) -> String { + if let Some(timestamp_str) = timestamp { + if let Ok(timestamp_val) = timestamp_str.parse::() { + let datetime_utc = if timestamp_val > i32::MAX as i64 { + let secs = timestamp_val / 1000; + let nsecs = ((timestamp_val % 1000) * 1_000_000) as u32; + + chrono::DateTime::::from_timestamp(secs, nsecs) + .unwrap_or_default() + } else { + chrono::DateTime::::from_timestamp(timestamp_val, 0) + .unwrap_or_default() + }; + + let datetime_local = datetime_utc.with_timezone(&chrono::Local); + format!("[{}]", datetime_local.format("%H:%M:%S")) + } else { + "[??:??:??]".to_string() + } + } else { + "[??:??:??]".to_string() + } + } + + fn append_to_log_file( + path: impl AsRef, + line: &str, + ) -> std::io::Result<()> { + let mut file = + OpenOptions::new().append(true).create(true).open(path)?; + + file.write_all(line.as_bytes())?; + Ok(()) + } + + async fn maybe_handle_server_join_logging( + profile_path: &str, + timestamp: &str, + message: &str, + ) -> crate::Result<()> { + let Some(host_port_string) = message.strip_prefix("Connecting to ") + else { + return Ok(()); + }; + let Some((host, port_string)) = host_port_string.rsplit_once(", ") + else { + return Ok(()); + }; + let Some(port) = port_string.parse::().ok() else { + return Ok(()); + }; + let timestamp = timestamp + .parse::() + .map(|x| x / 1000) + .map_err(|x| { + crate::ErrorKind::OtherError(format!( + "Failed to parse timestamp: {x}" + )) + }) + .and_then(|x| { + Utc.timestamp_opt(x, 0).single().ok_or_else(|| { + crate::ErrorKind::OtherError( + "Failed to convert timestamp to DateTime".to_string(), + ) + }) + })?; + + let state = crate::State::get().await?; + crate::state::server_join_log::JoinLogEntry { + profile_path: profile_path.to_owned(), + host: host.to_string(), + port, + join_time: timestamp, + } + .upsert(&state.pool) + .await?; + { + let profile_path = profile_path.to_owned(); + let host = host.to_owned(); + tokio::spawn(async move { + let _ = emit_profile( + &profile_path, + ProfilePayloadType::ServerJoined { + host, + port, + timestamp, + }, + ) + .await; + }); + } + + Ok(()) + } + // Spawns a new child process and inserts it into the hashmap // Also, as the process ends, it spawns the follow-up process if it exists // By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status @@ -204,6 +653,24 @@ impl Process { } }); + let logs_folder = state.directories.profile_logs_dir(&profile_path); + let log_path = logs_folder.join(LAUNCHER_LOG_PATH); + + if log_path.exists() { + if let Err(e) = Process::append_to_log_file( + &log_path, + &format!( + "\n# Process exited with status: {}\n", + mc_exit_status + ), + ) { + tracing::warn!( + "Failed to write exit status to log file: {}", + e + ); + } + } + let _ = state.discord_rpc.clear_to_default(true).await; let _ = state.friends_socket.update_status(None).await; diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs index 97b92cf8d..08c2b510f 100644 --- a/packages/app-lib/src/state/profiles.rs +++ b/packages/app-lib/src/state/profiles.rs @@ -23,6 +23,7 @@ pub struct Profile { pub icon_path: Option, pub game_version: String, + pub protocol_version: Option, pub loader: ModLoader, pub loader_version: Option, @@ -261,6 +262,7 @@ struct ProfileQueryResult { override_hook_pre_launch: Option, override_hook_wrapper: Option, override_hook_post_exit: Option, + protocol_version: Option, } impl TryFrom for Profile { @@ -273,6 +275,7 @@ impl TryFrom for Profile { name: x.name, icon_path: x.icon_path, game_version: x.game_version, + protocol_version: x.protocol_version.map(|x| x as i32), loader: ModLoader::from_string(&x.mod_loader), loader_version: x.mod_loader_version, groups: serde_json::from_value(x.groups).unwrap_or_default(), @@ -337,7 +340,7 @@ macro_rules! select_profiles_with_predicate { r#" SELECT path, install_stage, name, icon_path, - game_version, mod_loader, mod_loader_version, + game_version, protocol_version, mod_loader, mod_loader_version, json(groups) as "groups!: serde_json::Value", linked_project_id, linked_version_id, locked, created, modified, last_played, @@ -435,7 +438,8 @@ impl Profile { submitted_time_played, recent_time_played, override_java_path, override_extra_launch_args, override_custom_env_vars, override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y, - override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit + override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit, + protocol_version ) VALUES ( $1, $2, $3, $4, @@ -446,7 +450,8 @@ impl Profile { $15, $16, $17, jsonb($18), jsonb($19), $20, $21, $22, $23, - $24, $25, $26 + $24, $25, $26, + $27 ) ON CONFLICT (path) DO UPDATE SET install_stage = $2, @@ -480,7 +485,9 @@ impl Profile { override_hook_pre_launch = $24, override_hook_wrapper = $25, - override_hook_post_exit = $26 + override_hook_post_exit = $26, + + protocol_version = $27 ", self.path, install_stage, @@ -508,6 +515,7 @@ impl Profile { self.hooks.pre_launch, self.hooks.wrapper, self.hooks.post_exit, + self.protocol_version, ) .execute(exec) .await?; diff --git a/packages/app-lib/src/state/server_join_log.rs b/packages/app-lib/src/state/server_join_log.rs new file mode 100644 index 000000000..3de9e04fd --- /dev/null +++ b/packages/app-lib/src/state/server_join_log.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; + +use chrono::{DateTime, TimeZone, Utc}; + +pub struct JoinLogEntry { + pub profile_path: String, + pub host: String, + pub port: u16, + pub join_time: DateTime, +} + +impl JoinLogEntry { + pub async fn upsert( + &self, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let join_time = self.join_time.timestamp(); + + sqlx::query!( + " + INSERT INTO join_log (profile_path, host, port, join_time) + VALUES ($1, $2, $3, $4) + ON CONFLICT (profile_path, host, port) DO UPDATE SET + join_time = $4 + ", + self.profile_path, + self.host, + self.port, + join_time + ) + .execute(exec) + .await?; + + Ok(()) + } +} + +pub async fn get_joins( + instance: &str, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, +) -> crate::Result>> { + let joins = sqlx::query!( + " + SELECT profile_path, host, port, join_time + FROM join_log + WHERE profile_path = $1 + ", + instance + ) + .fetch_all(exec) + .await?; + + Ok(joins + .into_iter() + .map(|x| { + ( + (x.host, x.port as u16), + Utc.timestamp_opt(x.join_time, 0) + .single() + .unwrap_or_else(Utc::now), + ) + }) + .collect()) +} diff --git a/packages/app-lib/src/util/io.rs b/packages/app-lib/src/util/io.rs index 684cc2b9c..99ea83fc7 100644 --- a/packages/app-lib/src/util/io.rs +++ b/packages/app-lib/src/util/io.rs @@ -255,3 +255,29 @@ pub async fn remove_file( path: path.to_string_lossy().to_string(), }) } + +// remove dir +pub async fn remove_dir( + path: impl AsRef, +) -> Result<(), IOError> { + let path = path.as_ref(); + tokio::fs::remove_dir(path) + .await + .map_err(|e| IOError::IOPathError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +// metadata +pub async fn metadata( + path: impl AsRef, +) -> Result { + let path = path.as_ref(); + tokio::fs::metadata(path) + .await + .map_err(|e| IOError::IOPathError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} diff --git a/packages/app-lib/src/util/mod.rs b/packages/app-lib/src/util/mod.rs index 813ec149c..5a310291c 100644 --- a/packages/app-lib/src/util/mod.rs +++ b/packages/app-lib/src/util/mod.rs @@ -3,6 +3,7 @@ pub mod fetch; pub mod io; pub mod jre; pub mod platform; +pub mod server_ping; /// Wrap a builder which uses a mut reference into one which outputs an owned value macro_rules! wrap_ref_builder { diff --git a/packages/app-lib/src/util/server_ping.rs b/packages/app-lib/src/util/server_ping.rs new file mode 100644 index 000000000..7a22c1a7a --- /dev/null +++ b/packages/app-lib/src/util/server_ping.rs @@ -0,0 +1,223 @@ +use crate::error::Result; +use crate::ErrorKind; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use std::time::Duration; +use tokio::net::ToSocketAddrs; +use tokio::select; +use url::Url; + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ServerStatus { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub players: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub favicon: Option, + #[serde(default)] + pub enforces_secure_chat: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub ping: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ServerPlayers { + pub max: i32, + pub online: i32, + #[serde(default)] + pub sample: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ServerGameProfile { + pub id: String, + pub name: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ServerVersion { + pub name: String, + pub protocol: i32, +} + +pub async fn get_server_status( + address: &impl ToSocketAddrs, + original_address: (&str, u16), + protocol_version: Option, +) -> Result { + select! { + res = modern::status(address, original_address, protocol_version) => res, + _ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError( + format!("Ping of {}:{} timed out", original_address.0, original_address.1) + ).into()) + } +} + +mod modern { + use super::ServerStatus; + use crate::ErrorKind; + use chrono::Utc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpStream, ToSocketAddrs}; + + pub async fn status( + address: &impl ToSocketAddrs, + original_address: (&str, u16), + protocol_version: Option, + ) -> crate::Result { + let mut stream = TcpStream::connect(address).await?; + handshake(&mut stream, original_address, protocol_version).await?; + let mut result = status_body(&mut stream).await?; + result.ping = ping(&mut stream).await.ok(); + Ok(result) + } + + async fn handshake( + stream: &mut TcpStream, + original_address: (&str, u16), + protocol_version: Option, + ) -> crate::Result<()> { + let (host, port) = original_address; + let protocol_version = protocol_version.unwrap_or(-1); + + const PACKET_ID: i32 = 0; + const NEXT_STATE: i32 = 1; + + let packet_size = varint::get_byte_size(PACKET_ID) + + varint::get_byte_size(protocol_version) + + varint::get_byte_size(host.len() as i32) + + host.len() + + size_of::() + + varint::get_byte_size(NEXT_STATE); + + let mut packet_buffer = Vec::with_capacity( + varint::get_byte_size(packet_size as i32) + packet_size, + ); + + varint::write(&mut packet_buffer, packet_size as i32); + varint::write(&mut packet_buffer, PACKET_ID); + varint::write(&mut packet_buffer, protocol_version); + varint::write(&mut packet_buffer, host.len() as i32); + packet_buffer.extend_from_slice(host.as_bytes()); + packet_buffer.extend_from_slice(&port.to_be_bytes()); + varint::write(&mut packet_buffer, NEXT_STATE); + + stream.write_all(&packet_buffer).await?; + stream.flush().await?; + + Ok(()) + } + + async fn status_body( + stream: &mut TcpStream, + ) -> crate::Result { + stream.write_all(&[0x01, 0x00]).await?; + stream.flush().await?; + + let packet_length = varint::read(stream).await?; + if packet_length < 0 { + return Err(ErrorKind::InputError( + "Invalid status response packet length".to_string(), + ) + .into()); + } + + let mut packet_stream = stream.take(packet_length as u64); + let packet_id = varint::read(&mut packet_stream).await?; + if packet_id != 0x00 { + return Err(ErrorKind::InputError( + "Unexpected status response".to_string(), + ) + .into()); + } + let response_length = varint::read(&mut packet_stream).await?; + let mut json_response = vec![0_u8; response_length as usize]; + packet_stream.read_exact(&mut json_response).await?; + + if packet_stream.limit() > 0 { + tokio::io::copy(&mut packet_stream, &mut tokio::io::sink()).await?; + } + + Ok(serde_json::from_slice(&json_response)?) + } + + async fn ping(stream: &mut TcpStream) -> crate::Result { + let start_time = Utc::now(); + let ping_magic = start_time.timestamp_millis(); + + stream.write_all(&[0x09, 0x01]).await?; + stream.write_i64(ping_magic).await?; + stream.flush().await?; + + let mut response_prefix = [0_u8; 2]; + stream.read_exact(&mut response_prefix).await?; + let response_magic = stream.read_i64().await?; + if response_prefix != [0x09, 0x01] || response_magic != ping_magic { + return Err(ErrorKind::InputError( + "Unexpected ping response".to_string(), + ) + .into()); + } + + let response_time = Utc::now(); + Ok((response_time - start_time).num_milliseconds()) + } + + mod varint { + use std::io; + use tokio::io::{AsyncRead, AsyncReadExt}; + + const MAX_VARINT_SIZE: usize = 5; + const DATA_BITS_MASK: u32 = 0x7f; + const CONT_BIT_MASK_U8: u8 = 0x80; + const CONT_BIT_MASK_U32: u32 = CONT_BIT_MASK_U8 as u32; + const DATA_BITS_PER_BYTE: usize = 7; + + pub fn get_byte_size(x: i32) -> usize { + let x = x as u32; + for size in 1..MAX_VARINT_SIZE { + if (x & (u32::MAX << (size * DATA_BITS_PER_BYTE))) == 0 { + return size; + } + } + MAX_VARINT_SIZE + } + + pub fn write(out: &mut Vec, value: i32) { + let mut value = value as u32; + while value >= CONT_BIT_MASK_U32 { + out.push(((value & DATA_BITS_MASK) | CONT_BIT_MASK_U32) as u8); + value >>= DATA_BITS_PER_BYTE; + } + out.push(value as u8); + } + + pub async fn read( + reader: &mut R, + ) -> io::Result { + let mut result = 0; + let mut shift = 0; + + loop { + let b = reader.read_u8().await?; + result |= + (b as u32 & DATA_BITS_MASK) << (shift * DATA_BITS_PER_BYTE); + shift += 1; + if shift > MAX_VARINT_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "VarInt too big", + )); + } + if b & CONT_BIT_MASK_U8 == 0 { + return Ok(result as i32); + } + } + } + } +} diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index 7410fd31c..68fac36dc 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -13,3 +13,4 @@ rand = "0.8.5" either = "1.13" chrono = { version = "0.4.26", features = ["serde"] } serde_cbor = "0.11" +lazy_static = "1.5" diff --git a/packages/ariadne/src/lib.rs b/packages/ariadne/src/lib.rs index 4a4251a3e..a1ee76540 100644 --- a/packages/ariadne/src/lib.rs +++ b/packages/ariadne/src/lib.rs @@ -1,3 +1,4 @@ pub mod ids; pub mod networking; pub mod users; +pub mod versions; diff --git a/packages/ariadne/src/versions.rs b/packages/ariadne/src/versions.rs new file mode 100644 index 000000000..4ea00b659 --- /dev/null +++ b/packages/ariadne/src/versions.rs @@ -0,0 +1,47 @@ +use lazy_static::lazy_static; +use std::collections::HashMap; + +lazy_static! { + static ref SPECIAL_PARENTS: HashMap<&'static str, &'static str> = { + let mut m = HashMap::new(); + m.insert("15w14a", "1.8.3"); + m.insert("1.RV-Pre1", "1.9.2"); + m.insert("3D Shareware v1.34", "19w13b"); + m.insert("20w14infinite", "20w13b"); + m.insert("22w13oneblockatatime", "1.18.2"); + m.insert("23w13a_or_b", "23w13a"); + m.insert("24w14potato", "24w12a"); + m + }; +} + +pub fn is_feature_supported_in( + version: &str, + first_release: &str, + first_snapshot: &str, +) -> bool { + let version = SPECIAL_PARENTS.get(version).copied().unwrap_or(version); + if version.contains('w') && version.len() == 6 { + return version >= first_snapshot; + } + if version == first_release { + return true; + } + let parts_version = version.split('.'); + let parts_first_release = first_release.split('.'); + for (part_version, part_first_release) in + parts_version.zip(parts_first_release) + { + if part_version == part_first_release { + continue; + } + if let Ok(part_version) = part_version.parse::() { + if let Ok(part_first_release) = part_first_release.parse::() { + if part_version > part_first_release { + return true; + } + } + } + } + false +} diff --git a/packages/assets/external/pyro.svg b/packages/assets/external/pyro.svg deleted file mode 100644 index 16eebd786..000000000 --- a/packages/assets/external/pyro.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/assets/icons/blocks.svg b/packages/assets/icons/blocks.svg new file mode 100644 index 000000000..4b78f5051 --- /dev/null +++ b/packages/assets/icons/blocks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/no-signal.svg b/packages/assets/icons/no-signal.svg new file mode 100644 index 000000000..90318e971 --- /dev/null +++ b/packages/assets/icons/no-signal.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/assets/icons/pickaxe.svg b/packages/assets/icons/pickaxe.svg new file mode 100644 index 000000000..40e92c49c --- /dev/null +++ b/packages/assets/icons/pickaxe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/signal.svg b/packages/assets/icons/signal.svg new file mode 100644 index 000000000..dcdd37462 --- /dev/null +++ b/packages/assets/icons/signal.svg @@ -0,0 +1 @@ + diff --git a/packages/assets/icons/skull.svg b/packages/assets/icons/skull.svg new file mode 100644 index 000000000..f56453eb2 --- /dev/null +++ b/packages/assets/icons/skull.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/world.svg b/packages/assets/icons/world.svg new file mode 100644 index 000000000..cb5c68469 --- /dev/null +++ b/packages/assets/icons/world.svg @@ -0,0 +1 @@ + diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 1b7f51da9..63fead85b 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -27,7 +27,6 @@ import _TumblrIcon from './external/tumblr.svg?component' import _TwitterIcon from './external/twitter.svg?component' import _WindowsIcon from './external/windows.svg?component' import _YouTubeIcon from './external/youtube.svg?component' -import _PyroIcon from './external/pyro.svg?component' // Icons import _AlignLeftIcon from './icons/align-left.svg?component' @@ -37,6 +36,7 @@ import _AsteriskIcon from './icons/asterisk.svg?component' import _BanIcon from './icons/ban.svg?component' import _BellIcon from './icons/bell.svg?component' import _BellRingIcon from './icons/bell-ring.svg?component' +import _BlocksIcon from './icons/blocks.svg?component' import _BookIcon from './icons/book.svg?component' import _BookTextIcon from './icons/book-text.svg?component' import _BookmarkIcon from './icons/bookmark.svg?component' @@ -123,12 +123,14 @@ import _MoonIcon from './icons/moon.svg?component' import _MoreHorizontalIcon from './icons/more-horizontal.svg?component' import _MoreVerticalIcon from './icons/more-vertical.svg?component' import _NewspaperIcon from './icons/newspaper.svg?component' +import _NoSignalIcon from './icons/no-signal.svg?component' import _OmorphiaIcon from './icons/omorphia.svg?component' import _OrganizationIcon from './icons/organization.svg?component' import _PackageIcon from './icons/package.svg?component' import _PackageOpenIcon from './icons/package-open.svg?component' import _PackageClosedIcon from './icons/package-closed.svg?component' import _PaintBrushIcon from './icons/paintbrush.svg?component' +import _PickaxeIcon from './icons/pickaxe.svg?component' import _PlayIcon from './icons/play.svg?component' import _PlugIcon from './icons/plug.svg?component' import _PlusIcon from './icons/plus.svg?component' @@ -150,6 +152,8 @@ import _ServerIcon from './icons/server.svg?component' import _SettingsIcon from './icons/settings.svg?component' import _ShareIcon from './icons/share.svg?component' import _ShieldIcon from './icons/shield.svg?component' +import _SignalIcon from './icons/signal.svg?component' +import _SkullIcon from './icons/skull.svg?component' import _SlashIcon from './icons/slash.svg?component' import _SortAscendingIcon from './icons/sort-asc.svg?component' import _SortDescendingIcon from './icons/sort-desc.svg?component' @@ -179,6 +183,7 @@ import _UsersIcon from './icons/users.svg?component' import _VersionIcon from './icons/version.svg?component' import _WikiIcon from './icons/wiki.svg?component' import _WindowIcon from './icons/window.svg?component' +import _WorldIcon from './icons/world.svg?component' import _WrenchIcon from './icons/wrench.svg?component' import _XIcon from './icons/x.svg?component' import _XCircleIcon from './icons/x-circle.svg?component' @@ -226,7 +231,6 @@ export const MastodonIcon = _MastodonIcon export const OpenCollectiveIcon = _OpenCollectiveIcon export const PatreonIcon = _PatreonIcon export const PayPalIcon = _PayPalIcon -export const PyroIcon = _PyroIcon export const RedditIcon = _RedditIcon export const TumblrIcon = _TumblrIcon export const TwitterIcon = _TwitterIcon @@ -239,6 +243,7 @@ export const AsteriskIcon = _AsteriskIcon export const BanIcon = _BanIcon export const BellIcon = _BellIcon export const BellRingIcon = _BellRingIcon +export const BlocksIcon = _BlocksIcon export const BookIcon = _BookIcon export const BookTextIcon = _BookTextIcon export const BookmarkIcon = _BookmarkIcon @@ -325,12 +330,14 @@ export const MoonIcon = _MoonIcon export const MoreHorizontalIcon = _MoreHorizontalIcon export const MoreVerticalIcon = _MoreVerticalIcon export const NewspaperIcon = _NewspaperIcon +export const NoSignalIcon = _NoSignalIcon export const OmorphiaIcon = _OmorphiaIcon export const OrganizationIcon = _OrganizationIcon export const PackageIcon = _PackageIcon export const PackageOpenIcon = _PackageOpenIcon export const PackageClosedIcon = _PackageClosedIcon export const PaintBrushIcon = _PaintBrushIcon +export const PickaxeIcon = _PickaxeIcon export const PlayIcon = _PlayIcon export const PlugIcon = _PlugIcon export const PlusIcon = _PlusIcon @@ -352,6 +359,8 @@ export const ServerIcon = _ServerIcon export const SettingsIcon = _SettingsIcon export const ShareIcon = _ShareIcon export const ShieldIcon = _ShieldIcon +export const SignalIcon = _SignalIcon +export const SkullIcon = _SkullIcon export const SlashIcon = _SlashIcon export const SortAscendingIcon = _SortAscendingIcon export const SortDescendingIcon = _SortDescendingIcon @@ -381,6 +390,7 @@ export const UsersIcon = _UsersIcon export const VersionIcon = _VersionIcon export const WikiIcon = _WikiIcon export const WindowIcon = _WindowIcon +export const WorldIcon = _WorldIcon export const WrenchIcon = _WrenchIcon export const XIcon = _XIcon export const XCircleIcon = _XCircleIcon diff --git a/packages/assets/styles/defaults.scss b/packages/assets/styles/defaults.scss index 712817ee3..36ecd625b 100644 --- a/packages/assets/styles/defaults.scss +++ b/packages/assets/styles/defaults.scss @@ -59,7 +59,7 @@ textarea, padding: 0.5rem 1rem; font-weight: var(--font-weight-medium); transition: box-shadow 0.1s ease-in-out; - min-height: 40px; + min-height: 36px; box-shadow: var(--shadow-inset-sm), 0 0 0 0 transparent; @@ -159,7 +159,7 @@ input[type='number'] { @extend .transparent, .icon-only; position: absolute; - right: 0.25rem; + right: 0.125rem; z-index: 1; svg { diff --git a/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index f462c886d..315c98989 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -84,6 +84,8 @@ --color-platform-velocity: #4b98b0; --color-platform-waterfall: #5f83cb; --color-platform-sponge: #c49528; + + --hover-brightness: 0.9; } html { @@ -196,6 +198,8 @@ html { --color-platform-velocity: #83d5ef; --color-platform-waterfall: #78a4fb; --color-platform-sponge: #f9e580; + + --hover-brightness: 1.25; } .oled-mode { diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 690218b80..4c14937b2 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1,3 +1,4 @@ export * from './src/components/index' export { commonMessages, commonSettingsMessages } from './src/utils/common-messages' export * from './src/utils/search' +export { GAME_MODES } from './src/utils/game-modes' diff --git a/packages/ui/src/components/base/ButtonStyled.vue b/packages/ui/src/components/base/ButtonStyled.vue index 4be4a2fc4..09e0b54d7 100644 --- a/packages/ui/src/components/base/ButtonStyled.vue +++ b/packages/ui/src/components/base/ButtonStyled.vue @@ -245,7 +245,7 @@ const colorVariables = computed(() => { } &:not([disabled]):not([disabled='true']):not(.disabled) { - @apply active:scale-95 hover:brightness-125 focus-visible:brightness-125 hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text]; + @apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text]; &:hover svg:first-child, &:focus-visible svg:first-child { diff --git a/packages/ui/src/components/base/FilterBar.vue b/packages/ui/src/components/base/FilterBar.vue new file mode 100644 index 000000000..72f6e8b68 --- /dev/null +++ b/packages/ui/src/components/base/FilterBar.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/ui/src/components/base/HeadingLink.vue b/packages/ui/src/components/base/HeadingLink.vue new file mode 100644 index 000000000..ecc2f5012 --- /dev/null +++ b/packages/ui/src/components/base/HeadingLink.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/ui/src/components/base/RadialHeader.vue b/packages/ui/src/components/base/RadialHeader.vue index 89e80b67b..05de75440 100644 --- a/packages/ui/src/components/base/RadialHeader.vue +++ b/packages/ui/src/components/base/RadialHeader.vue @@ -1,8 +1,10 @@ + diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 9aae805a4..901b482c6 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -17,6 +17,9 @@ export { default as DropArea } from './base/DropArea.vue' export { default as DropdownSelect } from './base/DropdownSelect.vue' export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue' export { default as FileInput } from './base/FileInput.vue' +export { default as FilterBar } from './base/FilterBar.vue' +export type { FilterBarOption } from './base/FilterBar.vue' +export { default as HeadingLink } from './base/HeadingLink.vue' export { default as LoadingIndicator } from './base/LoadingIndicator.vue' export { default as ManySelect } from './base/ManySelect.vue' export { default as MarkdownEditor } from './base/MarkdownEditor.vue' @@ -34,6 +37,7 @@ export { default as ScrollablePanel } from './base/ScrollablePanel.vue' export { default as ServerNotice } from './base/ServerNotice.vue' export { default as SimpleBadge } from './base/SimpleBadge.vue' export { default as Slider } from './base/Slider.vue' +export { default as SmartClickable } from './base/SmartClickable.vue' export { default as StatItem } from './base/StatItem.vue' export { default as TagItem } from './base/TagItem.vue' export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue' diff --git a/packages/ui/src/components/modal/ConfirmModal.vue b/packages/ui/src/components/modal/ConfirmModal.vue index e9925d437..37c9c0cfe 100644 --- a/packages/ui/src/components/modal/ConfirmModal.vue +++ b/packages/ui/src/components/modal/ConfirmModal.vue @@ -6,11 +6,16 @@
-
+