Direct World Joining (#3457)
* Begin work on worlds backend * Finish implementing get_profile_worlds and get_server_status (except pinning) * Create TS types and manually copy unparsed chat components * Clippy fix * Update types.d.ts * Initial worlds UI work * Fix api::get_profile_worlds to take in a relative path * sanitize & security update * Fix sanitizePotentialFileUrl * Fix sanitizePotentialFileUrl (for real) * Fix empty motd causing error * Finally actually fix world icons * Fix world icon not being visible on non-Windows * Use the correct generics to take in AppHandle * Implement start_join_singleplayer_world and start_join_server for modern versions * Don't error if server has no cached icon * Migrate to own server pinging * Ignore missing server hidden field and missing saves dir * Update world list frontend * More frontend work * Server status player sample can be absent * Fix refresh state * Add get_profile_protocol_version * Add protocol_version column to database * SQL INTEGER is i64 in sqlx * sqlx prepare * Cache protocol version in database * Continue worlds UI work * Fix motds being bold * Remove legacy pinging and add a 30-second timeout * Remove pinned for now and match world (and server) parsing closer to spec * Move type ServerStatus to worlds.ts * Implement add_server_to_profile * Fix pack_status being ignored when joining from launcher * Make World path field be relative * Implement rename_world and reset_world_icon * Clippy fix * Fix rename_world * UI enhancements * Implement backup_world, which returns the backup size in bytes * Clippy fix * Return index when adding servers to profile * Fix backup * Implement delete_world * Implement edit_server_in_profile and remove_server_from_profile * Clippy fix * Log server joins * Add edit and delete support * Fix ts errors * Fix minecraft font * Switch font out for non-monospaced. * Fix font proper * Some more world cleanup, handle play state, check quickplay compatibility * Clear the cached protocol version when a profile's game version is changed * Fix tint colors in navbar * Fix server protocol version pinging * UI fixes * Fix protocol version handler * Fix MOTD parsing * Add worlds_updated profile event * fix pkg * Functional home screen with worlds * lint * Fix incorrect folder creation * Make items clickable * Add locked field to SingleplayerWorld indicating whether the world is locked by the game * Implement locking frontend * Fix locking condition * Split worlds_updated profile event into servers_updated and world_updated * Fix compile error * Use port from resolve SRV record * Fix serialization of ProfilePayload and ProfilePayloadType * Individual singleplayer world refreshing * Log when worlds are perceived to be updated * Push logging + total refresh lock * Unlisten fixes * Highlight current world when clicked * Launcher logs refactor (#3444) * Switch live log to use STDOUT * fix clippy, legacy logs support * Fix lint * Handle non-XML log messages in XML logging, and don't escape log messages into XML --------- Co-authored-by: Josiah Glosson <soujournme@gmail.com> * Update incompatibility text * Home page fixes, and unlock after close * Remove logging * Add join log database migration * Switch server join timing to being in the database instead of in a separate log file * Create optimized get_recent_worlds function that takes in a limit * Update dependencies and fix Cargo.lock * temp disable overflow menus * revert home page changes * Enable overflow menus again * Remove list * Revert * Push dev tools * Remove default filter * Disable debug renderer * Fix random app errors * Refactor * Fix missing computed import * Fix light mode issues * Fix TS errors * Lint * Fix bad link in change modpack version modal * fix lint * fix intl --------- Co-authored-by: Josiah Glosson <soujournme@gmail.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
388
Cargo.lock
generated
@ -345,7 +345,7 @@ dependencies = [
|
|||||||
"getrandom 0.2.15",
|
"getrandom 0.2.15",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"version_check",
|
"version_check",
|
||||||
"zerocopy",
|
"zerocopy 0.7.35",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -481,6 +481,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"either",
|
"either",
|
||||||
|
"lazy_static",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
@ -615,7 +616,7 @@ dependencies = [
|
|||||||
"futures-lite 2.3.0",
|
"futures-lite 2.3.0",
|
||||||
"parking",
|
"parking",
|
||||||
"polling",
|
"polling",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"slab",
|
"slab",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
@ -647,7 +648,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"event-listener 5.3.1",
|
"event-listener 5.3.1",
|
||||||
"futures-lite 2.3.0",
|
"futures-lite 2.3.0",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -674,7 +675,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"slab",
|
"slab",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
@ -761,6 +762,17 @@ dependencies = [
|
|||||||
"webpki-roots 0.26.6",
|
"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]]
|
[[package]]
|
||||||
name = "async_zip"
|
name = "async_zip"
|
||||||
version = "0.0.17"
|
version = "0.0.17"
|
||||||
@ -1809,6 +1821,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "critical-section"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.13"
|
version = "0.5.13"
|
||||||
@ -2463,9 +2481,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.13.0"
|
version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@ -2553,6 +2571,18 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
|
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]]
|
[[package]]
|
||||||
name = "enumflags2"
|
name = "enumflags2"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@ -2611,12 +2641,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.9"
|
version = "0.3.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
|
checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2829,6 +2859,17 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "fsevent-sys"
|
name = "fsevent-sys"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@ -3090,6 +3131,19 @@ dependencies = [
|
|||||||
"x11",
|
"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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@ -3107,7 +3161,7 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
|
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -3135,6 +3189,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "gif"
|
name = "gif"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -3444,6 +3510,54 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@ -3917,24 +4031,23 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "0.5.0"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
|
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-bidi",
|
"idna_adapter",
|
||||||
"unicode-normalization",
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna_adapter"
|
||||||
version = "1.0.2"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bd69211b9b519e98303c015e21a007e293db403b6c85b9b124e133d25e242cdd"
|
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_normalizer",
|
"icu_normalizer",
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
"smallvec",
|
|
||||||
"utf8_iter",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -4045,7 +4158,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"num-format",
|
"num-format",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"quick-xml 0.37.2",
|
"quick-xml 0.37.4",
|
||||||
"rgb",
|
"rgb",
|
||||||
"str_stack",
|
"str_stack",
|
||||||
]
|
]
|
||||||
@ -4088,6 +4201,18 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
@ -4487,7 +4612,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hostname",
|
"hostname",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"idna 1.0.2",
|
"idna 1.0.3",
|
||||||
"mime",
|
"mime",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"nom",
|
"nom",
|
||||||
@ -4524,9 +4649,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.159"
|
version = "0.2.172"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
|
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libdbus-sys"
|
name = "libdbus-sys"
|
||||||
@ -4598,6 +4723,12 @@ version = "0.4.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@ -4637,6 +4768,19 @@ version = "0.4.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
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]]
|
[[package]]
|
||||||
name = "lru-cache"
|
name = "lru-cache"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@ -4914,6 +5058,25 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.15.1"
|
version = "0.15.1"
|
||||||
@ -5515,6 +5678,7 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1"
|
checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"critical-section",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6055,7 +6219,7 @@ dependencies = [
|
|||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"hermit-abi 0.4.0",
|
"hermit-abi 0.4.0",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
@ -6093,7 +6257,7 @@ version = "0.2.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
|
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy",
|
"zerocopy 0.7.35",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -6202,7 +6366,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"procfs-core",
|
"procfs-core",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -6308,6 +6472,31 @@ dependencies = [
|
|||||||
"bytemuck",
|
"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]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@ -6353,11 +6542,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.37.2"
|
version = "0.37.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003"
|
checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -6423,6 +6613,12 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r2d2"
|
name = "r2d2"
|
||||||
version = "0.8.10"
|
version = "0.8.10"
|
||||||
@ -6465,6 +6661,17 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@ -6485,6 +6692,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@ -6503,6 +6720,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.15",
|
"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]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -6769,6 +6995,15 @@ dependencies = [
|
|||||||
"windows-registry 0.2.0",
|
"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]]
|
[[package]]
|
||||||
name = "result"
|
name = "result"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@ -7018,10 +7253,23 @@ dependencies = [
|
|||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys 0.4.14",
|
||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.20.9"
|
version = "0.20.9"
|
||||||
@ -8486,6 +8734,12 @@ dependencies = [
|
|||||||
"version-compare",
|
"version-compare",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tagptr"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.30.8"
|
version = "0.30.8"
|
||||||
@ -8954,7 +9208,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"fastrand 2.1.1",
|
"fastrand 2.1.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -8987,6 +9241,7 @@ dependencies = [
|
|||||||
"ariadne",
|
"ariadne",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"async-tungstenite",
|
"async-tungstenite",
|
||||||
|
"async-walkdir",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
@ -8999,13 +9254,17 @@ dependencies = [
|
|||||||
"dunce",
|
"dunce",
|
||||||
"either",
|
"either",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hickory-resolver",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"notify",
|
"notify",
|
||||||
"notify-debouncer-mini",
|
"notify-debouncer-mini",
|
||||||
"p256",
|
"p256",
|
||||||
"paste",
|
"paste",
|
||||||
|
"quartz_nbt",
|
||||||
|
"quick-xml 0.37.4",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.7",
|
"reqwest 0.12.7",
|
||||||
@ -9021,6 +9280,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.64",
|
"thiserror 1.0.64",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-error",
|
"tracing-error",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@ -9039,6 +9299,7 @@ dependencies = [
|
|||||||
"cocoa 0.25.0",
|
"cocoa 0.25.0",
|
||||||
"daedalus",
|
"daedalus",
|
||||||
"dashmap 6.1.0",
|
"dashmap 6.1.0",
|
||||||
|
"either",
|
||||||
"native-dialog",
|
"native-dialog",
|
||||||
"objc",
|
"objc",
|
||||||
"opener",
|
"opener",
|
||||||
@ -9063,6 +9324,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-error",
|
"tracing-error",
|
||||||
"url",
|
"url",
|
||||||
|
"urlencoding",
|
||||||
"uuid 1.12.0",
|
"uuid 1.12.0",
|
||||||
"window-shadows",
|
"window-shadows",
|
||||||
]
|
]
|
||||||
@ -9314,9 +9576,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.12"
|
version = "0.7.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a"
|
checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@ -9781,12 +10043,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.2"
|
version = "2.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
|
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"idna 0.5.0",
|
"idna 1.0.3",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@ -10000,6 +10262,15 @@ version = "0.11.0+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
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]]
|
[[package]]
|
||||||
name = "wasite"
|
name = "wasite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -10094,7 +10365,7 @@ checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"scoped-tls",
|
"scoped-tls",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"wayland-sys",
|
"wayland-sys",
|
||||||
@ -10107,7 +10378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280"
|
checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.6.0",
|
"bitflags 2.6.0",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-scanner",
|
"wayland-scanner",
|
||||||
]
|
]
|
||||||
@ -10322,7 +10593,7 @@ dependencies = [
|
|||||||
"either",
|
"either",
|
||||||
"home",
|
"home",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -10336,6 +10607,12 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "widestring"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@ -10768,6 +11045,15 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"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]]
|
[[package]]
|
||||||
name = "woothee"
|
name = "woothee"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@ -10869,8 +11155,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f"
|
checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys 0.4.14",
|
||||||
"rustix",
|
"rustix 0.38.37",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -11085,7 +11371,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"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]]
|
[[package]]
|
||||||
@ -11099,6 +11394,17 @@ dependencies = [
|
|||||||
"syn 2.0.90",
|
"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]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
"@modrinth/ui": "workspace:*",
|
"@modrinth/ui": "workspace:*",
|
||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
"@sentry/vue": "^8.27.0",
|
"@sentry/vue": "^8.27.0",
|
||||||
|
"@geometrically/minecraft-motd-parser": "^1.1.4",
|
||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||||
"@tauri-apps/plugin-os": "^2.2.0",
|
"@tauri-apps/plugin-os": "^2.2.0",
|
||||||
@ -50,7 +51,8 @@
|
|||||||
"tsconfig": "workspace:*",
|
"tsconfig": "workspace:*",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.6",
|
"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",
|
"packageManager": "pnpm@9.4.0",
|
||||||
"web-types": "../../web-types.json"
|
"web-types": "../../web-types.json"
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
RestoreIcon,
|
RestoreIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
WorldIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
||||||
@ -166,11 +167,17 @@ async function setupApp() {
|
|||||||
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||||
'criticalAnnouncements',
|
'criticalAnnouncements',
|
||||||
true,
|
true,
|
||||||
).then((res) => {
|
)
|
||||||
if (res && res.header && res.body) {
|
.then((res) => {
|
||||||
criticalErrorMessage.value = 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) => {
|
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||||
if (res && res.articles) {
|
if (res && res.articles) {
|
||||||
@ -359,7 +366,7 @@ function handleAuxClick(e) {
|
|||||||
<template>
|
<template>
|
||||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||||
<div id="teleports"></div>
|
<div id="teleports"></div>
|
||||||
<div v-if="stateInitialized" class="app-grid-layout relative">
|
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<AppSettingsModal ref="settingsModal" />
|
<AppSettingsModal ref="settingsModal" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@ -372,6 +379,9 @@ function handleAuxClick(e) {
|
|||||||
<NavButton v-tooltip.right="'Home'" to="/">
|
<NavButton v-tooltip.right="'Home'" to="/">
|
||||||
<HomeIcon />
|
<HomeIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
|
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
|
||||||
|
<WorldIcon />
|
||||||
|
</NavButton>
|
||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="'Discover content'"
|
v-tooltip.right="'Discover content'"
|
||||||
to="/browse/modpack"
|
to="/browse/modpack"
|
||||||
|
BIN
apps/app-frontend/src/assets/font/minecraft_font.ttf
Normal file
@ -2,8 +2,44 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'bundled-minecraft-font-mrapp';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('https://cdn.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'bundled-minecraft-font-mrapp';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('https://cdn.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'bundled-minecraft-font-mrapp';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 600;
|
||||||
|
src: url('https://cdn.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'bundled-minecraft-font-mrapp';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 600;
|
||||||
|
src: url('https://cdn.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-minecraft {
|
||||||
|
font-family: 'bundled-minecraft-font-mrapp', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: var(--font-standard);
|
font-family: var(--font-standard, sans-serif), sans-serif;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--view-width: calc(100% - 5rem);
|
--view-width: calc(100% - 5rem);
|
||||||
--expanded-view-width: calc(100% - 13rem);
|
--expanded-view-width: calc(100% - 13rem);
|
||||||
|
@ -10,7 +10,6 @@ import {
|
|||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
ChevronRightIcon,
|
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||||
import Instance from '@/components/ui/Instance.vue'
|
import Instance from '@/components/ui/Instance.vue'
|
||||||
@ -26,6 +25,7 @@ import { trackEvent } from '@/helpers/analytics'
|
|||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
import { install as installVersion } from '@/store/install.js'
|
import { install as installVersion } from '@/store/install.js'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
|
import { HeadingLink } from '@modrinth/ui'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -44,7 +44,9 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const actualInstances = computed(() =>
|
const actualInstances = computed(() =>
|
||||||
props.instances.filter((x) => x && x.instances && x.instances[0]),
|
props.instances.filter(
|
||||||
|
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const modsRow = ref(null)
|
const modsRow = ref(null)
|
||||||
@ -181,6 +183,10 @@ const maxInstancesPerRow = ref(1)
|
|||||||
const maxProjectsPerRow = ref(1)
|
const maxProjectsPerRow = ref(1)
|
||||||
|
|
||||||
const calculateCardsPerRow = () => {
|
const calculateCardsPerRow = () => {
|
||||||
|
if (rows.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate how many cards fit in one row
|
// Calculate how many cards fit in one row
|
||||||
const containerWidth = rows.value[0].clientWidth
|
const containerWidth = rows.value[0].clientWidth
|
||||||
// Convert container width from pixels to rem
|
// Convert container width from pixels to rem
|
||||||
@ -204,16 +210,21 @@ const calculateCardsPerRow = () => {
|
|||||||
|
|
||||||
const rowContainer = ref(null)
|
const rowContainer = ref(null)
|
||||||
const resizeObserver = ref(null)
|
const resizeObserver = ref(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
calculateCardsPerRow()
|
calculateCardsPerRow()
|
||||||
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
|
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
|
||||||
resizeObserver.value.observe(rowContainer.value)
|
if (rowContainer.value) {
|
||||||
|
resizeObserver.value.observe(rowContainer.value)
|
||||||
|
}
|
||||||
window.addEventListener('resize', calculateCardsPerRow)
|
window.addEventListener('resize', calculateCardsPerRow)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', calculateCardsPerRow)
|
window.removeEventListener('resize', calculateCardsPerRow)
|
||||||
resizeObserver.value.unobserve(rowContainer.value)
|
if (rowContainer.value) {
|
||||||
|
resizeObserver.value.unobserve(rowContainer.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -227,17 +238,10 @@ onUnmounted(() => {
|
|||||||
@proceed="deleteProfile"
|
@proceed="deleteProfile"
|
||||||
/>
|
/>
|
||||||
<div ref="rowContainer" class="flex flex-col gap-4">
|
<div ref="rowContainer" class="flex flex-col gap-4">
|
||||||
<div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
|
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
|
||||||
<router-link
|
<HeadingLink class="mt-1" :to="row.route">
|
||||||
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"
|
|
||||||
:class="{ 'mt-1': rowIndex > 0 }"
|
|
||||||
:to="row.route"
|
|
||||||
>
|
|
||||||
{{ row.label }}
|
{{ row.label }}
|
||||||
<ChevronRightIcon
|
</HeadingLink>
|
||||||
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
|
|
||||||
/>
|
|
||||||
</router-link>
|
|
||||||
<section
|
<section
|
||||||
v-if="row.instance"
|
v-if="row.instance"
|
||||||
ref="modsRow"
|
ref="modsRow"
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
|
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
|
||||||
query: breadcrumb.query,
|
query: breadcrumb.query,
|
||||||
}"
|
}"
|
||||||
|
class="text-primary"
|
||||||
>{{
|
>{{
|
||||||
breadcrumb.name.charAt(0) === '?'
|
breadcrumb.name.charAt(0) === '?'
|
||||||
? breadcrumbData.getName(breadcrumb.name.slice(1))
|
? breadcrumbData.getName(breadcrumb.name.slice(1))
|
||||||
|
@ -70,7 +70,7 @@ const onHide = () => {
|
|||||||
v-for="version in filteredVersions"
|
v-for="version in filteredVersions"
|
||||||
:key="version.id"
|
:key="version.id"
|
||||||
class="table-row with-columns selectable"
|
class="table-row with-columns selectable"
|
||||||
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
|
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
|
||||||
>
|
>
|
||||||
<div class="table-cell table-text">
|
<div class="table-cell table-text">
|
||||||
<Button
|
<Button
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
'router-link-active': isPrimary && isPrimary(route),
|
'router-link-active': isPrimary && isPrimary(route),
|
||||||
'subpage-active': isSubpage && isSubpage(route),
|
'subpage-active': isSubpage && isSubpage(route),
|
||||||
}"
|
}"
|
||||||
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
|
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
@ -14,7 +14,10 @@
|
|||||||
<div v-if="selectedProcess" class="status">
|
<div v-if="selectedProcess" class="status">
|
||||||
<span class="circle running" />
|
<span class="circle running" />
|
||||||
<div ref="profileButton" class="running-text">
|
<div ref="profileButton" class="running-text">
|
||||||
<router-link :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`">
|
<router-link
|
||||||
|
class="text-primary"
|
||||||
|
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
|
||||||
|
>
|
||||||
{{ selectedProcess.profile.name }}
|
{{ selectedProcess.profile.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div
|
<div
|
||||||
|
@ -41,6 +41,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
markdown: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['proceed'])
|
const emit = defineEmits(['proceed'])
|
||||||
@ -80,6 +84,7 @@ function proceed() {
|
|||||||
:on-hide="onModalHide"
|
:on-hide="onModalHide"
|
||||||
:noblur="!themeStore.advancedRendering"
|
:noblur="!themeStore.advancedRendering"
|
||||||
:danger="danger"
|
:danger="danger"
|
||||||
|
:markdown="markdown"
|
||||||
@proceed="proceed"
|
@proceed="proceed"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronRightIcon } from '@modrinth/assets'
|
||||||
|
import { Avatar } from '@modrinth/ui'
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
instance: GameInstance
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||||
|
<Avatar
|
||||||
|
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||||
|
size="24px"
|
||||||
|
:tint-by="instance.path"
|
||||||
|
/>
|
||||||
|
{{ instance.name }} <ChevronRightIcon />
|
||||||
|
</span>
|
||||||
|
</template>
|
@ -7,7 +7,7 @@ import { get, set } from '@/helpers/settings'
|
|||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
|
|
||||||
const settings = ref(await get())
|
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) {
|
function getStoreValue(key: string) {
|
||||||
return themeStore.featureFlags[key] ?? false
|
return themeStore.featureFlags[key] ?? false
|
||||||
@ -30,7 +30,7 @@ watch(
|
|||||||
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
|
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
|
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
|
||||||
{{ option }}
|
{{ option.replaceAll('_', ' ') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
220
apps/app-frontend/src/components/ui/world/InstanceItem.vue
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import {
|
||||||
|
EyeIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
PlayIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
StopCircleIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
|
||||||
|
import { useVIntl } from '@vintl/vintl'
|
||||||
|
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { showProfileInFolder } from '@/helpers/utils'
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
import { get_project } from '@/helpers/cache'
|
||||||
|
import { capitalizeString } from '@modrinth/utils'
|
||||||
|
import { kill, run } from '@/helpers/profile'
|
||||||
|
import { handleSevereError } from '@/store/error'
|
||||||
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
|
import { get_by_profile_path } from '@/helpers/process'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
|
import { process_listener } from '@/helpers/events'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'play' | 'stop'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instance: GameInstance
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const loadingModpack = ref(!!props.instance.linked_data)
|
||||||
|
|
||||||
|
const modpack = ref()
|
||||||
|
|
||||||
|
if (props.instance.linked_data) {
|
||||||
|
nextTick().then(async () => {
|
||||||
|
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
|
||||||
|
loadingModpack.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceIcon = computed(() => props.instance.icon_path)
|
||||||
|
|
||||||
|
const loader = computed(() => {
|
||||||
|
if (props.instance.loader === 'vanilla') {
|
||||||
|
return 'Minecraft'
|
||||||
|
} else if (props.instance.loader === 'neoforge') {
|
||||||
|
return 'NeoForge'
|
||||||
|
} else {
|
||||||
|
return capitalizeString(props.instance.loader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const playing = ref(false)
|
||||||
|
|
||||||
|
const play = async (event: MouseEvent) => {
|
||||||
|
event?.stopPropagation()
|
||||||
|
loading.value = true
|
||||||
|
await run(props.instance.path)
|
||||||
|
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
|
||||||
|
.finally(() => {
|
||||||
|
trackEvent('InstancePlay', {
|
||||||
|
loader: props.instance.loader,
|
||||||
|
game_version: props.instance.game_version,
|
||||||
|
source: 'InstanceItem',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
emit('play')
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = async (event: MouseEvent) => {
|
||||||
|
event?.stopPropagation()
|
||||||
|
loading.value = true
|
||||||
|
await kill(props.instance.path).catch(handleError)
|
||||||
|
trackEvent('InstanceStop', {
|
||||||
|
loader: props.instance.loader,
|
||||||
|
game_version: props.instance.game_version,
|
||||||
|
source: 'InstanceItem',
|
||||||
|
})
|
||||||
|
emit('stop')
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlistenProcesses = await process_listener(async () => {
|
||||||
|
await checkProcess()
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkProcess = async () => {
|
||||||
|
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
|
||||||
|
|
||||||
|
playing.value = runningProcesses.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkProcess()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlistenProcesses()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<SmartClickable>
|
||||||
|
<template #clickable>
|
||||||
|
<router-link
|
||||||
|
class="no-click-animation"
|
||||||
|
:to="`/instance/${encodeURIComponent(instance.path)}`"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
|
||||||
|
:tint-by="instance.path"
|
||||||
|
size="48px"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col col-span-2 justify-between h-full">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
|
||||||
|
{{ instance.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||||
|
<div
|
||||||
|
v-tooltip="
|
||||||
|
instance.last_played
|
||||||
|
? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A')
|
||||||
|
: null
|
||||||
|
"
|
||||||
|
class="w-fit shrink-0"
|
||||||
|
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
|
||||||
|
>
|
||||||
|
<template v-if="instance.last_played">
|
||||||
|
{{
|
||||||
|
formatMessage(commonMessages.playedLabel, {
|
||||||
|
time: dayjs(instance.last_played).fromNow(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<template v-else> Not played yet </template>
|
||||||
|
</div>
|
||||||
|
•
|
||||||
|
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
|
||||||
|
<router-link
|
||||||
|
class="inline-flex items-center gap-1 truncate hover:underline text-secondary"
|
||||||
|
:to="`/project/${modpack.id}`"
|
||||||
|
>
|
||||||
|
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
|
||||||
|
<span class="truncate">{{ modpack.title }}</span>
|
||||||
|
</router-link>
|
||||||
|
({{ loader }} {{ instance.game_version }})
|
||||||
|
</span>
|
||||||
|
<span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary">
|
||||||
|
<SpinnerIcon class="animate-spin shrink-0" />
|
||||||
|
<span class="truncate">Loading modpack...</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="flex items-center gap-1 truncate text-secondary">
|
||||||
|
{{ loader }}
|
||||||
|
{{ instance.game_version }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||||
|
<ButtonStyled v-if="playing && !loading" color="red">
|
||||||
|
<button @click="stop">
|
||||||
|
<StopCircleIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.stopButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else>
|
||||||
|
<button
|
||||||
|
v-tooltip="playing ? 'Instance is already open' : null"
|
||||||
|
:disabled="playing || loading"
|
||||||
|
@click="play"
|
||||||
|
>
|
||||||
|
<SpinnerIcon v-if="loading" class="animate-spin" />
|
||||||
|
<PlayIcon v-else aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.playButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular type="transparent">
|
||||||
|
<OverflowMenu
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'open-instance',
|
||||||
|
shown: !!instance.path,
|
||||||
|
action: () => router.push(encodeURI(`/instance/${instance.path}`)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-folder',
|
||||||
|
action: () => showProfileInFolder(instance.path),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
|
<template #open-instance>
|
||||||
|
<EyeIcon aria-hidden="true" />
|
||||||
|
View instance
|
||||||
|
</template>
|
||||||
|
<template #open-folder>
|
||||||
|
<FolderOpenIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.openFolderButton) }}
|
||||||
|
</template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SmartClickable>
|
||||||
|
</template>
|
275
apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type ServerWorld,
|
||||||
|
type ServerData,
|
||||||
|
type WorldWithProfile,
|
||||||
|
get_recent_worlds,
|
||||||
|
getWorldIdentifier,
|
||||||
|
get_profile_protocol_version,
|
||||||
|
refreshServerData,
|
||||||
|
start_join_server,
|
||||||
|
start_join_singleplayer_world,
|
||||||
|
} from '@/helpers/worlds.ts'
|
||||||
|
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
|
||||||
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
|
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||||
|
import { watch, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import type { Dayjs } from 'dayjs'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useTheming } from '@/store/theme'
|
||||||
|
import { kill } from '@/helpers/profile'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
|
import { process_listener, profile_listener } from '@/helpers/events'
|
||||||
|
import { get_all } from '@/helpers/process'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
recentInstances: GameInstance[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const theme = useTheming()
|
||||||
|
|
||||||
|
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||||
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
|
const protocolVersions = ref<Record<string, number | null>>({})
|
||||||
|
|
||||||
|
const MIN_JUMP_BACK_IN = 3
|
||||||
|
const MAX_JUMP_BACK_IN = 6
|
||||||
|
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
|
||||||
|
|
||||||
|
type BaseJumpBackInItem = {
|
||||||
|
last_played: Dayjs
|
||||||
|
instance: GameInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceJumpBackInItem = BaseJumpBackInItem & {
|
||||||
|
type: 'instance'
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorldJumpBackInItem = BaseJumpBackInItem & {
|
||||||
|
type: 'world'
|
||||||
|
world: WorldWithProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
|
||||||
|
|
||||||
|
watch(props.recentInstances, async () => {
|
||||||
|
await populateJumpBackIn().catch(() => {
|
||||||
|
console.error('Failed to populate jump back in')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await populateJumpBackIn().catch(() => {
|
||||||
|
console.error('Failed to populate jump back in')
|
||||||
|
})
|
||||||
|
|
||||||
|
async function populateJumpBackIn() {
|
||||||
|
console.info('Repopulating jump back in...')
|
||||||
|
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN)
|
||||||
|
|
||||||
|
const worldItems: WorldJumpBackInItem[] = []
|
||||||
|
worlds.forEach((world) => {
|
||||||
|
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
|
||||||
|
|
||||||
|
if (!instance || !world.last_played) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
worldItems.push({
|
||||||
|
type: 'world',
|
||||||
|
last_played: dayjs(world.last_played),
|
||||||
|
world: world,
|
||||||
|
instance: instance,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const servers: {
|
||||||
|
instancePath: string
|
||||||
|
address: string
|
||||||
|
}[] = worldItems
|
||||||
|
.filter((item) => item.world.type === 'server' && item.instance)
|
||||||
|
.map((item) => ({
|
||||||
|
instancePath: item.instance.path,
|
||||||
|
address: (item.world as ServerWorld).address,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// fetch protocol versions for all unique MC versions with server worlds
|
||||||
|
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
|
||||||
|
await Promise.all(
|
||||||
|
[...uniqueServerInstances].map((path) => {
|
||||||
|
get_profile_protocol_version(path)
|
||||||
|
.then((protoVer) => (protocolVersions.value[path] = protoVer))
|
||||||
|
.catch(() => {
|
||||||
|
console.error(`Failed to get profile protocol for: ${path} `)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// initialize server data
|
||||||
|
servers.forEach(({ address }) => {
|
||||||
|
if (!serverData.value[address]) {
|
||||||
|
serverData.value[address] = {
|
||||||
|
refreshing: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// fetch each server's data
|
||||||
|
await Promise.all(
|
||||||
|
servers.map(({ instancePath, address }) =>
|
||||||
|
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const instanceItems: InstanceJumpBackInItem[] = []
|
||||||
|
props.recentInstances.forEach((instance) => {
|
||||||
|
if (worldItems.some((item) => item.instance.path === instance.path) || !instance.last_played) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceItems.push({
|
||||||
|
type: 'instance',
|
||||||
|
last_played: dayjs(instance.last_played),
|
||||||
|
instance: instance,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
||||||
|
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
|
||||||
|
jumpBackInItems.value = items.filter(
|
||||||
|
(item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshServer(address: string, instancePath: string) {
|
||||||
|
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinWorld(world: WorldWithProfile) {
|
||||||
|
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||||
|
if (world.type === 'server') {
|
||||||
|
await start_join_server(world.profile, world.address).catch(handleError)
|
||||||
|
} else if (world.type === 'singleplayer') {
|
||||||
|
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopInstance(path: string) {
|
||||||
|
await kill(path).catch(handleError)
|
||||||
|
trackEvent('InstanceStop', {
|
||||||
|
source: 'RecentWorldsList',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentProfile = ref<string>()
|
||||||
|
const currentWorld = ref<string>()
|
||||||
|
|
||||||
|
const unlistenProcesses = await process_listener(async () => {
|
||||||
|
await checkProcesses()
|
||||||
|
})
|
||||||
|
|
||||||
|
const unlistenProfiles = await profile_listener(async () => {
|
||||||
|
await populateJumpBackIn().catch(() => {
|
||||||
|
console.error('Failed to populate jump back in')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const runningInstances = ref<string[]>([])
|
||||||
|
|
||||||
|
type ProcessMetadata = {
|
||||||
|
uuid: string
|
||||||
|
profile_path: string
|
||||||
|
start_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkProcesses = async () => {
|
||||||
|
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
|
||||||
|
|
||||||
|
const runningPaths = runningProcesses.map((x) => x.profile_path)
|
||||||
|
|
||||||
|
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
|
||||||
|
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
|
||||||
|
currentProfile.value = undefined
|
||||||
|
currentWorld.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
runningInstances.value = runningPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkProcesses()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlistenProcesses()
|
||||||
|
unlistenProfiles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
|
||||||
|
<HeadingLink
|
||||||
|
v-if="(theme.featureFlags as Record<string, boolean>)['worlds_tab']"
|
||||||
|
to="/worlds"
|
||||||
|
class="mt-1"
|
||||||
|
>
|
||||||
|
Jump back in
|
||||||
|
</HeadingLink>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
|
||||||
|
>
|
||||||
|
Jump back in
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<template
|
||||||
|
v-for="item in jumpBackInItems"
|
||||||
|
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
|
||||||
|
>
|
||||||
|
<WorldItem
|
||||||
|
v-if="item.type === 'world'"
|
||||||
|
:world="item.world"
|
||||||
|
:playing-instance="runningInstances.includes(item.instance.path)"
|
||||||
|
:playing-world="
|
||||||
|
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
|
||||||
|
"
|
||||||
|
:refreshing="
|
||||||
|
item.world.type === 'server'
|
||||||
|
? serverData[item.world.address].refreshing && !serverData[item.world.address].status
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
supports-quick-play
|
||||||
|
:server-status="
|
||||||
|
item.world.type === 'server' ? serverData[item.world.address].status : undefined
|
||||||
|
"
|
||||||
|
:rendered-motd="
|
||||||
|
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
|
||||||
|
"
|
||||||
|
:current-protocol="protocolVersions[item.instance.game_version]"
|
||||||
|
:game-mode="
|
||||||
|
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
||||||
|
"
|
||||||
|
:instance-path="item.instance.path"
|
||||||
|
:instance-name="item.instance.name"
|
||||||
|
:instance-icon="item.instance.icon_path"
|
||||||
|
@refresh="
|
||||||
|
() =>
|
||||||
|
item.world.type === 'server'
|
||||||
|
? refreshServer(item.world.address, item.instance.path)
|
||||||
|
: {}
|
||||||
|
"
|
||||||
|
@play="
|
||||||
|
() => {
|
||||||
|
currentProfile = item.instance.path
|
||||||
|
currentWorld = getWorldIdentifier(item.world)
|
||||||
|
joinWorld(item.world)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@stop="() => stopInstance(item.instance.path)"
|
||||||
|
/>
|
||||||
|
<InstanceItem v-else :instance="item.instance" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
470
apps/app-frontend/src/components/ui/world/WorldItem.vue
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts'
|
||||||
|
import { getWorldIdentifier, showWorldInFolder } from '@/helpers/worlds.ts'
|
||||||
|
import { formatNumber } from '@modrinth/utils'
|
||||||
|
import {
|
||||||
|
IssuesIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
EditIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
NoSignalIcon,
|
||||||
|
PlayIcon,
|
||||||
|
SignalIcon,
|
||||||
|
SkullIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
StopCircleIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UpdatedIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
|
||||||
|
import type { MessageDescriptor } from '@vintl/vintl'
|
||||||
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { copyToClipboard } from '@/helpers/utils'
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Tooltip } from 'floating-vue'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'play' | 'stop' | 'refresh' | 'edit' | 'delete'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
world: World
|
||||||
|
playingInstance?: boolean
|
||||||
|
playingWorld?: boolean
|
||||||
|
startingInstance?: boolean
|
||||||
|
supportsQuickPlay?: boolean
|
||||||
|
currentProtocol?: number | null
|
||||||
|
highlighted?: boolean
|
||||||
|
|
||||||
|
// Server only
|
||||||
|
refreshing?: boolean
|
||||||
|
serverStatus?: ServerStatus
|
||||||
|
renderedMotd?: string
|
||||||
|
|
||||||
|
// Singleplayer only
|
||||||
|
gameMode?: {
|
||||||
|
icon: Component
|
||||||
|
message: MessageDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance
|
||||||
|
instancePath?: string
|
||||||
|
instanceName?: string
|
||||||
|
instanceIcon?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
playingInstance: false,
|
||||||
|
playingWorld: false,
|
||||||
|
startingInstance: false,
|
||||||
|
supportsQuickPlay: false,
|
||||||
|
|
||||||
|
refreshing: false,
|
||||||
|
serverStatus: undefined,
|
||||||
|
renderedMotd: undefined,
|
||||||
|
|
||||||
|
gameMode: undefined,
|
||||||
|
|
||||||
|
instancePath: undefined,
|
||||||
|
instanceName: undefined,
|
||||||
|
instanceIcon: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
|
||||||
|
const hasPlayersTooltip = computed(
|
||||||
|
() => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0,
|
||||||
|
)
|
||||||
|
const serverIncompatible = computed(
|
||||||
|
() =>
|
||||||
|
!!props.serverStatus &&
|
||||||
|
!!props.serverStatus.version?.protocol &&
|
||||||
|
!!props.currentProtocol &&
|
||||||
|
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
function getPingLevel(ping: number) {
|
||||||
|
if (ping < 150) {
|
||||||
|
return 5
|
||||||
|
} else if (ping < 300) {
|
||||||
|
return 4
|
||||||
|
} else if (ping < 600) {
|
||||||
|
return 3
|
||||||
|
} else if (ping < 1000) {
|
||||||
|
return 2
|
||||||
|
} else {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
hardcore: {
|
||||||
|
id: 'instance.worlds.hardcore',
|
||||||
|
defaultMessage: 'Hardcore mode',
|
||||||
|
},
|
||||||
|
cantConnect: {
|
||||||
|
id: 'instance.worlds.cant_connect',
|
||||||
|
defaultMessage: "Can't connect to server",
|
||||||
|
},
|
||||||
|
aMinecraftServer: {
|
||||||
|
id: 'instance.worlds.a_minecraft_server',
|
||||||
|
defaultMessage: 'A Minecraft Server',
|
||||||
|
},
|
||||||
|
noQuickPlay: {
|
||||||
|
id: 'instance.worlds.no_quick_play',
|
||||||
|
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
|
||||||
|
},
|
||||||
|
gameAlreadyOpen: {
|
||||||
|
id: 'instance.worlds.game_already_open',
|
||||||
|
defaultMessage: 'Instance is already open',
|
||||||
|
},
|
||||||
|
copyAddress: {
|
||||||
|
id: 'instance.worlds.copy_address',
|
||||||
|
defaultMessage: 'Copy address',
|
||||||
|
},
|
||||||
|
viewInstance: {
|
||||||
|
id: 'instance.worlds.view_instance',
|
||||||
|
defaultMessage: 'View instance',
|
||||||
|
},
|
||||||
|
playAnyway: {
|
||||||
|
id: 'instance.worlds.play_anyway',
|
||||||
|
defaultMessage: 'Play anyway',
|
||||||
|
},
|
||||||
|
worldInUse: {
|
||||||
|
id: 'instance.worlds.world_in_use',
|
||||||
|
defaultMessage: 'World is in use',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<SmartClickable>
|
||||||
|
<template v-if="instancePath" #clickable>
|
||||||
|
<router-link
|
||||||
|
class="no-click-animation"
|
||||||
|
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl"
|
||||||
|
:class="{
|
||||||
|
'world-item-highlighted': highlighted,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="
|
||||||
|
world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon
|
||||||
|
"
|
||||||
|
size="48px"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col justify-between h-full">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
|
||||||
|
{{ world.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="world.type === 'singleplayer'"
|
||||||
|
class="text-sm text-secondary flex items-center gap-1 font-semibold"
|
||||||
|
>
|
||||||
|
<UserIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 text-secondary shrink-0"
|
||||||
|
stroke-width="3px"
|
||||||
|
/>
|
||||||
|
{{ formatMessage(commonMessages.singleplayerLabel) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="world.type === 'server'"
|
||||||
|
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<template v-if="refreshing">
|
||||||
|
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
|
||||||
|
Loading...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="serverStatus">
|
||||||
|
<template v-if="serverIncompatible">
|
||||||
|
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
|
||||||
|
<span class="text-orange">
|
||||||
|
Incompatible version {{ serverStatus.version?.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<SignalIcon
|
||||||
|
v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null"
|
||||||
|
aria-hidden="true"
|
||||||
|
:style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`"
|
||||||
|
stroke-width="3px"
|
||||||
|
class="shrink-0"
|
||||||
|
:class="{
|
||||||
|
'smart-clickable:allow-pointer-events': serverStatus,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<Tooltip :disabled="!hasPlayersTooltip">
|
||||||
|
<span :class="{ 'cursor-help': hasPlayersTooltip }">
|
||||||
|
{{ formatNumber(serverStatus.players?.online, false) }} online
|
||||||
|
</span>
|
||||||
|
<template #popper>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span v-for="player in serverStatus.players?.sample" :key="player.name">
|
||||||
|
{{ player.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" /> Offline
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||||
|
<div
|
||||||
|
v-tooltip="
|
||||||
|
world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null
|
||||||
|
"
|
||||||
|
class="w-fit shrink-0"
|
||||||
|
:class="{ 'cursor-help smart-clickable:allow-pointer-events': world.last_played }"
|
||||||
|
>
|
||||||
|
<template v-if="world.last_played">
|
||||||
|
{{
|
||||||
|
formatMessage(commonMessages.playedLabel, {
|
||||||
|
time: dayjs(world.last_played).fromNow(),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<template v-else> Not played yet </template>
|
||||||
|
</div>
|
||||||
|
<template v-if="instancePath">
|
||||||
|
•
|
||||||
|
<router-link
|
||||||
|
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
|
||||||
|
:to="`/instance/${instancePath}`"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
|
||||||
|
size="16px"
|
||||||
|
:tint-by="instancePath"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ instanceName }}</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="font-semibold flex items-center gap-1 justify-center text-center"
|
||||||
|
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
|
||||||
|
>
|
||||||
|
<template v-if="world.type === 'server'">
|
||||||
|
<template v-if="refreshing">
|
||||||
|
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||||
|
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else-if="renderedMotd"
|
||||||
|
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
|
||||||
|
v-html="renderedMotd"
|
||||||
|
/>
|
||||||
|
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
|
||||||
|
{{ formatMessage(messages.cantConnect) }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="font-normal font-minecraft text-secondary leading-5">
|
||||||
|
{{ formatMessage(messages.aMinecraftServer) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="world.type === 'singleplayer' && gameMode">
|
||||||
|
<template v-if="world.hardcore">
|
||||||
|
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||||
|
{{ formatMessage(messages.hardcore) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||||
|
{{ formatMessage(gameMode.message) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||||
|
<template v-if="world.type === 'singleplayer' || serverStatus">
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
<button @click="emit('stop')">
|
||||||
|
<StopCircleIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.stopButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else>
|
||||||
|
<button
|
||||||
|
v-tooltip="
|
||||||
|
serverIncompatible
|
||||||
|
? 'Server is incompatible'
|
||||||
|
: !supportsQuickPlay
|
||||||
|
? formatMessage(messages.noQuickPlay)
|
||||||
|
: playingOtherWorld || locked
|
||||||
|
? formatMessage(messages.gameAlreadyOpen)
|
||||||
|
: null
|
||||||
|
"
|
||||||
|
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||||
|
@click="emit('play')"
|
||||||
|
>
|
||||||
|
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||||
|
<PlayIcon v-else aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.playButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
<ButtonStyled v-else>
|
||||||
|
<button class="invisible">
|
||||||
|
<PlayIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.playButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular type="transparent">
|
||||||
|
<OverflowMenu
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'play-anyway',
|
||||||
|
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
||||||
|
action: () => emit('play'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-instance',
|
||||||
|
shown: !!instancePath,
|
||||||
|
action: () => router.push(encodeURI(`/instance/${instancePath}/worlds`)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refresh',
|
||||||
|
shown: world.type === 'server',
|
||||||
|
action: () => emit('refresh'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-address',
|
||||||
|
shown: world.type === 'server',
|
||||||
|
action: () => copyToClipboard((world as ServerWorld).address),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
action: () => emit('edit'),
|
||||||
|
shown: !instancePath,
|
||||||
|
disabled: locked,
|
||||||
|
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-folder',
|
||||||
|
shown: world.type === 'singleplayer',
|
||||||
|
action: () =>
|
||||||
|
world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: !instancePath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
color: 'red',
|
||||||
|
hoverFilled: true,
|
||||||
|
action: () => emit('delete'),
|
||||||
|
shown: !instancePath,
|
||||||
|
disabled: locked,
|
||||||
|
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
|
<template #play-anyway>
|
||||||
|
<PlayIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.playAnyway) }}
|
||||||
|
</template>
|
||||||
|
<template #open-instance>
|
||||||
|
<EyeIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.viewInstance) }}
|
||||||
|
</template>
|
||||||
|
<template #edit>
|
||||||
|
<EditIcon aria-hidden="true" /> {{ formatMessage(commonMessages.editButton) }}
|
||||||
|
</template>
|
||||||
|
<template #open-folder>
|
||||||
|
<FolderOpenIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.openFolderButton) }}
|
||||||
|
</template>
|
||||||
|
<template #copy-address>
|
||||||
|
<ClipboardCopyIcon aria-hidden="true" /> {{ formatMessage(messages.copyAddress) }}
|
||||||
|
</template>
|
||||||
|
<template #refresh>
|
||||||
|
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
|
||||||
|
</template>
|
||||||
|
<template #delete>
|
||||||
|
<TrashIcon aria-hidden="true" />
|
||||||
|
{{
|
||||||
|
formatMessage(
|
||||||
|
world.type === 'server'
|
||||||
|
? commonMessages.removeButton
|
||||||
|
: commonMessages.deleteLabel,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SmartClickable>
|
||||||
|
</template>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.world-item-highlighted {
|
||||||
|
position: relative;
|
||||||
|
animation: fade-highlight 4s ease-out;
|
||||||
|
filter: brightness(1);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
@apply rounded-xl inset-0 absolute;
|
||||||
|
|
||||||
|
animation: fade-opacity 4s ease-out;
|
||||||
|
|
||||||
|
content: '';
|
||||||
|
box-shadow: 0 0 8px 2px var(--color-brand);
|
||||||
|
border: 1.5px solid var(--color-brand);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-highlight {
|
||||||
|
0% {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-opacity {
|
||||||
|
0% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-mode .motd-renderer {
|
||||||
|
filter: brightness(0.75);
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
|
||||||
|
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
|
||||||
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
|
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [server: ServerWorld, play: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instance: GameInstance
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref()
|
||||||
|
|
||||||
|
const name = ref()
|
||||||
|
const address = ref()
|
||||||
|
const resourcePack = ref<ServerPackStatus>('enabled')
|
||||||
|
|
||||||
|
async function addServer(play: boolean) {
|
||||||
|
const serverName = name.value ? name.value : address.value
|
||||||
|
const resourcePackStatus = resourcePack.value
|
||||||
|
const index =
|
||||||
|
(await add_server_to_profile(
|
||||||
|
props.instance.path,
|
||||||
|
serverName,
|
||||||
|
address.value,
|
||||||
|
resourcePackStatus,
|
||||||
|
).catch(handleError)) ?? 0
|
||||||
|
emit(
|
||||||
|
'submit',
|
||||||
|
{
|
||||||
|
name: serverName,
|
||||||
|
type: 'server',
|
||||||
|
index,
|
||||||
|
address: address.value,
|
||||||
|
pack_status: resourcePackStatus,
|
||||||
|
},
|
||||||
|
play,
|
||||||
|
)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
name.value = ''
|
||||||
|
address.value = ''
|
||||||
|
resourcePack.value = 'enabled'
|
||||||
|
modal.value.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: {
|
||||||
|
id: 'instance.add-server.title',
|
||||||
|
defaultMessage: 'Add a server',
|
||||||
|
},
|
||||||
|
addServer: {
|
||||||
|
id: 'instance.add-server.add-server',
|
||||||
|
defaultMessage: 'Add server',
|
||||||
|
},
|
||||||
|
addAndPlay: {
|
||||||
|
id: 'instance.add-server.add-and-play',
|
||||||
|
defaultMessage: 'Add and play',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal">
|
||||||
|
<template #title>
|
||||||
|
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||||
|
<InstanceModalTitlePrefix :instance="instance" />
|
||||||
|
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ServerModalBody
|
||||||
|
v-model:name="name"
|
||||||
|
v-model:address="address"
|
||||||
|
v-model:resource-pack="resourcePack"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button :disabled="!address" @click="addServer(true)">
|
||||||
|
<PlayIcon />
|
||||||
|
{{ formatMessage(messages.addAndPlay) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="!address" @click="addServer(false)">
|
||||||
|
<PlusIcon />
|
||||||
|
{{ formatMessage(messages.addServer) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="hide()">
|
||||||
|
<XIcon />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SaveIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
import { edit_server_in_profile, type ServerWorld } from '@/helpers/worlds.ts'
|
||||||
|
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
|
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [server: ServerWorld]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instance: GameInstance
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref()
|
||||||
|
|
||||||
|
const name = ref()
|
||||||
|
const address = ref()
|
||||||
|
const resourcePack = ref('enabled')
|
||||||
|
const index = ref()
|
||||||
|
|
||||||
|
async function saveServer() {
|
||||||
|
const serverName = name.value ? name.value : address.value
|
||||||
|
const resourcePackStatus = resourcePack.value
|
||||||
|
await edit_server_in_profile(
|
||||||
|
props.instance.path,
|
||||||
|
index.value,
|
||||||
|
serverName,
|
||||||
|
address.value,
|
||||||
|
resourcePackStatus,
|
||||||
|
).catch(handleError)
|
||||||
|
emit('submit', {
|
||||||
|
name: serverName,
|
||||||
|
type: 'server',
|
||||||
|
index: index.value,
|
||||||
|
address: address.value,
|
||||||
|
pack_status: resourcePackStatus,
|
||||||
|
})
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(server: ServerWorld) {
|
||||||
|
name.value = server.name
|
||||||
|
address.value = server.address
|
||||||
|
resourcePack.value = server.pack_status
|
||||||
|
index.value = server.index
|
||||||
|
modal.value.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
|
||||||
|
const titleMessage = defineMessage({
|
||||||
|
id: 'instance.edit-server.title',
|
||||||
|
defaultMessage: 'Edit server',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal">
|
||||||
|
<template #title>
|
||||||
|
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
|
||||||
|
</template>
|
||||||
|
<ServerModalBody
|
||||||
|
v-model:name="name"
|
||||||
|
v-model:address="address"
|
||||||
|
v-model:resource-pack="resourcePack"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button :disabled="!address" @click="saveServer">
|
||||||
|
<SaveIcon />
|
||||||
|
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="hide()">
|
||||||
|
<XIcon />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
|
||||||
|
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
import type { SingleplayerWorld } from '@/helpers/worlds.ts'
|
||||||
|
import { rename_world, reset_world_icon } from '@/helpers/worlds.ts'
|
||||||
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [path: string, name: string, removeIcon: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instance: GameInstance
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref()
|
||||||
|
|
||||||
|
const icon = ref()
|
||||||
|
const name = ref()
|
||||||
|
const path = ref()
|
||||||
|
const removeIcon = ref(false)
|
||||||
|
|
||||||
|
async function saveWorld() {
|
||||||
|
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
|
||||||
|
|
||||||
|
if (removeIcon.value) {
|
||||||
|
await reset_world_icon(props.instance.path, path.value).catch(handleError)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', path.value, name.value, removeIcon.value)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(world: SingleplayerWorld) {
|
||||||
|
name.value = world.name
|
||||||
|
path.value = world.path
|
||||||
|
icon.value = world.icon
|
||||||
|
modal.value.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: {
|
||||||
|
id: 'instance.edit-world.title',
|
||||||
|
defaultMessage: 'Edit world',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
id: 'instance.edit-world.name',
|
||||||
|
defaultMessage: 'Name',
|
||||||
|
},
|
||||||
|
placeholderName: {
|
||||||
|
id: 'instance.edit-world.placeholder-name',
|
||||||
|
defaultMessage: 'Minecraft World',
|
||||||
|
},
|
||||||
|
resetIcon: {
|
||||||
|
id: 'instance.edit-world.reset-icon',
|
||||||
|
defaultMessage: 'Reset icon',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal">
|
||||||
|
<template #title>
|
||||||
|
<Avatar :src="removeIcon || !icon ? undefined : icon" size="24px" />
|
||||||
|
{{ instance.name }} <ChevronRightIcon />
|
||||||
|
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||||
|
</template>
|
||||||
|
<div class="w-[450px]">
|
||||||
|
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
|
||||||
|
{{ formatMessage(messages.name) }}
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.placeholderName)"
|
||||||
|
class="w-full"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button @click="saveWorld">
|
||||||
|
<SaveIcon />
|
||||||
|
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="removeIcon || !icon" @click="removeIcon = true">
|
||||||
|
<UndoIcon />
|
||||||
|
{{ formatMessage(messages.resetIcon) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="hide()">
|
||||||
|
<XIcon />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TeleportDropdownMenu } from '@modrinth/ui'
|
||||||
|
import type { ServerPackStatus } from '@/helpers/worlds.ts'
|
||||||
|
import { type MessageDescriptor, defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const name = defineModel<string>('name')
|
||||||
|
const address = defineModel<string>('address')
|
||||||
|
const resourcePack = defineModel<ServerPackStatus>('resourcePack')
|
||||||
|
|
||||||
|
const resourcePackOptions: ServerPackStatus[] = ['enabled', 'prompt', 'disabled']
|
||||||
|
|
||||||
|
const resourcePackOptionMessages: Record<ServerPackStatus, MessageDescriptor> = defineMessages({
|
||||||
|
enabled: {
|
||||||
|
id: 'instance.add-server.resource-pack.enabled',
|
||||||
|
defaultMessage: 'Enabled',
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
id: 'instance.add-server.resource-pack.prompt',
|
||||||
|
defaultMessage: 'Prompt',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
id: 'instance.add-server.resource-pack.disabled',
|
||||||
|
defaultMessage: 'Disabled',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
name: {
|
||||||
|
id: 'instance.server-modal.name',
|
||||||
|
defaultMessage: 'Name',
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
id: 'instance.server-modal.address',
|
||||||
|
defaultMessage: 'Address',
|
||||||
|
},
|
||||||
|
resourcePack: {
|
||||||
|
id: 'instance.server-modal.resource-pack',
|
||||||
|
defaultMessage: 'Resource pack',
|
||||||
|
},
|
||||||
|
placeholderName: {
|
||||||
|
id: 'instance.server-modal.placeholder-name',
|
||||||
|
defaultMessage: 'Minecraft Server',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ resourcePackOptions })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="w-[450px]">
|
||||||
|
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
|
||||||
|
{{ formatMessage(messages.name) }}
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.placeholderName)"
|
||||||
|
class="w-full"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
||||||
|
{{ formatMessage(messages.address) }}
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
v-model="address"
|
||||||
|
type="text"
|
||||||
|
placeholder="example.modrinth.gg"
|
||||||
|
class="w-full"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
||||||
|
{{ formatMessage(messages.resourcePack) }}
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<TeleportDropdownMenu
|
||||||
|
v-model="resourcePack"
|
||||||
|
:options="resourcePackOptions"
|
||||||
|
name="Server resource pack"
|
||||||
|
:display-name="
|
||||||
|
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -62,7 +62,7 @@ export async function process_listener(callback) {
|
|||||||
ProfilePayload {
|
ProfilePayload {
|
||||||
uuid: unique identification of the process in the state (currently identified by path, but that will change)
|
uuid: unique identification of the process in the state (currently identified by path, but that will change)
|
||||||
name: name of the profile
|
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)
|
path: path to profile (used for opening the profile in the OS file explorer)
|
||||||
event: event type ("Created", "Added", "Edited", "Removed")
|
event: event type ("Created", "Added", "Edited", "Removed")
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,13 @@ export async function restartApp() {
|
|||||||
return await invoke('restart_app')
|
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) => {
|
export const releaseColor = (releaseType) => {
|
||||||
switch (releaseType) {
|
switch (releaseType) {
|
||||||
case 'release':
|
case 'release':
|
||||||
@ -49,3 +56,7 @@ export const releaseColor = (releaseType) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function copyToClipboard(text) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
}
|
||||||
|
303
apps/app-frontend/src/helpers/worlds.ts
Normal file
@ -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<WorldWithProfile[]> {
|
||||||
|
return await invoke('plugin:worlds|get_recent_worlds', { limit })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_profile_worlds(path: string): Promise<World[]> {
|
||||||
|
return await invoke('plugin:worlds|get_profile_worlds', { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_singleplayer_world(
|
||||||
|
instance: string,
|
||||||
|
world: string,
|
||||||
|
): Promise<SingleplayerWorld> {
|
||||||
|
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rename_world(
|
||||||
|
instance: string,
|
||||||
|
world: string,
|
||||||
|
newName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return await invoke('plugin:worlds|rename_world', { instance, world, newName })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reset_world_icon(instance: string, world: string): Promise<void> {
|
||||||
|
return await invoke('plugin:worlds|reset_world_icon', { instance, world })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backup_world(instance: string, world: string): Promise<number> {
|
||||||
|
return await invoke('plugin:worlds|backup_world', { instance, world })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delete_world(instance: string, world: string): Promise<void> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_profile_protocol_version(path: string): Promise<number | null> {
|
||||||
|
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_server_status(
|
||||||
|
address: string,
|
||||||
|
protocolVersion: number | null = null,
|
||||||
|
): Promise<ServerStatus> {
|
||||||
|
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
|
||||||
|
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function start_join_server(path: string, address: string): Promise<unknown> {
|
||||||
|
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<void> {
|
||||||
|
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<string, ServerData>,
|
||||||
|
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<World[]> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
)
|
@ -20,12 +20,57 @@
|
|||||||
"app.settings.tabs.resource-management": {
|
"app.settings.tabs.resource-management": {
|
||||||
"message": "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": {
|
"instance.filter.disabled": {
|
||||||
"message": "Disabled projects"
|
"message": "Disabled projects"
|
||||||
},
|
},
|
||||||
"instance.filter.updates-available": {
|
"instance.filter.updates-available": {
|
||||||
"message": "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": {
|
"instance.settings.tabs.general": {
|
||||||
"message": "General"
|
"message": "General"
|
||||||
},
|
},
|
||||||
@ -308,6 +353,42 @@
|
|||||||
"instance.settings.title": {
|
"instance.settings.title": {
|
||||||
"message": "Settings"
|
"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": {
|
"search.filter.locked.instance": {
|
||||||
"message": "Provided by the instance"
|
"message": "Provided by the instance"
|
||||||
},
|
},
|
||||||
|
@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue'
|
|||||||
import 'floating-vue/dist/style.css'
|
import 'floating-vue/dist/style.css'
|
||||||
import { createPlugin } from '@vintl/vintl/plugin'
|
import { createPlugin } from '@vintl/vintl/plugin'
|
||||||
import * as Sentry from '@sentry/vue'
|
import * as Sentry from '@sentry/vue'
|
||||||
|
import { VueScanPlugin } from '@taijased/vue-render-tracker'
|
||||||
|
|
||||||
const VIntlPlugin = createPlugin({
|
const VIntlPlugin = createPlugin({
|
||||||
controllerOpts: {
|
controllerOpts: {
|
||||||
@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({
|
|||||||
injectInto: [],
|
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()
|
const pinia = createPinia()
|
||||||
|
|
||||||
let app = createApp(App)
|
let app = createApp(App)
|
||||||
@ -35,6 +43,7 @@ Sentry.init({
|
|||||||
tracesSampleRate: 0.1,
|
tracesSampleRate: 0.1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.use(vueScan)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(FloatingVue, {
|
app.use(FloatingVue, {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onUnmounted, computed } from 'vue'
|
import { ref, onUnmounted, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import RowDisplay from '@/components/RowDisplay.vue'
|
import RowDisplay from '@/components/RowDisplay.vue'
|
||||||
@ -8,19 +8,32 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
|
|||||||
import { handleError } from '@/store/notifications.js'
|
import { handleError } from '@/store/notifications.js'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { get_search_results } from '@/helpers/cache.js'
|
import { get_search_results } from '@/helpers/cache.js'
|
||||||
|
import type { SearchResult } from '@modrinth/utils'
|
||||||
const featuredModpacks = ref({})
|
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||||
const featuredMods = ref({})
|
|
||||||
const filter = ref('')
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const breadcrumbs = useBreadcrumbs()
|
const breadcrumbs = useBreadcrumbs()
|
||||||
|
|
||||||
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
|
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
|
||||||
|
|
||||||
const recentInstances = ref([])
|
const instances = ref<GameInstance[]>([])
|
||||||
|
|
||||||
const offline = ref(!navigator.onLine)
|
const featuredModpacks = ref<SearchResult[]>([])
|
||||||
|
const featuredMods = ref<SearchResult[]>([])
|
||||||
|
const installedModpacksFilter = ref('')
|
||||||
|
|
||||||
|
const recentInstances = computed(() =>
|
||||||
|
instances.value
|
||||||
|
.filter((x) => x.last_played)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasFeaturedProjects = computed(
|
||||||
|
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const offline = ref<boolean>(!navigator.onLine)
|
||||||
window.addEventListener('offline', () => {
|
window.addEventListener('offline', () => {
|
||||||
offline.value = true
|
offline.value = true
|
||||||
})
|
})
|
||||||
@ -28,34 +41,21 @@ window.addEventListener('online', () => {
|
|||||||
offline.value = false
|
offline.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
const getInstances = async () => {
|
async function fetchInstances() {
|
||||||
const profiles = await list().catch(handleError)
|
instances.value = await list().catch(handleError)
|
||||||
|
|
||||||
recentInstances.value = profiles
|
|
||||||
.filter((x) => x.last_played)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const dateA = dayjs(a.last_played)
|
|
||||||
const dateB = dayjs(b.last_played)
|
|
||||||
|
|
||||||
if (dateA.isSame(dateB)) {
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dateB - dateA
|
|
||||||
})
|
|
||||||
|
|
||||||
const filters = []
|
const filters = []
|
||||||
for (const instance of profiles) {
|
for (const instance of instances.value) {
|
||||||
if (instance.linked_data && instance.linked_data.project_id) {
|
if (instance.linked_data && instance.linked_data.project_id) {
|
||||||
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
|
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filter.value = filters.join(' AND ')
|
installedModpacksFilter.value = filters.join(' AND ')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFeaturedModpacks = async () => {
|
async function fetchFeaturedModpacks() {
|
||||||
const response = await get_search_results(
|
const response = await get_search_results(
|
||||||
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
|
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
@ -64,7 +64,8 @@ const getFeaturedModpacks = async () => {
|
|||||||
featuredModpacks.value = []
|
featuredModpacks.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const getFeaturedMods = async () => {
|
|
||||||
|
async function fetchFeaturedMods() {
|
||||||
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
|
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
@ -74,27 +75,21 @@ const getFeaturedMods = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await getInstances()
|
async function refreshFeaturedProjects() {
|
||||||
|
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
|
await fetchInstances()
|
||||||
|
await refreshFeaturedProjects()
|
||||||
|
|
||||||
const unlistenProfile = await profile_listener(async (e) => {
|
const unlistenProfile = await profile_listener(async (e) => {
|
||||||
await getInstances()
|
await fetchInstances()
|
||||||
|
|
||||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||||
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
|
await refreshFeaturedProjects()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// computed sums of recentInstances, featuredModpacks, featuredMods, treating them as arrays if they are not
|
|
||||||
const total = computed(() => {
|
|
||||||
return (
|
|
||||||
(recentInstances.value?.length ?? 0) +
|
|
||||||
(featuredModpacks.value?.length ?? 0) +
|
|
||||||
(featuredMods.value?.length ?? 0)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unlistenProfile()
|
unlistenProfile()
|
||||||
})
|
})
|
||||||
@ -104,17 +99,10 @@ onUnmounted(() => {
|
|||||||
<div class="p-6 flex flex-col gap-2">
|
<div class="p-6 flex flex-col gap-2">
|
||||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||||
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
|
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
|
||||||
|
<RecentWorldsList :recent-instances="recentInstances" />
|
||||||
<RowDisplay
|
<RowDisplay
|
||||||
v-if="total > 0"
|
v-if="hasFeaturedProjects"
|
||||||
:instances="[
|
:instances="[
|
||||||
{
|
|
||||||
label: 'Recently played',
|
|
||||||
route: '/library',
|
|
||||||
instances: recentInstances,
|
|
||||||
instance: true,
|
|
||||||
downloaded: true,
|
|
||||||
compact: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Discover a modpack',
|
label: 'Discover a modpack',
|
||||||
route: '/browse/modpack',
|
route: '/browse/modpack',
|
||||||
|
4
apps/app-frontend/src/pages/Worlds.vue
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
<template>
|
||||||
|
<div class="p-6 flex flex-col gap-2">Worlds</div>
|
||||||
|
</template>
|
@ -1,4 +1,5 @@
|
|||||||
import Index from './Index.vue'
|
import Index from './Index.vue'
|
||||||
import Browse from './Browse.vue'
|
import Browse from './Browse.vue'
|
||||||
|
import Worlds from './Worlds.vue'
|
||||||
|
|
||||||
export { Index, Browse }
|
export { Index, Browse, Worlds }
|
||||||
|
@ -1,152 +1,156 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div>
|
||||||
class="p-6 pr-2 pb-4"
|
<div
|
||||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
class="p-6 pr-2 pb-4"
|
||||||
>
|
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||||
<ExportModal ref="exportModal" :instance="instance" />
|
|
||||||
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
|
||||||
<ContentPageHeader>
|
|
||||||
<template #icon>
|
|
||||||
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
|
||||||
</template>
|
|
||||||
<template #title>
|
|
||||||
{{ instance.name }}
|
|
||||||
</template>
|
|
||||||
<template #summary> </template>
|
|
||||||
<template #stats>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
|
||||||
>
|
|
||||||
<GameIcon class="h-6 w-6 text-secondary" />
|
|
||||||
{{ instance.loader }} {{ instance.game_version }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 font-semibold">
|
|
||||||
<TimerIcon class="h-6 w-6 text-secondary" />
|
|
||||||
<template v-if="timePlayed > 0">
|
|
||||||
{{ timePlayedHumanized }}
|
|
||||||
</template>
|
|
||||||
<template v-else> Never played </template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="instance.install_stage.includes('installing')"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<button disabled>Installing...</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="instance.install_stage !== 'installed'"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<button @click="repairInstance()">
|
|
||||||
<DownloadIcon />
|
|
||||||
Repair
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
|
||||||
<button @click="stopInstance('InstancePage')">
|
|
||||||
<StopCircleIcon />
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="playing === false && loading === false"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<button @click="startInstance('InstancePage')">
|
|
||||||
<PlayIcon />
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="loading === true && playing === false"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<button disabled>Loading...</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" circular>
|
|
||||||
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
|
|
||||||
<SettingsIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" type="transparent" circular>
|
|
||||||
<OverflowMenu
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'open-folder',
|
|
||||||
action: () => showProfileInFolder(instance.path),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'export-mrpack',
|
|
||||||
action: () => $refs.exportModal.show(),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon />
|
|
||||||
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
|
||||||
<template #host-a-server> <ServerIcon /> Create a server </template>
|
|
||||||
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
|
||||||
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ContentPageHeader>
|
|
||||||
</div>
|
|
||||||
<div class="px-6">
|
|
||||||
<NavTabs :links="tabs" />
|
|
||||||
</div>
|
|
||||||
<div class="p-6 pt-4">
|
|
||||||
<RouterView v-slot="{ Component }" :key="instance.path">
|
|
||||||
<template v-if="Component">
|
|
||||||
<Suspense
|
|
||||||
:key="instance.path"
|
|
||||||
@pending="loadingBar.startLoading()"
|
|
||||||
@resolve="loadingBar.stopLoading()"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="Component"
|
|
||||||
:instance="instance"
|
|
||||||
:options="options"
|
|
||||||
:offline="offline"
|
|
||||||
:playing="playing"
|
|
||||||
:versions="modrinthVersions"
|
|
||||||
:installed="instance.install_stage !== 'installed'"
|
|
||||||
></component>
|
|
||||||
<template #fallback>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</template>
|
|
||||||
</Suspense>
|
|
||||||
</template>
|
|
||||||
</RouterView>
|
|
||||||
</div>
|
|
||||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
|
||||||
<template #play> <PlayIcon /> Play </template>
|
|
||||||
<template #stop> <StopCircleIcon /> Stop </template>
|
|
||||||
<template #add_content> <PlusIcon /> Add content </template>
|
|
||||||
<template #edit> <EditIcon /> Edit </template>
|
|
||||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
|
||||||
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
|
||||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
|
||||||
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
|
||||||
<template #copy_names><EditIcon />Copy names</template>
|
|
||||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
|
||||||
<template #copy_links><GlobeIcon />Copy links</template>
|
|
||||||
<template #toggle><EditIcon />Toggle selected</template>
|
|
||||||
<template #disable><XIcon />Disable selected</template>
|
|
||||||
<template #enable><CheckCircleIcon />Enable selected</template>
|
|
||||||
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
|
||||||
<template #update_all
|
|
||||||
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
|
||||||
>
|
>
|
||||||
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
<ExportModal ref="exportModal" :instance="instance" />
|
||||||
</ContextMenu>
|
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
||||||
|
<ContentPageHeader>
|
||||||
|
<template #icon>
|
||||||
|
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
{{ instance.name }}
|
||||||
|
</template>
|
||||||
|
<template #summary> </template>
|
||||||
|
<template #stats>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
||||||
|
>
|
||||||
|
<GameIcon class="h-6 w-6 text-secondary" />
|
||||||
|
{{ instance.loader }} {{ instance.game_version }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 font-semibold">
|
||||||
|
<TimerIcon class="h-6 w-6 text-secondary" />
|
||||||
|
<template v-if="timePlayed > 0">
|
||||||
|
{{ timePlayedHumanized }}
|
||||||
|
</template>
|
||||||
|
<template v-else> Never played </template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="instance.install_stage.includes('installing')"
|
||||||
|
color="brand"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<button disabled>Installing...</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
v-else-if="instance.install_stage !== 'installed'"
|
||||||
|
color="brand"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<button @click="repairInstance()">
|
||||||
|
<DownloadIcon />
|
||||||
|
Repair
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||||
|
<button @click="stopInstance('InstancePage')">
|
||||||
|
<StopCircleIcon />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
v-else-if="playing === false && loading === false"
|
||||||
|
color="brand"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<button @click="startInstance('InstancePage')">
|
||||||
|
<PlayIcon />
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
v-else-if="loading === true && playing === false"
|
||||||
|
color="brand"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<button disabled>Loading...</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled size="large" circular>
|
||||||
|
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
|
||||||
|
<SettingsIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled size="large" type="transparent" circular>
|
||||||
|
<OverflowMenu
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'open-folder',
|
||||||
|
action: () => showProfileInFolder(instance.path),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'export-mrpack',
|
||||||
|
action: () => $refs.exportModal.show(),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon />
|
||||||
|
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
||||||
|
<template #host-a-server> <ServerIcon /> Create a server </template>
|
||||||
|
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
||||||
|
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ContentPageHeader>
|
||||||
|
</div>
|
||||||
|
<div class="px-6">
|
||||||
|
<NavTabs :links="tabs" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!!instance" class="p-6 pt-4">
|
||||||
|
<RouterView v-slot="{ Component }" :key="instance.path">
|
||||||
|
<template v-if="Component">
|
||||||
|
<Suspense
|
||||||
|
:key="instance.path"
|
||||||
|
@pending="loadingBar.startLoading()"
|
||||||
|
@resolve="loadingBar.stopLoading()"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="Component"
|
||||||
|
:instance="instance"
|
||||||
|
:options="options"
|
||||||
|
:offline="offline"
|
||||||
|
:playing="playing"
|
||||||
|
:versions="modrinthVersions"
|
||||||
|
:installed="instance.install_stage !== 'installed'"
|
||||||
|
@play="updatePlayState"
|
||||||
|
@stop="() => stopInstance('InstanceSubpage')"
|
||||||
|
></component>
|
||||||
|
<template #fallback>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</template>
|
||||||
|
</RouterView>
|
||||||
|
</div>
|
||||||
|
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||||
|
<template #play> <PlayIcon /> Play </template>
|
||||||
|
<template #stop> <StopCircleIcon /> Stop </template>
|
||||||
|
<template #add_content> <PlusIcon /> Add content </template>
|
||||||
|
<template #edit> <EditIcon /> Edit </template>
|
||||||
|
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||||
|
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
||||||
|
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||||
|
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||||
|
<template #copy_names><EditIcon />Copy names</template>
|
||||||
|
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||||
|
<template #copy_links><GlobeIcon />Copy links</template>
|
||||||
|
<template #toggle><EditIcon />Toggle selected</template>
|
||||||
|
<template #disable><XIcon />Disable selected</template>
|
||||||
|
<template #enable><CheckCircleIcon />Enable selected</template>
|
||||||
|
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
||||||
|
<template #update_all
|
||||||
|
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
||||||
|
>
|
||||||
|
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
||||||
|
</ContextMenu>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
@ -238,6 +242,10 @@ async function fetchInstance() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updatePlayState()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePlayState() {
|
||||||
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
|
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
|
||||||
|
|
||||||
playing.value = runningProcesses.length > 0
|
playing.value = runningProcesses.length > 0
|
||||||
@ -253,14 +261,20 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
|
||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Content',
|
label: 'Content',
|
||||||
href: `/instance/${encodeURIComponent(route.params.id)}`,
|
href: `${basePath.value}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Worlds',
|
||||||
|
href: `${basePath.value}/worlds`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Logs',
|
label: 'Logs',
|
||||||
href: `/instance/${encodeURIComponent(route.params.id)}/logs`,
|
href: `${basePath.value}/logs`,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ -117,15 +117,37 @@ const route = useRoute()
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
instance: {
|
instance: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
default() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
offline: {
|
offline: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playing: {
|
playing: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
installed: {
|
||||||
|
type: Boolean,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1,251 +1,252 @@
|
|||||||
<template>
|
<template>
|
||||||
<template v-if="projects?.length > 0">
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<template v-if="projects?.length > 0">
|
||||||
<div class="iconified-input flex-grow">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<SearchIcon />
|
<div class="iconified-input flex-grow">
|
||||||
<input
|
<SearchIcon />
|
||||||
v-model="searchFilter"
|
<input
|
||||||
type="text"
|
v-model="searchFilter"
|
||||||
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
|
type="text"
|
||||||
class="text-input search-input"
|
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
|
||||||
autocomplete="off"
|
class="text-input search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<Button class="r-btn" @click="() => (searchFilter = '')">
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AddContentButton :instance="instance" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
|
||||||
|
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||||
|
<button
|
||||||
|
v-for="filter in filterOptions"
|
||||||
|
:key="filter"
|
||||||
|
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||||
|
@click="toggleArray(selectedFilters, filter.id)"
|
||||||
|
>
|
||||||
|
{{ filter.formattedName }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
v-if="search.length > 0"
|
||||||
|
:page="currentPage"
|
||||||
|
:count="Math.ceil(search.length / 20)"
|
||||||
|
:link-function="(page) => `?page=${page}`"
|
||||||
|
@switch-page="(page) => (currentPage = page)"
|
||||||
/>
|
/>
|
||||||
<Button class="r-btn" @click="() => (searchFilter = '')">
|
|
||||||
<XIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<AddContentButton :instance="instance" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
|
|
||||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
|
||||||
<button
|
|
||||||
v-for="filter in filterOptions"
|
|
||||||
:key="filter"
|
|
||||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
|
||||||
@click="toggleArray(selectedFilters, filter.id)"
|
|
||||||
>
|
|
||||||
{{ filter.formattedName }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Pagination
|
|
||||||
v-if="search.length > 0"
|
|
||||||
:page="currentPage"
|
|
||||||
:count="Math.ceil(search.length / 20)"
|
|
||||||
:link-function="(page) => `?page=${page}`"
|
|
||||||
@switch-page="(page) => (currentPage = page)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ContentListPanel
|
<ContentListPanel
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
:locked="isPackLocked"
|
:locked="isPackLocked"
|
||||||
:items="
|
:items="
|
||||||
search.map((x) => {
|
search.map((x) => {
|
||||||
const item: ContentItem<any> = {
|
const item: ContentItem<any> = {
|
||||||
path: x.path,
|
path: x.path,
|
||||||
disabled: x.disabled,
|
disabled: x.disabled,
|
||||||
filename: x.file_name,
|
filename: x.file_name,
|
||||||
icon: x.icon,
|
icon: x.icon,
|
||||||
title: x.name,
|
title: x.name,
|
||||||
data: x,
|
data: x,
|
||||||
}
|
|
||||||
|
|
||||||
if (x.version) {
|
|
||||||
item.version = x.version
|
|
||||||
item.versionId = x.version
|
|
||||||
}
|
|
||||||
|
|
||||||
if (x.id) {
|
|
||||||
item.project = {
|
|
||||||
id: x.id,
|
|
||||||
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
|
|
||||||
linkProps: {},
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (x.author) {
|
if (x.version) {
|
||||||
item.creator = {
|
item.version = x.version
|
||||||
name: x.author.name,
|
item.versionId = x.version
|
||||||
type: x.author.type,
|
|
||||||
id: x.author.slug,
|
|
||||||
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
|
|
||||||
linkProps: { target: '_blank' },
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return item
|
if (x.id) {
|
||||||
})
|
item.project = {
|
||||||
"
|
id: x.id,
|
||||||
:sort-column="sortColumn"
|
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
|
||||||
:sort-ascending="ascending"
|
linkProps: {},
|
||||||
:update-sort="sortProjects"
|
}
|
||||||
:current-page="currentPage"
|
}
|
||||||
>
|
|
||||||
<template v-if="selectedProjects.length > 0" #headers>
|
if (x.author) {
|
||||||
<div class="flex gap-2">
|
item.creator = {
|
||||||
|
name: x.author.name,
|
||||||
|
type: x.author.type,
|
||||||
|
id: x.author.slug,
|
||||||
|
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
|
||||||
|
linkProps: { target: '_blank' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:sort-column="sortColumn"
|
||||||
|
:sort-ascending="ascending"
|
||||||
|
:update-sort="sortProjects"
|
||||||
|
:current-page="currentPage"
|
||||||
|
>
|
||||||
|
<template v-if="selectedProjects.length > 0" #headers>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
|
||||||
|
color="brand"
|
||||||
|
color-fill="text"
|
||||||
|
hover-color-fill="text"
|
||||||
|
>
|
||||||
|
<button @click="updateSelected()"><DownloadIcon /> Update</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<OverflowMenu
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'share-names',
|
||||||
|
action: () => shareNames(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share-file-names',
|
||||||
|
action: () => shareFileNames(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share-urls',
|
||||||
|
action: () => shareUrls(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share-markdown',
|
||||||
|
action: () => shareMarkdown(),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<ShareIcon /> Share <DropdownIcon />
|
||||||
|
<template #share-names> <TextInputIcon /> Project names </template>
|
||||||
|
<template #share-file-names> <FileIcon /> File names </template>
|
||||||
|
<template #share-urls> <LinkIcon /> Project links </template>
|
||||||
|
<template #share-markdown> <CodeIcon /> Markdown links </template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
|
||||||
|
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
|
||||||
|
<button @click="disableAll()"><SlashIcon /> Disable</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-actions>
|
||||||
|
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
|
||||||
|
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
|
||||||
|
<UpdatedIcon />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
|
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
|
||||||
|
type="transparent"
|
||||||
|
color="brand"
|
||||||
|
color-fill="text"
|
||||||
|
hover-color-fill="text"
|
||||||
|
@click="updateAll"
|
||||||
|
>
|
||||||
|
<button class="w-max"><DownloadIcon /> Update all</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="canUpdatePack"
|
||||||
|
type="transparent"
|
||||||
color="brand"
|
color="brand"
|
||||||
color-fill="text"
|
color-fill="text"
|
||||||
hover-color-fill="text"
|
hover-color-fill="text"
|
||||||
>
|
>
|
||||||
<button @click="updateSelected()"><DownloadIcon /> Update</button>
|
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
|
||||||
|
<DownloadIcon /> Update pack
|
||||||
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled>
|
</template>
|
||||||
|
<template #actions="{ item }">
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="!isPackLocked && (item.data as any).outdated"
|
||||||
|
type="transparent"
|
||||||
|
color="brand"
|
||||||
|
circular
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-tooltip="`Update`"
|
||||||
|
:disabled="(item.data as any).updating"
|
||||||
|
@click="updateProject(item.data)"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<div v-else class="w-[36px]"></div>
|
||||||
|
<Toggle
|
||||||
|
class="!mx-2"
|
||||||
|
:model-value="!item.data.disabled"
|
||||||
|
@update:model-value="toggleDisableMod(item.data)"
|
||||||
|
/>
|
||||||
|
<ButtonStyled type="transparent" circular>
|
||||||
|
<button v-tooltip="'Remove'" @click="removeMod(item)">
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
|
||||||
|
<ButtonStyled type="transparent" circular>
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
id: 'share-names',
|
id: 'show-file',
|
||||||
action: () => shareNames(),
|
action: () => highlightModInProfile(instance.path, item.path),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'share-file-names',
|
id: 'copy-link',
|
||||||
action: () => shareFileNames(),
|
shown: item.data !== undefined && item.data.slug !== undefined,
|
||||||
},
|
action: () => copyModLink(item),
|
||||||
{
|
|
||||||
id: 'share-urls',
|
|
||||||
action: () => shareUrls(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'share-markdown',
|
|
||||||
action: () => shareMarkdown(),
|
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
|
direction="left"
|
||||||
>
|
>
|
||||||
<ShareIcon /> Share <DropdownIcon />
|
<MoreVerticalIcon />
|
||||||
<template #share-names> <TextInputIcon /> Project names </template>
|
<template #show-file> <ExternalIcon /> Show file </template>
|
||||||
<template #share-file-names> <FileIcon /> File names </template>
|
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
||||||
<template #share-urls> <LinkIcon /> Project links </template>
|
|
||||||
<template #share-markdown> <CodeIcon /> Markdown links </template>
|
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
|
</template>
|
||||||
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
|
</ContentListPanel>
|
||||||
</ButtonStyled>
|
<div class="flex justify-end mt-4">
|
||||||
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
|
<Pagination
|
||||||
<button @click="disableAll()"><SlashIcon /> Disable</button>
|
v-if="search.length > 0"
|
||||||
</ButtonStyled>
|
:page="currentPage"
|
||||||
<ButtonStyled color="red">
|
:count="Math.ceil(search.length / 20)"
|
||||||
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
|
:link-function="(page) => `?page=${page}`"
|
||||||
</ButtonStyled>
|
@switch-page="(page) => (currentPage = page)"
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #header-actions>
|
|
||||||
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
|
|
||||||
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
|
|
||||||
<UpdatedIcon />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
|
|
||||||
type="transparent"
|
|
||||||
color="brand"
|
|
||||||
color-fill="text"
|
|
||||||
hover-color-fill="text"
|
|
||||||
@click="updateAll"
|
|
||||||
>
|
|
||||||
<button class="w-max"><DownloadIcon /> Update all</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="canUpdatePack"
|
|
||||||
type="transparent"
|
|
||||||
color="brand"
|
|
||||||
color-fill="text"
|
|
||||||
hover-color-fill="text"
|
|
||||||
>
|
|
||||||
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
|
|
||||||
<DownloadIcon /> Update pack
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
<template #actions="{ item }">
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="!isPackLocked && (item.data as any).outdated"
|
|
||||||
type="transparent"
|
|
||||||
color="brand"
|
|
||||||
circular
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-tooltip="`Update`"
|
|
||||||
:disabled="(item.data as any).updating"
|
|
||||||
@click="updateProject(item.data)"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<div v-else class="w-[36px]"></div>
|
|
||||||
<Toggle
|
|
||||||
class="!mx-2"
|
|
||||||
:model-value="!item.data.disabled"
|
|
||||||
@update:model-value="toggleDisableMod(item.data)"
|
|
||||||
/>
|
/>
|
||||||
<ButtonStyled type="transparent" circular>
|
</div>
|
||||||
<button v-tooltip="'Remove'" @click="removeMod(item)">
|
</template>
|
||||||
<TrashIcon />
|
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
|
||||||
</button>
|
<RadialHeader class="">
|
||||||
</ButtonStyled>
|
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||||
|
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
|
||||||
<ButtonStyled type="transparent" circular>
|
<span class="text-contrast font-bold text-xl"
|
||||||
<OverflowMenu
|
>You haven't added any content to this instance yet.</span
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'show-file',
|
|
||||||
action: () => highlightModInProfile(instance.path, item.path),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'copy-link',
|
|
||||||
shown: item.data !== undefined && item.data.slug !== undefined,
|
|
||||||
action: () => copyModLink(item),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
direction="left"
|
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon />
|
</div>
|
||||||
<template #show-file> <ExternalIcon /> Show file </template>
|
</RadialHeader>
|
||||||
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
<div class="flex mt-4 mx-auto">
|
||||||
</OverflowMenu>
|
<AddContentButton :instance="instance" />
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</ContentListPanel>
|
|
||||||
<div class="flex justify-end mt-4">
|
|
||||||
<Pagination
|
|
||||||
v-if="search.length > 0"
|
|
||||||
:page="currentPage"
|
|
||||||
:count="Math.ceil(search.length / 20)"
|
|
||||||
:link-function="(page) => `?page=${page}`"
|
|
||||||
@switch-page="(page) => (currentPage = page)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-else class="w-full flex flex-col items-center justify-center mt-6 max-w-[48rem] mx-auto">
|
|
||||||
<div class="top-box w-full">
|
|
||||||
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
|
||||||
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
|
|
||||||
<span class="text-contrast font-bold text-xl"
|
|
||||||
>You haven't added any content to this instance yet.</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-box-divider"></div>
|
<ShareModalWrapper
|
||||||
<div class="flex items-center gap-6 py-4">
|
ref="shareModal"
|
||||||
<AddContentButton :instance="instance" />
|
share-title="Sharing modpack content"
|
||||||
</div>
|
share-text="Check out the projects I'm using in my modpack!"
|
||||||
|
:open-in-new-tab="false"
|
||||||
|
/>
|
||||||
|
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||||
|
<ModpackVersionModal
|
||||||
|
v-if="instance.linked_data"
|
||||||
|
ref="modpackVersionModal"
|
||||||
|
:instance="instance"
|
||||||
|
:versions="props.versions"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ShareModalWrapper
|
|
||||||
ref="shareModal"
|
|
||||||
share-title="Sharing modpack content"
|
|
||||||
share-text="Check out the projects I'm using in my modpack!"
|
|
||||||
:open-in-new-tab="false"
|
|
||||||
/>
|
|
||||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
|
||||||
<ModpackVersionModal
|
|
||||||
v-if="instance.linked_data"
|
|
||||||
ref="modpackVersionModal"
|
|
||||||
:instance="instance"
|
|
||||||
:versions="props.versions"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
@ -272,6 +273,7 @@ import {
|
|||||||
ContentListPanel,
|
ContentListPanel,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
RadialHeader,
|
||||||
Toggle,
|
Toggle,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { formatProjectType } from '@modrinth/utils'
|
import { formatProjectType } from '@modrinth/utils'
|
||||||
@ -323,10 +325,22 @@ const props = defineProps({
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
playing: {
|
||||||
|
type: Boolean,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
versions: {
|
versions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
installed: {
|
||||||
|
type: Boolean,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProjectListEntryAuthor = {
|
type ProjectListEntryAuthor = {
|
||||||
@ -458,7 +472,7 @@ const initProjects = async (cacheBehaviour?) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
projects.value = newProjects
|
projects.value = newProjects ?? []
|
||||||
|
|
||||||
const newSelectionMap = new Map()
|
const newSelectionMap = new Map()
|
||||||
for (const project of projects.value) {
|
for (const project of projects.value) {
|
||||||
|
15
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template>{{ instance.name }} overview</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||||
|
import type { Version } from '@modrinth/utils'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
instance: GameInstance
|
||||||
|
options: InstanceType<typeof ContextMenu>
|
||||||
|
offline: boolean
|
||||||
|
playing: boolean
|
||||||
|
versions: Version[]
|
||||||
|
installed: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
447
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
<template>
|
||||||
|
<AddServerModal
|
||||||
|
ref="addServerModal"
|
||||||
|
:instance="instance"
|
||||||
|
@submit="
|
||||||
|
(server, start) => {
|
||||||
|
addServer(server)
|
||||||
|
if (start) {
|
||||||
|
joinWorld(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
|
||||||
|
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
|
||||||
|
<ConfirmModalWrapper
|
||||||
|
ref="removeServerModal"
|
||||||
|
:title="`Are you sure you want to remove ${serverToRemove?.name ?? 'this server'}?`"
|
||||||
|
:description="`'${serverToRemove?.name}'${serverToRemove?.address === serverToRemove?.name ? ' ' : ` (${serverToRemove?.address})`} will be removed from your list, including in-game, and there will be no way to recover it.`"
|
||||||
|
:markdown="false"
|
||||||
|
@proceed="proceedRemoveServer"
|
||||||
|
/>
|
||||||
|
<ConfirmModalWrapper
|
||||||
|
ref="deleteWorldModal"
|
||||||
|
:title="`Are you sure you want to permanently delete this world?`"
|
||||||
|
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
|
||||||
|
@proceed="proceedDeleteWorld"
|
||||||
|
/>
|
||||||
|
<div v-if="worlds.length > 0" class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<div class="iconified-input flex-grow">
|
||||||
|
<SearchIcon />
|
||||||
|
<input
|
||||||
|
v-model="searchFilter"
|
||||||
|
type="text"
|
||||||
|
:placeholder="`Search worlds...`"
|
||||||
|
class="text-input search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<Button v-if="searchFilter" class="r-btn" @click="() => (searchFilter = '')">
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||||
|
<template v-if="refreshingAll">
|
||||||
|
<SpinnerIcon class="animate-spin" />
|
||||||
|
Refreshing...
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UpdatedIcon />
|
||||||
|
Refresh
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="addServerModal?.show()">
|
||||||
|
<PlusIcon />
|
||||||
|
Add a server
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<FilterBar v-model="filters" :options="filterOptions" />
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<WorldItem
|
||||||
|
v-for="world in filteredWorlds"
|
||||||
|
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||||
|
:world="world"
|
||||||
|
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||||
|
:supports-quick-play="supportsQuickPlay"
|
||||||
|
:current-protocol="protocolVersion"
|
||||||
|
:playing-instance="playing"
|
||||||
|
:playing-world="worldsMatch(world, worldPlaying)"
|
||||||
|
:starting-instance="startingInstance"
|
||||||
|
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
|
||||||
|
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
|
||||||
|
:rendered-motd="
|
||||||
|
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
|
||||||
|
"
|
||||||
|
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
||||||
|
@play="() => joinWorld(world)"
|
||||||
|
@stop="() => emit('stop')"
|
||||||
|
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||||
|
@edit="
|
||||||
|
() =>
|
||||||
|
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
|
||||||
|
"
|
||||||
|
@delete="() => promptToRemoveWorld(world)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
|
||||||
|
<RadialHeader class="">
|
||||||
|
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||||
|
<img src="@/assets/sad-modrinth-bot.webp" alt="" aria-hidden="true" class="h-24" />
|
||||||
|
<span class="text-contrast font-bold text-xl"> You don't have any worlds yet. </span>
|
||||||
|
</div>
|
||||||
|
</RadialHeader>
|
||||||
|
<div class="flex gap-2 mt-4 mx-auto">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="addServerModal?.show()">
|
||||||
|
<PlusIcon aria-hidden="true" />
|
||||||
|
Add a server
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||||
|
<template v-if="refreshingAll">
|
||||||
|
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||||
|
Refreshing...
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UpdatedIcon aria-hidden="true" />
|
||||||
|
Refresh
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonStyled,
|
||||||
|
RadialHeader,
|
||||||
|
FilterBar,
|
||||||
|
type FilterBarOption,
|
||||||
|
type GameVersion,
|
||||||
|
GAME_MODES,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
type SingleplayerWorld,
|
||||||
|
type World,
|
||||||
|
type ServerWorld,
|
||||||
|
type ServerData,
|
||||||
|
type ProfileEvent,
|
||||||
|
get_profile_protocol_version,
|
||||||
|
remove_server_from_profile,
|
||||||
|
delete_world,
|
||||||
|
start_join_server,
|
||||||
|
start_join_singleplayer_world,
|
||||||
|
getWorldIdentifier,
|
||||||
|
refreshServerData,
|
||||||
|
refreshWorld,
|
||||||
|
sortWorlds,
|
||||||
|
refreshServers,
|
||||||
|
hasQuickPlaySupport,
|
||||||
|
refreshWorlds,
|
||||||
|
handleDefaultProfileUpdateEvent,
|
||||||
|
} from '@/helpers/worlds.ts'
|
||||||
|
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||||
|
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||||
|
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
|
||||||
|
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||||
|
|
||||||
|
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
|
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||||
|
import type { Version } from '@modrinth/utils'
|
||||||
|
import { profile_listener } from '@/helpers/events'
|
||||||
|
import { get_game_versions } from '@/helpers/tags'
|
||||||
|
import { defineMessages } from '@vintl/vintl'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||||
|
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||||
|
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
|
||||||
|
const removeServerModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
|
||||||
|
const deleteWorldModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
|
||||||
|
|
||||||
|
const serverToRemove = ref<ServerWorld>()
|
||||||
|
const worldToDelete = ref<SingleplayerWorld>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'play', world: World): void
|
||||||
|
(event: 'stop'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instance: GameInstance
|
||||||
|
options: InstanceType<typeof ContextMenu> | null
|
||||||
|
offline: boolean
|
||||||
|
playing: boolean
|
||||||
|
versions: Version[]
|
||||||
|
installed: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const instance = computed(() => props.instance)
|
||||||
|
const playing = computed(() => props.playing)
|
||||||
|
|
||||||
|
function play(world: World) {
|
||||||
|
emit('play', world)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = ref<string[]>([])
|
||||||
|
const searchFilter = ref('')
|
||||||
|
|
||||||
|
const refreshingAll = ref(false)
|
||||||
|
const hadNoWorlds = ref(true)
|
||||||
|
const startingInstance = ref(false)
|
||||||
|
const worldPlaying = ref<World>()
|
||||||
|
|
||||||
|
const worlds = ref<World[]>([])
|
||||||
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
|
|
||||||
|
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
||||||
|
|
||||||
|
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||||
|
if (e.profile_path_id !== instance.value.path) return
|
||||||
|
|
||||||
|
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
|
||||||
|
|
||||||
|
if (e.event === 'servers_updated') {
|
||||||
|
await refreshAllWorlds()
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshAllWorlds()
|
||||||
|
|
||||||
|
async function refreshServer(address: string) {
|
||||||
|
await refreshServerData(serverData.value[address], protocolVersion.value, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAllWorlds() {
|
||||||
|
if (refreshingAll.value) {
|
||||||
|
console.log(`Already refreshing, cancelling refresh.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshingAll.value = true
|
||||||
|
|
||||||
|
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||||
|
() => (refreshingAll.value = false),
|
||||||
|
)
|
||||||
|
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||||
|
|
||||||
|
const hasNoWorlds = worlds.value.length === 0
|
||||||
|
|
||||||
|
if (hadNoWorlds.value && hasNoWorlds) {
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshingAll.value = false
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
refreshingAll.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
hadNoWorlds.value = hasNoWorlds
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addServer(server: ServerWorld) {
|
||||||
|
worlds.value.push(server)
|
||||||
|
sortWorlds(worlds.value)
|
||||||
|
await refreshServer(server.address)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editServer(server: ServerWorld) {
|
||||||
|
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
|
||||||
|
if (index !== -1) {
|
||||||
|
worlds.value[index] = server
|
||||||
|
sortWorlds(worlds.value)
|
||||||
|
await refreshServer(server.address)
|
||||||
|
} else {
|
||||||
|
handleError(`Error refreshing server, refreshing all worlds`)
|
||||||
|
await refreshAllWorlds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeServer(server: ServerWorld) {
|
||||||
|
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
|
||||||
|
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editWorld(path: string, name: string, removeIcon: boolean) {
|
||||||
|
const world = worlds.value.find((world) => world.type === 'singleplayer' && world.path === path)
|
||||||
|
if (world) {
|
||||||
|
world.name = name
|
||||||
|
if (removeIcon) {
|
||||||
|
world.icon = undefined
|
||||||
|
}
|
||||||
|
sortWorlds(worlds.value)
|
||||||
|
} else {
|
||||||
|
handleError(`Error finding world in list, refreshing all worlds`)
|
||||||
|
await refreshAllWorlds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteWorld(world: SingleplayerWorld) {
|
||||||
|
await delete_world(instance.value.path, world.path).catch(handleError)
|
||||||
|
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJoinError(err: unknown) {
|
||||||
|
handleError(err)
|
||||||
|
startingInstance.value = false
|
||||||
|
worldPlaying.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinWorld(world: World) {
|
||||||
|
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||||
|
startingInstance.value = true
|
||||||
|
worldPlaying.value = world
|
||||||
|
if (world.type === 'server') {
|
||||||
|
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
|
||||||
|
} else if (world.type === 'singleplayer') {
|
||||||
|
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
|
||||||
|
}
|
||||||
|
play(world)
|
||||||
|
startingInstance.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => playing.value,
|
||||||
|
(playing) => {
|
||||||
|
if (!playing) {
|
||||||
|
worldPlaying.value = undefined
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
for (const world of worlds.value) {
|
||||||
|
if (world.type === 'singleplayer' && world.locked) {
|
||||||
|
await refreshWorld(worlds.value, instance.value.path, world.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function worldsMatch(world: World, other: World | undefined) {
|
||||||
|
if (world.type === 'server' && other?.type === 'server') {
|
||||||
|
return world.address === other.address
|
||||||
|
} else if (world.type === 'singleplayer' && other?.type === 'singleplayer') {
|
||||||
|
return world.path === other.path
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||||
|
const supportsQuickPlay = computed(() =>
|
||||||
|
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||||
|
)
|
||||||
|
|
||||||
|
const filterOptions = computed(() => {
|
||||||
|
const options: FilterBarOption[] = []
|
||||||
|
|
||||||
|
if (worlds.value.some((x) => x.type === 'singleplayer')) {
|
||||||
|
options.push({
|
||||||
|
id: 'singleplayer',
|
||||||
|
message: messages.singleplayer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worlds.value.some((x) => x.type === 'server')) {
|
||||||
|
options.push({
|
||||||
|
id: 'server',
|
||||||
|
message: messages.server,
|
||||||
|
})
|
||||||
|
|
||||||
|
// add available filter if there's any offline ("unavailable") servers
|
||||||
|
if (
|
||||||
|
worlds.value.some(
|
||||||
|
(x) =>
|
||||||
|
x.type === 'server' &&
|
||||||
|
!serverData.value[x.address]?.status &&
|
||||||
|
!serverData.value[x.address]?.refreshing,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
options.push({
|
||||||
|
id: 'available',
|
||||||
|
message: messages.available,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredWorlds = computed(() =>
|
||||||
|
worlds.value.filter((x) => {
|
||||||
|
const availableFilter = filters.value.includes('available')
|
||||||
|
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
|
||||||
|
|
||||||
|
return (
|
||||||
|
(!typeFilter || filters.value.includes(x.type)) &&
|
||||||
|
(!availableFilter || x.type !== 'server' || serverData.value[x.address]?.status) &&
|
||||||
|
(!searchFilter.value || x.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const highlightedWorld = ref(route.query.highlight)
|
||||||
|
|
||||||
|
function promptToRemoveWorld(world: World): boolean {
|
||||||
|
if (world.type === 'server') {
|
||||||
|
serverToRemove.value = world
|
||||||
|
removeServerModal.value?.show()
|
||||||
|
return !!removeServerModal.value
|
||||||
|
} else {
|
||||||
|
worldToDelete.value = world
|
||||||
|
deleteWorldModal.value?.show()
|
||||||
|
return !!deleteWorldModal.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proceedRemoveServer() {
|
||||||
|
if (!serverToRemove.value) {
|
||||||
|
handleError(`Error removing server, no server marked for removal.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await removeServer(serverToRemove.value)
|
||||||
|
serverToRemove.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proceedDeleteWorld() {
|
||||||
|
if (!worldToDelete.value) {
|
||||||
|
handleError(`Error deleting world, no world marked for removal.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await deleteWorld(worldToDelete.value)
|
||||||
|
worldToDelete.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlistenProfile()
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
singleplayer: {
|
||||||
|
id: 'instance.worlds.type.singleplayer',
|
||||||
|
defaultMessage: 'Singleplayer',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
id: 'instance.worlds.type.server',
|
||||||
|
defaultMessage: 'Server',
|
||||||
|
},
|
||||||
|
available: {
|
||||||
|
id: 'instance.worlds.filter.available',
|
||||||
|
defaultMessage: 'Available',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,5 +1,7 @@
|
|||||||
import Index from './Index.vue'
|
import Index from './Index.vue'
|
||||||
|
import Overview from './Overview.vue'
|
||||||
|
import Worlds from './Worlds.vue'
|
||||||
import Mods from './Mods.vue'
|
import Mods from './Mods.vue'
|
||||||
import Logs from './Logs.vue'
|
import Logs from './Logs.vue'
|
||||||
|
|
||||||
export { Index, Mods, Logs }
|
export { Index, Overview, Worlds, Mods, Logs }
|
||||||
|
@ -192,6 +192,11 @@ const [allLoaders, allGameVersions] = await Promise.all([
|
|||||||
async function fetchProjectData() {
|
async function fetchProjectData() {
|
||||||
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
|
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
handleError('Error loading project')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
data.value = project
|
data.value = project
|
||||||
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
|
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
@ -18,6 +18,14 @@ export default new createRouter({
|
|||||||
breadcrumb: [{ name: 'Home' }],
|
breadcrumb: [{ name: 'Home' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/worlds',
|
||||||
|
name: 'Worlds',
|
||||||
|
component: Pages.Worlds,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: [{ name: 'Worlds' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/browse/:projectType',
|
path: '/browse/:projectType',
|
||||||
name: 'Discover content',
|
name: 'Discover content',
|
||||||
@ -106,13 +114,31 @@ export default new createRouter({
|
|||||||
component: Instance.Index,
|
component: Instance.Index,
|
||||||
props: true,
|
props: true,
|
||||||
children: [
|
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: '',
|
path: '',
|
||||||
name: 'Mods',
|
name: 'Mods',
|
||||||
component: Instance.Mods,
|
component: Instance.Mods,
|
||||||
meta: {
|
meta: {
|
||||||
useRootContext: true,
|
useRootContext: true,
|
||||||
breadcrumb: [{ name: '?Instance' }],
|
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -121,7 +147,7 @@ export default new createRouter({
|
|||||||
component: Instance.Mods,
|
component: Instance.Mods,
|
||||||
meta: {
|
meta: {
|
||||||
useRootContext: true,
|
useRootContext: true,
|
||||||
breadcrumb: [{ name: '?Instance' }],
|
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -3,9 +3,8 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
use tokio::signal::ctrl_c;
|
use theseus::worlds::get_recent_worlds;
|
||||||
|
|
||||||
// A simple Rust implementation of the authentication run
|
// 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)
|
// 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
|
// Initialize state
|
||||||
State::init().await?;
|
State::init().await?;
|
||||||
|
|
||||||
loop {
|
let worlds = get_recent_worlds(4).await?;
|
||||||
if State::get().await?.friends_socket.is_connected().await {
|
for world in worlds {
|
||||||
break;
|
println!(
|
||||||
}
|
"World: {:?}/{:?} played at {:?}: {:#?}",
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -30,8 +30,10 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
daedalus = { path = "../../packages/daedalus" }
|
daedalus = { path = "../../packages/daedalus" }
|
||||||
chrono = "0.4.26"
|
chrono = "0.4.26"
|
||||||
|
either = "1.15"
|
||||||
|
|
||||||
url = "2.2"
|
url = "2.2"
|
||||||
|
urlencoding = "2.1"
|
||||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||||
os_info = "3.7.0"
|
os_info = "3.7.0"
|
||||||
|
|
||||||
|
@ -240,6 +240,29 @@ fn main() {
|
|||||||
.default_permission(
|
.default_permission(
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
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");
|
.expect("Failed to run tauri-build");
|
||||||
|
@ -35,6 +35,7 @@
|
|||||||
"tags:default",
|
"tags:default",
|
||||||
"utils:default",
|
"utils:default",
|
||||||
"ads:default",
|
"ads:default",
|
||||||
"friends:default"
|
"friends:default",
|
||||||
|
"worlds:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ pub mod utils;
|
|||||||
pub mod ads;
|
pub mod ads;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod friends;
|
pub mod friends;
|
||||||
|
pub mod worlds;
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
use theseus::profile::QuickPlayType;
|
||||||
|
|
||||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::new("profile")
|
tauri::plugin::Builder::new("profile")
|
||||||
@ -250,7 +251,7 @@ pub async fn profile_get_pack_export_candidates(
|
|||||||
// invoke('plugin:profile|profile_run', path)
|
// invoke('plugin:profile|profile_run', path)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||||
let process = profile::run(path).await?;
|
let process = profile::run(path, &QuickPlayType::None).await?;
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
@ -264,7 +265,9 @@ pub async fn profile_run_credentials(
|
|||||||
path: &str,
|
path: &str,
|
||||||
credentials: Credentials,
|
credentials: Credentials,
|
||||||
) -> Result<ProcessMetadata> {
|
) -> Result<ProcessMetadata> {
|
||||||
let process = profile::run_credentials(path, &credentials).await?;
|
let process =
|
||||||
|
profile::run_credentials(path, &credentials, &QuickPlayType::None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
@ -347,6 +350,9 @@ pub async fn profile_edit(path: &str, edit_profile: EditProfile) -> Result<()> {
|
|||||||
prof.name = name;
|
prof.name = name;
|
||||||
}
|
}
|
||||||
if let Some(game_version) = edit_profile.game_version.clone() {
|
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;
|
prof.game_version = game_version;
|
||||||
}
|
}
|
||||||
if let Some(loader) = edit_profile.loader {
|
if let Some(loader) = edit_profile.loader {
|
||||||
|
@ -4,9 +4,11 @@ use theseus::{
|
|||||||
prelude::{CommandPayload, DirectoryInfo},
|
prelude::{CommandPayload, DirectoryInfo},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::api::Result;
|
use crate::api::{Result, TheseusSerializableError};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
use theseus::prelude::canonicalize;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::new("utils")
|
tauri::plugin::Builder::new("utils")
|
||||||
@ -140,3 +142,28 @@ pub async fn handle_command(command: String) -> Result<()> {
|
|||||||
tracing::info!("handle command: {command}");
|
tracing::info!("handle command: {command}");
|
||||||
Ok(theseus::handler::parse_and_emit_command(&command).await?)
|
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<Url> {
|
||||||
|
#[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}"))))
|
||||||
|
}
|
||||||
|
195
apps/app/src/api/worlds.rs
Normal file
@ -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<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||||
|
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<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorldWithProfile>> {
|
||||||
|
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<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Vec<World>> {
|
||||||
|
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<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
instance: &str,
|
||||||
|
world: &str,
|
||||||
|
) -> Result<World> {
|
||||||
|
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<R: Runtime>(app_handle: &AppHandle<R>, 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<u64> {
|
||||||
|
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<usize> {
|
||||||
|
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<Option<i32>> {
|
||||||
|
Ok(worlds::get_profile_protocol_version(path).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_server_status(
|
||||||
|
address: &str,
|
||||||
|
protocol_version: Option<i32>,
|
||||||
|
) -> Result<ServerStatus> {
|
||||||
|
Ok(worlds::get_server_status(address, protocol_version).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_join_singleplayer_world(
|
||||||
|
path: &str,
|
||||||
|
world: String,
|
||||||
|
) -> Result<ProcessMetadata> {
|
||||||
|
let process =
|
||||||
|
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
|
||||||
|
|
||||||
|
Ok(process)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_join_server(
|
||||||
|
path: &str,
|
||||||
|
address: &str,
|
||||||
|
) -> Result<ProcessMetadata> {
|
||||||
|
let process =
|
||||||
|
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
|
||||||
|
|
||||||
|
Ok(process)
|
||||||
|
}
|
@ -268,6 +268,7 @@ fn main() {
|
|||||||
.plugin(api::cache::init())
|
.plugin(api::cache::init())
|
||||||
.plugin(api::ads::init())
|
.plugin(api::ads::init())
|
||||||
.plugin(api::friends::init())
|
.plugin(api::friends::init())
|
||||||
|
.plugin(api::worlds::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
initialize_state,
|
initialize_state,
|
||||||
is_dev,
|
is_dev,
|
||||||
|
@ -76,7 +76,7 @@
|
|||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"assetProtocol": {
|
"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
|
"enable": true
|
||||||
},
|
},
|
||||||
"capabilities": ["ads", "core", "plugins"],
|
"capabilities": ["ads", "core", "plugins"],
|
||||||
|
@ -930,7 +930,7 @@ button {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding: 0.5rem 0 0.5rem 1rem;
|
padding: 0.5rem 0 0.5rem 1rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
min-height: 40px;
|
min-height: 36px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -451,7 +451,7 @@ textarea {
|
|||||||
var(--shadow-inset-sm),
|
var(--shadow-inset-sm),
|
||||||
0 0 0 0 transparent;
|
0 0 0 0 transparent;
|
||||||
transition: box-shadow 0.1s ease-in-out;
|
transition: box-shadow 0.1s ease-in-out;
|
||||||
min-height: 40px;
|
min-height: 36px;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
|
@ -1,660 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<div class="w-full overflow-hidden">
|
|
||||||
<div class="mb-4">
|
|
||||||
<div
|
|
||||||
v-for="(line, lineIndex) in motd"
|
|
||||||
:key="lineIndex"
|
|
||||||
class="relative mb-2 rounded bg-button-bg p-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="font-minecraft text-white"
|
|
||||||
:contenteditable="true"
|
|
||||||
spellcheck="false"
|
|
||||||
@input="handleInput($event, lineIndex)"
|
|
||||||
@keydown.enter.prevent
|
|
||||||
@paste.prevent="handlePaste($event, lineIndex)"
|
|
||||||
@mouseup="handleSelection(lineIndex)"
|
|
||||||
v-html="renderLine(line)"
|
|
||||||
></div>
|
|
||||||
<div class="text-sm text-gray-400">
|
|
||||||
{{ motd[lineIndex].reduce((sum, segment) => sum + segment.text.length, 0) }}/45
|
|
||||||
characters
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<transition name="fade">
|
|
||||||
<div
|
|
||||||
v-if="showPopup"
|
|
||||||
:style="{ top: `${popupY}px`, left: `${popupX}px` }"
|
|
||||||
class="fixed z-10 flex flex-col items-end gap-2 transition-all duration-300 ease-in-out"
|
|
||||||
>
|
|
||||||
<div class="rounded-xl border bg-table-alternateRow p-2 shadow-lg">
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
v-for="style in styles"
|
|
||||||
:key="style.name"
|
|
||||||
icon-only
|
|
||||||
transparent
|
|
||||||
@click="applyStyle({ [style.name]: !currentStyle[style.name] })"
|
|
||||||
>
|
|
||||||
<component :is="style.icon" class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div class="relative overflow-y-scroll">
|
|
||||||
<Button icon-only transparent :class="colorPicker ?? 'hidden'" @click="pickColor">
|
|
||||||
<PaintBrushIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="colorPicker"
|
|
||||||
icon-only
|
|
||||||
class="w-fit overflow-y-auto rounded-xl p-2 [&&]:bg-table-alternateRow"
|
|
||||||
>
|
|
||||||
<div :class="colorPicker ? `grid grid-flow-col grid-rows-4 gap-2` : '[&&]:hidden'">
|
|
||||||
<button
|
|
||||||
v-for="format in sortedFormatCodes()"
|
|
||||||
:key="format.code"
|
|
||||||
class="rounded-full p-3"
|
|
||||||
:style="{ backgroundColor: format.color }"
|
|
||||||
:title="format.description"
|
|
||||||
@click="applyStyle({ color: format.color })"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
ItalicIcon,
|
|
||||||
BoldIcon,
|
|
||||||
StrikethroughIcon,
|
|
||||||
UnderlineIcon,
|
|
||||||
PaintBrushIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
} from "@modrinth/assets";
|
|
||||||
import { Button } from "@modrinth/ui";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
server: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatCodes = [
|
|
||||||
{ code: "§f", color: "white", description: "White" },
|
|
||||||
{ code: "§7", color: "#AAAAAA", description: "Gray" },
|
|
||||||
{ code: "§8", color: "#555555", description: "Dark Gray" },
|
|
||||||
{ code: "§0", color: "#000000", description: "Black" },
|
|
||||||
{ code: "§9", color: "#5555FF", description: "Blue" },
|
|
||||||
{ code: "§1", color: "#0000AA", description: "Dark Blue" },
|
|
||||||
{ code: "§b", color: "#55FFFF", description: "Aqua" },
|
|
||||||
{ code: "§3", color: "#00AAAA", description: "Dark Aqua" },
|
|
||||||
{ code: "§a", color: "#55FF55", description: "Green" },
|
|
||||||
{ code: "§2", color: "#00AA00", description: "Dark Green" },
|
|
||||||
{ code: "§e", color: "#FFFF55", description: "Yellow" },
|
|
||||||
{ code: "§6", color: "#FFAA00", description: "Gold" },
|
|
||||||
{ code: "§c", color: "#FF5555", description: "Red" },
|
|
||||||
{ code: "§4", color: "#AA0000", description: "Dark Red" },
|
|
||||||
{ code: "§d", color: "#FF55FF", description: "Light Purple" },
|
|
||||||
{ code: "§5", color: "#AA00AA", description: "Dark Purple" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sortedFormatCodes = () => {
|
|
||||||
const colors = formatCodes;
|
|
||||||
if (colors[0].description === "White") {
|
|
||||||
colors.reverse();
|
|
||||||
}
|
|
||||||
return colors;
|
|
||||||
};
|
|
||||||
|
|
||||||
const minecraftEmojis = [
|
|
||||||
{ char: "☺", name: "SMILING FACE" },
|
|
||||||
{ char: "☹", name: "FROWNING FACE" },
|
|
||||||
{ char: "☠", name: "SKULL AND CROSSBONES" },
|
|
||||||
{ char: "❣", name: "HEART EXCLAMATION" },
|
|
||||||
{ char: "❤", name: "RED HEART" },
|
|
||||||
{ char: "✌", name: "VICTORY HAND" },
|
|
||||||
{ char: "☝", name: "INDEX POINTING UP" },
|
|
||||||
{ char: "✍", name: "WRITING HAND" },
|
|
||||||
{ char: "♨", name: "HOT SPRINGS" },
|
|
||||||
{ char: "✈", name: "AIRPLANE" },
|
|
||||||
{ char: "⌛", name: "HOURGLASS DONE" },
|
|
||||||
{ char: "⌚", name: "WATCH" },
|
|
||||||
{ char: "☀", name: "SUN" },
|
|
||||||
{ char: "☁", name: "CLOUD" },
|
|
||||||
{ char: "☂", name: "UMBRELLA" },
|
|
||||||
{ char: "❄", name: "SNOWFLAKE" },
|
|
||||||
{ char: "☃", name: "SNOWMAN" },
|
|
||||||
{ char: "☄", name: "COMET" },
|
|
||||||
{ char: "♠", name: "SPADE SUIT" },
|
|
||||||
{ char: "♥", name: "HEART SUIT" },
|
|
||||||
{ char: "♦", name: "DIAMOND SUIT" },
|
|
||||||
{ char: "♣", name: "CLUB SUIT" },
|
|
||||||
{ char: "♟", name: "CHESS PAWN" },
|
|
||||||
{ char: "☎", name: "TELEPHONE" },
|
|
||||||
{ char: "⌨", name: "KEYBOARD" },
|
|
||||||
{ char: "✉", name: "ENVELOPE" },
|
|
||||||
{ char: "✏", name: "PENCIL" },
|
|
||||||
{ char: "✒", name: "BLACK PEN" },
|
|
||||||
{ char: "✂", name: "SCISSORS" },
|
|
||||||
{ char: "☢", name: "RADIOACTIVE" },
|
|
||||||
{ char: "☣", name: "BIOHAZARD" },
|
|
||||||
{ char: "⬆", name: "UP ARROW" },
|
|
||||||
{ char: "⬇", name: "DOWN ARROW" },
|
|
||||||
{ char: "➡", name: "RIGHT ARROW" },
|
|
||||||
{ char: "⬅", name: "LEFT ARROW" },
|
|
||||||
{ char: "↗", name: "UP-RIGHT ARROW" },
|
|
||||||
{ char: "↘", name: "DOWN-RIGHT ARROW" },
|
|
||||||
{ char: "↙", name: "DOWN-LEFT ARROW" },
|
|
||||||
{ char: "↖", name: "UP-LEFT ARROW" },
|
|
||||||
{ char: "↕", name: "UP-DOWN ARROW" },
|
|
||||||
{ char: "↔", name: "LEFT-RIGHT ARROW" },
|
|
||||||
{ char: "↩", name: "RIGHT ARROW CURVING LEFT" },
|
|
||||||
{ char: "↪", name: "LEFT ARROW CURVING RIGHT" },
|
|
||||||
{ char: "✡", name: "STAR OF DAVID" },
|
|
||||||
{ char: "☸", name: "WHEEL OF DHARMA" },
|
|
||||||
{ char: "☯", name: "YIN YANG" },
|
|
||||||
{ char: "✝", name: "LATIN CROSS" },
|
|
||||||
{ char: "☦", name: "ORTHODOX CROSS" },
|
|
||||||
{ char: "☪", name: "STAR AND CRESCENT" },
|
|
||||||
{ char: "☮", name: "PEACE SYMBOL" },
|
|
||||||
{ char: "♈", name: "ARIES" },
|
|
||||||
{ char: "♉", name: "TAURUS" },
|
|
||||||
{ char: "♊", name: "GEMINI" },
|
|
||||||
{ char: "♋", name: "CANCER" },
|
|
||||||
{ char: "♌", name: "LEO" },
|
|
||||||
{ char: "♍", name: "VIRGO" },
|
|
||||||
{ char: "♎", name: "LIBRA" },
|
|
||||||
{ char: "♏", name: "SCORPIO" },
|
|
||||||
{ char: "♐", name: "SAGITTARIUS" },
|
|
||||||
{ char: "♑", name: "CAPRICORN" },
|
|
||||||
{ char: "♒", name: "AQUARIUS" },
|
|
||||||
{ char: "♓", name: "PISCES" },
|
|
||||||
{ char: "▶", name: "PLAY BUTTON" },
|
|
||||||
{ char: "◀", name: "REVERSE BUTTON" },
|
|
||||||
{ char: "♀", name: "FEMALE SIGN" },
|
|
||||||
{ char: "♂", name: "MALE SIGN" },
|
|
||||||
{ char: "✖", name: "MULTIPLY" },
|
|
||||||
{ char: "‼", name: "DOUBLE EXCLAMATION MARK" },
|
|
||||||
{ char: "〰", name: "WAVY DASH" },
|
|
||||||
{ char: "☑", name: "CHECK BOX WITH CHECK" },
|
|
||||||
{ char: "✔", name: "CHECK MARK" },
|
|
||||||
{ char: "✳", name: "EIGHT-SPOKED ASTERISK" },
|
|
||||||
{ char: "✴", name: "EIGHT-POINTED STAR" },
|
|
||||||
{ char: "❇", name: "SPARKLE" },
|
|
||||||
{ char: "©", name: "COPYRIGHT" },
|
|
||||||
{ char: "®", name: "REGISTERED" },
|
|
||||||
{ char: "™", name: "TRADE MARK" },
|
|
||||||
{ char: "Ⓜ", name: "CIRCLED M" },
|
|
||||||
{ char: "㊗", name: 'JAPANESE "CONGRATULATIONS" BUTTON' },
|
|
||||||
{ char: "㊙", name: 'JAPANESE "SECRET" BUTTON' },
|
|
||||||
{ char: "▪", name: "BLACK SMALL SQUARE" },
|
|
||||||
{ char: "▫", name: "WHITE SMALL SQUARE" },
|
|
||||||
{ char: "☷", name: "TRIGRAM FOR EARTH" },
|
|
||||||
{ char: "☵", name: "TRIGRAM FOR WATER" },
|
|
||||||
{ char: "☶", name: "TRIGRAM FOR MOUNTAIN" },
|
|
||||||
{ char: "☋", name: "DESCENDING NODE" },
|
|
||||||
{ char: "☌", name: "CONJUNCTION" },
|
|
||||||
{ char: "♜", name: "BLACK CHESS ROOK" },
|
|
||||||
{ char: "♕", name: "WHITE CHESS QUEEN" },
|
|
||||||
{ char: "♡", name: "WHITE HEART SUIT" },
|
|
||||||
{ char: "♬", name: "BEAMED SIXTEENTH NOTES" },
|
|
||||||
{ char: "☚", name: "BLACK LEFT POINTING INDEX" },
|
|
||||||
{ char: "♮", name: "MUSIC NATURAL SIGN" },
|
|
||||||
{ char: "♝", name: "BLACK CHESS BISHOP" },
|
|
||||||
{ char: "♯", name: "SHARP" },
|
|
||||||
{ char: "☴", name: "TRIGRAM FOR WIND" },
|
|
||||||
{ char: "♭", name: "FLAT" },
|
|
||||||
{ char: "☓", name: "SALTIRE" },
|
|
||||||
{ char: "☛", name: "BLACK RIGHT POINTING INDEX" },
|
|
||||||
{ char: "☭", name: "HAMMER AND SICKLE" },
|
|
||||||
{ char: "♢", name: "WHITE DIAMOND SUIT" },
|
|
||||||
{ char: "✐", name: "UPPER RIGHT PENCIL" },
|
|
||||||
{ char: "♖", name: "WHITE CHESS ROOK" },
|
|
||||||
{ char: "☈", name: "THUNDERSTORM" },
|
|
||||||
{ char: "☒", name: "BALLOT BOX WITH X" },
|
|
||||||
{ char: "★", name: "BLACK STAR" },
|
|
||||||
{ char: "♚", name: "BLACK CHESS KING" },
|
|
||||||
{ char: "♛", name: "BLACK CHESS QUEEN" },
|
|
||||||
{ char: "✎", name: "LOWER RIGHT PENCIL" },
|
|
||||||
{ char: "♪", name: "EIGHTH NOTE" },
|
|
||||||
{ char: "☰", name: "TRIGRAM FOR HEAVEN" },
|
|
||||||
{ char: "☽", name: "FIRST QUARTER MOON" },
|
|
||||||
{ char: "☡", name: "CAUTION SIGN" },
|
|
||||||
{ char: "☼", name: "WHITE SUN WITH RAYS" },
|
|
||||||
{ char: "♅", name: "URANUS" },
|
|
||||||
{ char: "☐", name: "BALLOT BOX" },
|
|
||||||
{ char: "☟", name: "WHITE DOWN POINTING INDEX" },
|
|
||||||
{ char: "❦", name: "FLORAL HEART" },
|
|
||||||
{ char: "☊", name: "ASCENDING NODE" },
|
|
||||||
{ char: "☍", name: "OPPOSITION" },
|
|
||||||
{ char: "☬", name: "ADI SHAKTI" },
|
|
||||||
{ char: "♧", name: "WHITE CLUB SUIT" },
|
|
||||||
{ char: "☫", name: "FARSI SYMBOL" },
|
|
||||||
{ char: "☱", name: "TRIGRAM FOR LAKE" },
|
|
||||||
{ char: "☾", name: "LAST QUARTER MOON" },
|
|
||||||
{ char: "☤", name: "CADUCEUS" },
|
|
||||||
{ char: "❧", name: "ROTATED FLORAL HEART BULLET" },
|
|
||||||
{ char: "♄", name: "SATURN" },
|
|
||||||
{ char: "♁", name: "EARTH" },
|
|
||||||
{ char: "♔", name: "WHITE CHESS KING" },
|
|
||||||
{ char: "❥", name: "ROTATED HEAVY BLACK HEART BULLET" },
|
|
||||||
{ char: "☥", name: "ANKH" },
|
|
||||||
{ char: "☻", name: "BLACK SMILING FACE" },
|
|
||||||
{ char: "♤", name: "WHITE SPADE SUIT" },
|
|
||||||
{ char: "♞", name: "BLACK CHESS KNIGHT" },
|
|
||||||
{ char: "♆", name: "NEPTUNE" },
|
|
||||||
{ char: "#", name: "HASH SIGN" },
|
|
||||||
{ char: "♃", name: "JUPITER" },
|
|
||||||
{ char: "♩", name: "QUARTER NOTE" },
|
|
||||||
{ char: "☇", name: "LIGHTNING" },
|
|
||||||
{ char: "☞", name: "WHITE RIGHT POINTING INDEX" },
|
|
||||||
{ char: "♫", name: "BEAMED EIGHTH NOTES" },
|
|
||||||
{ char: "☏", name: "WHITE TELEPHONE" },
|
|
||||||
{ char: "♘", name: "WHITE CHESS KNIGHT" },
|
|
||||||
{ char: "☧", name: "CHI RHO" },
|
|
||||||
{ char: "☉", name: "SUN" },
|
|
||||||
{ char: "♇", name: "PLUTO" },
|
|
||||||
{ char: "☩", name: "CROSS OF JERUSALEM" },
|
|
||||||
{ char: "♙", name: "WHITE CHESS PAWN" },
|
|
||||||
{ char: "☜", name: "WHITE LEFT POINTING INDEX" },
|
|
||||||
{ char: "☲", name: "TRIGRAM FOR FIRE" },
|
|
||||||
{ char: "☨", name: "CROSS OF LORRAINE" },
|
|
||||||
{ char: "♗", name: "WHITE CHESS BISHOP" },
|
|
||||||
{ char: "☳", name: "TRIGRAM FOR THUNDER" },
|
|
||||||
{ char: "⚔", name: "CROSSED SWORDS" },
|
|
||||||
{ char: "⚀", name: "DICE ONE" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const rawMotd = ref(props.server.general?.motd ?? "");
|
|
||||||
|
|
||||||
const motd = computed(() => {
|
|
||||||
const lines = rawMotd.value.split("\n");
|
|
||||||
return lines.map((line) => {
|
|
||||||
const segments = [];
|
|
||||||
let currentSegment = { text: "", color: "White" };
|
|
||||||
let i = 0;
|
|
||||||
while (i < line.length) {
|
|
||||||
if (line[i] === "§") {
|
|
||||||
if (currentSegment.text) {
|
|
||||||
segments.push({ ...currentSegment });
|
|
||||||
currentSegment = { text: "", color: "White" };
|
|
||||||
}
|
|
||||||
const formatCode = line.substr(i, 2);
|
|
||||||
const format = formatCodes.find((f) => f.code === formatCode);
|
|
||||||
console.log(format);
|
|
||||||
console.log(formatCode);
|
|
||||||
if (format) {
|
|
||||||
currentSegment.color = format.color;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
} else if (formatCode === "§l") {
|
|
||||||
currentSegment.bold = true;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
} else if (formatCode === "§o") {
|
|
||||||
currentSegment.italic = true;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
} else if (formatCode === "§n") {
|
|
||||||
currentSegment.underline = true;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
} else if (formatCode === "§m") {
|
|
||||||
currentSegment.strikethrough = true;
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentSegment.text += line[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (currentSegment.text) {
|
|
||||||
segments.push(currentSegment);
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const styles = [
|
|
||||||
{
|
|
||||||
name: "bold",
|
|
||||||
icon: BoldIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "italic",
|
|
||||||
icon: ItalicIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underline",
|
|
||||||
icon: UnderlineIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strikethrough",
|
|
||||||
icon: StrikethroughIcon,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const showPopup = ref(false);
|
|
||||||
const popupX = ref(0);
|
|
||||||
const popupY = ref(0);
|
|
||||||
const currentLineIndex = ref(0);
|
|
||||||
const selectionStart = ref(0);
|
|
||||||
const selectionEnd = ref(0);
|
|
||||||
const colorPicker = ref(false);
|
|
||||||
|
|
||||||
const pickColor = () => {
|
|
||||||
colorPicker.value = !colorPicker.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalCharacters = computed(() => {
|
|
||||||
return motd.value.reduce((sum, line) => {
|
|
||||||
return Math.max(
|
|
||||||
sum,
|
|
||||||
line.reduce((lineSum, segment) => lineSum + segment.text.length, 0),
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const minecraftFormat = computed(() => {
|
|
||||||
return motd.value
|
|
||||||
.map((line) => {
|
|
||||||
return line
|
|
||||||
.map((segment) => {
|
|
||||||
let format = getColorCode(segment.color);
|
|
||||||
if (segment.bold) format += "§l";
|
|
||||||
if (segment.italic) format += "§o";
|
|
||||||
if (segment.underline) format += "§n";
|
|
||||||
if (segment.strikethrough) format += "§m";
|
|
||||||
return format + segment.text;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentStyle = computed(() => {
|
|
||||||
const line = motd.value[currentLineIndex.value];
|
|
||||||
if (!line) return {};
|
|
||||||
|
|
||||||
let start = 0;
|
|
||||||
for (const segment of line) {
|
|
||||||
if (start + segment.text.length > selectionStart.value) {
|
|
||||||
return {
|
|
||||||
color: segment.color || "White",
|
|
||||||
bold: segment.bold || false,
|
|
||||||
italic: segment.italic || false,
|
|
||||||
underline: segment.underline || false,
|
|
||||||
strikethrough: segment.strikethrough || false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
start += segment.text.length;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
function getColorCode(color) {
|
|
||||||
const format = formatCodes.find((f) => f.description === color);
|
|
||||||
return format ? format.code : "§f";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLine(line) {
|
|
||||||
return line
|
|
||||||
.map((segment) => {
|
|
||||||
let style = `color: ${segment.color};`;
|
|
||||||
if (segment.bold) style += "font-weight: 900;";
|
|
||||||
if (segment.italic) style += "font-style: italic;";
|
|
||||||
if (segment.underline) style += "text-decoration: underline;";
|
|
||||||
if (segment.strikethrough) style += "text-decoration: line-through;";
|
|
||||||
return `<span style="${style}">${segment.text}</span>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelection(lineIndex) {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (selection.toString().length > 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const rect = range.getBoundingClientRect();
|
|
||||||
|
|
||||||
popupX.value = rect.left;
|
|
||||||
popupY.value = rect.bottom;
|
|
||||||
showPopup.value = true;
|
|
||||||
currentLineIndex.value = lineIndex;
|
|
||||||
|
|
||||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
|
||||||
const rangeClone = range.cloneRange();
|
|
||||||
rangeClone.selectNodeContents(lineElement);
|
|
||||||
rangeClone.setEnd(range.startContainer, range.startOffset);
|
|
||||||
selectionStart.value = rangeClone.toString().length;
|
|
||||||
selectionEnd.value = selectionStart.value + range.toString().length;
|
|
||||||
} else {
|
|
||||||
showPopup.value = false;
|
|
||||||
colorPicker.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyStyle(newStyle) {
|
|
||||||
const line = motd.value[currentLineIndex.value];
|
|
||||||
const newLine = [];
|
|
||||||
let currentPos = 0;
|
|
||||||
|
|
||||||
for (const segment of line) {
|
|
||||||
if (currentPos + segment.text.length <= selectionStart.value) {
|
|
||||||
newLine.push(segment);
|
|
||||||
} else if (currentPos >= selectionEnd.value) {
|
|
||||||
newLine.push(segment);
|
|
||||||
} else {
|
|
||||||
const beforeSelection = segment.text.slice(0, Math.max(0, selectionStart.value - currentPos));
|
|
||||||
const inSelection = segment.text.slice(
|
|
||||||
Math.max(0, selectionStart.value - currentPos),
|
|
||||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
|
||||||
);
|
|
||||||
const afterSelection = segment.text.slice(
|
|
||||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
|
||||||
);
|
|
||||||
console.log(beforeSelection);
|
|
||||||
console.log(inSelection);
|
|
||||||
console.log(afterSelection);
|
|
||||||
|
|
||||||
if (beforeSelection) newLine.push({ ...segment, text: beforeSelection });
|
|
||||||
if (inSelection) {
|
|
||||||
const mergedStyle = { ...segment, ...newStyle };
|
|
||||||
for (const key in newStyle) {
|
|
||||||
if (newStyle[key] === false) {
|
|
||||||
delete mergedStyle[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newLine.push({ ...mergedStyle, text: inSelection });
|
|
||||||
}
|
|
||||||
if (afterSelection) newLine.push({ ...segment, text: afterSelection });
|
|
||||||
}
|
|
||||||
currentPos += segment.text.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
motd.value[currentLineIndex.value] = newLine;
|
|
||||||
showPopup.value = false;
|
|
||||||
colorPicker.value = false;
|
|
||||||
|
|
||||||
// Rerender the line to reflect the changes
|
|
||||||
nextTick(() => {
|
|
||||||
const lineElement = document.querySelectorAll("[contenteditable]")[currentLineIndex.value];
|
|
||||||
lineElement.innerHTML = renderLine(newLine);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertEmoji() {
|
|
||||||
const emoji = "☺";
|
|
||||||
if (totalCharacters.value + emoji.length <= 90) {
|
|
||||||
applyStyle({ text: emoji });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(event, lineIndex) {
|
|
||||||
const newText = event.target.textContent;
|
|
||||||
const oldText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
|
||||||
const diff = newText.length - oldText.length;
|
|
||||||
|
|
||||||
if (newText.length <= 45) {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const cursorOffset = getCursorOffset(event.target, range);
|
|
||||||
|
|
||||||
const newLine = [];
|
|
||||||
let currentPos = 0;
|
|
||||||
for (const segment of motd.value[lineIndex]) {
|
|
||||||
const segmentEnd = currentPos + segment.text.length;
|
|
||||||
const newSegmentText = newText.slice(currentPos, Math.min(segmentEnd, newText.length));
|
|
||||||
if (newSegmentText) {
|
|
||||||
newLine.push({ ...segment, text: newSegmentText });
|
|
||||||
}
|
|
||||||
currentPos = segmentEnd;
|
|
||||||
if (currentPos >= newText.length) break;
|
|
||||||
}
|
|
||||||
if (currentPos < newText.length) {
|
|
||||||
newLine.push({ text: newText.slice(currentPos), color: "White" });
|
|
||||||
}
|
|
||||||
motd.value[lineIndex] = newLine;
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
const lineElement = event.target;
|
|
||||||
lineElement.innerHTML = renderLine(newLine);
|
|
||||||
|
|
||||||
const newRange = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
|
||||||
const { node, offset } = getCursorNodeAndOffset(lineElement, cursorOffset);
|
|
||||||
|
|
||||||
if (node) {
|
|
||||||
newRange.setStart(node, offset);
|
|
||||||
newRange.collapse(true);
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(newRange);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
event.target.innerHTML = renderLine(motd.value[lineIndex]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get cursor offset considering styled spans
|
|
||||||
function getCursorOffset(element, range) {
|
|
||||||
let offset = 0;
|
|
||||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
|
||||||
let node;
|
|
||||||
|
|
||||||
while ((node = walker.nextNode())) {
|
|
||||||
if (node === range.startContainer) {
|
|
||||||
return offset + range.startOffset;
|
|
||||||
}
|
|
||||||
offset += node.length;
|
|
||||||
}
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to find the node and offset for cursor placement
|
|
||||||
function getCursorNodeAndOffset(element, targetOffset) {
|
|
||||||
let currentOffset = 0;
|
|
||||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
|
||||||
let node;
|
|
||||||
|
|
||||||
while ((node = walker.nextNode())) {
|
|
||||||
if (currentOffset + node.length >= targetOffset) {
|
|
||||||
return { node, offset: targetOffset - currentOffset };
|
|
||||||
}
|
|
||||||
currentOffset += node.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we've gone past the end, return the last possible position
|
|
||||||
const lastTextNode = element.lastChild?.lastChild;
|
|
||||||
return { node: lastTextNode, offset: lastTextNode?.length || 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaste(event, lineIndex) {
|
|
||||||
event.preventDefault();
|
|
||||||
const pastedText = (event.clipboardData || window.clipboardData).getData("text");
|
|
||||||
const selection = window.getSelection();
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const startOffset = range.startOffset;
|
|
||||||
|
|
||||||
const currentText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
|
||||||
const newText = currentText.slice(0, startOffset) + pastedText + currentText.slice(startOffset);
|
|
||||||
|
|
||||||
if (newText.length <= 45) {
|
|
||||||
// Preserve existing styles by matching new text with old segments
|
|
||||||
const newLine = [];
|
|
||||||
let currentPos = 0;
|
|
||||||
for (const segment of motd.value[lineIndex]) {
|
|
||||||
if (currentPos < startOffset) {
|
|
||||||
const segmentEnd = Math.min(currentPos + segment.text.length, startOffset);
|
|
||||||
newLine.push({ ...segment, text: newText.slice(currentPos, segmentEnd) });
|
|
||||||
currentPos = segmentEnd;
|
|
||||||
} else if (currentPos >= startOffset + pastedText.length) {
|
|
||||||
newLine.push({ ...segment, text: newText.slice(currentPos) });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Insert pasted text as a new segment
|
|
||||||
if (currentPos < startOffset + pastedText.length) {
|
|
||||||
newLine.push({
|
|
||||||
text: newText.slice(currentPos, startOffset + pastedText.length),
|
|
||||||
color: "White",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
motd.value[lineIndex] = newLine;
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
|
||||||
lineElement.innerHTML = renderLine(newLine);
|
|
||||||
const newRange = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
|
||||||
newRange.setStart(lineElement.childNodes[0], startOffset + pastedText.length);
|
|
||||||
newRange.collapse(true);
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(newRange);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.minecraft-font {
|
|
||||||
font-family: "Minecraft", monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
[contenteditable] {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@font-face {
|
|
||||||
font-family: "Monocraft";
|
|
||||||
src: url("/Monocraft.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-minecraft {
|
|
||||||
font-family: "Monocraft", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mcbg {
|
|
||||||
background: url("@/assets/images/servers/minecraft-background-dark.png") repeat center center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,14 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a
|
|
||||||
href="https://pyro.host"
|
|
||||||
target="_blank"
|
|
||||||
class="mx-auto mt-8 flex select-none flex-row items-center gap-2 hover:underline"
|
|
||||||
>
|
|
||||||
<PyroIcon class="size-4 text-secondary" />
|
|
||||||
<span class="text-sm text-secondary">Powered by Pyro</span>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { PyroIcon } from "@modrinth/assets";
|
|
||||||
</script>
|
|
@ -91,8 +91,7 @@ pub async fn ws_init(
|
|||||||
let friend_statuses = if !friends.is_empty() {
|
let friend_statuses = if !friends.is_empty() {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
let redis = redis.clone();
|
let redis = redis.clone();
|
||||||
|
tokio_stream::iter(friends.iter())
|
||||||
let statuses = tokio_stream::iter(friends.iter())
|
|
||||||
.map(|x| {
|
.map(|x| {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
let redis = redis.clone();
|
let redis = redis.clone();
|
||||||
@ -112,9 +111,10 @@ pub async fn ws_init(
|
|||||||
})
|
})
|
||||||
.buffer_unordered(16)
|
.buffer_unordered(16)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.await;
|
.await
|
||||||
|
.into_iter()
|
||||||
statuses.into_iter().flatten().collect()
|
.flatten()
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
12
packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json
generated
Normal file
@ -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"
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -29,109 +29,114 @@
|
|||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mod_loader",
|
"name": "protocol_version",
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"type_info": "Text"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mod_loader_version",
|
"name": "mod_loader",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "groups!: serde_json::Value",
|
"name": "mod_loader_version",
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "groups!: serde_json::Value",
|
||||||
|
"ordinal": 8,
|
||||||
"type_info": "Null"
|
"type_info": "Null"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "linked_project_id",
|
"name": "linked_project_id",
|
||||||
"ordinal": 8,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "linked_version_id",
|
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "locked",
|
"name": "linked_version_id",
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"type_info": "Integer"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "created",
|
"name": "locked",
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "modified",
|
"name": "created",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "last_played",
|
"name": "modified",
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "submitted_time_played",
|
"name": "last_played",
|
||||||
"ordinal": 14,
|
"ordinal": 14,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "recent_time_played",
|
"name": "submitted_time_played",
|
||||||
"ordinal": 15,
|
"ordinal": 15,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_java_path",
|
"name": "recent_time_played",
|
||||||
"ordinal": 16,
|
"ordinal": 16,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "override_java_path",
|
||||||
|
"ordinal": 17,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_extra_launch_args!: serde_json::Value",
|
"name": "override_extra_launch_args!: serde_json::Value",
|
||||||
"ordinal": 17,
|
|
||||||
"type_info": "Null"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "override_custom_env_vars!: serde_json::Value",
|
|
||||||
"ordinal": 18,
|
"ordinal": 18,
|
||||||
"type_info": "Null"
|
"type_info": "Null"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_memory_max",
|
"name": "override_custom_env_vars!: serde_json::Value",
|
||||||
"ordinal": 19,
|
"ordinal": 19,
|
||||||
"type_info": "Integer"
|
"type_info": "Null"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_force_fullscreen",
|
"name": "override_mc_memory_max",
|
||||||
"ordinal": 20,
|
"ordinal": 20,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_game_resolution_x",
|
"name": "override_mc_force_fullscreen",
|
||||||
"ordinal": 21,
|
"ordinal": 21,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_game_resolution_y",
|
"name": "override_mc_game_resolution_x",
|
||||||
"ordinal": 22,
|
"ordinal": 22,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_hook_pre_launch",
|
"name": "override_mc_game_resolution_y",
|
||||||
"ordinal": 23,
|
"ordinal": 23,
|
||||||
"type_info": "Text"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_hook_wrapper",
|
"name": "override_hook_pre_launch",
|
||||||
"ordinal": 24,
|
"ordinal": 24,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_hook_post_exit",
|
"name": "override_hook_wrapper",
|
||||||
"ordinal": 25,
|
"ordinal": 25,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "override_hook_post_exit",
|
||||||
|
"ordinal": 26,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@ -143,6 +148,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
@ -166,5 +172,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1"
|
"hash": "1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb"
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@ -29,109 +29,114 @@
|
|||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mod_loader",
|
"name": "protocol_version",
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"type_info": "Text"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mod_loader_version",
|
"name": "mod_loader",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "groups!: serde_json::Value",
|
"name": "mod_loader_version",
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "groups!: serde_json::Value",
|
||||||
|
"ordinal": 8,
|
||||||
"type_info": "Null"
|
"type_info": "Null"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "linked_project_id",
|
"name": "linked_project_id",
|
||||||
"ordinal": 8,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "linked_version_id",
|
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "locked",
|
"name": "linked_version_id",
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"type_info": "Integer"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "created",
|
"name": "locked",
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "modified",
|
"name": "created",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "last_played",
|
"name": "modified",
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "submitted_time_played",
|
"name": "last_played",
|
||||||
"ordinal": 14,
|
"ordinal": 14,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "recent_time_played",
|
"name": "submitted_time_played",
|
||||||
"ordinal": 15,
|
"ordinal": 15,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_java_path",
|
"name": "recent_time_played",
|
||||||
"ordinal": 16,
|
"ordinal": 16,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "override_java_path",
|
||||||
|
"ordinal": 17,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_extra_launch_args!: serde_json::Value",
|
"name": "override_extra_launch_args!: serde_json::Value",
|
||||||
"ordinal": 17,
|
|
||||||
"type_info": "Null"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "override_custom_env_vars!: serde_json::Value",
|
|
||||||
"ordinal": 18,
|
"ordinal": 18,
|
||||||
"type_info": "Null"
|
"type_info": "Null"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_memory_max",
|
"name": "override_custom_env_vars!: serde_json::Value",
|
||||||
"ordinal": 19,
|
"ordinal": 19,
|
||||||
"type_info": "Integer"
|
"type_info": "Null"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_force_fullscreen",
|
"name": "override_mc_memory_max",
|
||||||
"ordinal": 20,
|
"ordinal": 20,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_game_resolution_x",
|
"name": "override_mc_force_fullscreen",
|
||||||
"ordinal": 21,
|
"ordinal": 21,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_mc_game_resolution_y",
|
"name": "override_mc_game_resolution_x",
|
||||||
"ordinal": 22,
|
"ordinal": 22,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_hook_pre_launch",
|
"name": "override_mc_game_resolution_y",
|
||||||
"ordinal": 23,
|
"ordinal": 23,
|
||||||
"type_info": "Text"
|
"type_info": "Integer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_hook_wrapper",
|
"name": "override_hook_pre_launch",
|
||||||
"ordinal": 24,
|
"ordinal": 24,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "override_hook_post_exit",
|
"name": "override_hook_wrapper",
|
||||||
"ordinal": 25,
|
"ordinal": 25,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "override_hook_post_exit",
|
||||||
|
"ordinal": 26,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@ -143,6 +148,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
@ -166,5 +172,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094"
|
"hash": "30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d"
|
||||||
}
|
}
|
38
packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json
generated
Normal file
@ -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"
|
||||||
|
}
|
@ -41,7 +41,7 @@
|
|||||||
{
|
{
|
||||||
"name": "display_claims!: serde_json::Value",
|
"name": "display_claims!: serde_json::Value",
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"type_info": "Null"
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
@ -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"
|
|
||||||
}
|
|
12
packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json
generated
Normal file
@ -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"
|
||||||
|
}
|
@ -11,13 +11,14 @@ serde_json = "1.0"
|
|||||||
serde_ini = "0.2.0"
|
serde_ini = "0.2.0"
|
||||||
sha1_smol = { version = "1.0.0", features = ["std"] }
|
sha1_smol = { version = "1.0.0", features = ["std"] }
|
||||||
sha2 = "0.10.8"
|
sha2 = "0.10.8"
|
||||||
url = "2.2"
|
url = { version = "2.2", features = ["serde"] }
|
||||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||||
zip = "0.6.5"
|
zip = "0.6.5"
|
||||||
async_zip = { version = "0.0.17", features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] }
|
async_zip = { version = "0.0.17", features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] }
|
||||||
flate2 = "1.0.28"
|
flate2 = "1.0.28"
|
||||||
tempfile = "3.5.0"
|
tempfile = "3.5.0"
|
||||||
dashmap = { version = "6.0.1", features = ["serde"] }
|
dashmap = { version = "6.0.1", features = ["serde"] }
|
||||||
|
quick-xml = { version = "0.37", features = ["async-tokio"] }
|
||||||
|
|
||||||
chrono = { version = "0.4.19", features = ["serde"] }
|
chrono = { version = "0.4.19", features = ["serde"] }
|
||||||
daedalus = { path = "../../packages/daedalus" }
|
daedalus = { path = "../../packages/daedalus" }
|
||||||
@ -42,7 +43,10 @@ async-tungstenite = { version = "0.27.0", features = ["tokio-runtime", "tokio-ru
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
reqwest = { version = "0.12.3", features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls", "charset", "http2", "macos-system-configuration"], default-features = false }
|
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 = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
async-recursion = "1.0.4"
|
async-recursion = "1.0.4"
|
||||||
|
fs4 = { version = "0.13", features = ["tokio"] }
|
||||||
|
async-walkdir = "2.1"
|
||||||
|
|
||||||
notify = { version = "6.1.1", default-features = false }
|
notify = { version = "6.1.1", default-features = false }
|
||||||
notify-debouncer-mini = { version = "0.4.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" ] }
|
sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros" ] }
|
||||||
|
|
||||||
|
quartz_nbt = { version = "0.2", features = ["serde"] }
|
||||||
|
hickory-resolver = "0.25"
|
||||||
|
|
||||||
ariadne = { path = "../ariadne" }
|
ariadne = { path = "../ariadne" }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE profiles ADD COLUMN protocol_version INTEGER NULL
|
10
packages/app-lib/migrations/20250408181656_add-join-log.sql
Normal file
@ -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);
|
@ -298,7 +298,7 @@ pub async fn get_latest_log_cursor(
|
|||||||
profile_path: &str,
|
profile_path: &str,
|
||||||
cursor: u64, // 0 to start at beginning of file
|
cursor: u64, // 0 to start at beginning of file
|
||||||
) -> crate::Result<LatestLogCursor> {
|
) -> crate::Result<LatestLogCursor> {
|
||||||
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]
|
#[tracing::instrument]
|
||||||
|
@ -12,6 +12,7 @@ pub mod process;
|
|||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
pub mod worlds;
|
||||||
|
|
||||||
pub mod data {
|
pub mod data {
|
||||||
pub use crate::state::{
|
pub use crate::state::{
|
||||||
|
@ -77,6 +77,7 @@ pub async fn profile_create(
|
|||||||
name,
|
name,
|
||||||
icon_path: None,
|
icon_path: None,
|
||||||
game_version,
|
game_version,
|
||||||
|
protocol_version: None,
|
||||||
loader: modloader,
|
loader: modloader,
|
||||||
loader_version: loader.map(|x| x.id),
|
loader_version: loader.map(|x| x.id),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
|
@ -36,6 +36,13 @@ use tokio::{fs::File, process::Command, sync::RwLock};
|
|||||||
pub mod create;
|
pub mod create;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum QuickPlayType {
|
||||||
|
None,
|
||||||
|
Singleplayer(String),
|
||||||
|
Server(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a profile
|
/// Remove a profile
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn remove(path: &str) -> crate::Result<()> {
|
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,
|
/// Run Minecraft using a profile and the default credentials, logged in credentials,
|
||||||
/// failing with an error if no credentials are available
|
/// failing with an error if no credentials are available
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn run(path: &str) -> crate::Result<ProcessMetadata> {
|
pub async fn run(
|
||||||
|
path: &str,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
|
) -> crate::Result<ProcessMetadata> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
|
|
||||||
let default_account = Credentials::get_default_credential(&state.pool)
|
let default_account = Credentials::get_default_credential(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?;
|
.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
|
/// Run Minecraft using a profile, and credentials for authentication
|
||||||
@ -640,6 +650,7 @@ pub async fn run(path: &str) -> crate::Result<ProcessMetadata> {
|
|||||||
pub async fn run_credentials(
|
pub async fn run_credentials(
|
||||||
path: &str,
|
path: &str,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
) -> crate::Result<ProcessMetadata> {
|
) -> crate::Result<ProcessMetadata> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
let settings = Settings::get(&state.pool).await?;
|
let settings = Settings::get(&state.pool).await?;
|
||||||
@ -719,6 +730,7 @@ pub async fn run_credentials(
|
|||||||
credentials,
|
credentials,
|
||||||
post_exit_hook,
|
post_exit_hook,
|
||||||
&profile,
|
&profile,
|
||||||
|
quick_play_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
830
packages/app-lib/src/api/worlds.rs
Normal file
@ -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<DateTime<Utc>>,
|
||||||
|
#[serde(
|
||||||
|
skip_serializing_if = "Option::is_none",
|
||||||
|
with = "either::serde_untagged_optional"
|
||||||
|
)]
|
||||||
|
pub icon: Option<Either<PathBuf, Url>>,
|
||||||
|
#[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<Option<bool>> for ServerPackStatus {
|
||||||
|
fn from(value: Option<bool>) -> Self {
|
||||||
|
match value {
|
||||||
|
Some(true) => ServerPackStatus::Enabled,
|
||||||
|
Some(false) => ServerPackStatus::Disabled,
|
||||||
|
None => ServerPackStatus::Prompt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ServerPackStatus> for Option<bool> {
|
||||||
|
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<Vec<WorldWithProfile>> {
|
||||||
|
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<Vec<World>> {
|
||||||
|
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<Vec<World>> {
|
||||||
|
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<World>,
|
||||||
|
) -> 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<World> {
|
||||||
|
read_singleplayer_world(get_world_dir(profile_path, world)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_singleplayer_world(world_path: PathBuf) -> Result<World> {
|
||||||
|
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<World> {
|
||||||
|
#[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<World>,
|
||||||
|
) -> 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<u64> {
|
||||||
|
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#"^(?<name>.*) \((?<count>\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::<i32>()
|
||||||
|
.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<tokio::fs::File> {
|
||||||
|
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<Option<tokio::fs::File>> {
|
||||||
|
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<usize> {
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ip: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub accept_textures: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read(instance_dir: &Path) -> Result<Vec<ServerData>> {
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct ServersData {
|
||||||
|
#[serde(default)]
|
||||||
|
servers: Vec<ServerData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<i32>> {
|
||||||
|
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<i32>,
|
||||||
|
) -> Result<ServerStatus> {
|
||||||
|
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::<u16>().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::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().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)))
|
||||||
|
}
|
@ -13,6 +13,12 @@ pub enum ErrorKind {
|
|||||||
#[error("Serialization error (JSON): {0}")]
|
#[error("Serialization error (JSON): {0}")]
|
||||||
JSONError(#[from] serde_json::Error),
|
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}")]
|
#[error("Serialization error (websocket): {0}")]
|
||||||
WebsocketSerializationError(
|
WebsocketSerializationError(
|
||||||
#[from] ariadne::networking::serialization::SerializationError,
|
#[from] ariadne::networking::serialization::SerializationError,
|
||||||
@ -116,6 +122,9 @@ pub enum ErrorKind {
|
|||||||
|
|
||||||
#[error("Move directory error: {0}")]
|
#[error("Move directory error: {0}")]
|
||||||
DirectoryMoveError(String),
|
DirectoryMoveError(String),
|
||||||
|
|
||||||
|
#[error("Error resolving DNS: {0}")]
|
||||||
|
DNSError(#[from] hickory_resolver::ResolveError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
//! Theseus state management system
|
//! Theseus state management system
|
||||||
use ariadne::users::{UserId, UserStatus};
|
use ariadne::users::{UserId, UserStatus};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{path::PathBuf, sync::Arc};
|
use std::{path::PathBuf, sync::Arc};
|
||||||
@ -234,13 +235,23 @@ pub enum ProcessPayloadType {
|
|||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct ProfilePayload {
|
pub struct ProfilePayload {
|
||||||
pub profile_path_id: String,
|
pub profile_path_id: String,
|
||||||
|
#[serde(flatten)]
|
||||||
pub event: ProfilePayloadType,
|
pub event: ProfilePayloadType,
|
||||||
}
|
}
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(tag = "event", rename_all = "snake_case")]
|
||||||
pub enum ProfilePayloadType {
|
pub enum ProfilePayloadType {
|
||||||
Created,
|
Created,
|
||||||
Synced,
|
Synced,
|
||||||
|
ServersUpdated,
|
||||||
|
WorldUpdated {
|
||||||
|
world: String,
|
||||||
|
},
|
||||||
|
ServerJoined {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
},
|
||||||
Edited,
|
Edited,
|
||||||
Removed,
|
Removed,
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
//! Minecraft CLI argument logic
|
//! Minecraft CLI argument logic
|
||||||
use crate::launcher::parse_rules;
|
use crate::launcher::parse_rules;
|
||||||
|
use crate::profile::QuickPlayType;
|
||||||
use crate::state::Credentials;
|
use crate::state::Credentials;
|
||||||
use crate::{
|
use crate::{
|
||||||
state::{MemorySettings, WindowSize},
|
state::{MemorySettings, WindowSize},
|
||||||
@ -31,7 +32,12 @@ pub fn get_class_paths(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|library| {
|
.filter_map(|library| {
|
||||||
if let Some(rules) = &library.rules {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -111,6 +117,7 @@ pub fn get_jvm_arguments(
|
|||||||
memory: MemorySettings,
|
memory: MemorySettings,
|
||||||
custom_args: Vec<String>,
|
custom_args: Vec<String>,
|
||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
log_config: Option<&LoggingConfiguration>,
|
log_config: Option<&LoggingConfiguration>,
|
||||||
) -> crate::Result<Vec<String>> {
|
) -> crate::Result<Vec<String>> {
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
@ -130,6 +137,7 @@ pub fn get_jvm_arguments(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
java_arch,
|
java_arch,
|
||||||
|
quick_play_type,
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
parsed_arguments.push(format!(
|
parsed_arguments.push(format!(
|
||||||
@ -214,6 +222,7 @@ pub fn get_minecraft_arguments(
|
|||||||
version_type: &VersionType,
|
version_type: &VersionType,
|
||||||
resolution: WindowSize,
|
resolution: WindowSize,
|
||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
) -> crate::Result<Vec<String>> {
|
) -> crate::Result<Vec<String>> {
|
||||||
if let Some(arguments) = arguments {
|
if let Some(arguments) = arguments {
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
@ -233,9 +242,11 @@ pub fn get_minecraft_arguments(
|
|||||||
assets_directory,
|
assets_directory,
|
||||||
version_type,
|
version_type,
|
||||||
resolution,
|
resolution,
|
||||||
|
quick_play_type,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
java_arch,
|
java_arch,
|
||||||
|
quick_play_type,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(parsed_arguments)
|
Ok(parsed_arguments)
|
||||||
@ -253,6 +264,7 @@ pub fn get_minecraft_arguments(
|
|||||||
assets_directory,
|
assets_directory,
|
||||||
version_type,
|
version_type,
|
||||||
resolution,
|
resolution,
|
||||||
|
quick_play_type,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
Ok(parsed_arguments)
|
Ok(parsed_arguments)
|
||||||
@ -273,6 +285,7 @@ fn parse_minecraft_argument(
|
|||||||
assets_directory: &Path,
|
assets_directory: &Path,
|
||||||
version_type: &VersionType,
|
version_type: &VersionType,
|
||||||
resolution: WindowSize,
|
resolution: WindowSize,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
) -> crate::Result<String> {
|
) -> crate::Result<String> {
|
||||||
Ok(argument
|
Ok(argument
|
||||||
.replace("${accessToken}", access_token)
|
.replace("${accessToken}", access_token)
|
||||||
@ -326,7 +339,21 @@ fn parse_minecraft_argument(
|
|||||||
)
|
)
|
||||||
.replace("${version_type}", version_type.as_str())
|
.replace("${version_type}", version_type.as_str())
|
||||||
.replace("${resolution_width}", &resolution.0.to_string())
|
.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<F>(
|
fn parse_arguments<F>(
|
||||||
@ -334,6 +361,7 @@ fn parse_arguments<F>(
|
|||||||
parsed_arguments: &mut Vec<String>,
|
parsed_arguments: &mut Vec<String>,
|
||||||
parse_function: F,
|
parse_function: F,
|
||||||
java_arch: &str,
|
java_arch: &str,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
) -> crate::Result<()>
|
) -> crate::Result<()>
|
||||||
where
|
where
|
||||||
F: Fn(&str) -> crate::Result<String>,
|
F: Fn(&str) -> crate::Result<String>,
|
||||||
@ -348,7 +376,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Argument::Ruled { rules, value } => {
|
Argument::Ruled { rules, value } => {
|
||||||
if parse_rules(rules, java_arch, true) {
|
if parse_rules(rules, java_arch, quick_play_type, true) {
|
||||||
match value {
|
match value {
|
||||||
ArgumentValue::Single(arg) => {
|
ArgumentValue::Single(arg) => {
|
||||||
parsed_arguments.push(parse_function(
|
parsed_arguments.push(parse_function(
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
//! Downloader for Minecraft data
|
//! Downloader for Minecraft data
|
||||||
|
|
||||||
use crate::launcher::parse_rules;
|
use crate::launcher::parse_rules;
|
||||||
|
use crate::profile::QuickPlayType;
|
||||||
use crate::{
|
use crate::{
|
||||||
event::{
|
event::{
|
||||||
emit::{emit_loading, loading_try_for_each_concurrent},
|
emit::{emit_loading, loading_try_for_each_concurrent},
|
||||||
@ -295,7 +296,7 @@ pub async fn download_libraries(
|
|||||||
stream::iter(libraries.iter())
|
stream::iter(libraries.iter())
|
||||||
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
|
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
|
||||||
if let Some(rules) = &library.rules {
|
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);
|
tracing::trace!("Skipped library {}", &library.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
|
|||||||
use crate::event::{LoadingBarId, LoadingBarType};
|
use crate::event::{LoadingBarId, LoadingBarType};
|
||||||
use crate::launcher::download::download_log_config;
|
use crate::launcher::download::download_log_config;
|
||||||
use crate::launcher::io::IOError;
|
use crate::launcher::io::IOError;
|
||||||
|
use crate::profile::QuickPlayType;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||||
};
|
};
|
||||||
@ -13,8 +14,10 @@ use chrono::Utc;
|
|||||||
use daedalus as d;
|
use daedalus as d;
|
||||||
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
||||||
use daedalus::modded::LoaderVersion;
|
use daedalus::modded::LoaderVersion;
|
||||||
|
use serde::Deserialize;
|
||||||
use st::Profile;
|
use st::Profile;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
mod args;
|
mod args;
|
||||||
@ -28,11 +31,14 @@ pub mod download;
|
|||||||
pub fn parse_rules(
|
pub fn parse_rules(
|
||||||
rules: &[d::minecraft::Rule],
|
rules: &[d::minecraft::Rule],
|
||||||
java_version: &str,
|
java_version: &str,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
minecraft_updated: bool,
|
minecraft_updated: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let mut x = rules
|
let mut x = rules
|
||||||
.iter()
|
.iter()
|
||||||
.map(|x| parse_rule(x, java_version, minecraft_updated))
|
.map(|x| {
|
||||||
|
parse_rule(x, java_version, quick_play_type, minecraft_updated)
|
||||||
|
})
|
||||||
.collect::<Vec<Option<bool>>>();
|
.collect::<Vec<Option<bool>>>();
|
||||||
|
|
||||||
if rules
|
if rules
|
||||||
@ -53,6 +59,7 @@ pub fn parse_rules(
|
|||||||
pub fn parse_rule(
|
pub fn parse_rule(
|
||||||
rule: &d::minecraft::Rule,
|
rule: &d::minecraft::Rule,
|
||||||
java_version: &str,
|
java_version: &str,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
minecraft_updated: bool,
|
minecraft_updated: bool,
|
||||||
) -> Option<bool> {
|
) -> Option<bool> {
|
||||||
use d::minecraft::{Rule, RuleAction};
|
use d::minecraft::{Rule, RuleAction};
|
||||||
@ -70,9 +77,14 @@ pub fn parse_rule(
|
|||||||
!features.is_demo_user.unwrap_or(true)
|
!features.is_demo_user.unwrap_or(true)
|
||||||
|| features.has_custom_resolution.unwrap_or(false)
|
|| features.has_custom_resolution.unwrap_or(false)
|
||||||
|| !features.has_quick_plays_support.unwrap_or(true)
|
|| !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_realms.unwrap_or(true)
|
||||||
|| !features.is_quick_play_singleplayer.unwrap_or(true)
|
|
||||||
}
|
}
|
||||||
_ => return Some(true),
|
_ => return Some(true),
|
||||||
};
|
};
|
||||||
@ -305,12 +317,11 @@ pub async fn install_minecraft(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let client_path = state
|
||||||
|
.directories
|
||||||
|
.version_dir(&version_jar)
|
||||||
|
.join(format!("{version_jar}.jar"));
|
||||||
if let Some(processors) = &version_info.processors {
|
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();
|
let libraries_dir = state.directories.libraries_dir();
|
||||||
|
|
||||||
if let Some(ref mut data) = version_info.data {
|
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| {
|
crate::api::profile::edit(&profile.path, |prof| {
|
||||||
prof.install_stage = ProfileInstallStage::Installed;
|
prof.install_stage = ProfileInstallStage::Installed;
|
||||||
|
prof.protocol_version = protocol_version;
|
||||||
|
|
||||||
async { Ok(()) }
|
async { Ok(()) }
|
||||||
})
|
})
|
||||||
@ -414,6 +428,34 @@ pub async fn install_minecraft(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn read_protocol_version_from_jar(
|
||||||
|
path: PathBuf,
|
||||||
|
) -> crate::Result<Option<i32>> {
|
||||||
|
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<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
#[tracing::instrument(skip_all)]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn launch_minecraft(
|
pub async fn launch_minecraft(
|
||||||
@ -426,6 +468,7 @@ pub async fn launch_minecraft(
|
|||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
post_exit_hook: Option<String>,
|
post_exit_hook: Option<String>,
|
||||||
profile: &Profile,
|
profile: &Profile,
|
||||||
|
quick_play_type: &QuickPlayType,
|
||||||
) -> crate::Result<ProcessMetadata> {
|
) -> crate::Result<ProcessMetadata> {
|
||||||
if profile.install_stage == ProfileInstallStage::PackInstalling
|
if profile.install_stage == ProfileInstallStage::PackInstalling
|
||||||
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
||||||
@ -581,6 +624,7 @@ pub async fn launch_minecraft(
|
|||||||
*memory,
|
*memory,
|
||||||
Vec::from(java_args),
|
Vec::from(java_args),
|
||||||
&java_version.architecture,
|
&java_version.architecture,
|
||||||
|
quick_play_type,
|
||||||
version_info
|
version_info
|
||||||
.logging
|
.logging
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -603,6 +647,7 @@ pub async fn launch_minecraft(
|
|||||||
&version.type_,
|
&version.type_,
|
||||||
*resolution,
|
*resolution,
|
||||||
&java_version.architecture,
|
&java_version.architecture,
|
||||||
|
quick_play_type,
|
||||||
)?
|
)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
@ -708,6 +753,12 @@ pub async fn launch_minecraft(
|
|||||||
// This also spawns the process and prepares the subsequent processes
|
// This also spawns the process and prepares the subsequent processes
|
||||||
state
|
state
|
||||||
.process_manager
|
.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
|
.await
|
||||||
}
|
}
|
||||||
|
@ -37,9 +37,7 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
|||||||
let mut found = false;
|
let mut found = false;
|
||||||
for component in e.path.components() {
|
for component in e.path.components() {
|
||||||
if found {
|
if found {
|
||||||
profile_path = Some(
|
profile_path = Some(component.as_os_str());
|
||||||
component.as_os_str().to_string_lossy(),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,26 +49,72 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(profile_path) = profile_path {
|
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()
|
.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
|
&& e.path
|
||||||
.extension()
|
.extension()
|
||||||
.map(|x| x == "txt")
|
.filter(|x| *x == "txt")
|
||||||
.unwrap_or(false)
|
.is_some()
|
||||||
{
|
{
|
||||||
crash_task(profile_path.to_string());
|
crash_task(profile_path_str);
|
||||||
} else if !visited_profiles.contains(&profile_path)
|
} else if !visited_profiles.contains(&profile_path)
|
||||||
{
|
{
|
||||||
let path = profile_path.to_string();
|
let event = if first_file_name
|
||||||
tokio::spawn(async move {
|
.filter(|x| *x == "servers.dat")
|
||||||
let _ = emit_profile(
|
.is_some()
|
||||||
&path,
|
{
|
||||||
ProfilePayloadType::Synced,
|
Some(ProfilePayloadType::ServersUpdated)
|
||||||
)
|
} else if first_file_name
|
||||||
.await;
|
.filter(|x| {
|
||||||
});
|
*x == "saves"
|
||||||
visited_profiles.push(profile_path);
|
&& 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);
|
let profile_path = dirs.profiles_dir().join(profile_path);
|
||||||
|
|
||||||
if profile_path.exists() && profile_path.is_dir() {
|
if profile_path.exists() && profile_path.is_dir() {
|
||||||
for folder in ProjectType::iterator()
|
for folder in ProjectType::iterator().map(|x| x.get_folder()).chain([
|
||||||
.map(|x| x.get_folder())
|
"crash-reports",
|
||||||
.chain(["crash-reports"])
|
"saves",
|
||||||
{
|
"servers.dat",
|
||||||
|
]) {
|
||||||
let path = profile_path.join(folder);
|
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 {
|
if let Err(e) = crate::util::io::create_dir_all(&path).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Failed to create directory for watcher {path:?}: {e}"
|
"Failed to create directory for watcher {path:?}: {e}"
|
||||||
|
@ -320,6 +320,7 @@ where
|
|||||||
name: profile.metadata.name,
|
name: profile.metadata.name,
|
||||||
icon_path: profile.metadata.icon,
|
icon_path: profile.metadata.icon,
|
||||||
game_version: profile.metadata.game_version,
|
game_version: profile.metadata.game_version,
|
||||||
|
protocol_version: None,
|
||||||
loader: profile.metadata.loader.into(),
|
loader: profile.metadata.loader.into(),
|
||||||
loader_version: profile
|
loader_version: profile
|
||||||
.metadata
|
.metadata
|
||||||
|
@ -45,6 +45,8 @@ pub use self::mr_auth::*;
|
|||||||
|
|
||||||
mod legacy_converter;
|
mod legacy_converter;
|
||||||
|
|
||||||
|
pub mod server_join_log;
|
||||||
|
|
||||||
// Global state
|
// Global state
|
||||||
// RwLock on state only has concurrent reads, except for config dir change which takes control of the State
|
// RwLock on state only has concurrent reads, except for config dir change which takes control of the State
|
||||||
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
|
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
|
||||||
|
@ -1,15 +1,23 @@
|
|||||||
use crate::event::emit::emit_process;
|
use crate::event::emit::{emit_process, emit_profile};
|
||||||
use crate::event::ProcessPayloadType;
|
use crate::event::{ProcessPayloadType, ProfilePayloadType};
|
||||||
use crate::profile;
|
use crate::profile;
|
||||||
use crate::util::io::IOError;
|
use crate::util::io::IOError;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::Reader;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitStatus;
|
use std::process::ExitStatus;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const LAUNCHER_LOG_PATH: &str = "launcher_log.txt";
|
||||||
|
|
||||||
pub struct ProcessManager {
|
pub struct ProcessManager {
|
||||||
processes: DashMap<Uuid, Process>,
|
processes: DashMap<Uuid, Process>,
|
||||||
}
|
}
|
||||||
@ -32,8 +40,16 @@ impl ProcessManager {
|
|||||||
profile_path: &str,
|
profile_path: &str,
|
||||||
mut mc_command: Command,
|
mut mc_command: Command,
|
||||||
post_exit_command: Option<String>,
|
post_exit_command: Option<String>,
|
||||||
|
logs_folder: PathBuf,
|
||||||
|
xml_logging: bool,
|
||||||
) -> crate::Result<ProcessMetadata> {
|
) -> crate::Result<ProcessMetadata> {
|
||||||
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 {
|
let process = Process {
|
||||||
metadata: ProcessMetadata {
|
metadata: ProcessMetadata {
|
||||||
@ -46,6 +62,65 @@ impl ProcessManager {
|
|||||||
|
|
||||||
let metadata = process.metadata.clone();
|
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(
|
tokio::spawn(Process::sequential_process_manager(
|
||||||
profile_path.to_string(),
|
profile_path.to_string(),
|
||||||
post_exit_command,
|
post_exit_command,
|
||||||
@ -120,7 +195,381 @@ struct Process {
|
|||||||
child: Child,
|
child: Child,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Log4jEvent {
|
||||||
|
timestamp: Option<String>,
|
||||||
|
logger: Option<String>,
|
||||||
|
level: Option<String>,
|
||||||
|
thread: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Process {
|
impl Process {
|
||||||
|
async fn process_output<R>(
|
||||||
|
profile_path: &str,
|
||||||
|
reader: R,
|
||||||
|
log_path: impl AsRef<Path>,
|
||||||
|
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::<i64>() {
|
||||||
|
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::<Utc>::from_timestamp(secs, nsecs)
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
chrono::DateTime::<Utc>::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<Path>,
|
||||||
|
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::<u16>().ok() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let timestamp = timestamp
|
||||||
|
.parse::<i64>()
|
||||||
|
.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
|
// 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
|
// 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
|
// 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.discord_rpc.clear_to_default(true).await;
|
||||||
|
|
||||||
let _ = state.friends_socket.update_status(None).await;
|
let _ = state.friends_socket.update_status(None).await;
|
||||||
|
@ -23,6 +23,7 @@ pub struct Profile {
|
|||||||
pub icon_path: Option<String>,
|
pub icon_path: Option<String>,
|
||||||
|
|
||||||
pub game_version: String,
|
pub game_version: String,
|
||||||
|
pub protocol_version: Option<i32>,
|
||||||
pub loader: ModLoader,
|
pub loader: ModLoader,
|
||||||
pub loader_version: Option<String>,
|
pub loader_version: Option<String>,
|
||||||
|
|
||||||
@ -261,6 +262,7 @@ struct ProfileQueryResult {
|
|||||||
override_hook_pre_launch: Option<String>,
|
override_hook_pre_launch: Option<String>,
|
||||||
override_hook_wrapper: Option<String>,
|
override_hook_wrapper: Option<String>,
|
||||||
override_hook_post_exit: Option<String>,
|
override_hook_post_exit: Option<String>,
|
||||||
|
protocol_version: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<ProfileQueryResult> for Profile {
|
impl TryFrom<ProfileQueryResult> for Profile {
|
||||||
@ -273,6 +275,7 @@ impl TryFrom<ProfileQueryResult> for Profile {
|
|||||||
name: x.name,
|
name: x.name,
|
||||||
icon_path: x.icon_path,
|
icon_path: x.icon_path,
|
||||||
game_version: x.game_version,
|
game_version: x.game_version,
|
||||||
|
protocol_version: x.protocol_version.map(|x| x as i32),
|
||||||
loader: ModLoader::from_string(&x.mod_loader),
|
loader: ModLoader::from_string(&x.mod_loader),
|
||||||
loader_version: x.mod_loader_version,
|
loader_version: x.mod_loader_version,
|
||||||
groups: serde_json::from_value(x.groups).unwrap_or_default(),
|
groups: serde_json::from_value(x.groups).unwrap_or_default(),
|
||||||
@ -337,7 +340,7 @@ macro_rules! select_profiles_with_predicate {
|
|||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
path, install_stage, name, icon_path,
|
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",
|
json(groups) as "groups!: serde_json::Value",
|
||||||
linked_project_id, linked_version_id, locked,
|
linked_project_id, linked_version_id, locked,
|
||||||
created, modified, last_played,
|
created, modified, last_played,
|
||||||
@ -435,7 +438,8 @@ impl Profile {
|
|||||||
submitted_time_played, recent_time_played,
|
submitted_time_played, recent_time_played,
|
||||||
override_java_path, override_extra_launch_args, override_custom_env_vars,
|
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_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 (
|
VALUES (
|
||||||
$1, $2, $3, $4,
|
$1, $2, $3, $4,
|
||||||
@ -446,7 +450,8 @@ impl Profile {
|
|||||||
$15, $16,
|
$15, $16,
|
||||||
$17, jsonb($18), jsonb($19),
|
$17, jsonb($18), jsonb($19),
|
||||||
$20, $21, $22, $23,
|
$20, $21, $22, $23,
|
||||||
$24, $25, $26
|
$24, $25, $26,
|
||||||
|
$27
|
||||||
)
|
)
|
||||||
ON CONFLICT (path) DO UPDATE SET
|
ON CONFLICT (path) DO UPDATE SET
|
||||||
install_stage = $2,
|
install_stage = $2,
|
||||||
@ -480,7 +485,9 @@ impl Profile {
|
|||||||
|
|
||||||
override_hook_pre_launch = $24,
|
override_hook_pre_launch = $24,
|
||||||
override_hook_wrapper = $25,
|
override_hook_wrapper = $25,
|
||||||
override_hook_post_exit = $26
|
override_hook_post_exit = $26,
|
||||||
|
|
||||||
|
protocol_version = $27
|
||||||
",
|
",
|
||||||
self.path,
|
self.path,
|
||||||
install_stage,
|
install_stage,
|
||||||
@ -508,6 +515,7 @@ impl Profile {
|
|||||||
self.hooks.pre_launch,
|
self.hooks.pre_launch,
|
||||||
self.hooks.wrapper,
|
self.hooks.wrapper,
|
||||||
self.hooks.post_exit,
|
self.hooks.post_exit,
|
||||||
|
self.protocol_version,
|
||||||
)
|
)
|
||||||
.execute(exec)
|
.execute(exec)
|
||||||
.await?;
|
.await?;
|
||||||
|
64
packages/app-lib/src/state/server_join_log.rs
Normal file
@ -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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HashMap<(String, u16), DateTime<Utc>>> {
|
||||||
|
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())
|
||||||
|
}
|
@ -255,3 +255,29 @@ pub async fn remove_file(
|
|||||||
path: path.to_string_lossy().to_string(),
|
path: path.to_string_lossy().to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remove dir
|
||||||
|
pub async fn remove_dir(
|
||||||
|
path: impl AsRef<std::path::Path>,
|
||||||
|
) -> 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<std::path::Path>,
|
||||||
|
) -> Result<std::fs::Metadata, IOError> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
tokio::fs::metadata(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| IOError::IOPathError {
|
||||||
|
source: e,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -3,6 +3,7 @@ pub mod fetch;
|
|||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod jre;
|
pub mod jre;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
pub mod server_ping;
|
||||||
|
|
||||||
/// Wrap a builder which uses a mut reference into one which outputs an owned value
|
/// Wrap a builder which uses a mut reference into one which outputs an owned value
|
||||||
macro_rules! wrap_ref_builder {
|
macro_rules! wrap_ref_builder {
|
||||||
|
223
packages/app-lib/src/util/server_ping.rs
Normal file
@ -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<Box<RawValue>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub players: Option<ServerPlayers>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<ServerVersion>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub favicon: Option<Url>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enforces_secure_chat: bool,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ping: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
|
pub struct ServerPlayers {
|
||||||
|
pub max: i32,
|
||||||
|
pub online: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sample: Vec<ServerGameProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<i32>,
|
||||||
|
) -> Result<ServerStatus> {
|
||||||
|
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<i32>,
|
||||||
|
) -> crate::Result<ServerStatus> {
|
||||||
|
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<i32>,
|
||||||
|
) -> 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::<u16>()
|
||||||
|
+ 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<ServerStatus> {
|
||||||
|
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<i64> {
|
||||||
|
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<u8>, 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<R: AsyncRead + Unpin>(
|
||||||
|
reader: &mut R,
|
||||||
|
) -> io::Result<i32> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -13,3 +13,4 @@ rand = "0.8.5"
|
|||||||
either = "1.13"
|
either = "1.13"
|
||||||
chrono = { version = "0.4.26", features = ["serde"] }
|
chrono = { version = "0.4.26", features = ["serde"] }
|
||||||
serde_cbor = "0.11"
|
serde_cbor = "0.11"
|
||||||
|
lazy_static = "1.5"
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod networking;
|
pub mod networking;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod versions;
|
||||||
|
47
packages/ariadne/src/versions.rs
Normal file
@ -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::<u32>() {
|
||||||
|
if let Ok(part_first_release) = part_first_release.parse::<u32>() {
|
||||||
|
if part_version > part_first_release {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
1
packages/assets/external/pyro.svg
vendored
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" fill="none" width="24" height="24"><path fill="currentColor" d="M9.1 23v-1.1c.3-1 1.1-1.7 2.2-1.9h2.5c1.1.2 2 1 2.2 1.9v2c0 .8 0 1.2-.2 1.5 0 .2-.3.3-.5.4-.3.2-.7.2-1.6.2h-2.3c-.8 0-1.3 0-1.6-.2-.2 0-.4-.2-.5-.4-.2-.3-.2-.7-.2-1.4v-1Z"></path><path fill="currentColor" d="M4 26h2.8v-.5c-1.5-10.4 9-7.2 9.8-13.9C17 7 14 7 15.2.6V0l-.7.2C9.5 3 6 8.2 7.3 11.5c.2.4.3.7.2.8-.2 0-.4 0-.8-.3-.7-.4-1.5-1-2.1-2l-.4-.4-.4.4c-6.2 6.7-3.7 13.5-.1 16H4Z"></path><path fill="currentColor" d="M19.3 10.2c.3 3-2 6.2-4.5 7.6-.4.2-.6.3-.6.5l.8.3c3.4 1 4.4 4.3 3.3 7v.3l.5.1H21c8-4.6 5.2-12.4-1-16.2l-.7-.3c-.2.1-.2.3-.1.7Z"></path></svg>
|
|
Before Width: | Height: | Size: 681 B |
1
packages/assets/icons/blocks.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-blocks"><rect width="7" height="7" x="14" y="3" rx="1"/><path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/></svg>
|
After Width: | Height: | Size: 367 B |
12
packages/assets/icons/no-signal.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||||
|
<path d="M2 20h.01"/>
|
||||||
|
<path d="M7,20v-4" />
|
||||||
|
<path d="M12,20v-8" />
|
||||||
|
<path d="M17,20v-12" />
|
||||||
|
<path d="M22,4v16" />
|
||||||
|
<g stroke="var(--color-red)">
|
||||||
|
<line x1="2" y1="4" x2="7.1" y2="9.1" />
|
||||||
|
<line x1="2" y1="9.1" x2="7.1" y2="4" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 459 B |
1
packages/assets/icons/pickaxe.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pickaxe"><path d="M14.531 12.469 6.619 20.38a1 1 0 1 1-3-3l7.912-7.912"/><path d="M15.686 4.314A12.5 12.5 0 0 0 5.461 2.958 1 1 0 0 0 5.58 4.71a22 22 0 0 1 6.318 3.393"/><path d="M17.7 3.7a1 1 0 0 0-1.4 0l-4.6 4.6a1 1 0 0 0 0 1.4l2.6 2.6a1 1 0 0 0 1.4 0l4.6-4.6a1 1 0 0 0 0-1.4z"/><path d="M19.686 8.314a12.501 12.501 0 0 1 1.356 10.225 1 1 0 0 1-1.751-.119 22 22 0 0 0-3.393-6.319"/></svg>
|
After Width: | Height: | Size: 592 B |
1
packages/assets/icons/signal.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-signal"><path stroke="var(--_signal-1, var(--_signal-2, var(--_signal-3, var(--_signal-4, var(--_signal-5, currentColor)))))" d="M2 20h.01"/><path stroke="var(--_signal-2, var(--_signal-3, var(--_signal-4, var(--_signal-5, currentColor))))" d="M7 20v-4"/><path stroke="var(--_signal-3, var(--_signal-4, var(--_signal-5, currentColor)))" d="M12 20v-8"/><path stroke="var(--_signal-4, var(--_signal-5, currentColor))" d="M17 20V8"/><path stroke="var(--_signal-5, currentColor)" d="M22 4v16"/></svg>
|
After Width: | Height: | Size: 699 B |
1
packages/assets/icons/skull.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-skull"><path d="m12.5 17-.5-1-.5 1h1z"/><path d="M15 22a1 1 0 0 0 1-1v-1a2 2 0 0 0 1.56-3.25 8 8 0 1 0-11.12 0A2 2 0 0 0 8 20v1a1 1 0 0 0 1 1z"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="12" r="1"/></svg>
|
After Width: | Height: | Size: 414 B |
1
packages/assets/icons/world.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-earth-icon lucide-earth"><path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/><path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"/><path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/><circle cx="12" cy="12" r="10"/></svg>
|
After Width: | Height: | Size: 479 B |
@ -27,7 +27,6 @@ import _TumblrIcon from './external/tumblr.svg?component'
|
|||||||
import _TwitterIcon from './external/twitter.svg?component'
|
import _TwitterIcon from './external/twitter.svg?component'
|
||||||
import _WindowsIcon from './external/windows.svg?component'
|
import _WindowsIcon from './external/windows.svg?component'
|
||||||
import _YouTubeIcon from './external/youtube.svg?component'
|
import _YouTubeIcon from './external/youtube.svg?component'
|
||||||
import _PyroIcon from './external/pyro.svg?component'
|
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import _AlignLeftIcon from './icons/align-left.svg?component'
|
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 _BanIcon from './icons/ban.svg?component'
|
||||||
import _BellIcon from './icons/bell.svg?component'
|
import _BellIcon from './icons/bell.svg?component'
|
||||||
import _BellRingIcon from './icons/bell-ring.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 _BookIcon from './icons/book.svg?component'
|
||||||
import _BookTextIcon from './icons/book-text.svg?component'
|
import _BookTextIcon from './icons/book-text.svg?component'
|
||||||
import _BookmarkIcon from './icons/bookmark.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 _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
|
||||||
import _MoreVerticalIcon from './icons/more-vertical.svg?component'
|
import _MoreVerticalIcon from './icons/more-vertical.svg?component'
|
||||||
import _NewspaperIcon from './icons/newspaper.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 _OmorphiaIcon from './icons/omorphia.svg?component'
|
||||||
import _OrganizationIcon from './icons/organization.svg?component'
|
import _OrganizationIcon from './icons/organization.svg?component'
|
||||||
import _PackageIcon from './icons/package.svg?component'
|
import _PackageIcon from './icons/package.svg?component'
|
||||||
import _PackageOpenIcon from './icons/package-open.svg?component'
|
import _PackageOpenIcon from './icons/package-open.svg?component'
|
||||||
import _PackageClosedIcon from './icons/package-closed.svg?component'
|
import _PackageClosedIcon from './icons/package-closed.svg?component'
|
||||||
import _PaintBrushIcon from './icons/paintbrush.svg?component'
|
import _PaintBrushIcon from './icons/paintbrush.svg?component'
|
||||||
|
import _PickaxeIcon from './icons/pickaxe.svg?component'
|
||||||
import _PlayIcon from './icons/play.svg?component'
|
import _PlayIcon from './icons/play.svg?component'
|
||||||
import _PlugIcon from './icons/plug.svg?component'
|
import _PlugIcon from './icons/plug.svg?component'
|
||||||
import _PlusIcon from './icons/plus.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 _SettingsIcon from './icons/settings.svg?component'
|
||||||
import _ShareIcon from './icons/share.svg?component'
|
import _ShareIcon from './icons/share.svg?component'
|
||||||
import _ShieldIcon from './icons/shield.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 _SlashIcon from './icons/slash.svg?component'
|
||||||
import _SortAscendingIcon from './icons/sort-asc.svg?component'
|
import _SortAscendingIcon from './icons/sort-asc.svg?component'
|
||||||
import _SortDescendingIcon from './icons/sort-desc.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 _VersionIcon from './icons/version.svg?component'
|
||||||
import _WikiIcon from './icons/wiki.svg?component'
|
import _WikiIcon from './icons/wiki.svg?component'
|
||||||
import _WindowIcon from './icons/window.svg?component'
|
import _WindowIcon from './icons/window.svg?component'
|
||||||
|
import _WorldIcon from './icons/world.svg?component'
|
||||||
import _WrenchIcon from './icons/wrench.svg?component'
|
import _WrenchIcon from './icons/wrench.svg?component'
|
||||||
import _XIcon from './icons/x.svg?component'
|
import _XIcon from './icons/x.svg?component'
|
||||||
import _XCircleIcon from './icons/x-circle.svg?component'
|
import _XCircleIcon from './icons/x-circle.svg?component'
|
||||||
@ -226,7 +231,6 @@ export const MastodonIcon = _MastodonIcon
|
|||||||
export const OpenCollectiveIcon = _OpenCollectiveIcon
|
export const OpenCollectiveIcon = _OpenCollectiveIcon
|
||||||
export const PatreonIcon = _PatreonIcon
|
export const PatreonIcon = _PatreonIcon
|
||||||
export const PayPalIcon = _PayPalIcon
|
export const PayPalIcon = _PayPalIcon
|
||||||
export const PyroIcon = _PyroIcon
|
|
||||||
export const RedditIcon = _RedditIcon
|
export const RedditIcon = _RedditIcon
|
||||||
export const TumblrIcon = _TumblrIcon
|
export const TumblrIcon = _TumblrIcon
|
||||||
export const TwitterIcon = _TwitterIcon
|
export const TwitterIcon = _TwitterIcon
|
||||||
@ -239,6 +243,7 @@ export const AsteriskIcon = _AsteriskIcon
|
|||||||
export const BanIcon = _BanIcon
|
export const BanIcon = _BanIcon
|
||||||
export const BellIcon = _BellIcon
|
export const BellIcon = _BellIcon
|
||||||
export const BellRingIcon = _BellRingIcon
|
export const BellRingIcon = _BellRingIcon
|
||||||
|
export const BlocksIcon = _BlocksIcon
|
||||||
export const BookIcon = _BookIcon
|
export const BookIcon = _BookIcon
|
||||||
export const BookTextIcon = _BookTextIcon
|
export const BookTextIcon = _BookTextIcon
|
||||||
export const BookmarkIcon = _BookmarkIcon
|
export const BookmarkIcon = _BookmarkIcon
|
||||||
@ -325,12 +330,14 @@ export const MoonIcon = _MoonIcon
|
|||||||
export const MoreHorizontalIcon = _MoreHorizontalIcon
|
export const MoreHorizontalIcon = _MoreHorizontalIcon
|
||||||
export const MoreVerticalIcon = _MoreVerticalIcon
|
export const MoreVerticalIcon = _MoreVerticalIcon
|
||||||
export const NewspaperIcon = _NewspaperIcon
|
export const NewspaperIcon = _NewspaperIcon
|
||||||
|
export const NoSignalIcon = _NoSignalIcon
|
||||||
export const OmorphiaIcon = _OmorphiaIcon
|
export const OmorphiaIcon = _OmorphiaIcon
|
||||||
export const OrganizationIcon = _OrganizationIcon
|
export const OrganizationIcon = _OrganizationIcon
|
||||||
export const PackageIcon = _PackageIcon
|
export const PackageIcon = _PackageIcon
|
||||||
export const PackageOpenIcon = _PackageOpenIcon
|
export const PackageOpenIcon = _PackageOpenIcon
|
||||||
export const PackageClosedIcon = _PackageClosedIcon
|
export const PackageClosedIcon = _PackageClosedIcon
|
||||||
export const PaintBrushIcon = _PaintBrushIcon
|
export const PaintBrushIcon = _PaintBrushIcon
|
||||||
|
export const PickaxeIcon = _PickaxeIcon
|
||||||
export const PlayIcon = _PlayIcon
|
export const PlayIcon = _PlayIcon
|
||||||
export const PlugIcon = _PlugIcon
|
export const PlugIcon = _PlugIcon
|
||||||
export const PlusIcon = _PlusIcon
|
export const PlusIcon = _PlusIcon
|
||||||
@ -352,6 +359,8 @@ export const ServerIcon = _ServerIcon
|
|||||||
export const SettingsIcon = _SettingsIcon
|
export const SettingsIcon = _SettingsIcon
|
||||||
export const ShareIcon = _ShareIcon
|
export const ShareIcon = _ShareIcon
|
||||||
export const ShieldIcon = _ShieldIcon
|
export const ShieldIcon = _ShieldIcon
|
||||||
|
export const SignalIcon = _SignalIcon
|
||||||
|
export const SkullIcon = _SkullIcon
|
||||||
export const SlashIcon = _SlashIcon
|
export const SlashIcon = _SlashIcon
|
||||||
export const SortAscendingIcon = _SortAscendingIcon
|
export const SortAscendingIcon = _SortAscendingIcon
|
||||||
export const SortDescendingIcon = _SortDescendingIcon
|
export const SortDescendingIcon = _SortDescendingIcon
|
||||||
@ -381,6 +390,7 @@ export const UsersIcon = _UsersIcon
|
|||||||
export const VersionIcon = _VersionIcon
|
export const VersionIcon = _VersionIcon
|
||||||
export const WikiIcon = _WikiIcon
|
export const WikiIcon = _WikiIcon
|
||||||
export const WindowIcon = _WindowIcon
|
export const WindowIcon = _WindowIcon
|
||||||
|
export const WorldIcon = _WorldIcon
|
||||||
export const WrenchIcon = _WrenchIcon
|
export const WrenchIcon = _WrenchIcon
|
||||||
export const XIcon = _XIcon
|
export const XIcon = _XIcon
|
||||||
export const XCircleIcon = _XCircleIcon
|
export const XCircleIcon = _XCircleIcon
|
||||||
|
@ -59,7 +59,7 @@ textarea,
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
transition: box-shadow 0.1s ease-in-out;
|
transition: box-shadow 0.1s ease-in-out;
|
||||||
min-height: 40px;
|
min-height: 36px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
var(--shadow-inset-sm),
|
var(--shadow-inset-sm),
|
||||||
0 0 0 0 transparent;
|
0 0 0 0 transparent;
|
||||||
@ -159,7 +159,7 @@ input[type='number'] {
|
|||||||
@extend .transparent, .icon-only;
|
@extend .transparent, .icon-only;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.25rem;
|
right: 0.125rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
@ -84,6 +84,8 @@
|
|||||||
--color-platform-velocity: #4b98b0;
|
--color-platform-velocity: #4b98b0;
|
||||||
--color-platform-waterfall: #5f83cb;
|
--color-platform-waterfall: #5f83cb;
|
||||||
--color-platform-sponge: #c49528;
|
--color-platform-sponge: #c49528;
|
||||||
|
|
||||||
|
--hover-brightness: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@ -196,6 +198,8 @@ html {
|
|||||||
--color-platform-velocity: #83d5ef;
|
--color-platform-velocity: #83d5ef;
|
||||||
--color-platform-waterfall: #78a4fb;
|
--color-platform-waterfall: #78a4fb;
|
||||||
--color-platform-sponge: #f9e580;
|
--color-platform-sponge: #f9e580;
|
||||||
|
|
||||||
|
--hover-brightness: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oled-mode {
|
.oled-mode {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export * from './src/components/index'
|
export * from './src/components/index'
|
||||||
export { commonMessages, commonSettingsMessages } from './src/utils/common-messages'
|
export { commonMessages, commonSettingsMessages } from './src/utils/common-messages'
|
||||||
export * from './src/utils/search'
|
export * from './src/utils/search'
|
||||||
|
export { GAME_MODES } from './src/utils/game-modes'
|
||||||
|
@ -245,7 +245,7 @@ const colorVariables = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
&: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,
|
&:hover svg:first-child,
|
||||||
&:focus-visible svg:first-child {
|
&:focus-visible svg:first-child {
|
||||||
|
52
packages/ui/src/components/base/FilterBar.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="options.length > 1" class="flex flex-wrap gap-1 items-center">
|
||||||
|
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||||
|
<button
|
||||||
|
v-for="filter in options"
|
||||||
|
:key="`filter-${filter.id}`"
|
||||||
|
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||||
|
@click="toggleFilter(filter.id)"
|
||||||
|
>
|
||||||
|
{{ formatMessage(filter.message) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FilterIcon } from '@modrinth/assets'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
export type FilterBarOption = {
|
||||||
|
id: string
|
||||||
|
message: MessageDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedFilters = defineModel<string[]>({ required: true })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
options: FilterBarOption[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
() => {
|
||||||
|
for (let i = 0; i < selectedFilters.value.length; i++) {
|
||||||
|
const option = selectedFilters.value[i]
|
||||||
|
if (!props.options.some((x) => x.id === option)) {
|
||||||
|
selectedFilters.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleFilter(option: string) {
|
||||||
|
if (selectedFilters.value.includes(option)) {
|
||||||
|
selectedFilters.value.splice(selectedFilters.value.indexOf(option), 1)
|
||||||
|
} else {
|
||||||
|
selectedFilters.value.push(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
20
packages/ui/src/components/base/HeadingLink.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<AutoLink
|
||||||
|
:to="to"
|
||||||
|
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group w-fit"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<ChevronRightIcon
|
||||||
|
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
|
||||||
|
/>
|
||||||
|
</AutoLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AutoLink from './AutoLink.vue'
|
||||||
|
import { ChevronRightIcon } from '@modrinth/assets'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
to: unknown
|
||||||
|
}>()
|
||||||
|
</script>
|
@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :style="colorClasses" class="radial-header relative pb-1" v-bind="$attrs">
|
<div>
|
||||||
<slot />
|
<div :style="colorClasses" class="radial-header relative" v-bind="$attrs">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="radial-header-divider" />
|
||||||
</div>
|
</div>
|
||||||
<div class="radial-header-divider" />
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
61
packages/ui/src/components/base/SmartClickable.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="smart-clickable" :class="{ 'smart-clickable--has-clickable': !!$slots.clickable }">
|
||||||
|
<slot name="clickable" />
|
||||||
|
<div v-bind="$attrs" class="smart-clickable__contents pointer-events-none">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.smart-clickable {
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-clickable__contents {
|
||||||
|
// Utility classes for contents
|
||||||
|
:deep(.smart-clickable\:allow-pointer-events) {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only apply effects when a clickable is present
|
||||||
|
.smart-clickable.smart-clickable--has-clickable {
|
||||||
|
// Setup base styles for contents
|
||||||
|
.smart-clickable__contents {
|
||||||
|
transition: scale 0.125s ease-out;
|
||||||
|
|
||||||
|
// Why? I don't know. It forces the SVGs to render differently, which fixes some shift on hover otherwise.
|
||||||
|
//filter: brightness(1.00001);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When clickable is being hovered or focus-visible, give contents an effect
|
||||||
|
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
|
||||||
|
filter: var(--hover-filter-weak);
|
||||||
|
|
||||||
|
// Utility classes for contents
|
||||||
|
:deep(.smart-clickable\:underline-on-hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility classes for contents
|
||||||
|
:deep(.smart-clickable\:highlight-on-hover) {
|
||||||
|
filter: brightness(var(--hover-brightness, 1.25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When clickable is being clicked, give contents an effect
|
||||||
|
&:has(> *:first-child:active) .smart-clickable__contents {
|
||||||
|
scale: 0.97;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|