From ff4c7f47b209483abcd73e0c263eef3baf8b0087 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Sat, 26 Apr 2025 18:09:58 -0700 Subject: [PATCH] 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 * 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 Co-authored-by: Jai A Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com> --- Cargo.lock | 388 +++++++- apps/app-frontend/package.json | 4 +- apps/app-frontend/src/App.vue | 22 +- .../src/assets/font/minecraft_font.ttf | Bin 0 -> 10700 bytes .../src/assets/stylesheets/global.scss | 38 +- .../src/components/RowDisplay.vue | 32 +- .../src/components/ui/Breadcrumbs.vue | 1 + .../src/components/ui/ModpackVersionModal.vue | 2 +- .../src/components/ui/NavButton.vue | 2 +- .../src/components/ui/RunningAppBar.vue | 5 +- .../ui/modal/ConfirmModalWrapper.vue | 5 + .../ui/modal/InstanceModalTitlePrefix.vue | 20 + .../ui/settings/FeatureFlagSettings.vue | 4 +- .../src/components/ui/world/InstanceItem.vue | 220 +++++ .../components/ui/world/RecentWorldsList.vue | 275 ++++++ .../src/components/ui/world/WorldItem.vue | 470 ++++++++++ .../ui/world/modal/AddServerModal.vue | 115 +++ .../ui/world/modal/EditServerModal.vue | 93 ++ .../modal/EditSingleplayerWorldModal.vue | 112 +++ .../ui/world/modal/ServerModalBody.vue | 86 ++ apps/app-frontend/src/helpers/events.js | 2 +- apps/app-frontend/src/helpers/utils.js | 11 + apps/app-frontend/src/helpers/worlds.ts | 303 +++++++ .../app-frontend/src/locales/en-US/index.json | 81 ++ apps/app-frontend/src/main.js | 9 + apps/app-frontend/src/pages/Index.vue | 86 +- apps/app-frontend/src/pages/Worlds.vue | 4 + apps/app-frontend/src/pages/index.js | 3 +- .../app-frontend/src/pages/instance/Index.vue | 312 +++---- apps/app-frontend/src/pages/instance/Logs.vue | 28 +- apps/app-frontend/src/pages/instance/Mods.vue | 454 +++++----- .../src/pages/instance/Overview.vue | 15 + .../src/pages/instance/Worlds.vue | 447 ++++++++++ apps/app-frontend/src/pages/instance/index.js | 4 +- apps/app-frontend/src/pages/project/Index.vue | 5 + apps/app-frontend/src/routes.js | 30 +- apps/app-playground/src/main.rs | 26 +- apps/app/Cargo.toml | 2 + apps/app/build.rs | 23 + apps/app/capabilities/plugins.json | 3 +- apps/app/src/api/mod.rs | 1 + apps/app/src/api/profile.rs | 10 +- apps/app/src/api/utils.rs | 31 +- apps/app/src/api/worlds.rs | 195 ++++ apps/app/src/main.rs | 1 + apps/app/tauri.conf.json | 2 +- .../src/assets/styles/components.scss | 2 +- apps/frontend/src/assets/styles/global.scss | 2 +- .../src/components/ui/servers/MOTDEditor.vue | 660 -------------- .../components/ui/servers/PoweredByPyro.vue | 14 - apps/frontend/src/public/Monocraft.ttf | Bin 202764 -> 0 bytes apps/labrinth/src/routes/internal/statuses.rs | 10 +- ...ed46853d4e8b369d5f97303241993d9d3a8e3.json | 12 + ...e69cc86189d182f04ae50ef8f894053d93cb.json} | 72 +- ...6d3ababcde700fa4cf8324d87fa2181fc47d.json} | 72 +- ...4a8f1543f6b962e54ecab491a006d28c9a18c.json | 38 + ...cdcf73da199ea6ac05ee3ee798ece80d877cf.json | 2 +- ...d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json | 12 - ...64f627ae744a20acf5affaa729e68e3eb6641.json | 12 + packages/app-lib/Cargo.toml | 9 +- .../20250318160526_protocol-versions.sql | 1 + .../20250408181656_add-join-log.sql | 10 + packages/app-lib/src/api/logs.rs | 2 +- packages/app-lib/src/api/mod.rs | 1 + packages/app-lib/src/api/profile/create.rs | 1 + packages/app-lib/src/api/profile/mod.rs | 16 +- packages/app-lib/src/api/worlds.rs | 830 ++++++++++++++++++ packages/app-lib/src/error.rs | 9 + packages/app-lib/src/event/mod.rs | 13 +- packages/app-lib/src/launcher/args.rs | 34 +- packages/app-lib/src/launcher/download.rs | 3 +- packages/app-lib/src/launcher/mod.rs | 69 +- packages/app-lib/src/state/fs_watcher.rs | 89 +- .../app-lib/src/state/legacy_converter.rs | 1 + packages/app-lib/src/state/mod.rs | 2 + packages/app-lib/src/state/process.rs | 475 +++++++++- packages/app-lib/src/state/profiles.rs | 16 +- packages/app-lib/src/state/server_join_log.rs | 64 ++ packages/app-lib/src/util/io.rs | 26 + packages/app-lib/src/util/mod.rs | 1 + packages/app-lib/src/util/server_ping.rs | 223 +++++ packages/ariadne/Cargo.toml | 1 + packages/ariadne/src/lib.rs | 1 + packages/ariadne/src/versions.rs | 47 + packages/assets/external/pyro.svg | 1 - packages/assets/icons/blocks.svg | 1 + packages/assets/icons/no-signal.svg | 12 + packages/assets/icons/pickaxe.svg | 1 + packages/assets/icons/signal.svg | 1 + packages/assets/icons/skull.svg | 1 + packages/assets/icons/world.svg | 1 + packages/assets/index.ts | 14 +- packages/assets/styles/defaults.scss | 4 +- packages/assets/styles/variables.scss | 4 + packages/ui/index.ts | 1 + .../ui/src/components/base/ButtonStyled.vue | 2 +- packages/ui/src/components/base/FilterBar.vue | 52 ++ .../ui/src/components/base/HeadingLink.vue | 20 + .../ui/src/components/base/RadialHeader.vue | 8 +- .../ui/src/components/base/SmartClickable.vue | 61 ++ packages/ui/src/components/index.ts | 4 + .../ui/src/components/modal/ConfirmModal.vue | 21 +- packages/ui/src/locales/en-US/index.json | 45 + packages/ui/src/utils/common-messages.ts | 36 + packages/ui/src/utils/game-modes.ts | 40 + pnpm-lock.yaml | 34 +- 106 files changed, 5852 insertions(+), 1346 deletions(-) create mode 100644 apps/app-frontend/src/assets/font/minecraft_font.ttf create mode 100644 apps/app-frontend/src/components/ui/modal/InstanceModalTitlePrefix.vue create mode 100644 apps/app-frontend/src/components/ui/world/InstanceItem.vue create mode 100644 apps/app-frontend/src/components/ui/world/RecentWorldsList.vue create mode 100644 apps/app-frontend/src/components/ui/world/WorldItem.vue create mode 100644 apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue create mode 100644 apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue create mode 100644 apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue create mode 100644 apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue create mode 100644 apps/app-frontend/src/helpers/worlds.ts create mode 100644 apps/app-frontend/src/pages/Worlds.vue create mode 100644 apps/app-frontend/src/pages/instance/Overview.vue create mode 100644 apps/app-frontend/src/pages/instance/Worlds.vue create mode 100644 apps/app/src/api/worlds.rs delete mode 100644 apps/frontend/src/components/ui/servers/MOTDEditor.vue delete mode 100644 apps/frontend/src/components/ui/servers/PoweredByPyro.vue delete mode 100644 apps/frontend/src/public/Monocraft.ttf create mode 100644 packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json rename packages/app-lib/.sqlx/{query-4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1.json => query-1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb.json} (75%) rename packages/app-lib/.sqlx/{query-5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094.json => query-30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d.json} (76%) create mode 100644 packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json delete mode 100644 packages/app-lib/.sqlx/query-db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json create mode 100644 packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json create mode 100644 packages/app-lib/migrations/20250318160526_protocol-versions.sql create mode 100644 packages/app-lib/migrations/20250408181656_add-join-log.sql create mode 100644 packages/app-lib/src/api/worlds.rs create mode 100644 packages/app-lib/src/state/server_join_log.rs create mode 100644 packages/app-lib/src/util/server_ping.rs create mode 100644 packages/ariadne/src/versions.rs delete mode 100644 packages/assets/external/pyro.svg create mode 100644 packages/assets/icons/blocks.svg create mode 100644 packages/assets/icons/no-signal.svg create mode 100644 packages/assets/icons/pickaxe.svg create mode 100644 packages/assets/icons/signal.svg create mode 100644 packages/assets/icons/skull.svg create mode 100644 packages/assets/icons/world.svg create mode 100644 packages/ui/src/components/base/FilterBar.vue create mode 100644 packages/ui/src/components/base/HeadingLink.vue create mode 100644 packages/ui/src/components/base/SmartClickable.vue create mode 100644 packages/ui/src/utils/game-modes.ts diff --git a/Cargo.lock b/Cargo.lock index 2ee6be34d..b90fdb911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ "getrandom 0.2.15", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -481,6 +481,7 @@ version = "0.1.0" dependencies = [ "chrono", "either", + "lazy_static", "rand 0.8.5", "serde", "serde_bytes", @@ -615,7 +616,7 @@ dependencies = [ "futures-lite 2.3.0", "parking", "polling", - "rustix", + "rustix 0.38.37", "slab", "tracing", "windows-sys 0.59.0", @@ -647,7 +648,7 @@ dependencies = [ "cfg-if", "event-listener 5.3.1", "futures-lite 2.3.0", - "rustix", + "rustix 0.38.37", "tracing", ] @@ -674,7 +675,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 0.38.37", "signal-hook-registry", "slab", "windows-sys 0.59.0", @@ -761,6 +762,17 @@ dependencies = [ "webpki-roots 0.26.6", ] +[[package]] +name = "async-walkdir" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37672978ae0febce7516ae0a85b53e6185159a9a28787391eb63fc44ec36037d" +dependencies = [ + "async-fs", + "futures-lite 2.3.0", + "thiserror 2.0.7", +] + [[package]] name = "async_zip" version = "0.0.17" @@ -1809,6 +1821,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.13" @@ -2463,9 +2481,9 @@ dependencies = [ [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] @@ -2553,6 +2571,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "enumflags2" version = "0.7.10" @@ -2611,12 +2641,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2829,6 +2859,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.0.5", + "tokio", + "windows-sys 0.59.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3090,6 +3131,19 @@ dependencies = [ "x11", ] +[[package]] +name = "generator" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bd114ceda131d3b1d665eba35788690ad37f5916457286b32ab6fd3c438dd" +dependencies = [ + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.58.0", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -3107,7 +3161,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" dependencies = [ - "rustix", + "rustix 0.38.37", "windows-targets 0.52.6", ] @@ -3135,6 +3189,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + [[package]] name = "gif" version = "0.13.1" @@ -3444,6 +3510,54 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d844af74f7b799e41c78221be863bade11c430d46042c3b49ca8ae0c6d27287" +dependencies = [ + "async-recursion", + "async-trait", + "cfg-if", + "critical-section", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 1.0.3", + "ipnet", + "once_cell", + "rand 0.9.0", + "ring 0.17.8", + "thiserror 2.0.7", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a128410b38d6f931fcc6ca5c107a3b02cabd6c05967841269a4ad65d23c44331" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.0", + "resolv-conf", + "smallvec", + "thiserror 2.0.7", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3917,24 +4031,23 @@ dependencies = [ [[package]] name = "idna" -version = "0.5.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "idna" -version = "1.0.2" +name = "idna_adapter" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd69211b9b519e98303c015e21a007e293db403b6c85b9b124e133d25e242cdd" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", - "smallvec", - "utf8_iter", ] [[package]] @@ -4045,7 +4158,7 @@ dependencies = [ "log", "num-format", "once_cell", - "quick-xml 0.37.2", + "quick-xml 0.37.4", "rgb", "str_stack", ] @@ -4088,6 +4201,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.10.0" @@ -4487,7 +4612,7 @@ dependencies = [ "futures-util", "hostname", "httpdate", - "idna 1.0.2", + "idna 1.0.3", "mime", "native-tls", "nom", @@ -4524,9 +4649,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.159" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libdbus-sys" @@ -4598,6 +4723,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "litemap" version = "0.7.3" @@ -4637,6 +4768,19 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -4914,6 +5058,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "moka" +version = "0.12.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "loom", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "thiserror 1.0.64", + "uuid 1.12.0", +] + [[package]] name = "muda" version = "0.15.1" @@ -5515,6 +5678,7 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" dependencies = [ + "critical-section", "portable-atomic", ] @@ -6055,7 +6219,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.4.0", "pin-project-lite", - "rustix", + "rustix 0.38.37", "tracing", "windows-sys 0.59.0", ] @@ -6093,7 +6257,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -6202,7 +6366,7 @@ dependencies = [ "hex", "lazy_static", "procfs-core", - "rustix", + "rustix 0.38.37", ] [[package]] @@ -6308,6 +6472,31 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quartz_nbt" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf389329ba2dad9c6d898b7955a64e58c89dd52d04f4e2753b9d86eb5f49821" +dependencies = [ + "anyhow", + "byteorder", + "cesu8", + "flate2", + "quartz_nbt_macros", + "serde", +] + +[[package]] +name = "quartz_nbt_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "289baa0c8a4d1f840d2de528a7f8c29e0e9af48b3018172b3edad4f716e8daed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -6353,11 +6542,12 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.2" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" dependencies = [ "memchr", + "tokio", ] [[package]] @@ -6423,6 +6613,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "r2d2" version = "0.8.10" @@ -6465,6 +6661,17 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "zerocopy 0.8.24", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -6485,6 +6692,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -6503,6 +6720,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -6769,6 +6995,15 @@ dependencies = [ "windows-registry 0.2.0", ] +[[package]] +name = "resolv-conf" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48375394603e3dd4b2d64371f7148fd8c7baa2680e28741f2cb8d23b59e3d4c4" +dependencies = [ + "hostname", +] + [[package]] name = "result" version = "1.0.0" @@ -7018,10 +7253,23 @@ dependencies = [ "bitflags 2.6.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.59.0", +] + [[package]] name = "rustls" version = "0.20.9" @@ -8486,6 +8734,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tao" version = "0.30.8" @@ -8954,7 +9208,7 @@ dependencies = [ "cfg-if", "fastrand 2.1.1", "once_cell", - "rustix", + "rustix 0.38.37", "windows-sys 0.59.0", ] @@ -8987,6 +9241,7 @@ dependencies = [ "ariadne", "async-recursion", "async-tungstenite", + "async-walkdir", "async_zip", "base64 0.22.1", "byteorder", @@ -8999,13 +9254,17 @@ dependencies = [ "dunce", "either", "flate2", + "fs4", "futures", + "hickory-resolver", "indicatif", "lazy_static", "notify", "notify-debouncer-mini", "p256", "paste", + "quartz_nbt", + "quick-xml 0.37.4", "rand 0.8.5", "regex", "reqwest 0.12.7", @@ -9021,6 +9280,7 @@ dependencies = [ "tempfile", "thiserror 1.0.64", "tokio", + "tokio-util", "tracing", "tracing-error", "tracing-subscriber", @@ -9039,6 +9299,7 @@ dependencies = [ "cocoa 0.25.0", "daedalus", "dashmap 6.1.0", + "either", "native-dialog", "objc", "opener", @@ -9063,6 +9324,7 @@ dependencies = [ "tracing", "tracing-error", "url", + "urlencoding", "uuid 1.12.0", "window-shadows", ] @@ -9314,9 +9576,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" dependencies = [ "bytes", "futures-core", @@ -9781,12 +10043,12 @@ dependencies = [ [[package]] name = "url" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna 0.5.0", + "idna 1.0.3", "percent-encoding", "serde", ] @@ -10000,6 +10262,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" @@ -10094,7 +10365,7 @@ checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 0.38.37", "scoped-tls", "smallvec", "wayland-sys", @@ -10107,7 +10378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" dependencies = [ "bitflags 2.6.0", - "rustix", + "rustix 0.38.37", "wayland-backend", "wayland-scanner", ] @@ -10322,7 +10593,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.37", ] [[package]] @@ -10336,6 +10607,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + [[package]] name = "winapi" version = "0.3.9" @@ -10768,6 +11045,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "woothee" version = "0.13.0" @@ -10869,8 +11155,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" dependencies = [ "libc", - "linux-raw-sys", - "rustix", + "linux-raw-sys 0.4.14", + "rustix 0.38.37", ] [[package]] @@ -11085,7 +11371,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive 0.8.24", ] [[package]] @@ -11099,6 +11394,17 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "zerofrom" version = "0.1.4" diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index fe9390f97..7b0a32838 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -16,6 +16,7 @@ "@modrinth/ui": "workspace:*", "@modrinth/utils": "workspace:*", "@sentry/vue": "^8.27.0", + "@geometrically/minecraft-motd-parser": "^1.1.4", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-os": "^2.2.0", @@ -50,7 +51,8 @@ "tsconfig": "workspace:*", "typescript": "^5.5.4", "vite": "^5.4.6", - "vue-tsc": "^2.1.6" + "vue-tsc": "^2.1.6", + "@taijased/vue-render-tracker": "^1.0.7" }, "packageManager": "pnpm@9.4.0", "web-types": "../../web-types.json" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index a744566aa..85d252745 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -16,6 +16,7 @@ import { RestoreIcon, RightArrowIcon, SettingsIcon, + WorldIcon, XIcon, } from '@modrinth/assets' import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui' @@ -166,11 +167,17 @@ async function setupApp() { `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`, 'criticalAnnouncements', true, - ).then((res) => { - if (res && res.header && res.body) { - criticalErrorMessage.value = res - } - }) + ) + .then((res) => { + if (res && res.header && res.body) { + criticalErrorMessage.value = res + } + }) + .catch(() => { + console.log( + `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`, + ) + }) useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => { if (res && res.articles) { @@ -359,7 +366,7 @@ function handleAuxClick(e) { diff --git a/apps/app-frontend/src/components/ui/modal/InstanceModalTitlePrefix.vue b/apps/app-frontend/src/components/ui/modal/InstanceModalTitlePrefix.vue new file mode 100644 index 000000000..c28213cae --- /dev/null +++ b/apps/app-frontend/src/components/ui/modal/InstanceModalTitlePrefix.vue @@ -0,0 +1,20 @@ + + diff --git a/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue b/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue index 24d27feb6..ebd04c015 100644 --- a/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue @@ -7,7 +7,7 @@ import { get, set } from '@/helpers/settings' const themeStore = useTheming() const settings = ref(await get()) -const options = ref(['project_background', 'page_path']) +const options = ref(['project_background', 'page_path', 'worlds_tab']) function getStoreValue(key: string) { return themeStore.featureFlags[key] ?? false @@ -30,7 +30,7 @@ watch(

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

diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue new file mode 100644 index 000000000..90bbef67c --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue @@ -0,0 +1,220 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue new file mode 100644 index 000000000..9ad347fca --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue @@ -0,0 +1,275 @@ + + + diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue new file mode 100644 index 000000000..cb39b7e2d --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue @@ -0,0 +1,470 @@ + + + diff --git a/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue new file mode 100644 index 000000000..00fab96ec --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue @@ -0,0 +1,115 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue new file mode 100644 index 000000000..b93ca749c --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue @@ -0,0 +1,93 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue new file mode 100644 index 000000000..2fc39c317 --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue @@ -0,0 +1,112 @@ + + diff --git a/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue new file mode 100644 index 000000000..64c82b27d --- /dev/null +++ b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue @@ -0,0 +1,86 @@ + + diff --git a/apps/app-frontend/src/helpers/events.js b/apps/app-frontend/src/helpers/events.js index 0ed288365..81a849b9e 100644 --- a/apps/app-frontend/src/helpers/events.js +++ b/apps/app-frontend/src/helpers/events.js @@ -62,7 +62,7 @@ export async function process_listener(callback) { ProfilePayload { uuid: unique identification of the process in the state (currently identified by path, but that will change) name: name of the profile - profile_path: relative path to profile (used for path identification) + profile_path: relative path toprofile_listener profile (used for path identification) path: path to profile (used for opening the profile in the OS file explorer) event: event type ("Created", "Added", "Edited", "Removed") } diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js index 569420101..89ebd52ba 100644 --- a/apps/app-frontend/src/helpers/utils.js +++ b/apps/app-frontend/src/helpers/utils.js @@ -37,6 +37,13 @@ export async function restartApp() { return await invoke('restart_app') } +/** + * @deprecated This method is no longer needed, and just returns its parameter + */ +export function sanitizePotentialFileUrl(url) { + return url +} + export const releaseColor = (releaseType) => { switch (releaseType) { case 'release': @@ -49,3 +56,7 @@ export const releaseColor = (releaseType) => { return '' } } + +export async function copyToClipboard(text) { + await navigator.clipboard.writeText(text) +} diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts new file mode 100644 index 000000000..13615cdf2 --- /dev/null +++ b/apps/app-frontend/src/helpers/worlds.ts @@ -0,0 +1,303 @@ +import { invoke } from '@tauri-apps/api/core' +import { get_full_path } from '@/helpers/profile' +import { openPath } from '@/helpers/utils' +import { autoToHTML } from '@geometrically/minecraft-motd-parser' +import dayjs from 'dayjs' +import type { GameVersion } from '@modrinth/ui' + +type BaseWorld = { + name: string + last_played?: string + icon?: string +} + +export type SingleplayerWorld = BaseWorld & { + type: 'singleplayer' + path: string + game_mode: SingleplayerGameMode + hardcore: boolean + locked: boolean +} + +export type ServerWorld = BaseWorld & { + type: 'server' + index: number + address: string + pack_status: ServerPackStatus +} + +export type World = SingleplayerWorld | ServerWorld + +export type WorldWithProfile = { + profile: string +} & World + +export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator' +export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt' + +export type ServerStatus = { + // https://minecraft.wiki/w/Text_component_format + description?: string | Chat + players?: { + max: number + online: number + sample: { name: string; id: string }[] + } + version?: { + name: string + protocol: number + } + favicon?: string + enforces_secure_chat: boolean + ping?: number +} + +export interface Chat { + text: string + bold: boolean + italic: boolean + underlined: boolean + strikethrough: boolean + obfuscated: boolean + color?: string + extra: Chat[] +} + +export type ServerData = { + refreshing: boolean + status?: ServerStatus + rawMotd?: string | Chat + renderedMotd?: string +} + +export async function get_recent_worlds(limit: number): Promise { + return await invoke('plugin:worlds|get_recent_worlds', { limit }) +} + +export async function get_profile_worlds(path: string): Promise { + return await invoke('plugin:worlds|get_profile_worlds', { path }) +} + +export async function get_singleplayer_world( + instance: string, + world: string, +): Promise { + return await invoke('plugin:worlds|get_singleplayer_world', { instance, world }) +} + +export async function rename_world( + instance: string, + world: string, + newName: string, +): Promise { + return await invoke('plugin:worlds|rename_world', { instance, world, newName }) +} + +export async function reset_world_icon(instance: string, world: string): Promise { + return await invoke('plugin:worlds|reset_world_icon', { instance, world }) +} + +export async function backup_world(instance: string, world: string): Promise { + return await invoke('plugin:worlds|backup_world', { instance, world }) +} + +export async function delete_world(instance: string, world: string): Promise { + return await invoke('plugin:worlds|delete_world', { instance, world }) +} + +export async function add_server_to_profile( + path: string, + name: string, + address: string, + packStatus: ServerPackStatus, +): Promise { + return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus }) +} + +export async function edit_server_in_profile( + path: string, + index: number, + name: string, + address: string, + packStatus: ServerPackStatus, +): Promise { + return await invoke('plugin:worlds|edit_server_in_profile', { + path, + index, + name, + address, + packStatus, + }) +} + +export async function remove_server_from_profile(path: string, index: number): Promise { + return await invoke('plugin:worlds|remove_server_from_profile', { path, index }) +} + +export async function get_profile_protocol_version(path: string): Promise { + return await invoke('plugin:worlds|get_profile_protocol_version', { path }) +} + +export async function get_server_status( + address: string, + protocolVersion: number | null = null, +): Promise { + return await invoke('plugin:worlds|get_server_status', { address, protocolVersion }) +} + +export async function start_join_singleplayer_world(path: string, world: string): Promise { + return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world }) +} + +export async function start_join_server(path: string, address: string): Promise { + return await invoke('plugin:worlds|start_join_server', { path, address }) +} + +export async function showWorldInFolder(instancePath: string, worldPath: string) { + const fullPath = await get_full_path(instancePath) + return await openPath(fullPath + '/saves/' + worldPath) +} + +export function getWorldIdentifier(world: World) { + return world.type === 'singleplayer' ? world.path : world.address +} + +export function sortWorlds(worlds: World[]) { + worlds.sort((a, b) => { + if (!a.last_played) { + return 1 + } + if (!b.last_played) { + return -1 + } + return dayjs(b.last_played).diff(dayjs(a.last_played)) + }) +} + +export function isSingleplayerWorld(world: World): world is SingleplayerWorld { + return world.type === 'singleplayer' +} + +export function isServerWorld(world: World): world is ServerWorld { + return world.type === 'server' +} + +export async function refreshServerData( + serverData: ServerData, + protocolVersion: number | null, + address: string, +): Promise { + serverData.refreshing = true + await get_server_status(address, protocolVersion) + .then((status) => { + serverData.status = status + if (status.description) { + serverData.rawMotd = status.description + serverData.renderedMotd = autoToHTML(status.description) + } + }) + .catch((err) => { + console.error(`Refreshing addr: ${address}`, err) + }) + .finally(() => { + serverData.refreshing = false + }) +} + +export async function refreshServers( + worlds: World[], + serverData: Record, + protocolVersion: number | null, +) { + const servers = worlds.filter(isServerWorld) + servers.forEach((server) => { + if (!serverData[server.address]) { + serverData[server.address] = { + refreshing: true, + } + } else { + serverData[server.address].refreshing = true + } + }) + + // noinspection ES6MissingAwait - handled with .then by refreshServerData already + Promise.all( + Object.keys(serverData).map((address) => + refreshServerData(serverData[address], protocolVersion, address), + ), + ) +} + +export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) { + const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath) + if (index !== -1) { + worlds[index] = await get_singleplayer_world(instancePath, worldPath) + sortWorlds(worlds) + } else { + console.error(`Error refreshing world, could not find world at path ${worldPath}.`) + } +} + +export async function handleDefaultProfileUpdateEvent( + worlds: World[], + instancePath: string, + e: ProfileEvent, +) { + if (e.event === 'world_updated') { + await refreshWorld(worlds, instancePath, e.world) + } + + if (e.event === 'server_joined') { + const world = worlds.find( + (w) => + w.type === 'server' && + (w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)), + ) + if (world) { + world.last_played = e.timestamp + sortWorlds(worlds) + } else { + console.error(`Could not find world for server join event: ${e.host}:${e.port}`) + } + } +} + +export async function refreshWorlds(instancePath: string): Promise { + const worlds = await get_profile_worlds(instancePath).catch((err) => { + console.error(`Error refreshing worlds for instance: ${instancePath}`, err) + }) + if (worlds) { + sortWorlds(worlds) + } + + return worlds ?? [] +} + +const FIRST_QUICK_PLAY_VERSION = '23w14a' + +export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) { + if (!gameVersions.length) { + return false + } + + const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion) + const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION) + + return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex +} + +export type ProfileEvent = { profile_path_id: string } & ( + | { + event: 'servers_updated' + } + | { + event: 'world_updated' + world: string + } + | { + event: 'server_joined' + host: string + port: number + timestamp: string + } +) diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 515e4e71a..4290c58b8 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -20,12 +20,57 @@ "app.settings.tabs.resource-management": { "message": "Resource management" }, + "instance.add-server.add-and-play": { + "message": "Add and play" + }, + "instance.add-server.add-server": { + "message": "Add server" + }, + "instance.add-server.resource-pack.disabled": { + "message": "Disabled" + }, + "instance.add-server.resource-pack.enabled": { + "message": "Enabled" + }, + "instance.add-server.resource-pack.prompt": { + "message": "Prompt" + }, + "instance.add-server.title": { + "message": "Add a server" + }, + "instance.edit-server.title": { + "message": "Edit server" + }, + "instance.edit-world.name": { + "message": "Name" + }, + "instance.edit-world.placeholder-name": { + "message": "Minecraft World" + }, + "instance.edit-world.reset-icon": { + "message": "Reset icon" + }, + "instance.edit-world.title": { + "message": "Edit world" + }, "instance.filter.disabled": { "message": "Disabled projects" }, "instance.filter.updates-available": { "message": "Updates available" }, + "instance.server-modal.address": { + "message": "Address" + }, + "instance.server-modal.name": { + "message": "Name" + }, + "instance.server-modal.placeholder-name": { + "message": "Minecraft Server" + }, + "instance.server-modal.resource-pack": { + "message": "Resource pack" + }, "instance.settings.tabs.general": { "message": "General" }, @@ -308,6 +353,42 @@ "instance.settings.title": { "message": "Settings" }, + "instance.worlds.a_minecraft_server": { + "message": "A Minecraft Server" + }, + "instance.worlds.cant_connect": { + "message": "Can't connect to server" + }, + "instance.worlds.copy_address": { + "message": "Copy address" + }, + "instance.worlds.filter.available": { + "message": "Available" + }, + "instance.worlds.game_already_open": { + "message": "Instance is already open" + }, + "instance.worlds.hardcore": { + "message": "Hardcore mode" + }, + "instance.worlds.no_quick_play": { + "message": "You can only jump straight into worlds on Minecraft 1.20+" + }, + "instance.worlds.play_anyway": { + "message": "Play anyway" + }, + "instance.worlds.type.server": { + "message": "Server" + }, + "instance.worlds.type.singleplayer": { + "message": "Singleplayer" + }, + "instance.worlds.view_instance": { + "message": "View instance" + }, + "instance.worlds.world_in_use": { + "message": "World is in use" + }, "search.filter.locked.instance": { "message": "Provided by the instance" }, diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js index ba6d3f49b..a37a7018f 100644 --- a/apps/app-frontend/src/main.js +++ b/apps/app-frontend/src/main.js @@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue' import 'floating-vue/dist/style.css' import { createPlugin } from '@vintl/vintl/plugin' import * as Sentry from '@sentry/vue' +import { VueScanPlugin } from '@taijased/vue-render-tracker' const VIntlPlugin = createPlugin({ controllerOpts: { @@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({ injectInto: [], }) +const vueScan = new VueScanPlugin({ + enabled: false, // Enable or disable the tracker + showOverlay: true, // Show overlay to visualize renders + log: false, // Log render events to the console + playSound: false, // Play sound on each render +}) + const pinia = createPinia() let app = createApp(App) @@ -35,6 +43,7 @@ Sentry.init({ tracesSampleRate: 0.1, }) +app.use(vueScan) app.use(router) app.use(pinia) app.use(FloatingVue, { diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index 9d9064eb5..e5c1e0689 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -1,4 +1,4 @@ - + diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js index 6c4866b46..82b0b3ec2 100644 --- a/apps/app-frontend/src/pages/index.js +++ b/apps/app-frontend/src/pages/index.js @@ -1,4 +1,5 @@ import Index from './Index.vue' import Browse from './Browse.vue' +import Worlds from './Worlds.vue' -export { Index, Browse } +export { Index, Browse, Worlds } diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index d2f3d52e0..65bfbf68f 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -1,152 +1,156 @@ diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue new file mode 100644 index 000000000..22eed8dbb --- /dev/null +++ b/apps/app-frontend/src/pages/instance/Worlds.vue @@ -0,0 +1,447 @@ + + diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js index e433570eb..fa77df524 100644 --- a/apps/app-frontend/src/pages/instance/index.js +++ b/apps/app-frontend/src/pages/instance/index.js @@ -1,5 +1,7 @@ import Index from './Index.vue' +import Overview from './Overview.vue' +import Worlds from './Worlds.vue' import Mods from './Mods.vue' import Logs from './Logs.vue' -export { Index, Mods, Logs } +export { Index, Overview, Worlds, Mods, Logs } diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 1f5082b13..74ed3fb6d 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -192,6 +192,11 @@ const [allLoaders, allGameVersions] = await Promise.all([ async function fetchProjectData() { const project = await get_project(route.params.id, 'must_revalidate').catch(handleError) + if (!project) { + handleError('Error loading project') + return + } + data.value = project ;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] = await Promise.all([ diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 49eae8461..6d5e4e372 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -18,6 +18,14 @@ export default new createRouter({ breadcrumb: [{ name: 'Home' }], }, }, + { + path: '/worlds', + name: 'Worlds', + component: Pages.Worlds, + meta: { + breadcrumb: [{ name: 'Worlds' }], + }, + }, { path: '/browse/:projectType', name: 'Discover content', @@ -106,13 +114,31 @@ export default new createRouter({ component: Instance.Index, props: true, children: [ + // { + // path: '', + // name: 'Overview', + // component: Instance.Overview, + // meta: { + // useRootContext: true, + // breadcrumb: [{ name: '?Instance' }], + // }, + // }, + { + path: 'worlds', + name: 'InstanceWorlds', + component: Instance.Worlds, + meta: { + useRootContext: true, + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }], + }, + }, { path: '', name: 'Mods', component: Instance.Mods, meta: { useRootContext: true, - breadcrumb: [{ name: '?Instance' }], + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }], }, }, { @@ -121,7 +147,7 @@ export default new createRouter({ component: Instance.Mods, meta: { useRootContext: true, - breadcrumb: [{ name: '?Instance' }], + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }], }, }, { diff --git a/apps/app-playground/src/main.rs b/apps/app-playground/src/main.rs index f8d943938..ac68a9627 100644 --- a/apps/app-playground/src/main.rs +++ b/apps/app-playground/src/main.rs @@ -3,9 +3,8 @@ windows_subsystem = "windows" )] -use std::time::Duration; use theseus::prelude::*; -use tokio::signal::ctrl_c; +use theseus::worlds::get_recent_worlds; // A simple Rust implementation of the authentication run // 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend) @@ -41,21 +40,16 @@ async fn main() -> theseus::Result<()> { // Initialize state State::init().await?; - loop { - if State::get().await?.friends_socket.is_connected().await { - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; + let worlds = get_recent_worlds(4).await?; + for world in worlds { + println!( + "World: {:?}/{:?} played at {:?}: {:#?}", + world.profile, + world.world.name, + world.world.last_played, + world.world.details + ); } - tracing::info!("Starting host"); - - let socket = State::get().await?.friends_socket.open_port(25565).await?; - tracing::info!("Running host on socket {}", socket.socket_id()); - - ctrl_c().await?; - tracing::info!("Stopping host"); - socket.shutdown().await?; - Ok(()) } diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index 06f38432d..4862a2bdd 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -30,8 +30,10 @@ tokio = { version = "1", features = ["full"] } thiserror = "1.0" daedalus = { path = "../../packages/daedalus" } chrono = "0.4.26" +either = "1.15" url = "2.2" +urlencoding = "2.1" uuid = { version = "1.1", features = ["serde", "v4"] } os_info = "3.7.0" diff --git a/apps/app/build.rs b/apps/app/build.rs index ae314cd9b..f04a80569 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -240,6 +240,29 @@ fn main() { .default_permission( DefaultPermissionRule::AllowAllCommands, ), + ) + .plugin( + "worlds", + InlinedPlugin::new() + .commands(&[ + "get_recent_worlds", + "get_profile_worlds", + "get_singleplayer_world", + "rename_world", + "reset_world_icon", + "backup_world", + "delete_world", + "add_server_to_profile", + "edit_server_in_profile", + "remove_server_from_profile", + "get_profile_protocol_version", + "get_server_status", + "start_join_singleplayer_world", + "start_join_server", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), ), ) .expect("Failed to run tauri-build"); diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index 1a93620cd..b9777b6d9 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -35,6 +35,7 @@ "tags:default", "utils:default", "ads:default", - "friends:default" + "friends:default", + "worlds:default" ] } diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 82c33888f..09d37e87a 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -19,6 +19,7 @@ pub mod utils; pub mod ads; pub mod cache; pub mod friends; +pub mod worlds; pub type Result = std::result::Result; diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs index 18e3831d3..db979be35 100644 --- a/apps/app/src/api/profile.rs +++ b/apps/app/src/api/profile.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use theseus::prelude::*; +use theseus::profile::QuickPlayType; pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("profile") @@ -250,7 +251,7 @@ pub async fn profile_get_pack_export_candidates( // invoke('plugin:profile|profile_run', path) #[tauri::command] pub async fn profile_run(path: &str) -> Result { - let process = profile::run(path).await?; + let process = profile::run(path, &QuickPlayType::None).await?; Ok(process) } @@ -264,7 +265,9 @@ pub async fn profile_run_credentials( path: &str, credentials: Credentials, ) -> Result { - let process = profile::run_credentials(path, &credentials).await?; + let process = + profile::run_credentials(path, &credentials, &QuickPlayType::None) + .await?; Ok(process) } @@ -347,6 +350,9 @@ pub async fn profile_edit(path: &str, edit_profile: EditProfile) -> Result<()> { prof.name = name; } if let Some(game_version) = edit_profile.game_version.clone() { + if game_version != prof.game_version { + prof.protocol_version = None; + } prof.game_version = game_version; } if let Some(loader) = edit_profile.loader { diff --git a/apps/app/src/api/utils.rs b/apps/app/src/api/utils.rs index 391ea50f4..2c906d124 100644 --- a/apps/app/src/api/utils.rs +++ b/apps/app/src/api/utils.rs @@ -4,9 +4,11 @@ use theseus::{ prelude::{CommandPayload, DirectoryInfo}, }; -use crate::api::Result; +use crate::api::{Result, TheseusSerializableError}; use dashmap::DashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use theseus::prelude::canonicalize; +use url::Url; pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("utils") @@ -140,3 +142,28 @@ pub async fn handle_command(command: String) -> Result<()> { tracing::info!("handle command: {command}"); Ok(theseus::handler::parse_and_emit_command(&command).await?) } + +// Remove when (and if) https://github.com/tauri-apps/tauri/issues/12022 is implemented +pub(crate) fn tauri_convert_file_src(path: &Path) -> Result { + #[cfg(any(windows, target_os = "android"))] + const BASE: &str = "http://asset.localhost/"; + #[cfg(not(any(windows, target_os = "android")))] + const BASE: &str = "asset://localhost/"; + + macro_rules! theseus_try { + ($test:expr) => { + match $test { + Ok(val) => val, + Err(e) => { + return Err(TheseusSerializableError::Theseus(e.into())) + } + } + }; + } + + let path = theseus_try!(canonicalize(path)); + let path = path.to_string_lossy(); + let encoded = urlencoding::encode(&path); + + Ok(theseus_try!(Url::parse(&format!("{BASE}{encoded}")))) +} diff --git a/apps/app/src/api/worlds.rs b/apps/app/src/api/worlds.rs new file mode 100644 index 000000000..82c003220 --- /dev/null +++ b/apps/app/src/api/worlds.rs @@ -0,0 +1,195 @@ +use crate::api::Result; +use either::Either; +use tauri::{AppHandle, Manager, Runtime}; +use theseus::prelude::ProcessMetadata; +use theseus::profile::{get_full_path, QuickPlayType}; +use theseus::worlds::{ + ServerPackStatus, ServerStatus, World, WorldWithProfile, +}; +use theseus::{profile, worlds}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("worlds") + .invoke_handler(tauri::generate_handler![ + get_recent_worlds, + get_profile_worlds, + get_singleplayer_world, + rename_world, + reset_world_icon, + backup_world, + delete_world, + add_server_to_profile, + edit_server_in_profile, + remove_server_from_profile, + get_profile_protocol_version, + get_server_status, + start_join_singleplayer_world, + start_join_server, + ]) + .build() +} + +#[tauri::command] +pub async fn get_recent_worlds( + app_handle: AppHandle, + limit: usize, +) -> Result> { + let mut result = worlds::get_recent_worlds(limit).await?; + for world in result.iter_mut() { + adapt_world_icon(&app_handle, &mut world.world); + } + Ok(result) +} + +#[tauri::command] +pub async fn get_profile_worlds( + app_handle: AppHandle, + path: &str, +) -> Result> { + let mut result = worlds::get_profile_worlds(path).await?; + for world in result.iter_mut() { + adapt_world_icon(&app_handle, world); + } + Ok(result) +} + +#[tauri::command] +pub async fn get_singleplayer_world( + app_handle: AppHandle, + instance: &str, + world: &str, +) -> Result { + let instance = get_full_path(instance).await?; + let mut world = worlds::get_singleplayer_world(&instance, world).await?; + adapt_world_icon(&app_handle, &mut world); + Ok(world) +} + +fn adapt_world_icon(app_handle: &AppHandle, world: &mut World) { + if let Some(Either::Left(icon_path)) = &world.icon { + let icon_path = icon_path.clone(); + if let Ok(new_url) = super::utils::tauri_convert_file_src(&icon_path) { + world.icon = Some(Either::Right(new_url)); + if let Err(e) = + app_handle.asset_protocol_scope().allow_file(&icon_path) + { + tracing::warn!( + "Failed to allow file access for icon {}: {}", + icon_path.display(), + e + ); + } + } else { + tracing::warn!( + "Encountered invalid icon path for world {}: {}", + world.name, + icon_path.display() + ); + world.icon = None; + } + } +} + +#[tauri::command] +pub async fn rename_world( + instance: &str, + world: &str, + new_name: &str, +) -> Result<()> { + let instance = get_full_path(instance).await?; + worlds::rename_world(&instance, world, new_name).await?; + Ok(()) +} + +#[tauri::command] +pub async fn reset_world_icon(instance: &str, world: &str) -> Result<()> { + let instance = get_full_path(instance).await?; + worlds::reset_world_icon(&instance, world).await?; + Ok(()) +} + +#[tauri::command] +pub async fn backup_world(instance: &str, world: &str) -> Result { + let instance = get_full_path(instance).await?; + Ok(worlds::backup_world(&instance, world).await?) +} + +#[tauri::command] +pub async fn delete_world(instance: &str, world: &str) -> Result<()> { + let instance = get_full_path(instance).await?; + worlds::delete_world(&instance, world).await?; + Ok(()) +} + +#[tauri::command] +pub async fn add_server_to_profile( + path: &str, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result { + let path = get_full_path(path).await?; + Ok( + worlds::add_server_to_profile(&path, name, address, pack_status) + .await?, + ) +} + +#[tauri::command] +pub async fn edit_server_in_profile( + path: &str, + index: usize, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result<()> { + let path = get_full_path(path).await?; + worlds::edit_server_in_profile(&path, index, name, address, pack_status) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn remove_server_from_profile( + path: &str, + index: usize, +) -> Result<()> { + let path = get_full_path(path).await?; + worlds::remove_server_from_profile(&path, index).await?; + Ok(()) +} + +#[tauri::command] +pub async fn get_profile_protocol_version(path: &str) -> Result> { + Ok(worlds::get_profile_protocol_version(path).await?) +} + +#[tauri::command] +pub async fn get_server_status( + address: &str, + protocol_version: Option, +) -> Result { + Ok(worlds::get_server_status(address, protocol_version).await?) +} + +#[tauri::command] +pub async fn start_join_singleplayer_world( + path: &str, + world: String, +) -> Result { + let process = + profile::run(path, &QuickPlayType::Singleplayer(world)).await?; + + Ok(process) +} + +#[tauri::command] +pub async fn start_join_server( + path: &str, + address: &str, +) -> Result { + let process = + profile::run(path, &QuickPlayType::Server(address.to_owned())).await?; + + Ok(process) +} diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index f313bb9a3..27bec5b0a 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -268,6 +268,7 @@ fn main() { .plugin(api::cache::init()) .plugin(api::ads::init()) .plugin(api::friends::init()) + .plugin(api::worlds::init()) .invoke_handler(tauri::generate_handler![ initialize_state, is_dev, diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index 7f3e31ac8..5c479f17c 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -76,7 +76,7 @@ ], "security": { "assetProtocol": { - "scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*"], + "scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*", "$APPDATA/profiles/*/saves/*/icon.png", "$APPCONFIG/profiles/*/saves/*/icon.png", "$CONFIG/profiles/*/saves/*/icon.png"], "enable": true }, "capabilities": ["ads", "core", "plugins"], diff --git a/apps/frontend/src/assets/styles/components.scss b/apps/frontend/src/assets/styles/components.scss index 0cc476281..a1ea3a1e9 100644 --- a/apps/frontend/src/assets/styles/components.scss +++ b/apps/frontend/src/assets/styles/components.scss @@ -930,7 +930,7 @@ button { color: var(--color-text); padding: 0.5rem 0 0.5rem 1rem; font-weight: var(--font-weight-medium); - min-height: 40px; + min-height: 36px; box-sizing: border-box; width: fit-content; align-items: center; diff --git a/apps/frontend/src/assets/styles/global.scss b/apps/frontend/src/assets/styles/global.scss index 06d8568c4..b0dcdc0ce 100644 --- a/apps/frontend/src/assets/styles/global.scss +++ b/apps/frontend/src/assets/styles/global.scss @@ -451,7 +451,7 @@ textarea { var(--shadow-inset-sm), 0 0 0 0 transparent; transition: box-shadow 0.1s ease-in-out; - min-height: 40px; + min-height: 36px; &:focus, &:focus-visible { diff --git a/apps/frontend/src/components/ui/servers/MOTDEditor.vue b/apps/frontend/src/components/ui/servers/MOTDEditor.vue deleted file mode 100644 index aa4c5d0e8..000000000 --- a/apps/frontend/src/components/ui/servers/MOTDEditor.vue +++ /dev/null @@ -1,660 +0,0 @@ - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/PoweredByPyro.vue b/apps/frontend/src/components/ui/servers/PoweredByPyro.vue deleted file mode 100644 index f6c54fa3c..000000000 --- a/apps/frontend/src/components/ui/servers/PoweredByPyro.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/apps/frontend/src/public/Monocraft.ttf b/apps/frontend/src/public/Monocraft.ttf deleted file mode 100644 index 4066b0a9889c2505d31b953487ac1d48fafaf9e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 202764 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

aqJ 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 H`;q?xJ@PKt diff --git a/apps/labrinth/src/routes/internal/statuses.rs b/apps/labrinth/src/routes/internal/statuses.rs index 800bf681f..233134820 100644 --- a/apps/labrinth/src/routes/internal/statuses.rs +++ b/apps/labrinth/src/routes/internal/statuses.rs @@ -91,8 +91,7 @@ pub async fn ws_init( let friend_statuses = if !friends.is_empty() { let db = db.clone(); let redis = redis.clone(); - - let statuses = tokio_stream::iter(friends.iter()) + tokio_stream::iter(friends.iter()) .map(|x| { let db = db.clone(); let redis = redis.clone(); @@ -112,9 +111,10 @@ pub async fn ws_init( }) .buffer_unordered(16) .collect::>() - .await; - - statuses.into_iter().flatten().collect() + .await + .into_iter() + .flatten() + .collect() } else { Vec::new() }; diff --git a/packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json b/packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json new file mode 100644 index 000000000..052e614c9 --- /dev/null +++ b/packages/app-lib/.sqlx/query-06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11,\n $12, $13, $14,\n $15, $16,\n $17, jsonb($18), jsonb($19),\n $20, $21, $22, $23,\n $24, $25, $26,\n $27\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n\n created = $12,\n modified = $13,\n last_played = $14,\n\n submitted_time_played = $15,\n recent_time_played = $16,\n\n override_java_path = $17,\n override_extra_launch_args = jsonb($18),\n override_custom_env_vars = jsonb($19),\n override_mc_memory_max = $20,\n override_mc_force_fullscreen = $21,\n override_mc_game_resolution_x = $22,\n override_mc_game_resolution_y = $23,\n\n override_hook_pre_launch = $24,\n override_hook_wrapper = $25,\n override_hook_post_exit = $26,\n\n protocol_version = $27\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 27 + }, + "nullable": [] + }, + "hash": "06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3" +} diff --git a/packages/app-lib/.sqlx/query-4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1.json b/packages/app-lib/.sqlx/query-1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb.json similarity index 75% rename from packages/app-lib/.sqlx/query-4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1.json rename to packages/app-lib/.sqlx/query-1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb.json index a28eadf3b..7fce3d030 100644 --- a/packages/app-lib/.sqlx/query-4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1.json +++ b/packages/app-lib/.sqlx/query-1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", + "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", "describe": { "columns": [ { @@ -29,109 +29,114 @@ "type_info": "Text" }, { - "name": "mod_loader", + "name": "protocol_version", "ordinal": 5, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "mod_loader_version", + "name": "mod_loader", "ordinal": 6, "type_info": "Text" }, { - "name": "groups!: serde_json::Value", + "name": "mod_loader_version", "ordinal": 7, + "type_info": "Text" + }, + { + "name": "groups!: serde_json::Value", + "ordinal": 8, "type_info": "Null" }, { "name": "linked_project_id", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "linked_version_id", "ordinal": 9, "type_info": "Text" }, { - "name": "locked", + "name": "linked_version_id", "ordinal": 10, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "created", + "name": "locked", "ordinal": 11, "type_info": "Integer" }, { - "name": "modified", + "name": "created", "ordinal": 12, "type_info": "Integer" }, { - "name": "last_played", + "name": "modified", "ordinal": 13, "type_info": "Integer" }, { - "name": "submitted_time_played", + "name": "last_played", "ordinal": 14, "type_info": "Integer" }, { - "name": "recent_time_played", + "name": "submitted_time_played", "ordinal": 15, "type_info": "Integer" }, { - "name": "override_java_path", + "name": "recent_time_played", "ordinal": 16, + "type_info": "Integer" + }, + { + "name": "override_java_path", + "ordinal": 17, "type_info": "Text" }, { "name": "override_extra_launch_args!: serde_json::Value", - "ordinal": 17, - "type_info": "Null" - }, - { - "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 18, "type_info": "Null" }, { - "name": "override_mc_memory_max", + "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 19, - "type_info": "Integer" + "type_info": "Null" }, { - "name": "override_mc_force_fullscreen", + "name": "override_mc_memory_max", "ordinal": 20, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_x", + "name": "override_mc_force_fullscreen", "ordinal": 21, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_y", + "name": "override_mc_game_resolution_x", "ordinal": 22, "type_info": "Integer" }, { - "name": "override_hook_pre_launch", + "name": "override_mc_game_resolution_y", "ordinal": 23, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "override_hook_wrapper", + "name": "override_hook_pre_launch", "ordinal": 24, "type_info": "Text" }, { - "name": "override_hook_post_exit", + "name": "override_hook_wrapper", "ordinal": 25, "type_info": "Text" + }, + { + "name": "override_hook_post_exit", + "ordinal": 26, + "type_info": "Text" } ], "parameters": { @@ -143,6 +148,7 @@ false, true, false, + true, false, true, null, @@ -166,5 +172,5 @@ true ] }, - "hash": "4acd47f6bad3d2d4df5e5d43b3441fa2714cb8ad978adc108acc67f042380df1" + "hash": "1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb" } diff --git a/packages/app-lib/.sqlx/query-5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094.json b/packages/app-lib/.sqlx/query-30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d.json similarity index 76% rename from packages/app-lib/.sqlx/query-5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094.json rename to packages/app-lib/.sqlx/query-30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d.json index 279912e85..89040a6c9 100644 --- a/packages/app-lib/.sqlx/query-5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094.json +++ b/packages/app-lib/.sqlx/query-30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", + "query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", "describe": { "columns": [ { @@ -29,109 +29,114 @@ "type_info": "Text" }, { - "name": "mod_loader", + "name": "protocol_version", "ordinal": 5, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "mod_loader_version", + "name": "mod_loader", "ordinal": 6, "type_info": "Text" }, { - "name": "groups!: serde_json::Value", + "name": "mod_loader_version", "ordinal": 7, + "type_info": "Text" + }, + { + "name": "groups!: serde_json::Value", + "ordinal": 8, "type_info": "Null" }, { "name": "linked_project_id", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "linked_version_id", "ordinal": 9, "type_info": "Text" }, { - "name": "locked", + "name": "linked_version_id", "ordinal": 10, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "created", + "name": "locked", "ordinal": 11, "type_info": "Integer" }, { - "name": "modified", + "name": "created", "ordinal": 12, "type_info": "Integer" }, { - "name": "last_played", + "name": "modified", "ordinal": 13, "type_info": "Integer" }, { - "name": "submitted_time_played", + "name": "last_played", "ordinal": 14, "type_info": "Integer" }, { - "name": "recent_time_played", + "name": "submitted_time_played", "ordinal": 15, "type_info": "Integer" }, { - "name": "override_java_path", + "name": "recent_time_played", "ordinal": 16, + "type_info": "Integer" + }, + { + "name": "override_java_path", + "ordinal": 17, "type_info": "Text" }, { "name": "override_extra_launch_args!: serde_json::Value", - "ordinal": 17, - "type_info": "Null" - }, - { - "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 18, "type_info": "Null" }, { - "name": "override_mc_memory_max", + "name": "override_custom_env_vars!: serde_json::Value", "ordinal": 19, - "type_info": "Integer" + "type_info": "Null" }, { - "name": "override_mc_force_fullscreen", + "name": "override_mc_memory_max", "ordinal": 20, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_x", + "name": "override_mc_force_fullscreen", "ordinal": 21, "type_info": "Integer" }, { - "name": "override_mc_game_resolution_y", + "name": "override_mc_game_resolution_x", "ordinal": 22, "type_info": "Integer" }, { - "name": "override_hook_pre_launch", + "name": "override_mc_game_resolution_y", "ordinal": 23, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "override_hook_wrapper", + "name": "override_hook_pre_launch", "ordinal": 24, "type_info": "Text" }, { - "name": "override_hook_post_exit", + "name": "override_hook_wrapper", "ordinal": 25, "type_info": "Text" + }, + { + "name": "override_hook_post_exit", + "ordinal": 26, + "type_info": "Text" } ], "parameters": { @@ -143,6 +148,7 @@ false, true, false, + true, false, true, null, @@ -166,5 +172,5 @@ true ] }, - "hash": "5265d5ad85da898855d628f6b45e39026908fc950aad3c7797be37b5d0b74094" + "hash": "30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d" } diff --git a/packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json b/packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json new file mode 100644 index 000000000..aaea116c7 --- /dev/null +++ b/packages/app-lib/.sqlx/query-54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT profile_path, host, port, join_time\n FROM join_log\n WHERE profile_path = $1\n ", + "describe": { + "columns": [ + { + "name": "profile_path", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "host", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "join_time", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "54a8629d3d660bfeed582b08aee4a8f1543f6b962e54ecab491a006d28c9a18c" +} diff --git a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json index 9742cb7b4..22e39e75b 100644 --- a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json +++ b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json @@ -41,7 +41,7 @@ { "name": "display_claims!: serde_json::Value", "ordinal": 7, - "type_info": "Null" + "type_info": "Text" } ], "parameters": { diff --git a/packages/app-lib/.sqlx/query-db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json b/packages/app-lib/.sqlx/query-db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json deleted file mode 100644 index 4d503ee1d..000000000 --- a/packages/app-lib/.sqlx/query-db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11,\n $12, $13, $14,\n $15, $16,\n $17, jsonb($18), jsonb($19),\n $20, $21, $22, $23,\n $24, $25, $26\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n\n created = $12,\n modified = $13,\n last_played = $14,\n\n submitted_time_played = $15,\n recent_time_played = $16,\n\n override_java_path = $17,\n override_extra_launch_args = jsonb($18),\n override_custom_env_vars = jsonb($19),\n override_mc_memory_max = $20,\n override_mc_force_fullscreen = $21,\n override_mc_game_resolution_x = $22,\n override_mc_game_resolution_y = $23,\n\n override_hook_pre_launch = $24,\n override_hook_wrapper = $25,\n override_hook_post_exit = $26\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 26 - }, - "nullable": [] - }, - "hash": "db1f94b9c17c790c029a7691620d6bbdcbdfcce4b069b8ed46dc3abd2f5f4e58" -} diff --git a/packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json b/packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json new file mode 100644 index 000000000..017a85166 --- /dev/null +++ b/packages/app-lib/.sqlx/query-e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO join_log (profile_path, host, port, join_time)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (profile_path, host, port) DO UPDATE SET\n join_time = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "e9f3d57ac9055575366d5ebd40b64f627ae744a20acf5affaa729e68e3eb6641" +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 9278509c4..a8fa45bfb 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -11,13 +11,14 @@ serde_json = "1.0" serde_ini = "0.2.0" sha1_smol = { version = "1.0.0", features = ["std"] } sha2 = "0.10.8" -url = "2.2" +url = { version = "2.2", features = ["serde"] } uuid = { version = "1.1", features = ["serde", "v4"] } zip = "0.6.5" async_zip = { version = "0.0.17", features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] } flate2 = "1.0.28" tempfile = "3.5.0" dashmap = { version = "6.0.1", features = ["serde"] } +quick-xml = { version = "0.37", features = ["async-tokio"] } chrono = { version = "0.4.19", features = ["serde"] } daedalus = { path = "../../packages/daedalus" } @@ -42,7 +43,10 @@ async-tungstenite = { version = "0.27.0", features = ["tokio-runtime", "tokio-ru futures = "0.3" reqwest = { version = "0.12.3", features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls", "charset", "http2", "macos-system-configuration"], default-features = false } tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" async-recursion = "1.0.4" +fs4 = { version = "0.13", features = ["tokio"] } +async-walkdir = "2.1" notify = { version = "6.1.1", default-features = false } notify-debouncer-mini = { version = "0.4.1", default-features = false } @@ -61,6 +65,9 @@ base64 = "0.22.0" sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros" ] } +quartz_nbt = { version = "0.2", features = ["serde"] } +hickory-resolver = "0.25" + ariadne = { path = "../ariadne" } [target.'cfg(windows)'.dependencies] diff --git a/packages/app-lib/migrations/20250318160526_protocol-versions.sql b/packages/app-lib/migrations/20250318160526_protocol-versions.sql new file mode 100644 index 000000000..d067b3fe3 --- /dev/null +++ b/packages/app-lib/migrations/20250318160526_protocol-versions.sql @@ -0,0 +1 @@ +ALTER TABLE profiles ADD COLUMN protocol_version INTEGER NULL diff --git a/packages/app-lib/migrations/20250408181656_add-join-log.sql b/packages/app-lib/migrations/20250408181656_add-join-log.sql new file mode 100644 index 000000000..119891eed --- /dev/null +++ b/packages/app-lib/migrations/20250408181656_add-join-log.sql @@ -0,0 +1,10 @@ +CREATE TABLE join_log ( + profile_path TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + join_time INTEGER NOT NULL, + + PRIMARY KEY (profile_path, host, port), + FOREIGN KEY (profile_path) REFERENCES profiles(path) ON DELETE CASCADE +); +CREATE INDEX join_log_profile_path ON join_log(profile_path); diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index 8ba9108b1..cff82794f 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -298,7 +298,7 @@ pub async fn get_latest_log_cursor( profile_path: &str, cursor: u64, // 0 to start at beginning of file ) -> crate::Result { - get_generic_live_log_cursor(profile_path, "latest.log", cursor).await + get_generic_live_log_cursor(profile_path, "launcher_log.txt", cursor).await } #[tracing::instrument] diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 81e4c5e67..3c142edc1 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -12,6 +12,7 @@ pub mod process; pub mod profile; pub mod settings; pub mod tags; +pub mod worlds; pub mod data { pub use crate::state::{ diff --git a/packages/app-lib/src/api/profile/create.rs b/packages/app-lib/src/api/profile/create.rs index b32c95e5c..aa840a3ac 100644 --- a/packages/app-lib/src/api/profile/create.rs +++ b/packages/app-lib/src/api/profile/create.rs @@ -77,6 +77,7 @@ pub async fn profile_create( name, icon_path: None, game_version, + protocol_version: None, loader: modloader, loader_version: loader.map(|x| x.id), groups: Vec::new(), diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs index 54ee1a435..c44333bc0 100644 --- a/packages/app-lib/src/api/profile/mod.rs +++ b/packages/app-lib/src/api/profile/mod.rs @@ -36,6 +36,13 @@ use tokio::{fs::File, process::Command, sync::RwLock}; pub mod create; pub mod update; +#[derive(Debug, Clone)] +pub enum QuickPlayType { + None, + Singleplayer(String), + Server(String), +} + /// Remove a profile #[tracing::instrument] pub async fn remove(path: &str) -> crate::Result<()> { @@ -623,14 +630,17 @@ fn pack_get_relative_path( /// Run Minecraft using a profile and the default credentials, logged in credentials, /// failing with an error if no credentials are available #[tracing::instrument] -pub async fn run(path: &str) -> crate::Result { +pub async fn run( + path: &str, + quick_play_type: &QuickPlayType, +) -> crate::Result { let state = State::get().await?; let default_account = Credentials::get_default_credential(&state.pool) .await? .ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?; - run_credentials(path, &default_account).await + run_credentials(path, &default_account, quick_play_type).await } /// Run Minecraft using a profile, and credentials for authentication @@ -640,6 +650,7 @@ pub async fn run(path: &str) -> crate::Result { pub async fn run_credentials( path: &str, credentials: &Credentials, + quick_play_type: &QuickPlayType, ) -> crate::Result { let state = State::get().await?; let settings = Settings::get(&state.pool).await?; @@ -719,6 +730,7 @@ pub async fn run_credentials( credentials, post_exit_hook, &profile, + quick_play_type, ) .await } diff --git a/packages/app-lib/src/api/worlds.rs b/packages/app-lib/src/api/worlds.rs new file mode 100644 index 000000000..1868f75ae --- /dev/null +++ b/packages/app-lib/src/api/worlds.rs @@ -0,0 +1,830 @@ +use crate::data::ModLoader; +use crate::launcher::get_loader_version_from_profile; +use crate::profile::get_full_path; +use crate::state::{server_join_log, Profile, ProfileInstallStage}; +pub use crate::util::server_ping::{ + ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion, +}; +use crate::util::{io, server_ping}; +use crate::{launcher, Error, ErrorKind, Result, State}; +use async_walkdir::WalkDir; +use async_zip::{Compression, ZipEntryBuilder}; +use chrono::{DateTime, Local, TimeZone, Utc}; +use either::Either; +use fs4::tokio::AsyncFileExt; +use futures::StreamExt; +use lazy_static::lazy_static; +use quartz_nbt::{NbtCompound, NbtTag}; +use regex::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; +use std::cmp::Reverse; +use std::io::Cursor; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; +use tokio_util::compat::FuturesAsyncWriteCompatExt; +use url::Url; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct WorldWithProfile { + pub profile: String, + #[serde(flatten)] + pub world: World, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct World { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_played: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + with = "either::serde_untagged_optional" + )] + pub icon: Option>, + #[serde(flatten)] + pub details: WorldDetails, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WorldDetails { + Singleplayer { + path: String, + game_mode: SingleplayerGameMode, + hardcore: bool, + locked: bool, + }, + Server { + index: usize, + address: String, + pack_status: ServerPackStatus, + }, +} + +#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum SingleplayerGameMode { + #[default] + Survival, + Creative, + Adventure, + Spectator, +} + +#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum ServerPackStatus { + Enabled, + Disabled, + #[default] + Prompt, +} + +impl From> for ServerPackStatus { + fn from(value: Option) -> Self { + match value { + Some(true) => ServerPackStatus::Enabled, + Some(false) => ServerPackStatus::Disabled, + None => ServerPackStatus::Prompt, + } + } +} + +impl From for Option { + fn from(val: ServerPackStatus) -> Self { + match val { + ServerPackStatus::Enabled => Some(true), + ServerPackStatus::Disabled => Some(false), + ServerPackStatus::Prompt => None, + } + } +} + +pub async fn get_recent_worlds(limit: usize) -> Result> { + let state = State::get().await?; + let profiles_dir = state.directories.profiles_dir(); + + let mut profiles = Profile::get_all(&state.pool).await?; + profiles.sort_by_key(|x| Reverse(x.last_played)); + + let mut result = Vec::with_capacity(limit); + + let mut least_recent_time = None; + for profile in profiles { + if result.len() >= limit && profile.last_played < least_recent_time { + break; + } + let profile_path = &profile.path; + let profile_dir = profiles_dir.join(profile_path); + let profile_worlds = + get_all_worlds_in_profile(profile_path, &profile_dir).await; + if let Err(e) = profile_worlds { + tracing::error!( + "Failed to get worlds for profile {}: {}", + profile_path, + e + ); + continue; + } + for world in profile_worlds? { + let is_older = least_recent_time.is_none() + || world.last_played < least_recent_time; + if result.len() >= limit && is_older { + continue; + } + if is_older { + least_recent_time = world.last_played; + } + result.push(WorldWithProfile { + profile: profile_path.clone(), + world, + }); + } + if result.len() > limit { + result.sort_by_key(|x| Reverse(x.world.last_played)); + result.truncate(limit); + } + } + + if result.len() <= limit { + result.sort_by_key(|x| Reverse(x.world.last_played)); + } + Ok(result) +} + +pub async fn get_profile_worlds(profile_path: &str) -> Result> { + get_all_worlds_in_profile(profile_path, &get_full_path(profile_path).await?) + .await +} + +async fn get_all_worlds_in_profile( + profile_path: &str, + profile_dir: &Path, +) -> Result> { + let mut worlds = vec![]; + get_singleplayer_worlds_in_profile(profile_dir, &mut worlds).await?; + get_server_worlds_in_profile(profile_path, profile_dir, &mut worlds) + .await?; + Ok(worlds) +} + +async fn get_singleplayer_worlds_in_profile( + instance_dir: &Path, + worlds: &mut Vec, +) -> Result<()> { + let saves_dir = instance_dir.join("saves"); + if !saves_dir.exists() { + return Ok(()); + } + let mut saves_dir = io::read_dir(saves_dir).await?; + while let Some(world_dir) = saves_dir.next_entry().await? { + let world_path = world_dir.path(); + let level_dat_path = world_path.join("level.dat"); + if !level_dat_path.exists() { + continue; + } + if let Ok(world) = read_singleplayer_world(world_path).await { + worlds.push(world); + } + } + + Ok(()) +} + +pub async fn get_singleplayer_world( + profile_path: &Path, + world: &str, +) -> Result { + read_singleplayer_world(get_world_dir(profile_path, world)).await +} + +async fn read_singleplayer_world(world_path: PathBuf) -> Result { + if let Some(_lock) = try_get_world_session_lock(&world_path).await? { + read_singleplayer_world_maybe_locked(world_path, false).await + } else { + read_singleplayer_world_maybe_locked(world_path, true).await + } +} + +async fn read_singleplayer_world_maybe_locked( + world_path: PathBuf, + locked: bool, +) -> Result { + #[derive(Deserialize, Debug)] + #[serde(rename_all = "PascalCase")] + struct LevelDataRoot { + data: LevelData, + } + + #[derive(Deserialize, Debug)] + #[serde(rename_all = "PascalCase")] + struct LevelData { + #[serde(default)] + level_name: String, + #[serde(default)] + last_played: i64, + #[serde(default)] + game_type: i32, + #[serde(default, rename = "hardcore")] + hardcore: bool, + } + + let level_data = io::read(world_path.join("level.dat")).await?; + let level_data: LevelDataRoot = quartz_nbt::serde::deserialize( + &level_data, + quartz_nbt::io::Flavor::GzCompressed, + )? + .0; + let level_data = level_data.data; + + let icon = Some(world_path.join("icon.png")).filter(|i| i.exists()); + + let game_mode = match level_data.game_type { + 0 => SingleplayerGameMode::Survival, + 1 => SingleplayerGameMode::Creative, + 2 => SingleplayerGameMode::Adventure, + 3 => SingleplayerGameMode::Spectator, + _ => SingleplayerGameMode::Survival, + }; + + Ok(World { + name: level_data.level_name, + last_played: Utc.timestamp_millis_opt(level_data.last_played).single(), + icon: icon.map(Either::Left), + details: WorldDetails::Singleplayer { + path: world_path + .file_name() + .unwrap() + .to_string_lossy() + .to_string(), + game_mode, + hardcore: level_data.hardcore, + locked, + }, + }) +} + +async fn get_server_worlds_in_profile( + profile_path: &str, + instance_dir: &Path, + worlds: &mut Vec, +) -> Result<()> { + let servers = servers_data::read(instance_dir).await?; + if servers.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let join_log = server_join_log::get_joins(profile_path, &state.pool) + .await + .ok(); + + for (index, server) in servers.into_iter().enumerate() { + if server.hidden { + // TODO: Figure out whether we want to hide or show direct connect servers + continue; + } + let icon = server.icon.and_then(|icon| { + Url::parse(&format!("data:image/png;base64,{}", icon)).ok() + }); + let last_played = join_log + .as_ref() + .and_then(|log| { + let address = parse_server_address(&server.ip).ok()?; + log.get(&(address.0.to_owned(), address.1)) + }) + .copied(); + let world = World { + name: server.name, + last_played, + icon: icon.map(Either::Right), + details: WorldDetails::Server { + index, + address: server.ip, + pack_status: server.accept_textures.into(), + }, + }; + worlds.push(world); + } + + Ok(()) +} + +pub async fn rename_world( + instance: &Path, + world: &str, + new_name: &str, +) -> Result<()> { + let world = get_world_dir(instance, world); + let level_dat_path = world.join("level.dat"); + if !level_dat_path.exists() { + return Ok(()); + } + let _lock = get_world_session_lock(&world).await?; + + let level_data = io::read(&level_dat_path).await?; + let (mut root_data, _) = quartz_nbt::io::read_nbt( + &mut Cursor::new(level_data), + quartz_nbt::io::Flavor::GzCompressed, + )?; + let data = root_data.get_mut::<_, &mut NbtCompound>("Data")?; + + data.insert( + "LevelName", + NbtTag::String(new_name.trim_ascii().to_string()), + ); + + let mut level_data = vec![]; + quartz_nbt::io::write_nbt( + &mut level_data, + None, + &root_data, + quartz_nbt::io::Flavor::GzCompressed, + )?; + io::write(level_dat_path, level_data).await?; + Ok(()) +} + +pub async fn reset_world_icon(instance: &Path, world: &str) -> Result<()> { + let world = get_world_dir(instance, world); + let icon = world.join("icon.png"); + if let Some(_lock) = try_get_world_session_lock(&world).await? { + let _ = io::remove_file(icon).await; + } + Ok(()) +} + +pub async fn backup_world(instance: &Path, world: &str) -> Result { + let world_dir = get_world_dir(instance, world); + let _lock = get_world_session_lock(&world_dir).await?; + let backups_dir = instance.join("backups"); + + io::create_dir_all(&backups_dir).await?; + + let name_base = { + let now = Local::now(); + let formatted_time = now.format("%Y-%m-%d_%H-%M-%S"); + format!("{}_{}", formatted_time, world) + }; + let output_path = + backups_dir.join(find_available_name(&backups_dir, &name_base, ".zip")); + + let writer = tokio::fs::File::create(&output_path).await?; + let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(writer); + + let mut walker = WalkDir::new(&world_dir); + while let Some(entry) = walker.next().await { + let entry = entry.map_err(|e| io::IOError::IOPathError { + path: e.path().unwrap().to_string_lossy().to_string(), + source: e.into_io().unwrap(), + })?; + if !entry.file_type().await?.is_file() { + continue; + } + if entry.file_name() == "session.lock" { + continue; + } + let zip_filename = format!( + "{world}/{}", + entry + .path() + .strip_prefix(&world_dir)? + .display() + .to_string() + .replace('\\', "/") + ); + let mut stream = writer + .write_entry_stream( + ZipEntryBuilder::new(zip_filename.into(), Compression::Deflate) + .build(), + ) + .await? + .compat_write(); + let mut source = tokio::fs::File::open(entry.path()).await?; + tokio::io::copy(&mut source, &mut stream).await?; + stream.into_inner().close().await?; + } + + writer.close().await?; + Ok(io::metadata(output_path).await?.len()) +} + +fn find_available_name(dir: &Path, file_name: &str, extension: &str) -> String { + lazy_static! { + static ref RESERVED_WINDOWS_FILENAMES: Regex = RegexBuilder::new(r#"^.*\.|(?:COM|CLOCK\$|CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$"#) + .case_insensitive(true) + .build() + .unwrap(); + static ref COPY_COUNTER_PATTERN: Regex = RegexBuilder::new(r#"^(?.*) \((?\d*)\)$"#) + .case_insensitive(true) + .unicode(true) + .build() + .unwrap(); + } + + let mut file_name = file_name.replace( + [ + '/', '\n', '\r', '\t', '\0', '\x0c', '`', '?', '*', '\\', '<', '>', + '|', '"', ':', '.', '/', '"', + ], + "_", + ); + if RESERVED_WINDOWS_FILENAMES.is_match(&file_name) { + file_name.insert(0, '_'); + file_name.push('_'); + } + + let mut count = 0; + if let Some(find) = COPY_COUNTER_PATTERN.captures(&file_name) { + count = find + .name("count") + .unwrap() + .as_str() + .parse::() + .unwrap_or(0); + let end = find.name("name").unwrap().end(); + drop(find); + file_name.truncate(end); + } + + if file_name.len() > 255 - extension.len() { + file_name.truncate(255 - extension.len()); + } + + let mut current_attempt = file_name.clone(); + loop { + if count != 0 { + let with_count = format!(" ({count})"); + if file_name.len() > 255 - with_count.len() { + current_attempt.truncate(255 - with_count.len()); + } + current_attempt.push_str(&with_count); + } + + current_attempt.push_str(extension); + + let result = dir.join(¤t_attempt); + if !result.exists() { + return current_attempt; + } + + count += 1; + current_attempt.replace_range(..current_attempt.len(), &file_name); + } +} + +pub async fn delete_world(instance: &Path, world: &str) -> Result<()> { + let world = get_world_dir(instance, world); + let lock = get_world_session_lock(&world).await?; + let lock_path = world.join("session.lock"); + + let mut dir = io::read_dir(&world).await?; + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + if entry.file_type().await?.is_dir() { + io::remove_dir_all(path).await?; + continue; + } + if path != lock_path { + io::remove_file(path).await?; + } + } + + drop(lock); + io::remove_file(lock_path).await?; + io::remove_dir(world).await?; + + Ok(()) +} + +fn get_world_dir(instance: &Path, world: &str) -> PathBuf { + instance.join("saves").join(world) +} + +async fn get_world_session_lock(world: &Path) -> Result { + let lock_path = world.join("session.lock"); + let mut file = tokio::fs::File::options() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path) + .await?; + file.write_all("☃".as_bytes()).await?; + file.sync_all().await?; + let locked = file.try_lock_exclusive()?; + locked.then_some(file).ok_or_else(|| { + io::IOError::IOPathError { + source: std::io::Error::new( + std::io::ErrorKind::ResourceBusy, + "already locked by Minecraft", + ), + path: lock_path.to_string_lossy().into_owned(), + } + .into() + }) +} + +async fn try_get_world_session_lock( + world: &Path, +) -> Result> { + let file = tokio::fs::File::options() + .create(true) + .write(true) + .truncate(false) + .open(world.join("session.lock")) + .await?; + file.sync_all().await?; + let locked = file.try_lock_exclusive()?; + Ok(locked.then_some(file)) +} + +pub async fn add_server_to_profile( + profile_path: &Path, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result { + let mut servers = servers_data::read(profile_path).await?; + let insert_index = servers + .iter() + .position(|x| x.hidden) + .unwrap_or(servers.len()); + servers.insert( + insert_index, + servers_data::ServerData { + name, + ip: address, + accept_textures: pack_status.into(), + hidden: false, + icon: None, + }, + ); + servers_data::write(profile_path, &servers).await?; + Ok(insert_index) +} + +pub async fn edit_server_in_profile( + profile_path: &Path, + index: usize, + name: String, + address: String, + pack_status: ServerPackStatus, +) -> Result<()> { + let mut servers = servers_data::read(profile_path).await?; + let server = + servers + .get_mut(index) + .filter(|x| !x.hidden) + .ok_or_else(|| { + ErrorKind::InputError(format!( + "No editable server at index {index}" + )) + .as_error() + })?; + server.name = name; + server.ip = address; + server.accept_textures = pack_status.into(); + servers_data::write(profile_path, &servers).await?; + Ok(()) +} + +pub async fn remove_server_from_profile( + profile_path: &Path, + index: usize, +) -> Result<()> { + let mut servers = servers_data::read(profile_path).await?; + if servers.get(index).filter(|x| !x.hidden).is_none() { + return Err(ErrorKind::InputError(format!( + "No removable server at index {index}" + )) + .into()); + } + servers.remove(index); + servers_data::write(profile_path, &servers).await?; + Ok(()) +} + +mod servers_data { + use crate::util::io; + use crate::Result; + use serde::{Deserialize, Serialize}; + use std::path::Path; + + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(rename_all = "camelCase")] + pub struct ServerData { + #[serde(default)] + pub hidden: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default)] + pub ip: String, + #[serde(default)] + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub accept_textures: Option, + } + + pub async fn read(instance_dir: &Path) -> Result> { + #[derive(Deserialize, Debug)] + struct ServersData { + #[serde(default)] + servers: Vec, + } + + let servers_dat_path = instance_dir.join("servers.dat"); + if !servers_dat_path.exists() { + return Ok(vec![]); + } + let servers_data = io::read(servers_dat_path).await?; + let servers_data: ServersData = quartz_nbt::serde::deserialize( + &servers_data, + quartz_nbt::io::Flavor::Uncompressed, + )? + .0; + Ok(servers_data.servers) + } + + pub async fn write( + instance_dir: &Path, + servers: &[ServerData], + ) -> Result<()> { + #[derive(Serialize, Debug)] + struct ServersData<'a> { + servers: &'a [ServerData], + } + + let servers_dat_path = instance_dir.join("servers.dat"); + let data = quartz_nbt::serde::serialize( + &ServersData { servers }, + None, + quartz_nbt::io::Flavor::Uncompressed, + )?; + io::write(servers_dat_path, data).await?; + Ok(()) + } +} + +pub async fn get_profile_protocol_version( + profile: &str, +) -> Result> { + let mut profile = super::profile::get(profile).await?.ok_or_else(|| { + ErrorKind::UnmanagedProfileError(format!( + "Could not find profile {}", + profile + )) + })?; + if profile.install_stage != ProfileInstallStage::Installed { + return Ok(None); + } + + if let Some(protocol_version) = profile.protocol_version { + return Ok(Some(protocol_version)); + } + + let minecraft = crate::api::metadata::get_minecraft_versions().await?; + let version_index = minecraft + .versions + .iter() + .position(|it| it.id == profile.game_version) + .ok_or(crate::ErrorKind::LauncherError(format!( + "Invalid game version: {}", + profile.game_version + )))?; + let version = &minecraft.versions[version_index]; + + let loader_version = get_loader_version_from_profile( + &profile.game_version, + profile.loader, + profile.loader_version.as_deref(), + ) + .await?; + if profile.loader != ModLoader::Vanilla && loader_version.is_none() { + return Ok(None); + } + + let version_jar = + loader_version.as_ref().map_or(version.id.clone(), |it| { + format!("{}-{}", version.id.clone(), it.id.clone()) + }); + + let state = State::get().await?; + let client_path = state + .directories + .version_dir(&version_jar) + .join(format!("{version_jar}.jar")); + + if !client_path.exists() { + return Ok(None); + } + + let version = launcher::read_protocol_version_from_jar(client_path).await?; + if version.is_some() { + profile.protocol_version = version; + profile.upsert(&state.pool).await?; + } + Ok(version) +} + +pub async fn get_server_status( + address: &str, + protocol_version: Option, +) -> Result { + let (original_host, original_port) = parse_server_address(address)?; + let (host, port) = + resolve_server_address(original_host, original_port).await?; + server_ping::get_server_status( + &(&host as &str, port), + (original_host, original_port), + protocol_version, + ) + .await +} + +pub fn parse_server_address(address: &str) -> Result<(&str, u16)> { + parse_server_address_inner(address) + .map_err(|e| Error::from(ErrorKind::InputError(e))) +} + +// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565 +fn parse_server_address_inner( + address: &str, +) -> std::result::Result<(&str, u16), String> { + let (host, port_str) = if address.starts_with("[") { + let colon_index = address.find(':'); + let close_bracket_index = address.rfind(']'); + if colon_index.is_none() || close_bracket_index.is_none() { + return Err(format!("Invalid bracketed host/port: {address}")); + } + let close_bracket_index = close_bracket_index.unwrap(); + + let host = &address[1..close_bracket_index]; + if close_bracket_index + 1 == address.len() { + (host, "") + } else { + if address.as_bytes().get(close_bracket_index).copied() + != Some(b':') + { + return Err(format!( + "Only a colon may follow a close bracket: {address}" + )); + } + let port_str = &address[close_bracket_index + 2..]; + for c in port_str.chars() { + if !c.is_ascii_digit() { + return Err(format!("Port must be numeric: {address}")); + } + } + (host, port_str) + } + } else { + let colon_pos = address.find(':'); + if let Some(colon_pos) = colon_pos { + (&address[..colon_pos], &address[colon_pos + 1..]) + } else { + (address, "") + } + }; + + let mut port = None; + if !port_str.is_empty() { + if port_str.starts_with('+') { + return Err(format!("Unparseable port number: {port_str}")); + } + port = port_str.parse::().ok(); + if port.is_none() { + return Err(format!("Unparseable port number: {port_str}")); + } + } + + Ok((host, port.unwrap_or(25565))) +} + +async fn resolve_server_address( + host: &str, + port: u16, +) -> Result<(String, u16)> { + if host.parse::().is_ok() || host.parse::().is_ok() { + return Ok((host.to_owned(), port)); + } + let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build(); + Ok(match resolver + .srv_lookup(format!("_minecraft._tcp.{}", host)) + .await + { + Err(e) + if e.proto() + .filter(|x| x.kind().is_no_records_found()) + .is_some() => + { + None + } + Err(e) => return Err(e.into()), + Ok(lookup) => lookup + .into_iter() + .next() + .map(|r| (r.target().to_string(), r.port())), + } + .unwrap_or_else(|| (host.to_owned(), port))) +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 9eabe33a7..587c9559a 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -13,6 +13,12 @@ pub enum ErrorKind { #[error("Serialization error (JSON): {0}")] JSONError(#[from] serde_json::Error), + #[error("Serialization error (NBT): {0}")] + NBTError(#[from] quartz_nbt::io::NbtIoError), + + #[error("NBT data structure error: {0}")] + NBTReprError(#[from] quartz_nbt::NbtReprError), + #[error("Serialization error (websocket): {0}")] WebsocketSerializationError( #[from] ariadne::networking::serialization::SerializationError, @@ -116,6 +122,9 @@ pub enum ErrorKind { #[error("Move directory error: {0}")] DirectoryMoveError(String), + + #[error("Error resolving DNS: {0}")] + DNSError(#[from] hickory_resolver::ResolveError), } #[derive(Debug)] diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index 0c0ac023e..0c2b22df8 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -1,5 +1,6 @@ //! Theseus state management system use ariadne::users::{UserId, UserStatus}; +use chrono::{DateTime, Utc}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use std::{path::PathBuf, sync::Arc}; @@ -234,13 +235,23 @@ pub enum ProcessPayloadType { #[derive(Serialize, Clone)] pub struct ProfilePayload { pub profile_path_id: String, + #[serde(flatten)] pub event: ProfilePayloadType, } #[derive(Serialize, Clone)] -#[serde(rename_all = "snake_case")] +#[serde(tag = "event", rename_all = "snake_case")] pub enum ProfilePayloadType { Created, Synced, + ServersUpdated, + WorldUpdated { + world: String, + }, + ServerJoined { + host: String, + port: u16, + timestamp: DateTime, + }, Edited, Removed, } diff --git a/packages/app-lib/src/launcher/args.rs b/packages/app-lib/src/launcher/args.rs index 919c33042..23583406f 100644 --- a/packages/app-lib/src/launcher/args.rs +++ b/packages/app-lib/src/launcher/args.rs @@ -1,5 +1,6 @@ //! Minecraft CLI argument logic use crate::launcher::parse_rules; +use crate::profile::QuickPlayType; use crate::state::Credentials; use crate::{ state::{MemorySettings, WindowSize}, @@ -31,7 +32,12 @@ pub fn get_class_paths( .iter() .filter_map(|library| { if let Some(rules) = &library.rules { - if !parse_rules(rules, java_arch, minecraft_updated) { + if !parse_rules( + rules, + java_arch, + &QuickPlayType::None, + minecraft_updated, + ) { return None; } } @@ -111,6 +117,7 @@ pub fn get_jvm_arguments( memory: MemorySettings, custom_args: Vec, java_arch: &str, + quick_play_type: &QuickPlayType, log_config: Option<&LoggingConfiguration>, ) -> crate::Result> { let mut parsed_arguments = Vec::new(); @@ -130,6 +137,7 @@ pub fn get_jvm_arguments( ) }, java_arch, + quick_play_type, )?; } else { parsed_arguments.push(format!( @@ -214,6 +222,7 @@ pub fn get_minecraft_arguments( version_type: &VersionType, resolution: WindowSize, java_arch: &str, + quick_play_type: &QuickPlayType, ) -> crate::Result> { if let Some(arguments) = arguments { let mut parsed_arguments = Vec::new(); @@ -233,9 +242,11 @@ pub fn get_minecraft_arguments( assets_directory, version_type, resolution, + quick_play_type, ) }, java_arch, + quick_play_type, )?; Ok(parsed_arguments) @@ -253,6 +264,7 @@ pub fn get_minecraft_arguments( assets_directory, version_type, resolution, + quick_play_type, )?); } Ok(parsed_arguments) @@ -273,6 +285,7 @@ fn parse_minecraft_argument( assets_directory: &Path, version_type: &VersionType, resolution: WindowSize, + quick_play_type: &QuickPlayType, ) -> crate::Result { Ok(argument .replace("${accessToken}", access_token) @@ -326,7 +339,21 @@ fn parse_minecraft_argument( ) .replace("${version_type}", version_type.as_str()) .replace("${resolution_width}", &resolution.0.to_string()) - .replace("${resolution_height}", &resolution.1.to_string())) + .replace("${resolution_height}", &resolution.1.to_string()) + .replace( + "${quickPlaySingleplayer}", + match quick_play_type { + QuickPlayType::Singleplayer(world) => world, + _ => "", + }, + ) + .replace( + "${quickPlayMultiplayer}", + match quick_play_type { + QuickPlayType::Server(address) => address, + _ => "", + }, + )) } fn parse_arguments( @@ -334,6 +361,7 @@ fn parse_arguments( parsed_arguments: &mut Vec, parse_function: F, java_arch: &str, + quick_play_type: &QuickPlayType, ) -> crate::Result<()> where F: Fn(&str) -> crate::Result, @@ -348,7 +376,7 @@ where } } Argument::Ruled { rules, value } => { - if parse_rules(rules, java_arch, true) { + if parse_rules(rules, java_arch, quick_play_type, true) { match value { ArgumentValue::Single(arg) => { parsed_arguments.push(parse_function( diff --git a/packages/app-lib/src/launcher/download.rs b/packages/app-lib/src/launcher/download.rs index fbd239c8d..047b3a2eb 100644 --- a/packages/app-lib/src/launcher/download.rs +++ b/packages/app-lib/src/launcher/download.rs @@ -1,6 +1,7 @@ //! Downloader for Minecraft data use crate::launcher::parse_rules; +use crate::profile::QuickPlayType; use crate::{ event::{ emit::{emit_loading, loading_try_for_each_concurrent}, @@ -295,7 +296,7 @@ pub async fn download_libraries( stream::iter(libraries.iter()) .map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move { if let Some(rules) = &library.rules { - if !parse_rules(rules, java_arch, minecraft_updated) { + if !parse_rules(rules, java_arch, &QuickPlayType::None, minecraft_updated) { tracing::trace!("Skipped library {}", &library.name); return Ok(()); } diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index ac9cd71a2..ca7c5cf4b 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -4,6 +4,7 @@ use crate::event::emit::{emit_loading, init_or_edit_loading}; use crate::event::{LoadingBarId, LoadingBarType}; use crate::launcher::download::download_log_config; use crate::launcher::io::IOError; +use crate::profile::QuickPlayType; use crate::state::{ Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage, }; @@ -13,8 +14,10 @@ use chrono::Utc; use daedalus as d; use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo}; use daedalus::modded::LoaderVersion; +use serde::Deserialize; use st::Profile; use std::collections::HashMap; +use std::path::PathBuf; use tokio::process::Command; mod args; @@ -28,11 +31,14 @@ pub mod download; pub fn parse_rules( rules: &[d::minecraft::Rule], java_version: &str, + quick_play_type: &QuickPlayType, minecraft_updated: bool, ) -> bool { let mut x = rules .iter() - .map(|x| parse_rule(x, java_version, minecraft_updated)) + .map(|x| { + parse_rule(x, java_version, quick_play_type, minecraft_updated) + }) .collect::>>(); if rules @@ -53,6 +59,7 @@ pub fn parse_rules( pub fn parse_rule( rule: &d::minecraft::Rule, java_version: &str, + quick_play_type: &QuickPlayType, minecraft_updated: bool, ) -> Option { use d::minecraft::{Rule, RuleAction}; @@ -70,9 +77,14 @@ pub fn parse_rule( !features.is_demo_user.unwrap_or(true) || features.has_custom_resolution.unwrap_or(false) || !features.has_quick_plays_support.unwrap_or(true) - || !features.is_quick_play_multiplayer.unwrap_or(true) + || (features.is_quick_play_singleplayer.unwrap_or(false) + && matches!( + quick_play_type, + QuickPlayType::Singleplayer(_) + )) + || (features.is_quick_play_multiplayer.unwrap_or(false) + && matches!(quick_play_type, QuickPlayType::Server(..))) || !features.is_quick_play_realms.unwrap_or(true) - || !features.is_quick_play_singleplayer.unwrap_or(true) } _ => return Some(true), }; @@ -305,12 +317,11 @@ pub async fn install_minecraft( ) .await?; + let client_path = state + .directories + .version_dir(&version_jar) + .join(format!("{version_jar}.jar")); if let Some(processors) = &version_info.processors { - let client_path = state - .directories - .version_dir(&version_jar) - .join(format!("{version_jar}.jar")); - let libraries_dir = state.directories.libraries_dir(); if let Some(ref mut data) = version_info.data { @@ -403,8 +414,11 @@ pub async fn install_minecraft( } } + let protocol_version = read_protocol_version_from_jar(client_path).await?; + crate::api::profile::edit(&profile.path, |prof| { prof.install_stage = ProfileInstallStage::Installed; + prof.protocol_version = protocol_version; async { Ok(()) } }) @@ -414,6 +428,34 @@ pub async fn install_minecraft( Ok(()) } +pub async fn read_protocol_version_from_jar( + path: PathBuf, +) -> crate::Result> { + let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?; + let Some(entry_index) = zip + .file() + .entries() + .iter() + .position(|x| matches!(x.filename().as_str(), Ok("version.json"))) + else { + return Ok(None); + }; + + #[derive(Deserialize, Debug)] + struct VersionData { + protocol_version: Option, + } + + let mut data = vec![]; + zip.reader_with_entry(entry_index) + .await? + .read_to_end_checked(&mut data) + .await?; + let data: VersionData = serde_json::from_slice(&data)?; + + Ok(data.protocol_version) +} + #[tracing::instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn launch_minecraft( @@ -426,6 +468,7 @@ pub async fn launch_minecraft( credentials: &Credentials, post_exit_hook: Option, profile: &Profile, + quick_play_type: &QuickPlayType, ) -> crate::Result { if profile.install_stage == ProfileInstallStage::PackInstalling || profile.install_stage == ProfileInstallStage::MinecraftInstalling @@ -581,6 +624,7 @@ pub async fn launch_minecraft( *memory, Vec::from(java_args), &java_version.architecture, + quick_play_type, version_info .logging .as_ref() @@ -603,6 +647,7 @@ pub async fn launch_minecraft( &version.type_, *resolution, &java_version.architecture, + quick_play_type, )? .into_iter() .collect::>(), @@ -708,6 +753,12 @@ pub async fn launch_minecraft( // This also spawns the process and prepares the subsequent processes state .process_manager - .insert_new_process(&profile.path, command, post_exit_hook) + .insert_new_process( + &profile.path, + command, + post_exit_hook, + state.directories.profile_logs_dir(&profile.path), + version_info.logging.is_some(), + ) .await } diff --git a/packages/app-lib/src/state/fs_watcher.rs b/packages/app-lib/src/state/fs_watcher.rs index f30e3a3cf..30e22c39a 100644 --- a/packages/app-lib/src/state/fs_watcher.rs +++ b/packages/app-lib/src/state/fs_watcher.rs @@ -37,9 +37,7 @@ pub async fn init_watcher() -> crate::Result { let mut found = false; for component in e.path.components() { if found { - profile_path = Some( - component.as_os_str().to_string_lossy(), - ); + profile_path = Some(component.as_os_str()); break; } @@ -51,26 +49,72 @@ pub async fn init_watcher() -> crate::Result { } if let Some(profile_path) = profile_path { - if e.path + let profile_path_str = + profile_path.to_string_lossy().to_string(); + let first_file_name = e + .path .components() - .any(|x| x.as_os_str() == "crash-reports") + .skip_while(|x| x.as_os_str() != profile_path) + .nth(1) + .map(|x| x.as_os_str()); + if first_file_name + .filter(|x| *x == "crash-reports") + .is_some() && e.path .extension() - .map(|x| x == "txt") - .unwrap_or(false) + .filter(|x| *x == "txt") + .is_some() { - crash_task(profile_path.to_string()); + crash_task(profile_path_str); } else if !visited_profiles.contains(&profile_path) { - let path = profile_path.to_string(); - tokio::spawn(async move { - let _ = emit_profile( - &path, - ProfilePayloadType::Synced, - ) - .await; - }); - visited_profiles.push(profile_path); + let event = if first_file_name + .filter(|x| *x == "servers.dat") + .is_some() + { + Some(ProfilePayloadType::ServersUpdated) + } else if first_file_name + .filter(|x| { + *x == "saves" + && e.path + .file_name() + .filter(|x| *x == "level.dat") + .is_some() + }) + .is_some() + { + tracing::info!( + "World updated: {}", + e.path.display() + ); + Some(ProfilePayloadType::WorldUpdated { + world: e + .path + .parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + .to_string(), + }) + } else if first_file_name + .filter(|x| *x == "saves") + .is_none() + { + Some(ProfilePayloadType::Synced) + } else { + None + }; + if let Some(event) = event { + tokio::spawn(async move { + let _ = emit_profile( + &profile_path_str, + event, + ) + .await; + }); + visited_profiles.push(profile_path); + } } } }); @@ -111,13 +155,14 @@ pub(crate) async fn watch_profile( let profile_path = dirs.profiles_dir().join(profile_path); if profile_path.exists() && profile_path.is_dir() { - for folder in ProjectType::iterator() - .map(|x| x.get_folder()) - .chain(["crash-reports"]) - { + for folder in ProjectType::iterator().map(|x| x.get_folder()).chain([ + "crash-reports", + "saves", + "servers.dat", + ]) { let path = profile_path.join(folder); - if !path.exists() && !path.is_symlink() { + if !path.exists() && !path.is_symlink() && !folder.contains(".") { if let Err(e) = crate::util::io::create_dir_all(&path).await { tracing::error!( "Failed to create directory for watcher {path:?}: {e}" diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index e0cb60c32..1ec52ea65 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -320,6 +320,7 @@ where name: profile.metadata.name, icon_path: profile.metadata.icon, game_version: profile.metadata.game_version, + protocol_version: None, loader: profile.metadata.loader.into(), loader_version: profile .metadata diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 75f73f689..5ca7ffbe2 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -45,6 +45,8 @@ pub use self::mr_auth::*; mod legacy_converter; +pub mod server_join_log; + // Global state // RwLock on state only has concurrent reads, except for config dir change which takes control of the State static LAUNCHER_STATE: OnceCell> = OnceCell::const_new(); diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index cfce1fd90..485457e95 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -1,15 +1,23 @@ -use crate::event::emit::emit_process; -use crate::event::ProcessPayloadType; +use crate::event::emit::{emit_process, emit_profile}; +use crate::event::{ProcessPayloadType, ProfilePayloadType}; use crate::profile; use crate::util::io::IOError; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use dashmap::DashMap; +use quick_xml::events::Event; +use quick_xml::Reader; use serde::Deserialize; use serde::Serialize; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::ExitStatus; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; use uuid::Uuid; +const LAUNCHER_LOG_PATH: &str = "launcher_log.txt"; + pub struct ProcessManager { processes: DashMap, } @@ -32,8 +40,16 @@ impl ProcessManager { profile_path: &str, mut mc_command: Command, post_exit_command: Option, + logs_folder: PathBuf, + xml_logging: bool, ) -> crate::Result { - let mc_proc = mc_command.spawn().map_err(IOError::from)?; + mc_command.stdout(std::process::Stdio::piped()); + mc_command.stderr(std::process::Stdio::piped()); + + let mut mc_proc = mc_command.spawn().map_err(IOError::from)?; + + let stdout = mc_proc.stdout.take(); + let stderr = mc_proc.stderr.take(); let process = Process { metadata: ProcessMetadata { @@ -46,6 +62,65 @@ impl ProcessManager { let metadata = process.metadata.clone(); + if !logs_folder.exists() { + tokio::fs::create_dir_all(&logs_folder) + .await + .map_err(|e| IOError::with_path(e, &logs_folder))?; + } + + let log_path = logs_folder.join(LAUNCHER_LOG_PATH); + + { + let mut log_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&log_path) + .map_err(|e| IOError::with_path(e, &log_path))?; + + // Initialize with timestamp header + let now = chrono::Local::now(); + writeln!( + log_file, + "# Minecraft launcher log started at {}", + now.format("%Y-%m-%d %H:%M:%S") + ) + .map_err(|e| IOError::with_path(e, &log_path))?; + writeln!(log_file, "# Profile: {} \n", profile_path) + .map_err(|e| IOError::with_path(e, &log_path))?; + writeln!(log_file).map_err(|e| IOError::with_path(e, &log_path))?; + } + + if let Some(stdout) = stdout { + let log_path_clone = log_path.clone(); + + let profile_path = metadata.profile_path.clone(); + tokio::spawn(async move { + Process::process_output( + &profile_path, + stdout, + log_path_clone, + xml_logging, + ) + .await; + }); + } + + if let Some(stderr) = stderr { + let log_path_clone = log_path.clone(); + + let profile_path = metadata.profile_path.clone(); + tokio::spawn(async move { + Process::process_output( + &profile_path, + stderr, + log_path_clone, + xml_logging, + ) + .await; + }); + } + tokio::spawn(Process::sequential_process_manager( profile_path.to_string(), post_exit_command, @@ -120,7 +195,381 @@ struct Process { child: Child, } +#[derive(Debug, Default)] +struct Log4jEvent { + timestamp: Option, + logger: Option, + level: Option, + thread: Option, + message: Option, +} + impl Process { + async fn process_output( + profile_path: &str, + reader: R, + log_path: impl AsRef, + xml_logging: bool, + ) where + R: tokio::io::AsyncRead + Unpin, + { + let mut buf_reader = BufReader::new(reader); + + if xml_logging { + let mut reader = Reader::from_reader(buf_reader); + reader.config_mut().enable_all_checks(false); + + let mut buf = Vec::new(); + let mut current_event = Log4jEvent::default(); + let mut in_event = false; + let mut in_message = false; + let mut in_throwable = false; + let mut current_content = String::new(); + + loop { + match reader.read_event_into_async(&mut buf).await { + Err(e) => { + tracing::error!( + "Error at position {}: {:?}", + reader.buffer_position(), + e + ); + break; + } + // exits the loop when reaching end of file + Ok(Event::Eof) => break, + + Ok(Event::Start(e)) => { + match e.name().as_ref() { + b"log4j:Event" => { + // Reset for new event + current_event = Log4jEvent::default(); + in_event = true; + + // Extract attributes + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy( + attr.key.into_inner(), + ) + .to_string(); + let value = + String::from_utf8_lossy(&attr.value) + .to_string(); + + match key.as_str() { + "logger" => { + current_event.logger = Some(value) + } + "level" => { + current_event.level = Some(value) + } + "thread" => { + current_event.thread = Some(value) + } + "timestamp" => { + current_event.timestamp = + Some(value) + } + _ => {} + } + } + } + b"log4j:Message" => { + in_message = true; + current_content = String::new(); + } + b"log4j:Throwable" => { + in_throwable = true; + current_content = String::new(); + } + _ => {} + } + } + Ok(Event::End(e)) => { + match e.name().as_ref() { + b"log4j:Message" => { + in_message = false; + current_event.message = + Some(current_content.clone()); + } + b"log4j:Throwable" => { + in_throwable = false; + // Process and write the log entry + let thread = current_event + .thread + .as_deref() + .unwrap_or(""); + let level = current_event + .level + .as_deref() + .unwrap_or(""); + let logger = current_event + .logger + .as_deref() + .unwrap_or(""); + + if let Some(message) = ¤t_event.message { + let formatted_time = + Process::format_timestamp( + current_event.timestamp.as_deref(), + ); + let formatted_log = format!( + "{} [{}] [{}{}]: {}\n", + formatted_time, + thread, + if !logger.is_empty() { + format!("{}/", logger) + } else { + String::new() + }, + level, + message.trim() + ); + + // Write the log message + if let Err(e) = Process::append_to_log_file( + &log_path, + &formatted_log, + ) { + tracing::error!( + "Failed to write to log file: {}", + e + ); + } + + // Write the throwable if present + if !current_content.is_empty() { + if let Err(e) = + Process::append_to_log_file( + &log_path, + ¤t_content, + ) + { + tracing::error!("Failed to write throwable to log file: {}", e); + } + } + } + } + b"log4j:Event" => { + in_event = false; + // If no throwable was present, write the log entry at the end of the event + if current_event.message.is_some() + && !in_throwable + { + let thread = current_event + .thread + .as_deref() + .unwrap_or(""); + let level = current_event + .level + .as_deref() + .unwrap_or(""); + let logger = current_event + .logger + .as_deref() + .unwrap_or(""); + let message = current_event + .message + .as_deref() + .unwrap_or("") + .trim(); + + let formatted_time = + Process::format_timestamp( + current_event.timestamp.as_deref(), + ); + let formatted_log = format!( + "{} [{}] [{}{}]: {}\n", + formatted_time, + thread, + if !logger.is_empty() { + format!("{}/", logger) + } else { + String::new() + }, + level, + message + ); + + // Write the log message + if let Err(e) = Process::append_to_log_file( + &log_path, + &formatted_log, + ) { + tracing::error!( + "Failed to write to log file: {}", + e + ); + } + + if let Some(timestamp) = + current_event.timestamp.as_deref() + { + if let Err(e) = Self::maybe_handle_server_join_logging( + profile_path, + timestamp, + message + ).await { + tracing::error!("Failed to handle server join logging: {e}"); + } + } + } + } + _ => {} + } + } + Ok(Event::Text(mut e)) => { + if in_message || in_throwable { + if let Ok(text) = e.unescape() { + current_content.push_str(&text); + } + } else if !in_event + && !e.inplace_trim_end() + && !e.inplace_trim_start() + { + if let Ok(text) = e.unescape() { + if let Err(e) = Process::append_to_log_file( + &log_path, + &format!("{text}\n"), + ) { + tracing::error!( + "Failed to write to log file: {}", + e + ); + } + } + } + } + Ok(Event::CData(e)) => { + if in_message || in_throwable { + if let Ok(text) = e + .escape() + .map_err(|x| x.into()) + .and_then(|x| x.unescape()) + { + current_content.push_str(&text); + } + } + } + _ => (), + } + + buf.clear(); + } + } else { + let mut line = String::new(); + + while let Ok(bytes_read) = buf_reader.read_line(&mut line).await { + if bytes_read == 0 { + break; // End of stream + } + + if !line.is_empty() { + if let Err(e) = Self::append_to_log_file(&log_path, &line) { + tracing::warn!("Failed to write to log file: {}", e); + } + } + + line.clear(); + } + } + } + + fn format_timestamp(timestamp: Option<&str>) -> String { + if let Some(timestamp_str) = timestamp { + if let Ok(timestamp_val) = timestamp_str.parse::() { + let datetime_utc = if timestamp_val > i32::MAX as i64 { + let secs = timestamp_val / 1000; + let nsecs = ((timestamp_val % 1000) * 1_000_000) as u32; + + chrono::DateTime::::from_timestamp(secs, nsecs) + .unwrap_or_default() + } else { + chrono::DateTime::::from_timestamp(timestamp_val, 0) + .unwrap_or_default() + }; + + let datetime_local = datetime_utc.with_timezone(&chrono::Local); + format!("[{}]", datetime_local.format("%H:%M:%S")) + } else { + "[??:??:??]".to_string() + } + } else { + "[??:??:??]".to_string() + } + } + + fn append_to_log_file( + path: impl AsRef, + line: &str, + ) -> std::io::Result<()> { + let mut file = + OpenOptions::new().append(true).create(true).open(path)?; + + file.write_all(line.as_bytes())?; + Ok(()) + } + + async fn maybe_handle_server_join_logging( + profile_path: &str, + timestamp: &str, + message: &str, + ) -> crate::Result<()> { + let Some(host_port_string) = message.strip_prefix("Connecting to ") + else { + return Ok(()); + }; + let Some((host, port_string)) = host_port_string.rsplit_once(", ") + else { + return Ok(()); + }; + let Some(port) = port_string.parse::().ok() else { + return Ok(()); + }; + let timestamp = timestamp + .parse::() + .map(|x| x / 1000) + .map_err(|x| { + crate::ErrorKind::OtherError(format!( + "Failed to parse timestamp: {x}" + )) + }) + .and_then(|x| { + Utc.timestamp_opt(x, 0).single().ok_or_else(|| { + crate::ErrorKind::OtherError( + "Failed to convert timestamp to DateTime".to_string(), + ) + }) + })?; + + let state = crate::State::get().await?; + crate::state::server_join_log::JoinLogEntry { + profile_path: profile_path.to_owned(), + host: host.to_string(), + port, + join_time: timestamp, + } + .upsert(&state.pool) + .await?; + { + let profile_path = profile_path.to_owned(); + let host = host.to_owned(); + tokio::spawn(async move { + let _ = emit_profile( + &profile_path, + ProfilePayloadType::ServerJoined { + host, + port, + timestamp, + }, + ) + .await; + }); + } + + Ok(()) + } + // Spawns a new child process and inserts it into the hashmap // Also, as the process ends, it spawns the follow-up process if it exists // By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status @@ -204,6 +653,24 @@ impl Process { } }); + let logs_folder = state.directories.profile_logs_dir(&profile_path); + let log_path = logs_folder.join(LAUNCHER_LOG_PATH); + + if log_path.exists() { + if let Err(e) = Process::append_to_log_file( + &log_path, + &format!( + "\n# Process exited with status: {}\n", + mc_exit_status + ), + ) { + tracing::warn!( + "Failed to write exit status to log file: {}", + e + ); + } + } + let _ = state.discord_rpc.clear_to_default(true).await; let _ = state.friends_socket.update_status(None).await; diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs index 97b92cf8d..08c2b510f 100644 --- a/packages/app-lib/src/state/profiles.rs +++ b/packages/app-lib/src/state/profiles.rs @@ -23,6 +23,7 @@ pub struct Profile { pub icon_path: Option, pub game_version: String, + pub protocol_version: Option, pub loader: ModLoader, pub loader_version: Option, @@ -261,6 +262,7 @@ struct ProfileQueryResult { override_hook_pre_launch: Option, override_hook_wrapper: Option, override_hook_post_exit: Option, + protocol_version: Option, } impl TryFrom for Profile { @@ -273,6 +275,7 @@ impl TryFrom for Profile { name: x.name, icon_path: x.icon_path, game_version: x.game_version, + protocol_version: x.protocol_version.map(|x| x as i32), loader: ModLoader::from_string(&x.mod_loader), loader_version: x.mod_loader_version, groups: serde_json::from_value(x.groups).unwrap_or_default(), @@ -337,7 +340,7 @@ macro_rules! select_profiles_with_predicate { r#" SELECT path, install_stage, name, icon_path, - game_version, mod_loader, mod_loader_version, + game_version, protocol_version, mod_loader, mod_loader_version, json(groups) as "groups!: serde_json::Value", linked_project_id, linked_version_id, locked, created, modified, last_played, @@ -435,7 +438,8 @@ impl Profile { submitted_time_played, recent_time_played, override_java_path, override_extra_launch_args, override_custom_env_vars, override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y, - override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit + override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit, + protocol_version ) VALUES ( $1, $2, $3, $4, @@ -446,7 +450,8 @@ impl Profile { $15, $16, $17, jsonb($18), jsonb($19), $20, $21, $22, $23, - $24, $25, $26 + $24, $25, $26, + $27 ) ON CONFLICT (path) DO UPDATE SET install_stage = $2, @@ -480,7 +485,9 @@ impl Profile { override_hook_pre_launch = $24, override_hook_wrapper = $25, - override_hook_post_exit = $26 + override_hook_post_exit = $26, + + protocol_version = $27 ", self.path, install_stage, @@ -508,6 +515,7 @@ impl Profile { self.hooks.pre_launch, self.hooks.wrapper, self.hooks.post_exit, + self.protocol_version, ) .execute(exec) .await?; diff --git a/packages/app-lib/src/state/server_join_log.rs b/packages/app-lib/src/state/server_join_log.rs new file mode 100644 index 000000000..3de9e04fd --- /dev/null +++ b/packages/app-lib/src/state/server_join_log.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; + +use chrono::{DateTime, TimeZone, Utc}; + +pub struct JoinLogEntry { + pub profile_path: String, + pub host: String, + pub port: u16, + pub join_time: DateTime, +} + +impl JoinLogEntry { + pub async fn upsert( + &self, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let join_time = self.join_time.timestamp(); + + sqlx::query!( + " + INSERT INTO join_log (profile_path, host, port, join_time) + VALUES ($1, $2, $3, $4) + ON CONFLICT (profile_path, host, port) DO UPDATE SET + join_time = $4 + ", + self.profile_path, + self.host, + self.port, + join_time + ) + .execute(exec) + .await?; + + Ok(()) + } +} + +pub async fn get_joins( + instance: &str, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, +) -> crate::Result>> { + let joins = sqlx::query!( + " + SELECT profile_path, host, port, join_time + FROM join_log + WHERE profile_path = $1 + ", + instance + ) + .fetch_all(exec) + .await?; + + Ok(joins + .into_iter() + .map(|x| { + ( + (x.host, x.port as u16), + Utc.timestamp_opt(x.join_time, 0) + .single() + .unwrap_or_else(Utc::now), + ) + }) + .collect()) +} diff --git a/packages/app-lib/src/util/io.rs b/packages/app-lib/src/util/io.rs index 684cc2b9c..99ea83fc7 100644 --- a/packages/app-lib/src/util/io.rs +++ b/packages/app-lib/src/util/io.rs @@ -255,3 +255,29 @@ pub async fn remove_file( path: path.to_string_lossy().to_string(), }) } + +// remove dir +pub async fn remove_dir( + path: impl AsRef, +) -> Result<(), IOError> { + let path = path.as_ref(); + tokio::fs::remove_dir(path) + .await + .map_err(|e| IOError::IOPathError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +// metadata +pub async fn metadata( + path: impl AsRef, +) -> Result { + let path = path.as_ref(); + tokio::fs::metadata(path) + .await + .map_err(|e| IOError::IOPathError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} diff --git a/packages/app-lib/src/util/mod.rs b/packages/app-lib/src/util/mod.rs index 813ec149c..5a310291c 100644 --- a/packages/app-lib/src/util/mod.rs +++ b/packages/app-lib/src/util/mod.rs @@ -3,6 +3,7 @@ pub mod fetch; pub mod io; pub mod jre; pub mod platform; +pub mod server_ping; /// Wrap a builder which uses a mut reference into one which outputs an owned value macro_rules! wrap_ref_builder { diff --git a/packages/app-lib/src/util/server_ping.rs b/packages/app-lib/src/util/server_ping.rs new file mode 100644 index 000000000..7a22c1a7a --- /dev/null +++ b/packages/app-lib/src/util/server_ping.rs @@ -0,0 +1,223 @@ +use crate::error::Result; +use crate::ErrorKind; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use std::time::Duration; +use tokio::net::ToSocketAddrs; +use tokio::select; +use url::Url; + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ServerStatus { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub players: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub favicon: Option, + #[serde(default)] + pub enforces_secure_chat: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub ping: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ServerPlayers { + pub max: i32, + pub online: i32, + #[serde(default)] + pub sample: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ServerGameProfile { + pub id: String, + pub name: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ServerVersion { + pub name: String, + pub protocol: i32, +} + +pub async fn get_server_status( + address: &impl ToSocketAddrs, + original_address: (&str, u16), + protocol_version: Option, +) -> Result { + select! { + res = modern::status(address, original_address, protocol_version) => res, + _ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError( + format!("Ping of {}:{} timed out", original_address.0, original_address.1) + ).into()) + } +} + +mod modern { + use super::ServerStatus; + use crate::ErrorKind; + use chrono::Utc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpStream, ToSocketAddrs}; + + pub async fn status( + address: &impl ToSocketAddrs, + original_address: (&str, u16), + protocol_version: Option, + ) -> crate::Result { + let mut stream = TcpStream::connect(address).await?; + handshake(&mut stream, original_address, protocol_version).await?; + let mut result = status_body(&mut stream).await?; + result.ping = ping(&mut stream).await.ok(); + Ok(result) + } + + async fn handshake( + stream: &mut TcpStream, + original_address: (&str, u16), + protocol_version: Option, + ) -> crate::Result<()> { + let (host, port) = original_address; + let protocol_version = protocol_version.unwrap_or(-1); + + const PACKET_ID: i32 = 0; + const NEXT_STATE: i32 = 1; + + let packet_size = varint::get_byte_size(PACKET_ID) + + varint::get_byte_size(protocol_version) + + varint::get_byte_size(host.len() as i32) + + host.len() + + size_of::() + + varint::get_byte_size(NEXT_STATE); + + let mut packet_buffer = Vec::with_capacity( + varint::get_byte_size(packet_size as i32) + packet_size, + ); + + varint::write(&mut packet_buffer, packet_size as i32); + varint::write(&mut packet_buffer, PACKET_ID); + varint::write(&mut packet_buffer, protocol_version); + varint::write(&mut packet_buffer, host.len() as i32); + packet_buffer.extend_from_slice(host.as_bytes()); + packet_buffer.extend_from_slice(&port.to_be_bytes()); + varint::write(&mut packet_buffer, NEXT_STATE); + + stream.write_all(&packet_buffer).await?; + stream.flush().await?; + + Ok(()) + } + + async fn status_body( + stream: &mut TcpStream, + ) -> crate::Result { + stream.write_all(&[0x01, 0x00]).await?; + stream.flush().await?; + + let packet_length = varint::read(stream).await?; + if packet_length < 0 { + return Err(ErrorKind::InputError( + "Invalid status response packet length".to_string(), + ) + .into()); + } + + let mut packet_stream = stream.take(packet_length as u64); + let packet_id = varint::read(&mut packet_stream).await?; + if packet_id != 0x00 { + return Err(ErrorKind::InputError( + "Unexpected status response".to_string(), + ) + .into()); + } + let response_length = varint::read(&mut packet_stream).await?; + let mut json_response = vec![0_u8; response_length as usize]; + packet_stream.read_exact(&mut json_response).await?; + + if packet_stream.limit() > 0 { + tokio::io::copy(&mut packet_stream, &mut tokio::io::sink()).await?; + } + + Ok(serde_json::from_slice(&json_response)?) + } + + async fn ping(stream: &mut TcpStream) -> crate::Result { + let start_time = Utc::now(); + let ping_magic = start_time.timestamp_millis(); + + stream.write_all(&[0x09, 0x01]).await?; + stream.write_i64(ping_magic).await?; + stream.flush().await?; + + let mut response_prefix = [0_u8; 2]; + stream.read_exact(&mut response_prefix).await?; + let response_magic = stream.read_i64().await?; + if response_prefix != [0x09, 0x01] || response_magic != ping_magic { + return Err(ErrorKind::InputError( + "Unexpected ping response".to_string(), + ) + .into()); + } + + let response_time = Utc::now(); + Ok((response_time - start_time).num_milliseconds()) + } + + mod varint { + use std::io; + use tokio::io::{AsyncRead, AsyncReadExt}; + + const MAX_VARINT_SIZE: usize = 5; + const DATA_BITS_MASK: u32 = 0x7f; + const CONT_BIT_MASK_U8: u8 = 0x80; + const CONT_BIT_MASK_U32: u32 = CONT_BIT_MASK_U8 as u32; + const DATA_BITS_PER_BYTE: usize = 7; + + pub fn get_byte_size(x: i32) -> usize { + let x = x as u32; + for size in 1..MAX_VARINT_SIZE { + if (x & (u32::MAX << (size * DATA_BITS_PER_BYTE))) == 0 { + return size; + } + } + MAX_VARINT_SIZE + } + + pub fn write(out: &mut Vec, value: i32) { + let mut value = value as u32; + while value >= CONT_BIT_MASK_U32 { + out.push(((value & DATA_BITS_MASK) | CONT_BIT_MASK_U32) as u8); + value >>= DATA_BITS_PER_BYTE; + } + out.push(value as u8); + } + + pub async fn read( + reader: &mut R, + ) -> io::Result { + let mut result = 0; + let mut shift = 0; + + loop { + let b = reader.read_u8().await?; + result |= + (b as u32 & DATA_BITS_MASK) << (shift * DATA_BITS_PER_BYTE); + shift += 1; + if shift > MAX_VARINT_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "VarInt too big", + )); + } + if b & CONT_BIT_MASK_U8 == 0 { + return Ok(result as i32); + } + } + } + } +} diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index 7410fd31c..68fac36dc 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -13,3 +13,4 @@ rand = "0.8.5" either = "1.13" chrono = { version = "0.4.26", features = ["serde"] } serde_cbor = "0.11" +lazy_static = "1.5" diff --git a/packages/ariadne/src/lib.rs b/packages/ariadne/src/lib.rs index 4a4251a3e..a1ee76540 100644 --- a/packages/ariadne/src/lib.rs +++ b/packages/ariadne/src/lib.rs @@ -1,3 +1,4 @@ pub mod ids; pub mod networking; pub mod users; +pub mod versions; diff --git a/packages/ariadne/src/versions.rs b/packages/ariadne/src/versions.rs new file mode 100644 index 000000000..4ea00b659 --- /dev/null +++ b/packages/ariadne/src/versions.rs @@ -0,0 +1,47 @@ +use lazy_static::lazy_static; +use std::collections::HashMap; + +lazy_static! { + static ref SPECIAL_PARENTS: HashMap<&'static str, &'static str> = { + let mut m = HashMap::new(); + m.insert("15w14a", "1.8.3"); + m.insert("1.RV-Pre1", "1.9.2"); + m.insert("3D Shareware v1.34", "19w13b"); + m.insert("20w14infinite", "20w13b"); + m.insert("22w13oneblockatatime", "1.18.2"); + m.insert("23w13a_or_b", "23w13a"); + m.insert("24w14potato", "24w12a"); + m + }; +} + +pub fn is_feature_supported_in( + version: &str, + first_release: &str, + first_snapshot: &str, +) -> bool { + let version = SPECIAL_PARENTS.get(version).copied().unwrap_or(version); + if version.contains('w') && version.len() == 6 { + return version >= first_snapshot; + } + if version == first_release { + return true; + } + let parts_version = version.split('.'); + let parts_first_release = first_release.split('.'); + for (part_version, part_first_release) in + parts_version.zip(parts_first_release) + { + if part_version == part_first_release { + continue; + } + if let Ok(part_version) = part_version.parse::() { + if let Ok(part_first_release) = part_first_release.parse::() { + if part_version > part_first_release { + return true; + } + } + } + } + false +} diff --git a/packages/assets/external/pyro.svg b/packages/assets/external/pyro.svg deleted file mode 100644 index 16eebd786..000000000 --- a/packages/assets/external/pyro.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/assets/icons/blocks.svg b/packages/assets/icons/blocks.svg new file mode 100644 index 000000000..4b78f5051 --- /dev/null +++ b/packages/assets/icons/blocks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/no-signal.svg b/packages/assets/icons/no-signal.svg new file mode 100644 index 000000000..90318e971 --- /dev/null +++ b/packages/assets/icons/no-signal.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/assets/icons/pickaxe.svg b/packages/assets/icons/pickaxe.svg new file mode 100644 index 000000000..40e92c49c --- /dev/null +++ b/packages/assets/icons/pickaxe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/signal.svg b/packages/assets/icons/signal.svg new file mode 100644 index 000000000..dcdd37462 --- /dev/null +++ b/packages/assets/icons/signal.svg @@ -0,0 +1 @@ + diff --git a/packages/assets/icons/skull.svg b/packages/assets/icons/skull.svg new file mode 100644 index 000000000..f56453eb2 --- /dev/null +++ b/packages/assets/icons/skull.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/world.svg b/packages/assets/icons/world.svg new file mode 100644 index 000000000..cb5c68469 --- /dev/null +++ b/packages/assets/icons/world.svg @@ -0,0 +1 @@ + diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 1b7f51da9..63fead85b 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -27,7 +27,6 @@ import _TumblrIcon from './external/tumblr.svg?component' import _TwitterIcon from './external/twitter.svg?component' import _WindowsIcon from './external/windows.svg?component' import _YouTubeIcon from './external/youtube.svg?component' -import _PyroIcon from './external/pyro.svg?component' // Icons import _AlignLeftIcon from './icons/align-left.svg?component' @@ -37,6 +36,7 @@ import _AsteriskIcon from './icons/asterisk.svg?component' import _BanIcon from './icons/ban.svg?component' import _BellIcon from './icons/bell.svg?component' import _BellRingIcon from './icons/bell-ring.svg?component' +import _BlocksIcon from './icons/blocks.svg?component' import _BookIcon from './icons/book.svg?component' import _BookTextIcon from './icons/book-text.svg?component' import _BookmarkIcon from './icons/bookmark.svg?component' @@ -123,12 +123,14 @@ import _MoonIcon from './icons/moon.svg?component' import _MoreHorizontalIcon from './icons/more-horizontal.svg?component' import _MoreVerticalIcon from './icons/more-vertical.svg?component' import _NewspaperIcon from './icons/newspaper.svg?component' +import _NoSignalIcon from './icons/no-signal.svg?component' import _OmorphiaIcon from './icons/omorphia.svg?component' import _OrganizationIcon from './icons/organization.svg?component' import _PackageIcon from './icons/package.svg?component' import _PackageOpenIcon from './icons/package-open.svg?component' import _PackageClosedIcon from './icons/package-closed.svg?component' import _PaintBrushIcon from './icons/paintbrush.svg?component' +import _PickaxeIcon from './icons/pickaxe.svg?component' import _PlayIcon from './icons/play.svg?component' import _PlugIcon from './icons/plug.svg?component' import _PlusIcon from './icons/plus.svg?component' @@ -150,6 +152,8 @@ import _ServerIcon from './icons/server.svg?component' import _SettingsIcon from './icons/settings.svg?component' import _ShareIcon from './icons/share.svg?component' import _ShieldIcon from './icons/shield.svg?component' +import _SignalIcon from './icons/signal.svg?component' +import _SkullIcon from './icons/skull.svg?component' import _SlashIcon from './icons/slash.svg?component' import _SortAscendingIcon from './icons/sort-asc.svg?component' import _SortDescendingIcon from './icons/sort-desc.svg?component' @@ -179,6 +183,7 @@ import _UsersIcon from './icons/users.svg?component' import _VersionIcon from './icons/version.svg?component' import _WikiIcon from './icons/wiki.svg?component' import _WindowIcon from './icons/window.svg?component' +import _WorldIcon from './icons/world.svg?component' import _WrenchIcon from './icons/wrench.svg?component' import _XIcon from './icons/x.svg?component' import _XCircleIcon from './icons/x-circle.svg?component' @@ -226,7 +231,6 @@ export const MastodonIcon = _MastodonIcon export const OpenCollectiveIcon = _OpenCollectiveIcon export const PatreonIcon = _PatreonIcon export const PayPalIcon = _PayPalIcon -export const PyroIcon = _PyroIcon export const RedditIcon = _RedditIcon export const TumblrIcon = _TumblrIcon export const TwitterIcon = _TwitterIcon @@ -239,6 +243,7 @@ export const AsteriskIcon = _AsteriskIcon export const BanIcon = _BanIcon export const BellIcon = _BellIcon export const BellRingIcon = _BellRingIcon +export const BlocksIcon = _BlocksIcon export const BookIcon = _BookIcon export const BookTextIcon = _BookTextIcon export const BookmarkIcon = _BookmarkIcon @@ -325,12 +330,14 @@ export const MoonIcon = _MoonIcon export const MoreHorizontalIcon = _MoreHorizontalIcon export const MoreVerticalIcon = _MoreVerticalIcon export const NewspaperIcon = _NewspaperIcon +export const NoSignalIcon = _NoSignalIcon export const OmorphiaIcon = _OmorphiaIcon export const OrganizationIcon = _OrganizationIcon export const PackageIcon = _PackageIcon export const PackageOpenIcon = _PackageOpenIcon export const PackageClosedIcon = _PackageClosedIcon export const PaintBrushIcon = _PaintBrushIcon +export const PickaxeIcon = _PickaxeIcon export const PlayIcon = _PlayIcon export const PlugIcon = _PlugIcon export const PlusIcon = _PlusIcon @@ -352,6 +359,8 @@ export const ServerIcon = _ServerIcon export const SettingsIcon = _SettingsIcon export const ShareIcon = _ShareIcon export const ShieldIcon = _ShieldIcon +export const SignalIcon = _SignalIcon +export const SkullIcon = _SkullIcon export const SlashIcon = _SlashIcon export const SortAscendingIcon = _SortAscendingIcon export const SortDescendingIcon = _SortDescendingIcon @@ -381,6 +390,7 @@ export const UsersIcon = _UsersIcon export const VersionIcon = _VersionIcon export const WikiIcon = _WikiIcon export const WindowIcon = _WindowIcon +export const WorldIcon = _WorldIcon export const WrenchIcon = _WrenchIcon export const XIcon = _XIcon export const XCircleIcon = _XCircleIcon diff --git a/packages/assets/styles/defaults.scss b/packages/assets/styles/defaults.scss index 712817ee3..36ecd625b 100644 --- a/packages/assets/styles/defaults.scss +++ b/packages/assets/styles/defaults.scss @@ -59,7 +59,7 @@ textarea, padding: 0.5rem 1rem; font-weight: var(--font-weight-medium); transition: box-shadow 0.1s ease-in-out; - min-height: 40px; + min-height: 36px; box-shadow: var(--shadow-inset-sm), 0 0 0 0 transparent; @@ -159,7 +159,7 @@ input[type='number'] { @extend .transparent, .icon-only; position: absolute; - right: 0.25rem; + right: 0.125rem; z-index: 1; svg { diff --git a/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index f462c886d..315c98989 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -84,6 +84,8 @@ --color-platform-velocity: #4b98b0; --color-platform-waterfall: #5f83cb; --color-platform-sponge: #c49528; + + --hover-brightness: 0.9; } html { @@ -196,6 +198,8 @@ html { --color-platform-velocity: #83d5ef; --color-platform-waterfall: #78a4fb; --color-platform-sponge: #f9e580; + + --hover-brightness: 1.25; } .oled-mode { diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 690218b80..4c14937b2 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1,3 +1,4 @@ export * from './src/components/index' export { commonMessages, commonSettingsMessages } from './src/utils/common-messages' export * from './src/utils/search' +export { GAME_MODES } from './src/utils/game-modes' diff --git a/packages/ui/src/components/base/ButtonStyled.vue b/packages/ui/src/components/base/ButtonStyled.vue index 4be4a2fc4..09e0b54d7 100644 --- a/packages/ui/src/components/base/ButtonStyled.vue +++ b/packages/ui/src/components/base/ButtonStyled.vue @@ -245,7 +245,7 @@ const colorVariables = computed(() => { } &:not([disabled]):not([disabled='true']):not(.disabled) { - @apply active:scale-95 hover:brightness-125 focus-visible:brightness-125 hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text]; + @apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text]; &:hover svg:first-child, &:focus-visible svg:first-child { diff --git a/packages/ui/src/components/base/FilterBar.vue b/packages/ui/src/components/base/FilterBar.vue new file mode 100644 index 000000000..72f6e8b68 --- /dev/null +++ b/packages/ui/src/components/base/FilterBar.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/ui/src/components/base/HeadingLink.vue b/packages/ui/src/components/base/HeadingLink.vue new file mode 100644 index 000000000..ecc2f5012 --- /dev/null +++ b/packages/ui/src/components/base/HeadingLink.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/ui/src/components/base/RadialHeader.vue b/packages/ui/src/components/base/RadialHeader.vue index 89e80b67b..05de75440 100644 --- a/packages/ui/src/components/base/RadialHeader.vue +++ b/packages/ui/src/components/base/RadialHeader.vue @@ -1,8 +1,10 @@ + diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 9aae805a4..901b482c6 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -17,6 +17,9 @@ export { default as DropArea } from './base/DropArea.vue' export { default as DropdownSelect } from './base/DropdownSelect.vue' export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue' export { default as FileInput } from './base/FileInput.vue' +export { default as FilterBar } from './base/FilterBar.vue' +export type { FilterBarOption } from './base/FilterBar.vue' +export { default as HeadingLink } from './base/HeadingLink.vue' export { default as LoadingIndicator } from './base/LoadingIndicator.vue' export { default as ManySelect } from './base/ManySelect.vue' export { default as MarkdownEditor } from './base/MarkdownEditor.vue' @@ -34,6 +37,7 @@ export { default as ScrollablePanel } from './base/ScrollablePanel.vue' export { default as ServerNotice } from './base/ServerNotice.vue' export { default as SimpleBadge } from './base/SimpleBadge.vue' export { default as Slider } from './base/Slider.vue' +export { default as SmartClickable } from './base/SmartClickable.vue' export { default as StatItem } from './base/StatItem.vue' export { default as TagItem } from './base/TagItem.vue' export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue' diff --git a/packages/ui/src/components/modal/ConfirmModal.vue b/packages/ui/src/components/modal/ConfirmModal.vue index e9925d437..37c9c0cfe 100644 --- a/packages/ui/src/components/modal/ConfirmModal.vue +++ b/packages/ui/src/components/modal/ConfirmModal.vue @@ -6,11 +6,16 @@

-
+