From 29d1f1c9d2d686cfbfa29e138bb16a210f11fa53 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Wed, 15 Jul 2026 13:41:43 -0400 Subject: [PATCH] Bugs Fixed #492 Fixed #510 Fixed #506 Fixed #568 Fixed #582 Fixed #578 Fixed #567 Fixed #588 Fixed #585 Fixed #571 Fixed #587 Fixed #584 --- Cargo.lock | 670 +- Cargo.toml | 13 + packages/oneclient_app/examples/min_freya.rs | 1 - packages/oneclient_app/src/assets.rs | 15 +- packages/oneclient_app/src/bridge/commands.rs | 57 +- packages/oneclient_app/src/bridge/mod.rs | 1 - packages/oneclient_app/src/bridge/runtime.rs | 77 +- packages/oneclient_app/src/bridge/snapshot.rs | 5 +- .../src/components/active_cluster_panel.rs | 10 +- .../oneclient_app/src/components/button.rs | 16 +- .../src/components/cluster_update_popup.rs | 14 +- .../oneclient_app/src/components/dropdown.rs | 12 +- .../src/components/dynamic_art.rs | 9 +- .../src/components/log_viewer.rs | 22 +- .../src/components/major_version_card.rs | 11 +- .../src/components/microsoft_login.rs | 4 +- .../src/components/navbar/app_navbar.rs | 18 +- .../src/components/package_row.rs | 20 +- .../src/components/player_model.rs | 2 +- .../src/components/recents_row.rs | 10 +- .../src/components/screenshot_viewer.rs | 26 +- .../src/components/scrollview.rs | 6 +- .../oneclient_app/src/components/tab_bar.rs | 2 +- .../oneclient_app/src/components/toasts.rs | 1 - .../src/components/update_prompt.rs | 2 +- packages/oneclient_app/src/constants.rs | 2 +- packages/oneclient_app/src/hooks/dispatch.rs | 35 +- packages/oneclient_app/src/hooks/mod.rs | 38 +- .../src/hooks/queries/analytics.rs | 2 - .../oneclient_app/src/hooks/queries/auth.rs | 1 - .../src/hooks/queries/bundles.rs | 16 +- .../src/hooks/queries/changelog.rs | 13 +- .../src/hooks/queries/cluster_content.rs | 5 +- .../src/hooks/queries/clusters.rs | 4 +- .../oneclient_app/src/hooks/queries/java.rs | 16 +- .../oneclient_app/src/hooks/queries/logs.rs | 4 +- .../oneclient_app/src/hooks/queries/mod.rs | 2 + .../src/hooks/queries/mutations.rs | 28 +- .../src/hooks/queries/packages.rs | 41 +- .../src/hooks/queries/player_profile.rs | 5 +- .../src/hooks/queries/screenshots.rs | 2 - .../oneclient_app/src/hooks/queries/skin.rs | 2 +- .../oneclient_app/src/hooks/queries/tos.rs | 61 + .../src/hooks/queries/version_metadata.rs | 6 +- .../src/hooks/queries/versions.rs | 4 +- .../oneclient_app/src/hooks/view_state.rs | 56 +- .../oneclient_app/src/layout/app_shell.rs | 2 +- .../oneclient_app/src/layout/cluster_shell.rs | 2 +- packages/oneclient_app/src/layout/mod.rs | 2 +- .../src/layout/settings_shell.rs | 46 +- packages/oneclient_app/src/lib.rs | 2 +- packages/oneclient_app/src/main.rs | 9 +- packages/oneclient_app/src/routes.rs | 9 +- packages/oneclient_app/src/theme/colors.rs | 2 +- packages/oneclient_app/src/ui.rs | 16 +- packages/oneclient_app/src/updater.rs | 5 +- packages/oneclient_app/src/utils.rs | 32 +- .../oneclient_app/src/view/app/accounts.rs | 281 +- .../src/view/app/analytics/charts.rs | 48 +- .../src/view/app/analytics/mod.rs | 6 +- .../src/view/app/analytics/servers.rs | 22 +- .../src/view/app/analytics/tiles.rs | 26 +- .../src/view/app/browser/index/cards.rs | 144 +- .../src/view/app/browser/index/mod.rs | 8 +- .../src/view/app/browser/index/sidebar.rs | 1 - .../src/view/app/browser/index/skeletons.rs | 4 +- .../src/view/app/browser/package/mod.rs | 5 +- .../src/view/app/browser/package/panels.rs | 6 +- .../src/view/app/browser/package/sidebar.rs | 5 +- .../src/view/app/cluster/cluster_settings.rs | 40 +- .../src/view/app/cluster/logs.rs | 7 +- .../oneclient_app/src/view/app/cluster/mod.rs | 8 +- .../src/view/app/cluster/mods.rs | 16 +- .../view/app/cluster/package_manager/mod.rs | 52 +- .../view/app/cluster/package_manager/views.rs | 22 +- .../src/view/app/cluster/process_logs.rs | 5 +- .../src/view/app/cluster/screenshots.rs | 21 +- .../src/view/app/cluster/shaders.rs | 16 +- .../src/view/app/cluster/textures.rs | 16 +- .../src/view/app/clusters/page.rs | 27 +- packages/oneclient_app/src/view/app/mod.rs | 10 +- .../src/view/app/settings/apis.rs | 2 +- .../src/view/app/settings/appearance.rs | 2 +- .../src/view/app/settings/changelog.rs | 20 +- .../src/view/app/settings/developer.rs | 4 +- .../src/view/app/settings/java.rs | 33 +- .../src/view/app/settings/language.rs | 15 +- .../src/view/app/settings/launcher.rs | 15 +- .../src/view/app/settings/minecraft.rs | 18 +- .../src/view/app/settings/mod.rs | 4 +- packages/oneclient_app/src/view/app/skins.rs | 7 +- packages/oneclient_app/src/view/mod.rs | 4 +- packages/oneclient_app/src/view/not_found.rs | 2 +- .../src/view/onboarding/account.rs | 7 +- .../src/view/onboarding/bundles/card.rs | 4 +- .../src/view/onboarding/bundles/mod.rs | 4 +- .../src/view/onboarding/bundles/popup.rs | 8 +- .../view/onboarding/downloading/summary.rs | 5 +- .../src/view/onboarding/downloading/tips.rs | 9 +- .../src/view/onboarding/language.rs | 25 +- .../src/view/onboarding/migration.rs | 47 +- .../oneclient_app/src/view/onboarding/mod.rs | 41 +- .../src/view/onboarding/terms.rs | 304 + .../src/view/onboarding/welcome.rs | 11 +- packages/oneclient_app/src/view/startup.rs | 66 +- packages/oneclient_core/Cargo.toml | 14 +- .../oneclient_core/examples/discord_rpc.rs | 33 + packages/oneclient_core/src/constants.rs | 8 + packages/oneclient_core/src/crypto.rs | 2 +- packages/oneclient_core/src/dev.rs | 1 + packages/oneclient_core/src/discord.rs | 226 + packages/oneclient_core/src/game/launch.rs | 9 + .../oneclient_core/src/game/shared_dir.rs | 2 +- packages/oneclient_core/src/lib.rs | 6 + packages/oneclient_core/src/logger.rs | 4 + packages/oneclient_core/src/logs/manage.rs | 11 +- packages/oneclient_core/src/logs/mod.rs | 1 + packages/oneclient_core/src/migration/mod.rs | 2 +- .../oneclient_core/src/packages/store/mod.rs | 122 +- packages/oneclient_core/src/plus.rs | 333 + packages/oneclient_core/src/reporting.rs | 76 + .../oneclient_core/src/settings/launcher.rs | 6 + packages/oneclient_core/src/state.rs | 4 + packages/oneclient_core/src/tos.rs | 197 + uncommitted.patch | 5975 +++++++++++++++++ 125 files changed, 9155 insertions(+), 824 deletions(-) create mode 100644 packages/oneclient_app/src/hooks/queries/tos.rs create mode 100644 packages/oneclient_app/src/view/onboarding/terms.rs create mode 100644 packages/oneclient_core/examples/discord_rpc.rs create mode 100644 packages/oneclient_core/src/discord.rs create mode 100644 packages/oneclient_core/src/plus.rs create mode 100644 packages/oneclient_core/src/reporting.rs create mode 100644 packages/oneclient_core/src/tos.rs create mode 100644 uncommitted.patch diff --git a/Cargo.lock b/Cargo.lock index 4bbfb520..1b2edf4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ - "uuid", + "uuid 1.23.1", ] [[package]] @@ -62,7 +62,7 @@ dependencies = [ "hashbrown 0.16.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2-ui-kit 0.2.2", ] [[package]] @@ -126,6 +126,163 @@ dependencies = [ "winit", ] +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.12.1", + "bytes 1.11.1", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "bitflags 2.12.1", + "bytes 1.11.1", + "bytestring", + "derive_more", + "encoding_rs", + "foldhash 0.2.0", + "futures-core", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "mime", + "percent-encoding", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +dependencies = [ + "actix-codec", + "actix-http", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "bytes 1.11.1", + "bytestring", + "cfg-if", + "derive_more", + "encoding_rs", + "foldhash 0.2.0", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.4", + "time", + "tracing", + "url", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -510,7 +667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8a57b75c36e16f4d015e60e6a177552976a99b6947724403c551bcfa7cb1207" dependencies = [ "tokio", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -524,6 +681,23 @@ dependencies = [ "syn", ] +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + [[package]] name = "async_zip" version = "0.0.18" @@ -663,6 +837,21 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" @@ -871,6 +1060,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes 1.11.1", +] + [[package]] name = "bzip2" version = "0.6.1" @@ -943,7 +1141,7 @@ dependencies = [ "dirs", "dunce", "flate2", - "http", + "http 1.4.1", "log", "minisign-verify", "percent-encoding", @@ -1160,6 +1358,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "copypasta" version = "0.10.2" @@ -1386,6 +1593,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid 1.23.1", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1398,6 +1615,29 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -1461,6 +1701,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "discord-rich-presence" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" +dependencies = [ + "log", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "uuid 0.8.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1777,6 +2032,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "flate2" version = "1.1.9" @@ -2335,6 +2602,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gl" version = "0.14.0" @@ -2451,7 +2724,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.1", "indexmap", "slab", "tokio", @@ -2587,6 +2860,17 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "hotpath" version = "0.15.1" @@ -2602,6 +2886,17 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51066092dc750f8cbcac4afa04592377bf7463a911d1beca430ddd788deb0757" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes 1.11.1", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.1" @@ -2619,7 +2914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes 1.11.1", - "http", + "http 1.4.1", ] [[package]] @@ -2630,7 +2925,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes 1.11.1", "futures-core", - "http", + "http 1.4.1", "http-body", "pin-project-lite", ] @@ -2641,6 +2936,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.12" @@ -2661,7 +2962,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.4.1", "http-body", "httparse", "itoa", @@ -2677,7 +2978,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.1", "hyper", "hyper-util", "rustls", @@ -2697,14 +2998,14 @@ dependencies = [ "bytes 1.11.1", "futures-channel", "futures-util", - "http", + "http 1.4.1", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -2883,6 +3184,12 @@ version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +[[package]] +name = "impl-more" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" + [[package]] name = "include-flate" version = "0.3.3" @@ -2980,7 +3287,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result 0.4.1", @@ -3142,6 +3449,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" @@ -3299,6 +3612,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.14" @@ -3474,6 +3793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3492,7 +3812,7 @@ dependencies = [ "portable-atomic", "smallvec", "tagptr", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -3566,6 +3886,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -3796,7 +4128,7 @@ dependencies = [ "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", ] @@ -3905,6 +4237,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -4084,13 +4426,34 @@ dependencies = [ "objc2-cloud-kit 0.2.2", "objc2-core-data 0.2.2", "objc2-core-image 0.2.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", "objc2-link-presentation", "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", - "objc2-user-notifications", + "objc2-user-notifications 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.12.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", ] [[package]] @@ -4113,10 +4476,29 @@ dependencies = [ "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4152,7 +4534,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -4163,12 +4545,14 @@ dependencies = [ "async-stream", "async-tempfile", "async-trait", + "async-tungstenite", "async_zip", "base64", "bytes 1.11.1", "chrono", "digest 0.10.7", "directories", + "discord-rich-presence", "dunce", "futures-lite", "futures-util", @@ -4182,7 +4566,10 @@ dependencies = [ "rand 0.10.1", "regex", "reqwest 0.13.3", + "reqwest-websocket", "sanitize-filename", + "sentry", + "sentry-tracing", "serde", "serde_json", "serde_repr", @@ -4199,7 +4586,7 @@ dependencies = [ "tracing-subscriber", "trash", "url", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -4266,6 +4653,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -4659,7 +5062,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -4697,7 +5100,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -4956,6 +5359,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -4973,7 +5382,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", + "http 1.4.1", "http-body", "http-body-util", "hyper", @@ -5018,7 +5427,7 @@ dependencies = [ "futures-util", "h2", "hickory-resolver", - "http", + "http 1.4.1", "http-body", "http-body-util", "hyper", @@ -5052,6 +5461,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-websocket" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7705b649c3b66b85c4e9c304a6898b1ae3eecb880c474720ebf925e4a932ae02" +dependencies = [ + "async-tungstenite", + "bytes 1.11.1", + "futures-util", + "reqwest 0.13.3", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tungstenite", + "web-sys", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -5157,6 +5584,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5368,6 +5801,128 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "sentry" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d631477761f57c76148456e55e80e9a479ff3fa4c65b2b4a0c3acf1167fd4638" +dependencies = [ + "cfg_aliases", + "httpdate", + "reqwest 0.13.3", + "rustls", + "sentry-actix", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-actix" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119ede4e37790ec04e8a14073c8414f0a4b2648402856c0563495a9a5cf54d57" +dependencies = [ + "actix-http", + "actix-web", + "bytes 1.11.1", + "futures-util", + "sentry-core", +] + +[[package]] +name = "sentry-backtrace" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "448b0981fbde6cdc9eb087ba3dc01035a253fef9a0d9e79aaf198fc26acb2e64" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e909d02170ba6d1dc5ebd05bef7999280f5d960e3eaee5d1a18d88d41b334" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf0b1a4a4e9ec88395b52e6fa4868b95564c1fa96b26a0606f5f6288a0f7149" +dependencies = [ + "rand 0.9.4", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-debug-images" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81d7749c57fc78ed52134889e8121da874065bc40da788d5798cce0f8c19f15" +dependencies = [ + "findshlibs", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7d93d6ecb55d2251c5fc084c55c03a2bc68904918d76117e602c999e92f00f" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58d26379236c4ef97eaf081bca3c7bf0ef6f06b3aa881eca2ee8e8dbc923e5b8" +dependencies = [ + "bitflags 2.12.1", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239099d47e76857b0825a182ecdb90159add7aa1097b60247c6e7ca6bf49c6a" +dependencies = [ + "debugid", + "hex", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid 1.23.1", +] + [[package]] name = "serde" version = "1.0.228" @@ -5642,6 +6197,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -5741,7 +6306,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -5808,7 +6373,7 @@ dependencies = [ "sqlx-core", "thiserror 2.0.18", "tracing", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -5844,7 +6409,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "uuid", + "uuid 1.23.1", "whoami", ] @@ -5871,7 +6436,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "url", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -6180,7 +6745,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -6334,7 +6899,7 @@ dependencies = [ "bytes 1.11.1", "futures-core", "futures-util", - "http", + "http 1.4.1", "http-body", "http-body-util", "pin-project-lite", @@ -6474,6 +7039,23 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes 1.11.1", + "data-encoding", + "http 1.4.1", + "httparse", + "log", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.20.1" @@ -6491,6 +7073,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicase" version = "2.9.0" @@ -6578,7 +7169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64", - "http", + "http 1.4.1", "httparse", "log", ] @@ -6602,6 +7193,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -6614,6 +7211,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "uuid" version = "1.23.1" @@ -7457,7 +8063,7 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2-ui-kit 0.2.2", "orbclient", "percent-encoding", "pin-project", @@ -7731,7 +8337,7 @@ dependencies = [ "serde_repr", "tracing", "uds_windows", - "uuid", + "uuid 1.23.1", "windows-sys 0.61.2", "winnow", "zbus_macros", diff --git a/Cargo.toml b/Cargo.toml index fd4a4990..3a93c211 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,12 +28,24 @@ oneclient_macro = { path = "./packages/oneclient_macro" } # core (extra) gamemode_rs = {path = "./packages/gamemode_rs"} interfrost = {version = "=1.3.2"} +discord-rich-presence = {version = "=1.1.0"} # logging tracing = {version = "=0.1.44"} tracing-futures = {version = "=0.2.5"} tracing-subscriber = {version = "=0.3.23", features = ["env-filter", "fmt", "ansi", "json", "parking_lot", "registry"]} +sentry = {version = "=0.48.5", default-features = false, features = [ + "backtrace", + "contexts", + "debug-images", + "panic", + "release-health", + "reqwest", + "rustls", +]} +sentry-tracing = {version = "=0.48.5"} + # database sqlx = {version = "=0.9.0", default-features = false, features = [ "runtime-tokio", @@ -65,6 +77,7 @@ reqwest = {version = "=0.13.3", default-features = false, features = [ "form", "hickory-dns", ]} +reqwest-websocket = {version = "=0.6.0"} tokio = {version = "=1.52.3", features = ["full"]} tokio-stream = {version = "=0.1.18", features = ["full"]} tokio-util = {version = "=0.7.18", features = ["full"]} diff --git a/packages/oneclient_app/examples/min_freya.rs b/packages/oneclient_app/examples/min_freya.rs index dafed100..34bcb9b6 100644 --- a/packages/oneclient_app/examples/min_freya.rs +++ b/packages/oneclient_app/examples/min_freya.rs @@ -1,4 +1,3 @@ - use freya::prelude::*; struct MinApp; diff --git a/packages/oneclient_app/src/assets.rs b/packages/oneclient_app/src/assets.rs index 89639f76..23c97127 100644 --- a/packages/oneclient_app/src/assets.rs +++ b/packages/oneclient_app/src/assets.rs @@ -4,17 +4,12 @@ pub struct AppAssets; impl AppAssets { pub fn get_bytes(path: &str) -> Option { - AppAssets::get(path).map(|file| { - match file.data { - std::borrow::Cow::Borrowed(slice) => { - bytes::Bytes::copy_from_slice(slice) - }, - std::borrow::Cow::Owned(vec) => { - tracing::warn!("Cloning asset data for '{}'", path); - bytes::Bytes::from(vec) - }, + AppAssets::get(path).map(|file| match file.data { + std::borrow::Cow::Borrowed(slice) => bytes::Bytes::copy_from_slice(slice), + std::borrow::Cow::Owned(vec) => { + tracing::warn!("Cloning asset data for '{}'", path); + bytes::Bytes::from(vec) } }) } } - diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index 5e64e9f6..63637f45 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -1,4 +1,3 @@ - use std::path::PathBuf; use oneclient_core::notification::UserChoice; @@ -12,25 +11,43 @@ use crate::notifications::{ClusterUpdateSummary, NotificationSpec}; pub enum BridgeCommand { ReloadSettings, SaveSettings, - SetSettings { settings: LauncherSettings }, - RecordSeenVersion { version: String }, + SetSettings { + settings: LauncherSettings, + }, + RecordSeenVersion { + version: String, + }, MarkOnboardingSeen, + AcceptTos { + terms_version: u32, + privacy_version: u32, + }, - SaveGlobalProfile { profile: GameSettingsProfile }, - UpdateGlobalProfile { update: ProfileUpdate }, + SaveGlobalProfile { + profile: GameSettingsProfile, + }, + UpdateGlobalProfile { + update: ProfileUpdate, + }, - CreateSettingsProfile { name: String }, + CreateSettingsProfile { + name: String, + }, CreateProfileFromGlobal { name: String, mem_max: Option, force_fullscreen: Option, }, - UpsertNamedProfile { profile: GameSettingsProfile }, + UpsertNamedProfile { + profile: GameSettingsProfile, + }, UpdateNamedProfile { name: String, update: ProfileUpdate, }, - DeleteNamedProfile { name: String }, + DeleteNamedProfile { + name: String, + }, UpdateClusterProfile { cluster_id: ClusterId, @@ -49,8 +66,12 @@ pub enum BridgeCommand { vendor: oneclient_core::java::JavaVendor, major: u32, }, - AddCustomJavaRuntime { path: PathBuf }, - RemoveJavaRuntime { path: String }, + AddCustomJavaRuntime { + path: PathBuf, + }, + RemoveJavaRuntime { + path: String, + }, ToggleNotificationCenter, CloseNotificationCenter, @@ -64,14 +85,20 @@ pub enum BridgeCommand { AnswerPrompt(UserChoice), OpenClusterUpdate(ClusterUpdateSummary), CloseClusterUpdate, - SendNotification { spec: NotificationSpec }, + SendNotification { + spec: NotificationSpec, + }, SendTestProgress { current: u64, total: u64, }, - LaunchCluster { cluster_id: ClusterId }, - KillCluster { cluster_id: ClusterId }, + LaunchCluster { + cluster_id: ClusterId, + }, + KillCluster { + cluster_id: ClusterId, + }, DismissGameError, ImportLocalFile { @@ -91,7 +118,9 @@ pub enum BridgeCommand { bundle_name: String, skip_compatibility: bool, }, - ApplyBundleUpdates { cluster_id: ClusterId }, + ApplyBundleUpdates { + cluster_id: ClusterId, + }, SyncBundles, ImportLauncher { diff --git a/packages/oneclient_app/src/bridge/mod.rs b/packages/oneclient_app/src/bridge/mod.rs index 54cfff55..a6b5cce4 100644 --- a/packages/oneclient_app/src/bridge/mod.rs +++ b/packages/oneclient_app/src/bridge/mod.rs @@ -1,4 +1,3 @@ - mod commands; mod runtime; mod snapshot; diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 7f3f70b4..919ee01a 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -9,8 +9,8 @@ use tokio::sync::{mpsc, watch}; use crate::components::IconType; use crate::notifications::{ - ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind, - NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, + ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, + NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, }; use super::commands::BridgeCommand; @@ -243,6 +243,7 @@ impl CoreBridgeRuntime { LauncherState::initialize(notifier).await?; oneclient_core::status::start(); + oneclient_core::plus::start(); let data_dir = oneclient_core::paths::launcher_dir() .map(|p| p.display().to_string()) @@ -277,10 +278,12 @@ impl CoreBridgeRuntime { snapshots.settings.error = None; let state = LauncherState::get()?; let loaded = store::load_settings(Some(&state.services.notifier)).await; + let discord_enabled = loaded.discord_enabled; { let mut lock = state.settings.write(); *lock = loaded; } + state.discord.set_enabled(discord_enabled); refresh_settings(snapshots)?; } BridgeCommand::SaveSettings => { @@ -298,6 +301,7 @@ impl CoreBridgeRuntime { mutate_settings(snapshots, |s| *s = settings)?; let state = LauncherState::get()?; let snapshot = state.settings.read().clone(); + state.discord.set_enabled(snapshot.discord_enabled); if let Err(err) = save_settings(&snapshot).await { snapshots.settings.error = Some(err.to_string()); } @@ -319,6 +323,20 @@ impl CoreBridgeRuntime { snapshots.settings.error = Some(err.to_string()); } } + BridgeCommand::AcceptTos { + terms_version, + privacy_version, + } => { + mutate_settings(snapshots, |settings| { + settings.accepted_tos_version = terms_version; + settings.accepted_privacy_version = privacy_version; + })?; + let state = LauncherState::get()?; + let snapshot = state.settings.read().clone(); + if let Err(err) = save_settings(&snapshot).await { + snapshots.settings.error = Some(err.to_string()); + } + } BridgeCommand::SaveGlobalProfile { profile } => { let state = LauncherState::get()?; @@ -609,20 +627,18 @@ impl CoreBridgeRuntime { version_id, } => { let state = LauncherState::get()?; - match install_package(&state, provider, &project_id, &version_id, cluster_id).await - { - Ok(()) => { - state - .services - .notifier - .send_info("Installed", "Package added to cluster"); - bump_clusters_generation(snapshots); + tokio::spawn(async move { + let notifier = state.services.notifier.clone(); + match install_package(&state, provider, &project_id, &version_id, cluster_id) + .await + { + Ok(name) => { + notifier.send_info("Installed", &format!("Added {name}")); + notifier.invalidate_clusters(); + } + Err(err) => notifier.send_error("Install failed", &err.to_string()), } - Err(err) => state - .services - .notifier - .send_error("Install failed", &err.to_string()), - } + }); } BridgeCommand::InstallBundle { @@ -631,15 +647,24 @@ impl CoreBridgeRuntime { skip_compatibility, } => { let state = LauncherState::get()?; - oneclient_core::install_bundle( - cluster_id, - &bundle_name, - skip_compatibility, - state.bundles.as_ref(), - &state.services, - ) - .await?; - bump_clusters_generation(snapshots); + tokio::spawn(async move { + let notifier = state.services.notifier.clone(); + match oneclient_core::install_bundle( + cluster_id, + &bundle_name, + skip_compatibility, + state.bundles.as_ref(), + &state.services, + ) + .await + { + Ok(_) => { + notifier.send_info("Installed", &format!("Added {bundle_name}")); + notifier.invalidate_clusters(); + } + Err(err) => notifier.send_error("Install failed", &err.to_string()), + } + }); } BridgeCommand::ApplyBundleUpdates { cluster_id } => { let state = LauncherState::get()?; @@ -792,7 +817,7 @@ async fn install_package( project_id: &str, version_id: &str, cluster_id: i64, -) -> anyhow::Result<()> { +) -> anyhow::Result { let provider_impl = state.services.packages.get(provider)?; let project = provider_impl .get_project(project_id, &state.services) @@ -823,7 +848,7 @@ async fn install_package( child.finish(); session.finish(); result?; - Ok(()) + Ok(project.name) } fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: LaunchStage) { diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs index baa7908f..b8784083 100644 --- a/packages/oneclient_app/src/bridge/snapshot.rs +++ b/packages/oneclient_app/src/bridge/snapshot.rs @@ -32,10 +32,7 @@ impl GameSnapshot { } pub fn logs_for(&self, cluster_id: i64) -> Arc>> { - self.logs - .get(&cluster_id) - .cloned() - .unwrap_or_default() + self.logs.get(&cluster_id).cloned().unwrap_or_default() } } diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 773865f7..9e703b5b 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -60,7 +60,9 @@ impl Component for ActiveClusterPanel { let p = intro.get().value(); let slide_x = (p - 1.0) * 48.0; - let parsed = active.as_ref().and_then(|c| parse_mc_version(&c.mc_version)); + let parsed = active + .as_ref() + .and_then(|c| parse_mc_version(&c.mc_version)); let metadata = use_version_metadata( parsed.as_ref().map(|p| p.major), parsed.and_then(|p| p.minor), @@ -114,7 +116,11 @@ impl Component for ActiveClusterPanel { .spacing(8.) .cross_align(Alignment::Center) .margin(Gaps::new(8., 0., 0., 0.)) - .child(launch_button(cluster_id, dispatch, launch_button_state(&game, cluster_id))) + .child(launch_button( + cluster_id, + dispatch, + launch_button_state(&game, cluster_id), + )) .child(cluster_settings_button(cluster_id)), ) } diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index cf9ac799..61a3d4e7 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -3,7 +3,10 @@ use std::borrow::Cow; use freya::prelude::*; -use crate::{components::{IconType, Icon}, theme::colors}; +use crate::{ + components::{Icon, IconType}, + theme::colors, +}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ButtonVariant { @@ -285,8 +288,7 @@ impl Component for Button { rect.get_style().clone_from(&self.style); - rect = rect.background(background) - .color(foreground); + rect = rect.background(background).color(foreground); if let Some(border) = border_color { rect = rect.border( @@ -420,7 +422,11 @@ fn size_layout(size: ButtonSize) -> (Gaps, CornerRadius, f32, Size, Size) { pub fn link_button() -> Button { Button::new() .primary() - .child(Icon::new(IconType::LinkExternal01).size(14.).color(colors::fg_primary())) + .child( + Icon::new(IconType::LinkExternal01) + .size(14.) + .color(colors::fg_primary()), + ) .child(label().text("Open")) } @@ -433,4 +439,4 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { crate::platform::open_url(&folder.to_string_lossy()); }) .child(Icon::new(IconType::Folder).size(16.)) -} \ No newline at end of file +} diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index 7880627c..3c5f35a0 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -49,7 +49,13 @@ fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> im .corner_radius(CornerRadius::new_all(16.)) .background(CARD_BG) .border(border_all_color(1., colors::component_border())) - .shadow(Shadow::from((0., 18., 52., 0., Color::from_argb(150, 0, 0, 0)))) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) .child(banner(summary)) .child(content(summary, dispatch)) } @@ -93,7 +99,11 @@ fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement { .horizontal() .cross_align(Alignment::Center) .spacing(5.) - .child(Icon::new(IconType::RefreshCw01).size(12.).color(Color::WHITE)) + .child( + Icon::new(IconType::RefreshCw01) + .size(12.) + .color(Color::WHITE), + ) .child( label() .text("Updated") diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index f40a0ebf..5ebecd2c 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -81,8 +81,8 @@ impl Component for Dropdown { }; let visible = options.len().min(MAX_VISIBLE_OPTIONS); - let list_h = visible as f32 * OPTION_HEIGHT - + visible.saturating_sub(1) as f32 * OPTION_SPACING; + let list_h = + visible as f32 * OPTION_HEIGHT + visible.saturating_sub(1) as f32 * OPTION_SPACING; let offset_y = match (button_area(), list_size()) { (Some(button), Some(list)) => { @@ -141,10 +141,10 @@ impl Component for Dropdown { .font_size(12.) .color(colors::fg_primary()), ) - .child(Icon::new( - IconType::ChevronDown) - .size(14.) - .color(colors::fg_secondary()), + .child( + Icon::new(IconType::ChevronDown) + .size(14.) + .color(colors::fg_secondary()), ), ) .maybe_child(is_open.then(|| { diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 4c25af2c..636734c5 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -83,9 +83,12 @@ pub fn use_art_bytes( let state = reader.state(); match (&art_url, &*state) { (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { - (format!("{max_edge}|{url}"), bytes.clone()) - } + | ( + Some(url), + QueryStateData::Loading { + res: Some(Ok(bytes)), + }, + ) => (format!("{max_edge}|{url}"), bytes.clone()), _ => ( format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), fallback.read().clone(), diff --git a/packages/oneclient_app/src/components/log_viewer.rs b/packages/oneclient_app/src/components/log_viewer.rs index 80f531f2..306991fe 100644 --- a/packages/oneclient_app/src/components/log_viewer.rs +++ b/packages/oneclient_app/src/components/log_viewer.rs @@ -1,4 +1,3 @@ - use std::sync::{Arc, LazyLock}; use freya::prelude::*; @@ -289,12 +288,11 @@ fn auto_scroll_toggle(mut auto_scroll: State) -> impl IntoElement { } else { colors::fg_secondary() })) - .child( - label() - .text("Auto-scroll") - .font_size(11.) - .color(if on { colors::brand() } else { colors::fg_secondary() }), - ) + .child(label().text("Auto-scroll").font_size(11.).color(if on { + colors::brand() + } else { + colors::fg_secondary() + })) .into_element() } @@ -308,11 +306,15 @@ struct PosGeom { corrected_y: f32, } -fn pos_at_screen(lines: &[Arc], geom: PosGeom, screen_x: f32, screen_y: f32) -> (usize, usize) { +fn pos_at_screen( + lines: &[Arc], + geom: PosGeom, + screen_x: f32, + screen_y: f32, +) -> (usize, usize) { let region_y = screen_y - geom.viewport_top_px; let band_y = region_y - geom.corrected_y - BODY_PAD_TOP; - let line = - ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize; + let line = ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize; let text_origin_x = ROW_PAD_X + geom.num_col_w + NUM_GAP; let local_x = screen_x - geom.viewport_left_px - geom.corrected_x - text_origin_x; diff --git a/packages/oneclient_app/src/components/major_version_card.rs b/packages/oneclient_app/src/components/major_version_card.rs index b8c1600c..641b6ff2 100644 --- a/packages/oneclient_app/src/components/major_version_card.rs +++ b/packages/oneclient_app/src/components/major_version_card.rs @@ -55,11 +55,7 @@ impl Component for MajorVersionCard { let on_press = self.on_press.clone(); let opacity = if selected || hovered || focused { - if selected { - 1.0 - } else { - 0.85 - } + if selected { 1.0 } else { 0.85 } } else { 0.6 }; @@ -108,7 +104,10 @@ impl Component for MajorVersionCard { .main_align(Alignment::SpaceBetween) .corner_radius(CornerRadius::new_all(12.)) .cross_align(Alignment::Start) - .border(border_all_color(1., border_color).alignment(BorderAlignment::Inner)) + .border( + border_all_color(1., border_color) + .alignment(BorderAlignment::Inner), + ) .layer(Layer::Relative(3)) .background( LinearGradient::new() diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs index b84e4df4..27309f4b 100644 --- a/packages/oneclient_app/src/components/microsoft_login.rs +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -84,7 +84,7 @@ pub fn use_microsoft_login() -> MicrosoftLogin { return; } - // open the browser immediately + // open the browser immediately platform::open_url(session.auth_url()); finish.mutate(session.clone()); pending_login.set(Some(session)); @@ -100,7 +100,7 @@ pub fn use_microsoft_login() -> MicrosoftLogin { } }); - // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress + // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress let locked = status.as_ref().is_some_and(|s| s.current > 0); let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading(); let pending = in_flight && !*cancelled.read(); diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 898dfb7c..d9020868 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -37,7 +37,7 @@ impl Component for Navbar { .window_drag() .width(Size::window_percent(100.)) .height(Size::px(theme::NAVBAR_HEIGHT_PX)) - .position(Position::new_absolute().top(0.).left(0.).right(0.)) + .position(Position::new_absolute().top(0.).left(0.).right(0.)), ) } } @@ -95,8 +95,8 @@ struct NavLink { impl Component for NavLink { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); - let a11y_id = use_a11y(); - let focused = use_focus(a11y_id); + let a11y_id = use_a11y(); + let focused = use_focus(a11y_id); let active = self.active; let target = self.target.clone(); @@ -125,12 +125,12 @@ impl Component for NavLink { .cross_align(Alignment::Center) .spacing(2.) .width(Size::px(nav_label.len() as f32 * 10. + 10.)) - // TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly. - // This basically fools freya into thinking it indeed has a box and properly handles pointer events - .background(Color::RED.with_a(0)) - .a11y_id(a11y_id) - .a11y_focusable(true) - .a11y_role(AccessibilityRole::Button) + // TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly. + // This basically fools freya into thinking it indeed has a box and properly handles pointer events + .background(Color::RED.with_a(0)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_all_press(move |e: Event| { e.prevent_default(); let _ = RouterContext::get().push(target.clone()); diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 4742b31b..7e6d1324 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -391,9 +391,12 @@ fn remote_icon( let reader = icon_query.read(); let loaded = match (icon_url, &*reader.state()) { (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { - Some((url.clone(), bytes.clone())) - } + | ( + Some(url), + QueryStateData::Loading { + res: Some(Ok(bytes)), + }, + ) => Some((url.clone(), bytes.clone())), _ => None, }; @@ -420,7 +423,11 @@ fn icon_box(icon: IconType, size: f32) -> Element { .height(Size::px(size)) .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) - .child(Icon::new(icon).size(size * 0.4).color(colors::fg_secondary())) + .child( + Icon::new(icon) + .size(size * 0.4) + .color(colors::fg_secondary()), + ) .into_element() } @@ -436,7 +443,10 @@ fn meta_size(size: u64) -> impl IntoElement { } fn provider_badge(provider: ProviderId) -> Element { - badge(Icon::new(provider).size(12.).into_element(), provider.to_string()) + badge( + Icon::new(provider).size(12.).into_element(), + provider.to_string(), + ) } fn local_badge() -> Element { diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs index 1f5054ab..b7151b9e 100644 --- a/packages/oneclient_app/src/components/player_model.rs +++ b/packages/oneclient_app/src/components/player_model.rs @@ -128,7 +128,7 @@ impl Component for PlayerModel { let (w, h) = (ctx.size.width, ctx.size.height); - if w <= 0.0 || h <= 0.0 { + if w <= 0.0 || h <= 0.0 { return; } diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 3a3c80bf..93a9d85d 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -49,7 +49,9 @@ impl Component for RecentsRow { conf.on_creation(OnCreation::Run); conf.on_change(OnChange::Rerun); let total = CARD_MS + (count.saturating_sub(1) as u64) * STAGGER_MS; - AnimNum::new(0., 1.).time(total.max(1)).function(Function::Linear) + AnimNum::new(0., 1.) + .time(total.max(1)) + .function(Function::Linear) }); let progress = intro.get().value(); @@ -112,11 +114,7 @@ impl Component for ClusterCard { let eased = 1.0 - (1.0 - local).powi(3); let rise = (1.0 - eased) * CARD_RISE_PX; - let title = format!( - "{} {}", - self.cluster.mc_version, - self.cluster.mc_loader - ); + let title = format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader); let cluster_id = self.cluster.id; let on_press = move |_| { diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 1eb5a310..cdb65394 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -102,11 +102,15 @@ impl Component for ScreenshotViewer { .height(Size::fill()) .child(preview), ) - .child(chevron_btn(IconType::ArrowRight, has_next, move |_| { - if idx + 1 < len { - index.set(idx + 1); - } - })), + .child(chevron_btn( + IconType::ArrowRight, + has_next, + move |_| { + if idx + 1 < len { + index.set(idx + 1); + } + }, + )), ) .child( rect() @@ -119,7 +123,9 @@ impl Component for ScreenshotViewer { .secondary() .on_press(move |_| { if let Some(dir) = open_path.parent() { - crate::platform::open_url(&dir.to_string_lossy()); + crate::platform::open_url( + &dir.to_string_lossy(), + ); } }) .child(Icon::new(IconType::Folder).size(14.)) @@ -198,8 +204,12 @@ fn header_row( len: usize, on_close: impl Into>>, ) -> impl IntoElement { - let mut meta = format!("{} • {}", format_size(info.size_bytes), fmt_date(info.created)); - + let mut meta = format!( + "{} • {}", + format_size(info.size_bytes), + fmt_date(info.created) + ); + if let Some(res) = info.resolution { meta = format!("{} • {}", format_res(res), meta); } diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index ba49b6c3..471e9737 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -314,7 +314,11 @@ impl ScrollArea { let shift = *shift_held.read(); let h_delta = if horizontal { - if shift && e.delta_x == 0.0 { e.delta_y } else { e.delta_x } + if shift && e.delta_x == 0.0 { + e.delta_y + } else { + e.delta_x + } } else { 0.0 }; diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index f79d3409..1aa5d327 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -19,7 +19,7 @@ impl TabItem { } } - #[allow(dead_code)] + #[allow(dead_code)] pub fn count_text(mut self, text: impl Into) -> Self { self.count = Some(text.into()); self diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 2ea7c958..0976248b 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -32,7 +32,6 @@ impl Variant { Self::Message } } - } fn entry_icon(entry: &InboxEntry) -> (IconType, Color) { diff --git a/packages/oneclient_app/src/components/update_prompt.rs b/packages/oneclient_app/src/components/update_prompt.rs index 2c3071f7..3bb1a5b9 100644 --- a/packages/oneclient_app/src/components/update_prompt.rs +++ b/packages/oneclient_app/src/components/update_prompt.rs @@ -19,7 +19,7 @@ impl Component for UpdatePromptOverlay { let Some(prompt) = snapshot.pending_prompt.clone() else { return rect().into_element(); }; - + if prompt.kind != PromptKind::Update { return rect().into_element(); } diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs index 4b2b07e7..23f6ae53 100644 --- a/packages/oneclient_app/src/constants.rs +++ b/packages/oneclient_app/src/constants.rs @@ -1,2 +1,2 @@ pub const APP_ID: &str = "org.polyfrost.OneClient"; -pub const WINDOW_TITLE: &str = "OneClient"; \ No newline at end of file +pub const WINDOW_TITLE: &str = "OneClient"; diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 488dd8b8..0b042bf0 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -42,6 +42,13 @@ impl BridgeDispatch { self.send(BridgeCommand::MarkOnboardingSeen); } + pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { + self.send(BridgeCommand::AcceptTos { + terms_version, + privacy_version, + }); + } + pub fn import_launcher( &self, source: oneclient_core::MigrationSource, @@ -64,9 +71,7 @@ impl BridgeDispatch { } pub fn create_settings_profile(&self, name: impl Into) { - self.send(BridgeCommand::CreateSettingsProfile { - name: name.into(), - }); + self.send(BridgeCommand::CreateSettingsProfile { name: name.into() }); } pub fn create_profile_from_global( @@ -94,20 +99,11 @@ impl BridgeDispatch { } pub fn delete_named_profile(&self, name: impl Into) { - self.send(BridgeCommand::DeleteNamedProfile { - name: name.into(), - }); + self.send(BridgeCommand::DeleteNamedProfile { name: name.into() }); } - pub fn update_cluster_profile( - &self, - cluster_id: ClusterId, - update: ProfileUpdate, - ) { - self.send(BridgeCommand::UpdateClusterProfile { - cluster_id, - update, - }); + pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) { + self.send(BridgeCommand::UpdateClusterProfile { cluster_id, update }); } pub fn create_and_assign_cluster_profile( @@ -121,11 +117,7 @@ impl BridgeDispatch { }); } - pub fn set_cluster_loader_version( - &self, - cluster_id: ClusterId, - version: impl Into, - ) { + pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into) { self.send(BridgeCommand::SetClusterLoaderVersion { cluster_id, version: version.into(), @@ -318,6 +310,7 @@ impl NotificationBuilder { } pub fn send(self) { - self.dispatch.send(BridgeCommand::SendNotification { spec: self.spec }); + self.dispatch + .send(BridgeCommand::SendNotification { spec: self.spec }); } } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 90623621..87b90eeb 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -19,30 +19,30 @@ pub use queries::{ CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, - SetDefaultAccountKeys, UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, - UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, - accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list, - changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, + SetDefaultAccountKeys, TermsQuery, UploadLogKeys, UploadLogMutation, UseLogAction, + UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, + VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, + category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, java_runtimes, loader_versions, login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch, pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items, - search_pending, search_total, try_account, try_accounts, try_cluster_analytics, - try_cluster_logs, try_cluster_screenshots, try_default_account, try_game_profile, - try_global_analytics, try_log_content, use_account, use_accounts, use_add_microsoft_account, - use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, - use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, - use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_mutation, - use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters, - use_current_account, use_default_account, use_finish_microsoft_login, use_game_profile, - use_global_analytics, use_java_runtimes, use_loader_versions, use_local_image, use_log_action, - use_log_content, use_migration, use_named_profiles, use_onboarding_bundles, - use_package_categories, use_package_meta_batch, use_package_project, use_package_search, - use_package_versions, use_player_profile, use_player_skin, use_provider_versions, - use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, - use_set_default_account, use_upload_log, use_version_metadata, use_versions, version_list, - versions_total, + search_pending, search_total, terms_document, terms_error, terms_is_loading, try_account, + try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, + try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account, + use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login, + use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image, + use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content, + use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, + use_cluster_settings, use_clusters, use_current_account, use_default_account, + use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes, + use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration, + use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, + use_package_project, use_package_search, use_package_versions, use_player_profile, + use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts, + use_remove_account, use_screenshot_action, use_set_default_account, use_terms, use_upload_log, + use_version_metadata, use_versions, version_list, versions_total, }; use crate::{ diff --git a/packages/oneclient_app/src/hooks/queries/analytics.rs b/packages/oneclient_app/src/hooks/queries/analytics.rs index 1d433faa..9181aa50 100644 --- a/packages/oneclient_app/src/hooks/queries/analytics.rs +++ b/packages/oneclient_app/src/hooks/queries/analytics.rs @@ -2,7 +2,6 @@ use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; use oneclient_core::LauncherError; use oneclient_core::game::{Analytics, cluster_analytics, global_analytics}; - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct GlobalAnalyticsKeys; @@ -33,7 +32,6 @@ pub fn try_global_analytics(query: &UseQuery) -> Option Some(list.clone()), - QueryStateData::Loading { res: Some(Ok(list)) } => Some(list.clone()), + QueryStateData::Loading { + res: Some(Ok(list)), + } => Some(list.clone()), _ => None, } } @@ -75,12 +77,8 @@ impl QueryCapability for BundlesWithStatusQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; let state = LauncherState::get()?; - get_bundles_with_update_status( - self.cluster_id, - state.bundles.as_ref(), - &state.services, - ) - .await + get_bundles_with_update_status(self.cluster_id, state.bundles.as_ref(), &state.services) + .await } } @@ -90,7 +88,9 @@ pub fn bundles_with_status_items( let reader = query.read(); match &*reader.state() { QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), + QueryStateData::Loading { + res: Some(Ok(list)), + } => list.clone(), _ => Vec::new(), } } diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs index 04b3f89c..c9ad5da8 100644 --- a/packages/oneclient_app/src/hooks/queries/changelog.rs +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -1,5 +1,7 @@ use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::{fetch_changelog, parse_changelog, ChangelogGroup, LauncherError, LauncherState}; +use oneclient_core::{ + ChangelogGroup, LauncherError, LauncherState, fetch_changelog, parse_changelog, +}; use crate::hooks::use_settings_snapshot; @@ -32,15 +34,14 @@ pub fn use_changelog() -> UseQuery { .trim() .to_string(); - use_query(Query::new( - ChangelogKeys { meta_url_base }, - ChangelogQuery, - )) + use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) } pub fn changelog_groups(query: &UseQuery) -> Option> { match &*query.read().state() { - QueryStateData::Settled { res: Ok(groups), .. } => Some(groups.clone()), + QueryStateData::Settled { + res: Ok(groups), .. + } => Some(groups.clone()), QueryStateData::Loading { res: Some(Ok(groups)), } => Some(groups.clone()), diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index 4e727320..2159fcbb 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -1,4 +1,3 @@ - use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; use oneclient_core::packages::{ContentType, PackageStore}; use oneclient_core::{LauncherError, LauncherState, LinkedArtifactInfo}; @@ -51,7 +50,9 @@ pub fn cluster_content_items(query: &UseQuery) -> Vec list.clone(), - QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), + QueryStateData::Loading { + res: Some(Ok(list)), + } => list.clone(), _ => Vec::new(), } } diff --git a/packages/oneclient_app/src/hooks/queries/clusters.rs b/packages/oneclient_app/src/hooks/queries/clusters.rs index 0f097118..61f487f0 100644 --- a/packages/oneclient_app/src/hooks/queries/clusters.rs +++ b/packages/oneclient_app/src/hooks/queries/clusters.rs @@ -1,7 +1,7 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::clusters::{Cluster, ClusterManager}; use oneclient_core::LauncherError; use oneclient_core::LauncherState; +use oneclient_core::clusters::{Cluster, ClusterManager}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ListClustersQuery; @@ -23,5 +23,3 @@ impl QueryCapability for ListClustersQuery { pub fn use_clusters() -> UseQuery { use_query(Query::new(ListClustersKeys, ListClustersQuery)) } - - diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs index e2c7b7e4..df34a5c4 100644 --- a/packages/oneclient_app/src/hooks/queries/java.rs +++ b/packages/oneclient_app/src/hooks/queries/java.rs @@ -1,6 +1,4 @@ -use freya::query::{ - QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query, -}; +use freya::query::{QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query}; use oneclient_core::LauncherState; use oneclient_core::java::{AvailableJava, JavaManager, JavaRuntime, JavaVendor}; @@ -31,7 +29,9 @@ pub fn java_runtimes(query: &UseQuery) -> Vec list.clone(), - QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), + QueryStateData::Loading { + res: Some(Ok(list)), + } => list.clone(), _ => Vec::new(), } } @@ -68,14 +68,14 @@ pub fn use_provider_versions(vendor: JavaVendor) -> UseQuery, -) -> (Vec, bool) { +pub fn provider_versions(query: &UseQuery) -> (Vec, bool) { let reader = query.read(); match &*reader.state() { QueryStateData::Settled { res: Ok(list), .. } => (list.clone(), false), QueryStateData::Settled { res: Err(_), .. } => (Vec::new(), false), - QueryStateData::Loading { res: Some(Ok(list)) } => (list.clone(), true), + QueryStateData::Loading { + res: Some(Ok(list)), + } => (list.clone(), true), _ => (Vec::new(), true), } } diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs index eb1ba9fd..4d51fdb3 100644 --- a/packages/oneclient_app/src/hooks/queries/logs.rs +++ b/packages/oneclient_app/src/hooks/queries/logs.rs @@ -89,7 +89,9 @@ where { match &*query.read().state() { QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), - QueryStateData::Loading { res: Some(Ok(value)) } => Some(value.clone()), + QueryStateData::Loading { + res: Some(Ok(value)), + } => Some(value.clone()), _ => None, } } diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index ac8cd273..84da2cc4 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -14,6 +14,7 @@ mod player_profile; mod screenshots; mod settings_profiles; mod skin; +mod tos; mod version_metadata; mod versions; @@ -69,5 +70,6 @@ pub use settings_profiles::{ use_named_profiles, }; pub use skin::use_player_skin; +pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; pub use version_metadata::{pick_version_metadata, use_version_metadata}; pub use versions::{loader_versions, use_loader_versions, use_versions}; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index b2f05da5..54d59e08 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -1,4 +1,3 @@ - use std::path::PathBuf; use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; @@ -37,9 +36,18 @@ pub struct ClusterMutation; #[derive(Clone, PartialEq, Eq, Hash)] pub enum ClusterAction { - ToggleArtifact { cluster_id: ClusterId, hash: String }, - RemoveArtifact { cluster_id: ClusterId, hash: String }, - RemoveBundlePackageFromDisk { cluster_id: ClusterId, hash: String }, + ToggleArtifact { + cluster_id: ClusterId, + hash: String, + }, + RemoveArtifact { + cluster_id: ClusterId, + hash: String, + }, + RemoveBundlePackageFromDisk { + cluster_id: ClusterId, + hash: String, + }, SetBundlePackageEnabled { cluster_id: ClusterId, bundle_name: String, @@ -106,10 +114,14 @@ impl MutationCapability for ClusterMutation { ClusterAction::SetDedicatedDir { cluster_id, dedicated, - } => oneclient_core::clusters::ClusterManager::set_dedicated_dir( - &state, *cluster_id, *dedicated, - ) - .await, + } => { + oneclient_core::clusters::ClusterManager::set_dedicated_dir( + &state, + *cluster_id, + *dedicated, + ) + .await + } }; result.map_err(|e| e.to_string()) } diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 85c427a2..1ca3c6b2 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -1,4 +1,3 @@ - use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; use oneclient_core::packages::domain::GameLoader; use oneclient_core::packages::types::{ @@ -48,7 +47,8 @@ impl QueryCapability for PackageSearchQuery { &SearchFilters { query: (!keys.query.trim().is_empty()).then(|| keys.query.trim().to_string()), content_type: Some(keys.content_type), - game_versions: (!keys.game_versions.is_empty()).then(|| keys.game_versions.clone()), + game_versions: (!keys.game_versions.is_empty()) + .then(|| keys.game_versions.clone()), loaders: (!keys.loaders.is_empty()).then(|| keys.loaders.clone()), categories: (!keys.categories.is_empty()).then(|| keys.categories.clone()), sort: Some(keys.sort), @@ -91,7 +91,9 @@ pub fn search_items(query: &UseQuery) -> Vec let reader = query.read(); match &*reader.state() { QueryStateData::Settled { res: Ok(page), .. } => page.items.clone(), - QueryStateData::Loading { res: Some(Ok(page)) } => page.items.clone(), + QueryStateData::Loading { + res: Some(Ok(page)), + } => page.items.clone(), _ => Vec::new(), } } @@ -100,13 +102,18 @@ pub fn search_total(query: &UseQuery) -> usize { let reader = query.read(); match &*reader.state() { QueryStateData::Settled { res: Ok(page), .. } => page.total, - QueryStateData::Loading { res: Some(Ok(page)) } => page.total, + QueryStateData::Loading { + res: Some(Ok(page)), + } => page.total, _ => 0, } } pub fn search_pending(query: &UseQuery) -> bool { - matches!(&*query.read().state(), QueryStateData::Pending | QueryStateData::Loading { .. }) + matches!( + &*query.read().state(), + QueryStateData::Pending | QueryStateData::Loading { .. } + ) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -126,7 +133,9 @@ impl QueryCapability for PackageProjectQuery { async fn run(&self, keys: &Self::Keys) -> Result { let state = LauncherState::get()?; let provider = state.services.packages.get(keys.provider)?; - provider.get_project(&keys.project_id, &state.services).await + provider + .get_project(&keys.project_id, &state.services) + .await } } @@ -146,8 +155,12 @@ pub fn use_package_project( pub fn project_detail(query: &UseQuery) -> Option { let reader = query.read(); match &*reader.state() { - QueryStateData::Settled { res: Ok(detail), .. } => Some(detail.clone()), - QueryStateData::Loading { res: Some(Ok(detail)) } => Some(detail.clone()), + QueryStateData::Settled { + res: Ok(detail), .. + } => Some(detail.clone()), + QueryStateData::Loading { + res: Some(Ok(detail)), + } => Some(detail.clone()), _ => None, } } @@ -260,7 +273,9 @@ pub fn version_list(query: &UseQuery) -> Vec page.items.clone(), - QueryStateData::Loading { res: Some(Ok(page)) } => page.items.clone(), + QueryStateData::Loading { + res: Some(Ok(page)), + } => page.items.clone(), _ => Vec::new(), } } @@ -269,7 +284,9 @@ pub fn versions_total(query: &UseQuery) -> usize { let reader = query.read(); match &*reader.state() { QueryStateData::Settled { res: Ok(page), .. } => page.total, - QueryStateData::Loading { res: Some(Ok(page)) } => page.total, + QueryStateData::Loading { + res: Some(Ok(page)), + } => page.total, _ => 0, } } @@ -315,7 +332,9 @@ pub fn category_list(query: &UseQuery) -> Vec { let reader = query.read(); match &*reader.state() { QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), + QueryStateData::Loading { + res: Some(Ok(list)), + } => list.clone(), _ => Vec::new(), } } diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs index 5bb3d3c8..df42b8b4 100644 --- a/packages/oneclient_app/src/hooks/queries/player_profile.rs +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -39,7 +39,10 @@ impl QueryCapability for FetchPlayerProfileQuery { // } } -pub fn use_player_profile(uuid: impl Into, access_token: Option>) -> UseQuery { +pub fn use_player_profile( + uuid: impl Into, + access_token: Option>, +) -> UseQuery { use_query( Query::new( PlayerProfileQueryKeys { diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs index 3b01dd9c..214aa564 100644 --- a/packages/oneclient_app/src/hooks/queries/screenshots.rs +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -12,7 +12,6 @@ use tokio::sync::Semaphore; static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ClusterScreenshotsKeys { pub cluster_id: i64, @@ -96,7 +95,6 @@ pub fn try_cluster_screenshots( } } - pub async fn invalidate_screenshots_queries() { QueriesStorage::::try_invalidate_all().await; QueriesStorage::::try_invalidate_all().await; diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs index 6d3b1a8d..f0d5bdfd 100644 --- a/packages/oneclient_app/src/hooks/queries/skin.rs +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -42,4 +42,4 @@ fn java_string_hash(s: &str) -> i32 { h = h.wrapping_mul(31).wrapping_add(c as i32); } h -} \ No newline at end of file +} diff --git a/packages/oneclient_app/src/hooks/queries/tos.rs b/packages/oneclient_app/src/hooks/queries/tos.rs new file mode 100644 index 00000000..bec73ab8 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/tos.rs @@ -0,0 +1,61 @@ +use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use oneclient_core::{LauncherError, LauncherState, TermsDocument, fetch_terms}; + +use crate::hooks::use_settings_snapshot; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TermsKeys { + pub meta_url_base: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TermsQuery; + +impl QueryCapability for TermsQuery { + type Ok = TermsDocument; + type Err = LauncherError; + type Keys = TermsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = LauncherState::get()?; + fetch_terms(&state.services).await + } +} + +pub fn use_terms() -> UseQuery { + let settings = use_settings_snapshot().settings; + let meta_url_base = settings + .custom_meta_url_base + .clone() + .unwrap_or_default() + .trim() + .to_string(); + + use_query(Query::new(TermsKeys { meta_url_base }, TermsQuery)) +} + +pub fn terms_document(query: &UseQuery) -> Option { + match &*query.read().state() { + QueryStateData::Settled { + res: Ok(document), .. + } => Some(document.clone()), + QueryStateData::Loading { + res: Some(Ok(document)), + } => Some(document.clone()), + _ => None, + } +} + +pub fn terms_error(query: &UseQuery) -> Option { + match &*query.read().state() { + QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), + _ => None, + } +} + +pub fn terms_is_loading(query: &UseQuery) -> bool { + matches!( + &*query.read().state(), + QueryStateData::Pending | QueryStateData::Loading { res: None } + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index c589718c..f30f513f 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -1,6 +1,6 @@ use freya::query::QueryStateData; -use oneclient_core::packages::domain::GameLoader; use oneclient_core::VersionMetadata; +use oneclient_core::packages::domain::GameLoader; use super::use_versions; @@ -46,7 +46,9 @@ pub fn use_version_metadata( let state = reader.state(); let list = match &*state { QueryStateData::Settled { res: Ok(list), .. } => list, - QueryStateData::Loading { res: Some(Ok(list)) } => list, + QueryStateData::Loading { + res: Some(Ok(list)), + } => list, _ => return None, }; diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index 8f4d053b..b48c48fb 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -69,7 +69,9 @@ pub fn loader_versions(query: &UseQuery) -> Vec { let reader = query.read(); match &*reader.state() { QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), + QueryStateData::Loading { + res: Some(Ok(list)), + } => list.clone(), _ => Vec::new(), } } diff --git a/packages/oneclient_app/src/hooks/view_state.rs b/packages/oneclient_app/src/hooks/view_state.rs index 0d8ec346..64696a34 100644 --- a/packages/oneclient_app/src/hooks/view_state.rs +++ b/packages/oneclient_app/src/hooks/view_state.rs @@ -5,38 +5,38 @@ use super::{use_dispatch, use_settings_snapshot}; #[derive(Clone, Copy)] pub struct PersistedView { - pub layout: State, - pub sort: State>, + pub layout: State, + pub sort: State>, } pub fn use_view_state(key: &str) -> PersistedView { - let key = key.to_string(); - let settings = use_settings_snapshot().settings; - let dispatch = use_dispatch(); + let key = key.to_string(); + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); - let initial = settings.view_state(&key); - let init_layout = initial.layout; - let init_sort = initial.sort; - let layout = use_state(move || init_layout); - let sort = use_state(move || init_sort.clone()); + let initial = settings.view_state(&key); + let init_layout = initial.layout; + let init_sort = initial.sort; + let layout = use_state(move || init_layout); + let sort = use_state(move || init_sort.clone()); - let mut first = use_state(|| true); - use_side_effect(move || { - let next = ViewState { - layout: *layout.read(), - sort: sort.read().clone(), - }; - if *first.peek() { - first.set(false); - return; - } - if settings.view_state(&key) == next { - return; - } - let mut updated = settings.clone(); - updated.set_view_state(key.clone(), next); - dispatch.set_settings(updated); - }); + let mut first = use_state(|| true); + use_side_effect(move || { + let next = ViewState { + layout: *layout.read(), + sort: sort.read().clone(), + }; + if *first.peek() { + first.set(false); + return; + } + if settings.view_state(&key) == next { + return; + } + let mut updated = settings.clone(); + updated.set_view_state(key.clone(), next); + dispatch.set_settings(updated); + }); - PersistedView { layout, sort } + PersistedView { layout, sort } } diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index e727b02f..4b898b0f 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -9,8 +9,8 @@ use skia_safe::sampling_options::CubicResampler; use crate::Route; use crate::components::Button; -use crate::layout::AnimatedAppOutlet; use crate::components::{AppNavbar, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::layout::AnimatedAppOutlet; use crate::theme; use crate::use_settings_snapshot; use oneclient_db::models::ClusterId; diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 49b13df2..2572ac07 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -33,7 +33,7 @@ impl ClusterViewShellTab { match self { Self::Overview => "Overview", Self::Logs => "Logs", - Self::GameLog => "Game Log", + Self::GameLog => "Live Log", Self::Screenshots => "Screenshots", Self::Mods => "Mods", Self::Shaders => "Shaders", diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs index bab99f5a..9342e22d 100644 --- a/packages/oneclient_app/src/layout/mod.rs +++ b/packages/oneclient_app/src/layout/mod.rs @@ -6,8 +6,8 @@ mod root_layout; mod settings_shell; pub use animated_outlet::AnimatedAppOutlet; -pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET}; pub(crate) use app_shell::gradient_overlay_radial; +pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET}; pub use cluster_shell::{ClusterShell, cluster_content}; pub use onboarding_shell::OnboardingShell; pub use root_layout::RootLayout; diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index 773be6c1..8fe9d55e 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -198,7 +198,10 @@ const SEARCH_INDEX: &[SearchItem] = &[ icon: IconType::Globe01, title: "Language", description: "Choose the launcher language.", - keywords: &["language", "locale", "english", "spanish", "french", "german", "russian", "japanese", "chinese"], + keywords: &[ + "language", "locale", "english", "spanish", "french", "german", "russian", "japanese", + "chinese", + ], route: Route::SettingsLanguage {}, }, // Developer @@ -332,10 +335,7 @@ const GROUPS: &[SettingsGroup] = &[ }, SettingsGroup { label: "ABOUT", - tabs: &[ - SettingsTab::DeveloperOptions, - SettingsTab::Changelog, - ], + tabs: &[SettingsTab::DeveloperOptions, SettingsTab::Changelog], }, ]; @@ -490,18 +490,20 @@ fn search_results(query: String, mut search: State) -> Vec { matches.truncate(SEARCH_RESULTS_MAX); if matches.is_empty() { - return vec![rect() - .width(Size::fill()) - .padding(Gaps::new_symmetric(10., 14.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .child( - label() - .text("No settings matched your search.") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .into_element()]; + return vec![ + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text("No settings matched your search.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(), + ]; } let mut out: Vec = Vec::with_capacity(matches.len() + 1); @@ -696,11 +698,11 @@ impl Component for SidebarInfo { .spacing(4.) .font_size(12.) .color(colors::fg_secondary()) - .children(items.into_iter().map(|item| { - label() - .text(item) - .into_element() - })) + .children( + items + .into_iter() + .map(|item| label().text(item).into_element()), + ) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) .on_press(copy_to_clipboard) diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 673e9f73..4f2b6b3c 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -12,8 +12,8 @@ mod notifications; pub mod platform; mod routes; pub mod theme; -pub mod updater; mod ui; +pub mod updater; pub(crate) mod utils; mod view; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 76bad9d8..d5e7a082 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -39,12 +39,15 @@ fn main() { let _tokio_guard = rt.enter(); let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); - + if settings.log_debug { oneclient_core::logger::init_debug() } else { oneclient_core::logger::init() - }.expect("Failed to initialize logger"); + } + .expect("Failed to initialize logger"); + + let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); let (bridge, handle): (OneClientBridge, CoreBridgeHandle) = OneClientBridge::new(); handle.spawn_runtime(); @@ -67,7 +70,7 @@ fn main() { std::env::var("ONECLIENT_GPU_CACHE_MB") .ok() .and_then(|v| v.parse::().ok()) - .unwrap_or(32) + .unwrap_or(32), ) .with_default_font(theme::DEFAULT_FONT); diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index ba20fc95..09a4db72 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -17,13 +17,13 @@ use crate::view::{ ClusterShaders, ClusterTextures, ProcessLogs, }, settings::{ - SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, - SettingsJava, SettingsLanguage, SettingsLauncher, SettingsMinecraft, + SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, SettingsJava, + SettingsLanguage, SettingsLauncher, SettingsMinecraft, }, }, onboarding::{ OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, - OnboardingMigration, OnboardingPreferences, OnboardingWelcome, + OnboardingMigration, OnboardingPreferences, OnboardingTerms, OnboardingWelcome, }, }; @@ -37,6 +37,8 @@ pub enum Route { #[layout(OnboardingShell)] #[route("/onboarding")] OnboardingWelcome {}, + #[route("/onboarding/terms")] + OnboardingTerms {}, #[route("/onboarding/migration")] OnboardingMigration {}, #[route("/onboarding/language")] @@ -120,6 +122,7 @@ impl Route { match self { Route::Startup { .. } => "Startup".to_string(), Route::OnboardingWelcome { .. } => "Welcome".to_string(), + Route::OnboardingTerms { .. } => "Terms & Privacy".to_string(), Route::OnboardingMigration { .. } => "Migration".to_string(), Route::OnboardingLanguage { .. } => "Language".to_string(), Route::OnboardingAccount { .. } => "Account".to_string(), diff --git a/packages/oneclient_app/src/theme/colors.rs b/packages/oneclient_app/src/theme/colors.rs index 9dddbfa0..0db8798d 100644 --- a/packages/oneclient_app/src/theme/colors.rs +++ b/packages/oneclient_app/src/theme/colors.rs @@ -149,4 +149,4 @@ pub fn selection_bg() -> Color { pub fn toast_action() -> Color { Color::from_rgb(155, 161, 166) -} \ No newline at end of file +} diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 5900812b..33feece1 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -17,14 +17,12 @@ pub fn border_all(width: f32) -> Border { } pub fn border_all_color(width: f32, color: Color) -> Border { - Border::new() - .fill(color) - .width(BorderWidth { - top: width, - right: width, - bottom: width, - left: width, - }) + Border::new().fill(color).width(BorderWidth { + top: width, + right: width, + bottom: width, + left: width, + }) } pub fn border_left(width: f32) -> Border { @@ -79,4 +77,4 @@ pub fn entrance_motion_layer( .child(child), ) .into_element() -} \ No newline at end of file +} diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index ffc60e6e..69c07c5a 100644 --- a/packages/oneclient_app/src/updater.rs +++ b/packages/oneclient_app/src/updater.rs @@ -55,10 +55,7 @@ fn check_for_update() -> anyhow::Result> { Ok(check_update(current, config)?) } -async fn download_and_install( - update: Update, - notifier: NotificationService, -) -> anyhow::Result<()> { +async fn download_and_install(update: Update, notifier: NotificationService) -> anyhow::Result<()> { let progress_id = Uuid::new_v4(); let version = update.version.clone(); let label = format!("Downloading OneClient {version}"); diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index daab86b8..ab046e6e 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -16,7 +16,10 @@ pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { let Some(parsed) = parse_mc_version(&cluster.mc_version) else { continue; }; - groups.entry(parsed.major).or_default().push(cluster.clone()); + groups + .entry(parsed.major) + .or_default() + .push(cluster.clone()); } for list in groups.values_mut() { @@ -82,9 +85,7 @@ pub fn resolve_cluster( .iter() .find(|cluster| { let version_ok = minor.is_none_or(|minor| { - parse_mc_version(&cluster.mc_version) - .and_then(|parsed| parsed.minor) - == Some(minor) + parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.minor) == Some(minor) }); let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader); version_ok && loader_ok @@ -241,21 +242,22 @@ fn version_sort_key(cluster: &Cluster) -> (u32, u32) { .unwrap_or((0, 0)) } - #[cfg(not(target_os = "linux"))] pub fn is_wayland() -> bool { - false + false } #[cfg(target_os = "linux")] pub fn is_wayland() -> bool { - static IS_WAYLAND: OnceLock = OnceLock::new(); + static IS_WAYLAND: OnceLock = OnceLock::new(); - *IS_WAYLAND.get_or_init(|| { - if cfg!(target_os = "linux") { - std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false) - } else { - false - } - }) -} \ No newline at end of file + *IS_WAYLAND.get_or_init(|| { + if cfg!(target_os = "linux") { + std::env::var("XDG_SESSION_TYPE") + .map(|v| v == "wayland") + .unwrap_or(false) + } else { + false + } + }) +} diff --git a/packages/oneclient_app/src/view/app/accounts.rs b/packages/oneclient_app/src/view/app/accounts.rs index c94bc143..1148231b 100644 --- a/packages/oneclient_app/src/view/app/accounts.rs +++ b/packages/oneclient_app/src/view/app/accounts.rs @@ -1,7 +1,9 @@ -use chrono::Utc; +use chrono::{DateTime, Utc}; +use freya::animation::{AnimNum, Function, OnFinish, use_animation}; use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; use oneclient_core::auth::{AccountKind, MinecraftAccount}; +use uuid::Uuid; use crate::components::{ Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, ScrollArea, TextInput, @@ -74,13 +76,17 @@ impl Component for Accounts { let mut rows: Vec = accounts .iter() .map(|account| { - account_row( - account, - Some(account.id) == default_id, + AccountRow { + id: account.id, + username: account.username.clone(), + kind: account.kind, + expires: account.expires, + is_default: Some(account.id) == default_id, set_default, remove, refresh, - ) + } + .into_element() }) .collect(); if rows.is_empty() { @@ -339,113 +345,182 @@ fn hint_line(icon: IconType, text: String, color: Color) -> impl IntoElement { .into_element() } -fn account_row( - account: &MinecraftAccount, +const REFRESH_SPIN_TIME: u64 = 800; + +const REFRESHING_OPACITY: f32 = 0.7; + +struct AccountRow { + id: Uuid, + username: String, + kind: AccountKind, + expires: DateTime, is_default: bool, set_default: crate::hooks::UseSetDefaultAccount, remove: crate::hooks::UseRemoveAccount, refresh: crate::hooks::UseRefreshAccount, -) -> Element { - let id = account.id; - let is_microsoft = account.is_microsoft(); - let expired = is_microsoft && account.expires <= Utc::now(); +} - let border_color = if expired { - colors::danger() - } else if is_default { - colors::brand() - } else { - colors::component_border() - }; +impl PartialEq for AccountRow { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.username == other.username + && self.kind == other.kind + && self.expires == other.expires + && self.is_default == other.is_default + } +} - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .content(Content::Flex) - .spacing(16.) - .padding(Gaps::new_all(12.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .border(border_all_color(1., border_color)) - .a11y_role(AccessibilityRole::Button) - .maybe(!is_default, |el| { - el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) - }) - .child( - Avatar::new(id.to_string()) - .width(Size::px(40.)) - .height(Size::px(40.)), - ) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .spacing(4.) - .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .child( - label() - .text(account.username.clone()) - .font_size(16.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child(kind_badge(account.kind)) - .maybe_child(is_default.then(default_badge)) - .maybe_child(expired.then(expired_badge)), - ) - .child( - label() - .text(id.to_string()) - .font_size(11.) - .color(colors::fg_secondary()), - ), - ) - .maybe_child(is_microsoft.then(|| { - Button::new() - .ghost() - .icon() - .on_press(move |e: Event| { - e.stop_propagation(); - refresh.mutate(RefreshAccountKeys { id }); - }) - .child( - Icon::new(IconType::RefreshCw01) - .size(18.) - .color(if expired { - colors::danger() - } else { - colors::fg_secondary() - }), - ) - .into_element() - })) - .child( - Button::new().ghost().icon().enabled(false).child( - Icon::new(IconType::Pencil01) - .size(18.) - .color(colors::fg_secondary()), - ), - ) - .child( - Button::new() - .ghost() - .icon() - .on_press(move |e: Event| { - e.stop_propagation(); - remove.mutate(RemoveAccountKeys { id }); - }) - .child( - Icon::new(IconType::Trash01) +impl Component for AccountRow { + fn render_key(&self) -> DiffKey { + DiffKey::from(&self.id) + } + + fn render(&self) -> impl IntoElement { + let id = self.id; + let is_default = self.is_default; + let set_default = self.set_default; + let remove = self.remove; + let refresh = self.refresh; + + let is_microsoft = self.kind == AccountKind::Microsoft; + let expired = is_microsoft && self.expires <= Utc::now(); + + let mut refreshing = use_state(|| false); + let is_refreshing = *refreshing.read(); + + let spin = use_animation(|conf| { + conf.on_finish(OnFinish::restart()); + AnimNum::new(0., 360.) + .time(REFRESH_SPIN_TIME) + .function(Function::Linear) + }); + + use_side_effect_with_deps(&is_refreshing, move |&is_refreshing| { + let mut spin = spin; + if is_refreshing { + spin.start(); + } else { + spin.reset(); + } + }); + + let rotation = if is_refreshing { + spin.get().value() + } else { + 0. + }; + + let border_color = if expired { + colors::danger() + } else if is_default { + colors::brand() + } else { + colors::component_border() + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(16.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., border_color)) + .opacity(if is_refreshing { + REFRESHING_OPACITY + } else { + 1. + }) + .a11y_role(AccessibilityRole::Button) + .maybe(!is_default, |el| { + el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) + }) + .child( + Avatar::new(id.to_string()) + .width(Size::px(40.)) + .height(Size::px(40.)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(4.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(self.username.clone()) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(kind_badge(self.kind)) + .maybe_child(is_default.then(default_badge)) + .maybe_child(expired.then(expired_badge)), + ) + .child( + label() + .text(id.to_string()) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_microsoft.then(|| { + Button::new() + .ghost() + .icon() + .on_press(move |e: Event| { + e.stop_propagation(); + if *refreshing.peek() { + return; + } + refreshing.set(true); + spawn(async move { + refresh.mutate_async(RefreshAccountKeys { id }).await; + refreshing.set(false); + }); + }) + .child( + rect().rotate(rotation).child( + Icon::new(IconType::RefreshCw01) + .size(18.) + .color(if expired { + colors::danger() + } else { + colors::fg_secondary() + }), + ), + ) + .into_element() + })) + .child( + Button::new().ghost().icon().enabled(false).child( + Icon::new(IconType::Pencil01) .size(18.) .color(colors::fg_secondary()), ), - ) - .into_element() + ) + .child( + Button::new() + .ghost() + .icon() + .on_press(move |e: Event| { + e.stop_propagation(); + remove.mutate(RemoveAccountKeys { id }); + }) + .child( + Icon::new(IconType::Trash01) + .size(18.) + .color(colors::fg_secondary()), + ), + ) + .into_element() + } } fn kind_badge(kind: AccountKind) -> impl IntoElement { diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs index b43f531a..7aeb7756 100644 --- a/packages/oneclient_app/src/view/app/analytics/charts.rs +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -62,7 +62,10 @@ impl Component for WhenChart { ) } WhenMode::Hour => { - let hour = self.peak_hour.map(format_hour).unwrap_or_else(|| "—".to_string()); + let hour = self + .peak_hour + .map(format_hour) + .unwrap_or_else(|| "—".to_string()); ( format!("Peak around {hour}"), BarChart::new(self.per_hour.clone(), hour_labels()) @@ -148,7 +151,12 @@ impl Component for DailyChart { let win_total: i64 = values.iter().sum(); let subtitle = match (slice.first(), slice.last()) { (Some((a, _)), Some((b, _))) => { - format!("{} · {} – {}", format_duration_hm(win_total), format_day(*a), format_day(*b)) + format!( + "{} · {} – {}", + format_duration_hm(win_total), + format_day(*a), + format_day(*b) + ) } _ => format_duration_hm(win_total), }; @@ -159,24 +167,16 @@ impl Component for DailyChart { .horizontal() .cross_align(Alignment::Center) .spacing(8.) - .child(nav_button( - IconType::ChevronsLeft, - can_older, - move |_| { - if can_older { - *offset.write() = off + 1; - } - }, - )) - .child(nav_button( - IconType::ChevronsRight, - can_newer, - move |_| { - if can_newer { - *offset.write() = off - 1; - } - }, - )) + .child(nav_button(IconType::ChevronsLeft, can_older, move |_| { + if can_older { + *offset.write() = off + 1; + } + })) + .child(nav_button(IconType::ChevronsRight, can_newer, move |_| { + if can_newer { + *offset.write() = off - 1; + } + })) .child( SegmentedControl::new(range) .height(30.) @@ -272,6 +272,12 @@ fn weekday_labels() -> Vec { fn hour_labels() -> Vec { (0..24) - .map(|h| if h % 6 == 0 { format_hour(h) } else { String::new() }) + .map(|h| { + if h % 6 == 0 { + format_hour(h) + } else { + String::new() + } + }) .collect() } diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs index f09a358e..2235de77 100644 --- a/packages/oneclient_app/src/view/app/analytics/mod.rs +++ b/packages/oneclient_app/src/view/app/analytics/mod.rs @@ -45,11 +45,7 @@ fn charts_grid(cards: Vec) -> Element { let mut grid = rect().vertical().width(Size::fill()).spacing(16.); for pair in cards.chunks(2) { if pair.len() == 1 { - grid = grid.child( - rect() - .width(Size::fill()) - .child(pair[0].clone()), - ); + grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); continue; } diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs index fe5743d0..a8e3e7cc 100644 --- a/packages/oneclient_app/src/view/app/analytics/servers.rs +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -12,7 +12,12 @@ use super::{card, card_header}; pub(super) fn servers_section(servers: &[ServerStat]) -> Element { let total_joins: i64 = servers.iter().map(|s| s.joins).sum(); - let max_secs = servers.iter().map(|s| s.total_secs).max().unwrap_or(0).max(1); + let max_secs = servers + .iter() + .map(|s| s.total_secs) + .max() + .unwrap_or(0) + .max(1); let mut list = rect().vertical().width(Size::fill()).spacing(4.); for server in servers.iter().take(10) { @@ -155,9 +160,12 @@ impl Component for ServerIcon { let reader = query.read(); let loaded = match (&self.url, &*reader.state()) { (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { - Some((url.clone(), bytes.clone())) - } + | ( + Some(url), + QueryStateData::Loading { + res: Some(Ok(bytes)), + }, + ) => Some((url.clone(), bytes.clone())), _ => None, }; @@ -185,7 +193,11 @@ fn server_icon_placeholder(is_ip: bool, size: f32) -> Element { .height(Size::px(size)) .corner_radius(CornerRadius::new_all(6.)) .background(colors::component_bg()) - .child(Icon::new(icon).size(size * 0.5).color(colors::fg_secondary())) + .child( + Icon::new(icon) + .size(size * 0.5) + .color(colors::fg_secondary()), + ) .into_element() } diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs index aaf072c9..10bb6bd2 100644 --- a/packages/oneclient_app/src/view/app/analytics/tiles.rs +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -3,9 +3,9 @@ use freya::prelude::*; use oneclient_core::game::{Analytics, Persona}; use crate::components::{Icon, IconType}; -use crate::utils::format_duration_hm; use crate::theme::colors; use crate::ui::border_all_color; +use crate::utils::format_duration_hm; use super::card; @@ -19,10 +19,22 @@ pub(super) fn tiles_row(analytics: &Analytics) -> Element { let total_joins: i64 = analytics.servers.iter().map(|s| s.joins).sum(); let mut tiles = vec![ - stat_tile(IconType::ClockRewind, "Total playtime", format_duration_hm(stats.total_secs)), + stat_tile( + IconType::ClockRewind, + "Total playtime", + format_duration_hm(stats.total_secs), + ), stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), - stat_tile(IconType::Calendar, "Avg / session", format_duration_hm(avg_session)), - stat_tile(IconType::Rocket02, "Active days", stats.active_days.to_string()), + stat_tile( + IconType::Calendar, + "Avg / session", + format_duration_hm(avg_session), + ), + stat_tile( + IconType::Rocket02, + "Active days", + stats.active_days.to_string(), + ), ]; if !analytics.servers.is_empty() { tiles.push(stat_tile( @@ -97,7 +109,11 @@ fn persona_card(persona: Persona) -> Element { .corner_radius(CornerRadius::new_all(22.)) .background(colors::brand().with_a(40)) .center() - .child(Icon::new(persona_icon(persona)).size(22.).color(colors::brand())), + .child( + Icon::new(persona_icon(persona)) + .size(22.) + .color(colors::brand()), + ), ) .child( rect() diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index bc9c10cb..c919849f 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -1,17 +1,19 @@ use super::*; use freya::router::RouterContext; -use oneclient_core::packages::types::ProjectSummary; use oneclient_core::packages::ProviderId; +use oneclient_core::packages::types::ProjectSummary; -use crate::components::{ - Icon, IconType, -}; +use crate::components::{Icon, IconType}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; -pub(super) fn grid_row(row: Vec, cluster_id: i64, package_type: &str) -> impl IntoElement { +pub(super) fn grid_row( + row: Vec, + cluster_id: i64, + package_type: &str, +) -> impl IntoElement { let package_type = package_type.to_string(); let fill = GRID_COLUMNS - row.len(); @@ -154,7 +156,11 @@ impl Component for PackageCard { } } -pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str) -> impl IntoElement { +pub(super) fn list_row( + item: ProjectSummary, + cluster_id: i64, + package_type: &str, +) -> impl IntoElement { ListRow { item, cluster_id, @@ -185,69 +191,69 @@ impl Component for ListRow { Cursor::set(CursorIcon::default()); }); - rect() - .horizontal() - .width(Size::fill()) - .height(Size::px(LIST_ROW_H)) - .cross_align(Alignment::Center) - .spacing(12.) - .padding(Gaps::new_all(16.)) - .corner_radius(CornerRadius::new_all(10.)) - .background(CARD_BG) - .border(border_all_color( - 1., - if focused { - colors::brand() - } else { - colors::component_border() - }, - )) - .overflow(Overflow::Clip) - .content(Content::Flex) - .a11y_id(a11y_id) - .a11y_focusable(true) - .a11y_role(AccessibilityRole::Button) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) - .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .spacing(3.) - .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .child( - label() - .text(item.name.clone()) - .font_size(15.) - .font_weight(FontWeight::MEDIUM) - .max_lines(1) - .color(CARD_NAME), - ) - .child( - label() - .text(format!("by {}", item.author)) - .font_size(10.) - .max_lines(1) - .color(colors::fg_secondary()), - ) - .child(Icon::new(item.provider).size(12.)), - ) - .child( - label() - .text(item.summary.clone()) - .font_size(11.) - .max_lines(2) - .width(Size::fill()) - .color(colors::fg_secondary()), - ), - ) - .child(downloads_row(item.downloads)) + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(LIST_ROW_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .border(border_all_color( + 1., + if focused { + colors::brand() + } else { + colors::component_border() + }, + )) + .overflow(Overflow::Clip) + .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(item.name.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(CARD_NAME), + ) + .child( + label() + .text(format!("by {}", item.author)) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child(Icon::new(item.provider).size(12.)), + ) + .child( + label() + .text(item.summary.clone()) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .child(downloads_row(item.downloads)) } } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index cb4c7991..77df9ea6 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -1,6 +1,5 @@ use freya::animation::{ - AnimNum, Ease, Function, OnChange, OnCreation, - use_animation_with_dependencies, + AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, }; use freya::prelude::*; use oneclient_core::packages::types::ProjectSummary; @@ -174,7 +173,9 @@ impl Component for Browser { match mode { ViewLayout::Grid => { let rows = BROWSE_PAGE_SIZE.div_ceil(GRID_COLUMNS); - sa.lazy(rows, CARD_H, GRID_SPACING, |_| skeleton_grid_row().into_element()) + sa.lazy(rows, CARD_H, GRID_SPACING, |_| { + skeleton_grid_row().into_element() + }) } ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { SkeletonListRow.into_element() @@ -289,4 +290,3 @@ fn controls( ), ) } - diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs index 1e03d381..38080837 100644 --- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -120,4 +120,3 @@ impl Component for CategoryRow { ) } } - diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs index d7e9aa48..30f6d2aa 100644 --- a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs +++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs @@ -1,8 +1,6 @@ use super::*; -use freya::animation::{ - AnimNum, Ease, Function, OnCreation, OnFinish, use_animation, -}; +use freya::animation::{AnimNum, Ease, Function, OnCreation, OnFinish, use_animation}; use crate::theme::colors; use crate::ui::border_all_color; diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index db7aadd6..b3cdbce6 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -3,8 +3,8 @@ use oneclient_core::packages::{ContentType, ProviderId}; use crate::components::ScrollArea; use crate::hooks::{ - content_type_for_slug, project_detail, use_browser_compat, use_dispatch, - use_link_confirm, use_package_project, use_package_versions, version_list, versions_total, + content_type_for_slug, project_detail, use_browser_compat, use_dispatch, use_link_confirm, + use_package_project, use_package_versions, version_list, versions_total, }; use crate::theme::colors; use crate::ui::border_all_color; @@ -205,4 +205,3 @@ impl Component for BrowserPackage { .into_element() } } - diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index c0e854d7..42f88fc5 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -1,14 +1,14 @@ use super::*; use freya::query::QueryStateData; +use oneclient_core::packages::ProviderId; use oneclient_core::packages::types::{ GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary, }; -use oneclient_core::packages::ProviderId; use crate::BridgeDispatch; use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; -use crate::hooks::{use_cached_image, VERSIONS_PAGE_SIZE}; +use crate::hooks::{VERSIONS_PAGE_SIZE, use_cached_image}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; @@ -16,7 +16,6 @@ use crate::utils::format_size; const GALLERY_IMAGE_H: f32 = 360.; const GALLERY_EDGE: u32 = 1024; - pub(super) fn loading_body() -> impl IntoElement { rect() .width(Size::fill()) @@ -356,4 +355,3 @@ fn release_badge(release_type: ReleaseType) -> impl IntoElement { .color(color), ) } - diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 3c4e40fc..2b3b0173 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -1,16 +1,13 @@ use super::*; -use oneclient_core::packages::types::{ - ProjectDetail, ProjectMember, -}; use oneclient_core::packages::ProviderId; +use oneclient_core::packages::types::{ProjectDetail, ProjectMember}; use crate::BridgeDispatch; use crate::components::{Button, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; - pub(super) fn sidebar( project: Option, latest_version: Option, diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 827836ee..4d760ff9 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -1,4 +1,3 @@ - use freya::prelude::*; use oneclient_core::Patch; use oneclient_core::java::JavaRuntime; @@ -6,7 +5,8 @@ use oneclient_core::packages::domain::GameLoader; use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution}; use crate::components::{ - Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number + Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, + validate_number, }; use crate::hooks::{ ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation, @@ -37,7 +37,10 @@ impl Component for ClusterSettings { .as_ref() .map(|c| c.mc_version.clone()) .unwrap_or_default(); - let loader = cluster.as_ref().map(|c| c.mc_loader).unwrap_or(GameLoader::Fabric); + let loader = cluster + .as_ref() + .map(|c| c.mc_loader) + .unwrap_or(GameLoader::Fabric); let versions_query = use_loader_versions(mc_version, loader); let runtimes_query = use_java_runtimes(); @@ -149,13 +152,11 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen Button::new() .small() - .ghost() - .icon() + .ghost() + .icon() .corner_radius(CornerRadius::new_all(7.)) - .maybe(overridden, |el| { - el.on_press(move |_| on_reset.call(())) - }) - .enabled(overridden) + .maybe(overridden, |el| el.on_press(move |_| on_reset.call(()))) + .enabled(overridden) .child(Icon::new(IconType::RefreshCcw02).size(15.).color(color)) .into_element() } @@ -394,7 +395,11 @@ impl Component for ResolutionRow { .text_align(TextAlign::Center) .width(Size::px(70.)), ) - .child(Icon::new(IconType::X).size(14.).color(colors::fg_secondary())) + .child( + Icon::new(IconType::X) + .size(14.) + .color(colors::fg_secondary()), + ) .child( TextInput::new(height) .placeholder("480") @@ -527,7 +532,12 @@ impl Component for TextRow { .placeholder(placeholder) .width(Size::px(220.)); - settings_row(icon, title, description, override_cell(control, overridden, on_reset)) + settings_row( + icon, + title, + description, + override_cell(control, overridden, on_reset), + ) } } @@ -564,8 +574,7 @@ impl Component for JavaRow { .map(runtime_label) .unwrap_or_else(|| "Automatic".into()); - let paths: Vec = - runtimes.iter().map(|r| r.absolute_path.clone()).collect(); + let paths: Vec = runtimes.iter().map(|r| r.absolute_path.clone()).collect(); let dispatch = dispatch.clone(); Dropdown::new(selected, options) @@ -641,7 +650,10 @@ impl Component for LoaderRow { settings_row( IconType::Rocket02, "Loader Version", - format!("The {} loader version used to launch this cluster.", self.loader), + format!( + "The {} loader version used to launch this cluster.", + self.loader + ), control, ) } diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index fa61a342..e1369056 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -65,7 +65,8 @@ enum Confirm { fn kind_badge(kind: LogKind) -> &'static str { match kind { - LogKind::Game { .. } => "Launcher", + LogKind::Game { .. } => "Game output", + LogKind::Launcher => "Launcher logs", LogKind::Minecraft => "Minecraft", LogKind::CrashReport => "Crash report", LogKind::Other => "Other", @@ -151,7 +152,7 @@ impl Component for ClusterLogs { let value = search.read().clone(); (!value.trim().is_empty()).then_some(value) }; - + let content_query = use_log_content( selected_path.clone(), level.read().to_level(), @@ -162,7 +163,7 @@ impl Component for ClusterLogs { let Some(cluster) = load_cluster(cluster_id) else { return cluster_not_found(); }; - let folder = cluster.game_dir().ok().map(|d| d.join("logs")); + let folder = cluster.dir().ok().map(|d| d.join("logs")); let files = try_cluster_logs(&logs_query).unwrap_or_default(); let has_log = !files.is_empty(); diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs index bc95a97e..1cf1f0ec 100644 --- a/packages/oneclient_app/src/view/app/cluster/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/mod.rs @@ -1,23 +1,23 @@ -mod overview; mod logs; +mod overview; mod package_manager; mod cluster_settings; mod mods; +mod process_logs; mod screenshots; mod shaders; mod textures; -mod process_logs; -pub use overview::ClusterOverview; pub use logs::ClusterLogs; +pub use overview::ClusterOverview; pub use cluster_settings::ClusterSettings; pub use mods::ClusterMods; +pub use process_logs::ProcessLogs; pub use screenshots::ClusterScreenshots; pub use shaders::ClusterShaders; pub use textures::ClusterTextures; -pub use process_logs::ProcessLogs; use freya::prelude::*; use freya::query::QueryStateData; diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs index 6387b549..9c8622e6 100644 --- a/packages/oneclient_app/src/view/app/cluster/mods.rs +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -1,14 +1,14 @@ use freya::prelude::*; -use oneclient_core::packages::{ContentType, ProviderId}; +use oneclient_core::packages::ContentType; use crate::hooks::{ - bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, - use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, + bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, + use_bundles_with_status, use_cluster_content, }; use crate::layout::cluster_content; use super::package_manager::{ - PackageManager, bundle_categories, bundle_packages, managed_project_ids, + PackageManager, bundle_categories, bundle_packages, use_content_meta, }; use super::{cluster_not_found, load_cluster}; @@ -23,8 +23,8 @@ impl Component for ClusterMods { let bundles = use_bundles_with_status(self.cluster_id); let overrides = use_bundle_overrides(self.cluster_id); let bundle_items = bundles_with_status_items(&bundles); - let project_ids = managed_project_ids(&bundle_items, ContentType::Mod); - let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); let Some(_cluster) = load_cluster(self.cluster_id) else { return cluster_not_found(); @@ -32,10 +32,10 @@ impl Component for ClusterMods { let all_categories = bundle_categories(&bundle_items); let items = bundle_packages( - cluster_content_items(&content), + content_items, &bundle_items, &bundle_overrides_map(&overrides), - &package_meta_batch(&meta), + &meta, ContentType::Mod, ); diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 20fdcbcb..3665b0aa 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -1,4 +1,3 @@ - use std::collections::{HashMap, HashSet}; use freya::prelude::*; @@ -6,7 +5,7 @@ use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId}; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; use crate::components::{CardLayout, PackageEntry}; -use crate::hooks::use_view_state; +use crate::hooks::{package_meta_batch, use_package_meta_batch, use_view_state}; mod views; use views::{ContentBox, ContentKind, EnabledFilter, SortMode, toolbar_bar}; @@ -18,9 +17,13 @@ const GRID_GAP: f32 = 10.; const GRID_MIN_W: f32 = 260.; const LAZY_OVERSCAN: i64 = 2; -pub fn managed_project_ids( +pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +fn provider_project_ids( + content: &[LinkedArtifactInfo], bundles: &[BundleWithUpdateStatus], content_type: ContentType, + provider: ProviderId, ) -> Vec { let mut ids = Vec::new(); for bundle in bundles { @@ -28,19 +31,49 @@ pub fn managed_project_ids( if file.content_type() != content_type { continue; } - if let BundleFileKind::Managed { project_id, .. } = &file.kind { + if let BundleFileKind::Managed { + project_id, + provider: file_provider, + .. + } = &file.kind + && *file_provider == provider + { ids.push(project_id.clone()); } } } + for info in content { + if info.content_type != content_type || info.provider != Some(provider) { + continue; + } + if let Some(project_id) = &info.project_id { + ids.push(project_id.clone()); + } + } ids } +pub fn use_content_meta( + content: &[LinkedArtifactInfo], + bundles: &[BundleWithUpdateStatus], + content_type: ContentType, +) -> PackageMetaMap { + let mut out = PackageMetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids = provider_project_ids(content, bundles, content_type, provider); + let query = use_package_meta_batch(provider, ids); + for (project_id, meta) in package_meta_batch(&query) { + out.insert((provider, project_id), meta); + } + } + out +} + pub fn bundle_packages( content: Vec, bundles: &[BundleWithUpdateStatus], overrides: &HashMap<(String, String), String>, - meta: &HashMap, + meta: &PackageMetaMap, content_type: ContentType, ) -> Vec { let mut by_project: HashMap<&str, &LinkedArtifactInfo> = HashMap::new(); @@ -104,10 +137,7 @@ pub fn bundle_packages( } for info in &content { - let in_bundle = info - .project_id - .as_deref() - .is_some_and(|p| seen.contains(p)) + let in_bundle = info.project_id.as_deref().is_some_and(|p| seen.contains(p)) || seen.contains(&info.hash); if in_bundle { continue; @@ -141,10 +171,10 @@ fn make_row( categories: Vec, enabled: bool, installed_info: Option<&LinkedArtifactInfo>, - meta: &HashMap, + meta: &PackageMetaMap, fallback_name: String, ) -> PackageEntry { - let m = meta.get(&package_id); + let m = meta.get(&(provider, package_id.clone())); let name = m .map(|p| p.name.clone()) .filter(|s| !s.is_empty()) diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 529adc16..4b30c038 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -264,7 +264,13 @@ impl Component for FilterPopover { .background(colors::page_elevated().with_a(230)) .blur(12.) .border(crate::ui::border_all_color(1., colors::component_border())) - .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(120, 0, 0, 0)))) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) .a11y_id(a11y_id) .a11y_role(AccessibilityRole::Dialog) .on_global_key_down(move |e: Event| { @@ -527,12 +533,16 @@ impl Component for ContentBox { let header = (count > 0) .then(|| match kind { - ContentKind::External => Some( - action_header(browse_button(cluster_id, package_type)).into_element(), - ), + ContentKind::External => { + Some(action_header(browse_button(cluster_id, package_type)).into_element()) + } ContentKind::Local => Some( - action_header(add_from_file_button(cluster_id, content_type, dispatch.clone())) - .into_element(), + action_header(add_from_file_button( + cluster_id, + content_type, + dispatch.clone(), + )) + .into_element(), ), ContentKind::Other => None, }) diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs index 8568ae69..df8b1f78 100644 --- a/packages/oneclient_app/src/view/app/cluster/process_logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs @@ -1,4 +1,3 @@ - use freya::prelude::*; use crate::components::LogViewer; @@ -30,9 +29,7 @@ impl Component for ProcessLogs { not_running() }; - cluster_content() - .child(body) - .into_element() + cluster_content().child(body).into_element() } } diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index ed13be2d..c9fd68e1 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -8,8 +8,7 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, - open_folder_button, - Segment, SegmentedControl, + Segment, SegmentedControl, open_folder_button, }; use crate::hooks::{ ScreenshotAction, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, @@ -57,7 +56,14 @@ impl Component for ClusterScreenshots { let grid_width = use_state(|| 0f32); let mut menu = use_state(|| None::<(f32, f32, PathBuf)>); - let toolbar = toolbar_row(&shots, folder, view_mode, edit_mode, selected, confirm_delete); + let toolbar = toolbar_row( + &shots, + folder, + view_mode, + edit_mode, + selected, + confirm_delete, + ); let content: Element = if shots.is_empty() { empty_state(matches!( @@ -210,7 +216,12 @@ fn toolbar_row( .horizontal() .cross_align(Alignment::Center) .spacing(10.) - .maybe_child((!editing).then_some(folder).flatten().map(open_folder_button)); + .maybe_child( + (!editing) + .then_some(folder) + .flatten() + .map(open_folder_button), + ); if editing { let select_all_paths = all_paths.clone(); @@ -422,8 +433,6 @@ fn confirm_panel( .into_element() } - - fn res_badge(res: Option<(u32, u32)>) -> Option { res.map(format_res).map(|text| { rect() diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs index 61fde57d..a69000bc 100644 --- a/packages/oneclient_app/src/view/app/cluster/shaders.rs +++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs @@ -1,14 +1,14 @@ use freya::prelude::*; -use oneclient_core::packages::{ContentType, ProviderId}; +use oneclient_core::packages::ContentType; use crate::hooks::{ - bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, - use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, + bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, + use_bundles_with_status, use_cluster_content, }; use crate::layout::cluster_content; use super::package_manager::{ - PackageManager, bundle_categories, bundle_packages, managed_project_ids, + PackageManager, bundle_categories, bundle_packages, use_content_meta, }; use super::{cluster_not_found, load_cluster}; @@ -23,8 +23,8 @@ impl Component for ClusterShaders { let bundles = use_bundles_with_status(self.cluster_id); let overrides = use_bundle_overrides(self.cluster_id); let bundle_items = bundles_with_status_items(&bundles); - let project_ids = managed_project_ids(&bundle_items, ContentType::Shader); - let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::Shader); let Some(_cluster) = load_cluster(self.cluster_id) else { return cluster_not_found(); @@ -32,10 +32,10 @@ impl Component for ClusterShaders { let all_categories = bundle_categories(&bundle_items); let items = bundle_packages( - cluster_content_items(&content), + content_items, &bundle_items, &bundle_overrides_map(&overrides), - &package_meta_batch(&meta), + &meta, ContentType::Shader, ); diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs index fc42cbdf..9f08ec8c 100644 --- a/packages/oneclient_app/src/view/app/cluster/textures.rs +++ b/packages/oneclient_app/src/view/app/cluster/textures.rs @@ -1,14 +1,14 @@ use freya::prelude::*; -use oneclient_core::packages::{ContentType, ProviderId}; +use oneclient_core::packages::ContentType; use crate::hooks::{ - bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, - use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, + bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, + use_bundles_with_status, use_cluster_content, }; use crate::layout::cluster_content; use super::package_manager::{ - PackageManager, bundle_categories, bundle_packages, managed_project_ids, + PackageManager, bundle_categories, bundle_packages, use_content_meta, }; use super::{cluster_not_found, load_cluster}; @@ -23,8 +23,8 @@ impl Component for ClusterTextures { let bundles = use_bundles_with_status(self.cluster_id); let overrides = use_bundle_overrides(self.cluster_id); let bundle_items = bundles_with_status_items(&bundles); - let project_ids = managed_project_ids(&bundle_items, ContentType::ResourcePack); - let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::ResourcePack); let Some(_cluster) = load_cluster(self.cluster_id) else { return cluster_not_found(); @@ -32,10 +32,10 @@ impl Component for ClusterTextures { let all_categories = bundle_categories(&bundle_items); let items = bundle_packages( - cluster_content_items(&content), + content_items, &bundle_items, &bundle_overrides_map(&overrides), - &package_meta_batch(&meta), + &meta, ContentType::ResourcePack, ); diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 14b4e411..3d416fe9 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -8,7 +8,6 @@ use crate::components::{Button, ClusterLandscapeArt, Dropdown, Icon, IconType, M use crate::hooks::{ use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_version_metadata, }; -use crate::view::app::launch_button_state; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; @@ -16,6 +15,7 @@ use crate::utils::{ default_loader, default_major, default_minor, group_clusters_by_major, loaders_for_major, major_pretty_name, minor_label, minor_versions, resolve_cluster, }; +use crate::view::app::launch_button_state; const GRID_GAP_PX: f32 = 12.; const MIN_CARD_WIDTH_PX: f32 = 280.; @@ -212,7 +212,10 @@ fn detail_sidebar( .as_ref() .and_then(|m| m.long_description.clone()) .unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string()); - let tags = metadata.as_ref().map(|m| m.tags.clone()).unwrap_or_default(); + let tags = metadata + .as_ref() + .map(|m| m.tags.clone()) + .unwrap_or_default(); rect() .width(Size::px(SIDEBAR_WIDTH_PX)) @@ -225,17 +228,9 @@ fn detail_sidebar( .background(colors::page_elevated()) .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) - .child( - rect() - .width(Size::fill()) - .max_height(Size::px(140.)) - .child(ClusterLandscapeArt::for_version( - major, - minor_value, - loader_value, - false, - )), - ) + .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( + ClusterLandscapeArt::for_version(major, minor_value, loader_value, false), + )) .child( rect() .vertical() @@ -274,7 +269,11 @@ fn detail_sidebar( .content(Content::Flex) .main_align(Alignment::Center) .spacing(8.) - .child(play_button(cluster_id, dispatch, launch_button_state(&game, cluster_id))) + .child(play_button( + cluster_id, + dispatch, + launch_button_state(&game, cluster_id), + )) .child(view_button(cluster_id, active_id)), ), ) diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index 9df3240c..1cf93964 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -1,14 +1,14 @@ -mod analytics; -mod home; mod accounts; +mod analytics; mod clusters; mod debug; +mod home; mod skins; mod stats; pub mod browser; -pub mod settings; pub mod cluster; +pub mod settings; use crate::bridge::GameSnapshot; use oneclient_core::notification::LaunchStage; @@ -23,10 +23,10 @@ pub fn launch_button_state(game: &GameSnapshot, cluster_id: i64) -> (&'static st } } -pub(crate) use analytics::analytics_body; pub use accounts::Accounts; -pub use home::Home; +pub(crate) use analytics::analytics_body; pub use clusters::Clusters; pub use debug::Debug; +pub use home::Home; pub use skins::AccountSkins; pub use stats::Stats; diff --git a/packages/oneclient_app/src/view/app/settings/apis.rs b/packages/oneclient_app/src/view/app/settings/apis.rs index ac420472..13dd6729 100644 --- a/packages/oneclient_app/src/view/app/settings/apis.rs +++ b/packages/oneclient_app/src/view/app/settings/apis.rs @@ -1,8 +1,8 @@ use freya::prelude::*; +use super::settings_page; use crate::components::{IconType, TextInput}; use crate::hooks::{use_dispatch, use_settings_snapshot}; -use super::settings_page; use crate::view::app::settings::{section_header, settings_row}; fn normalize(value: &str) -> Option { diff --git a/packages/oneclient_app/src/view/app/settings/appearance.rs b/packages/oneclient_app/src/view/app/settings/appearance.rs index 7a43f380..3edc388b 100644 --- a/packages/oneclient_app/src/view/app/settings/appearance.rs +++ b/packages/oneclient_app/src/view/app/settings/appearance.rs @@ -1,8 +1,8 @@ use freya::prelude::*; +use super::settings_page; use crate::components::{Icon, IconType, toggle}; use crate::hooks::{use_dispatch, use_settings_snapshot}; -use super::settings_page; use crate::theme::colors; use crate::ui::border_all_color; use crate::view::app::settings::settings_row; diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs index 8238e3f9..791bf819 100644 --- a/packages/oneclient_app/src/view/app/settings/changelog.rs +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -1,8 +1,8 @@ use freya::prelude::*; +use super::settings_page; use crate::components::{Icon, IconType}; use crate::hooks::{changelog_error, changelog_groups, changelog_is_loading, use_changelog}; -use super::settings_page; use crate::theme::colors; #[derive(PartialEq)] @@ -119,14 +119,16 @@ impl Component for ReleaseCard { .spacing(4.) .padding(Gaps::new(0., 0., 0., 6.)) .children(if changes.is_empty() { - vec![rect() - .child( - label() - .text("No changes recorded for this version.") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .into_element()] + vec![ + rect() + .child( + label() + .text("No changes recorded for this version.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(), + ] } else { changes .into_iter() diff --git a/packages/oneclient_app/src/view/app/settings/developer.rs b/packages/oneclient_app/src/view/app/settings/developer.rs index 4e5983c2..3a354900 100644 --- a/packages/oneclient_app/src/view/app/settings/developer.rs +++ b/packages/oneclient_app/src/view/app/settings/developer.rs @@ -1,10 +1,10 @@ use freya::prelude::*; use freya::router::RouterContext; +use super::settings_page; use crate::Route; use crate::components::{Button, IconType, link_button, toggle}; use crate::hooks::{use_browser_compat, use_dispatch, use_settings_snapshot}; -use super::settings_page; use crate::view::app::settings::{section_header, settings_row}; #[derive(PartialEq)] @@ -67,7 +67,7 @@ impl Component for SettingsDeveloper { "Debug Page", "View the debug page.", link_button().on_press(move |_| { - let _ = RouterContext::get().push(Route::Debug { }); + let _ = RouterContext::get().push(Route::Debug {}); }), )) .into_element() diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 5a9c3e63..bc8cda85 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -1,12 +1,12 @@ use freya::prelude::*; use oneclient_core::java::{AvailableJava, JavaRuntime, JavaVendor}; +use super::settings_page; use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; use crate::hooks::{ - java_runtimes, provider_versions, use_dispatch, use_java_runtimes, use_provider_versions, - BridgeDispatch, + BridgeDispatch, java_runtimes, provider_versions, use_dispatch, use_java_runtimes, + use_provider_versions, }; -use super::settings_page; use crate::theme::colors; use crate::ui::border_all_color; use crate::view::app::settings::section_header; @@ -151,15 +151,13 @@ fn table_header() -> impl IntoElement { .child(head("VENDOR", Size::px(130.))) .child(head("VERSION", Size::px(90.))) .child( - rect() - .width(Size::flex(1.0)) - .child( - label() - .text("PATH") - .font_size(11.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ), + rect().width(Size::flex(1.0)).child( + label() + .text("PATH") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), ) .child(rect().width(Size::px(34.))) .into_element() @@ -206,7 +204,10 @@ impl Component for RuntimeRow { .fill(colors::component_border()), ) }) - .child(cell(format!("{} {}", runtime.vendor, runtime.major), Size::px(130.))) + .child(cell( + format!("{} {}", runtime.vendor, runtime.major), + Size::px(130.), + )) .child(cell(runtime.version.clone(), Size::px(90.))) .child( rect() @@ -241,7 +242,11 @@ fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement { .ghost() .small() .on_press(move |_| dispatch.remove_java_runtime(path.clone())) - .child(Icon::new(IconType::Trash01).size(14.).color(colors::danger())) + .child( + Icon::new(IconType::Trash01) + .size(14.) + .color(colors::danger()), + ) .into_element() } diff --git a/packages/oneclient_app/src/view/app/settings/language.rs b/packages/oneclient_app/src/view/app/settings/language.rs index baba05a7..02483963 100644 --- a/packages/oneclient_app/src/view/app/settings/language.rs +++ b/packages/oneclient_app/src/view/app/settings/language.rs @@ -1,13 +1,11 @@ use freya::prelude::*; -use crate::components::{IconType, Icon}; use super::settings_page; +use crate::components::{Icon, IconType}; use crate::theme::colors; use crate::view::app::settings::section_header; -const LANGUAGES: &[(&str, &str)] = &[ - ("English", "English (US)"), -]; +const LANGUAGES: &[(&str, &str)] = &[("English", "English (US)")]; #[derive(PartialEq)] pub struct SettingsLanguage; @@ -63,8 +61,11 @@ fn language_row( .color(colors::fg_secondary()), ), ) - .maybe_child( - is_selected.then(|| Icon::new(IconType::Check).size(20.).color(colors::brand()).into_element()), - ) + .maybe_child(is_selected.then(|| { + Icon::new(IconType::Check) + .size(20.) + .color(colors::brand()) + .into_element() + })) .into_element() } diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index cc518d2d..c5420fb7 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -1,8 +1,8 @@ use freya::prelude::*; +use super::settings_page; use crate::components::{IconType, link_button, toggle}; use crate::hooks::{use_dispatch, use_launcher, use_settings_snapshot}; -use super::settings_page; use crate::platform; use crate::view::app::settings::{section_header, settings_row}; @@ -20,17 +20,24 @@ impl Component for SettingsLauncher { move || v }); + let crash_reporting = use_state({ + let v = settings.crash_reporting; + move || v + }); + let mut first = use_state(|| true); { let settings = settings.clone(); use_side_effect(move || { let discord = *discord_rpc.read(); + let crash = *crash_reporting.read(); if *first.peek() { first.set(false); return; } let mut next = settings.clone(); next.discord_enabled = discord; + next.crash_reporting = crash; dispatch.set_settings(next); }); } @@ -46,6 +53,12 @@ impl Component for SettingsLauncher { "Enable Discord Rich Presence.", toggle(discord_rpc), )) + .child(settings_row( + IconType::AlertTriangle, + "Crash Reporting", + "Send anonymous crash and error reports to help fix bugs. Applies on restart.", + toggle(crash_reporting), + )) .child(section_header("FOLDERS AND FILES")) .child(settings_row( IconType::Folder, diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index 5b88f8dc..bbd2041b 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -2,9 +2,9 @@ use freya::prelude::*; use oneclient_core::Patch; use oneclient_core::settings::{ProfileUpdate, Resolution}; +use super::settings_page; use crate::components::{Icon, IconType, TextInput, toggle, validate_number}; use crate::hooks::{use_dispatch, use_settings_snapshot}; -use super::settings_page; use crate::theme::colors; use crate::view::app::settings::{section_header, settings_row}; @@ -21,11 +21,17 @@ impl Component for SettingsMinecraft { move || v }); let width = use_state({ - let v = profile.resolution.map(|r| r.width.to_string()).unwrap_or_default(); + let v = profile + .resolution + .map(|r| r.width.to_string()) + .unwrap_or_default(); move || v }); let height = use_state({ - let v = profile.resolution.map(|r| r.height.to_string()).unwrap_or_default(); + let v = profile + .resolution + .map(|r| r.height.to_string()) + .unwrap_or_default(); move || v }); let memory = use_state({ @@ -165,7 +171,11 @@ fn resolution_field(width: State, height: State) -> impl IntoEle .width(Size::px(70.)) .text_align(TextAlign::Center), ) - .child(Icon::new(IconType::X).size(14.).color(colors::fg_secondary())) + .child( + Icon::new(IconType::X) + .size(14.) + .color(colors::fg_secondary()), + ) .child( TextInput::new(height) .placeholder("480") diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs index 88e39f5a..16a2434c 100644 --- a/packages/oneclient_app/src/view/app/settings/mod.rs +++ b/packages/oneclient_app/src/view/app/settings/mod.rs @@ -15,14 +15,14 @@ pub use apis::SettingsApis; pub use changelog::SettingsChangelog; pub use developer::SettingsDeveloper; // pub use feedback::SettingsFeedback; -pub use java::SettingsJava; pub use appearance::SettingsAppearance; +pub use java::SettingsJava; pub use language::SettingsLanguage; pub use launcher::SettingsLauncher; pub use minecraft::SettingsMinecraft; use crate::{ - components::{IconType, Icon}, + components::{Icon, IconType}, theme::colors, }; diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs index 121faa43..b9f4b395 100644 --- a/packages/oneclient_app/src/view/app/skins.rs +++ b/packages/oneclient_app/src/view/app/skins.rs @@ -1,4 +1,3 @@ - use freya::prelude::*; use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel}; @@ -120,7 +119,11 @@ fn side_panel() -> impl IntoElement { fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement { Button::new() - .variant(if primary { ButtonVariant::Primary } else { ButtonVariant::Secondary }) + .variant(if primary { + ButtonVariant::Primary + } else { + ButtonVariant::Secondary + }) .child(Icon::new(icon).size(16.).color(colors::fg_primary())) .text(text) .into_element() diff --git a/packages/oneclient_app/src/view/mod.rs b/packages/oneclient_app/src/view/mod.rs index cf49c6a3..6714e422 100644 --- a/packages/oneclient_app/src/view/mod.rs +++ b/packages/oneclient_app/src/view/mod.rs @@ -1,8 +1,8 @@ mod not_found; mod startup; -pub mod onboarding; pub mod app; +pub mod onboarding; pub use not_found::NotFound; -pub use startup::Startup; \ No newline at end of file +pub use startup::Startup; diff --git a/packages/oneclient_app/src/view/not_found.rs b/packages/oneclient_app/src/view/not_found.rs index 4edd701f..8dfac539 100644 --- a/packages/oneclient_app/src/view/not_found.rs +++ b/packages/oneclient_app/src/view/not_found.rs @@ -32,4 +32,4 @@ impl Component for NotFound { .color(theme::colors::fg_secondary()), ) } -} \ No newline at end of file +} diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs index 707bd39a..72982825 100644 --- a/packages/oneclient_app/src/view/onboarding/account.rs +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -125,12 +125,7 @@ fn sign_in_card( .size(13.) .color(colors::danger()), ) - .child( - label() - .text(message) - .font_size(12.) - .color(colors::danger()), - ) + .child(label().text(message).font_size(12.).color(colors::danger())) .into_element() })) .into_element() diff --git a/packages/oneclient_app/src/view/onboarding/bundles/card.rs b/packages/oneclient_app/src/view/onboarding/bundles/card.rs index 8398ecfd..4263ad22 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/card.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/card.rs @@ -2,9 +2,7 @@ use super::*; use oneclient_core::clusters::Cluster; -use crate::components::{ - Button, DynamicArt, Icon, IconType, -}; +use crate::components::{Button, DynamicArt, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index dff095c6..78a1643f 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -8,8 +8,7 @@ use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; use crate::components::ScrollArea; use crate::hooks::{ - ClusterBundles, onboarding_bundles_items, - use_onboarding_bundles, use_onboarding_selection, + ClusterBundles, onboarding_bundles_items, use_onboarding_bundles, use_onboarding_selection, }; use crate::routes::Route; use crate::theme::colors; @@ -321,4 +320,3 @@ impl Component for OnboardingBundles { .maybe_child(popup) } } - diff --git a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs b/packages/oneclient_app/src/view/onboarding/bundles/popup.rs index 5259193a..05d888d2 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/popup.rs @@ -6,12 +6,8 @@ use freya::query::QueryStateData; use oneclient_core::packages::{CachedPackageMeta, ProviderId}; use oneclient_core::{BundleArchive, BundleFileKind}; -use crate::components::{ - Button, Icon, OverlayPopup, ScrollArea, TabBar, TabItem, -}; -use crate::hooks::{ - package_meta_batch, use_cached_image, use_package_meta_batch, -}; +use crate::components::{Button, Icon, OverlayPopup, ScrollArea, TabBar, TabItem}; +use crate::hooks::{package_meta_batch, use_cached_image, use_package_meta_batch}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs index 5e9dcc93..ff5d2c98 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs @@ -46,7 +46,10 @@ pub(super) fn summary_view( let migration_section: Element = match migration { Some((source, folder, target)) => { - let mut rows = vec![summary_line("From", &source), summary_line("Files", &folder)]; + let mut rows = vec![ + summary_line("From", &source), + summary_line("Files", &folder), + ]; if !target.is_empty() { rows.push(summary_line("Destination", &target)); } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs index 8025652d..75b15ad9 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs @@ -50,7 +50,14 @@ pub(super) fn use_onboarding_tip() -> String { let Ok(state) = oneclient_core::LauncherState::get() else { return; }; - match state.services.requester.http().get(FUNFACTS_URL).send().await { + match state + .services + .requester + .http() + .get(FUNFACTS_URL) + .send() + .await + { Ok(res) if res.status().is_success() => match res.text().await { Ok(text) => { let parsed = parse_funfacts(&text); diff --git a/packages/oneclient_app/src/view/onboarding/language.rs b/packages/oneclient_app/src/view/onboarding/language.rs index c4bb80ad..7166f534 100644 --- a/packages/oneclient_app/src/view/onboarding/language.rs +++ b/packages/oneclient_app/src/view/onboarding/language.rs @@ -14,9 +14,10 @@ struct Language { percentage: u32, } -const LANGUAGES: [Language; 1] = [ - Language { name: "English", percentage: 100 }, -]; +const LANGUAGES: [Language; 1] = [Language { + name: "English", + percentage: 100, +}]; #[derive(PartialEq)] pub struct OnboardingLanguage; @@ -29,15 +30,13 @@ impl Component for OnboardingLanguage { let back = if has_migration_data(&migration_query) { Route::OnboardingMigration {} } else { - Route::OnboardingWelcome {} + Route::OnboardingTerms {} }; let rows: Vec = LANGUAGES .iter() .enumerate() - .map(|(index, lang)| { - language_row(lang, index == *selected.read(), selected, language) - }) + .map(|(index, lang)| language_row(lang, index == *selected.read(), selected, language)) .collect(); let content = rect() @@ -55,7 +54,13 @@ impl Component for OnboardingLanguage { .font_weight(FontWeight::MEDIUM) .color(colors::fg_primary()), ) - .child(rect().vertical().width(Size::fill()).spacing(4.).children(rows)) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .children(rows), + ) .into_element(); onboarding_page( @@ -92,7 +97,9 @@ fn language_row( .padding(Gaps::new_symmetric(14., 20.)) .corner_radius(CornerRadius::new_all(10.)) .background(background) - .maybe(active, |el| el.border(border_all_color(1., colors::brand()))) + .maybe(active, |el| { + el.border(border_all_color(1., colors::brand())) + }) .a11y_role(AccessibilityRole::Button) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index aca061c5..f457a13d 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -27,9 +27,10 @@ fn build_migrated_selection(source: &[SourceInstance], new: &[ClusterBundles]) - let mut selected = HashSet::new(); for cb in new { - let Some(instance) = source.iter().find(|o| { - o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader - }) else { + let Some(instance) = source + .iter() + .find(|o| o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader) + else { continue; }; @@ -89,7 +90,10 @@ impl Component for OnboardingMigration { // No detection yet -> render nothing meaningful; the shell only routes // here when a source was detected, so this is a transient loading state. let Some(detection) = migration_detection(&migration_query) else { - return rect().width(Size::fill()).height(Size::fill()).into_element(); + return rect() + .width(Size::fill()) + .height(Size::fill()) + .into_element(); }; let source_name = detection.source.display_name(); let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); @@ -103,8 +107,7 @@ impl Component for OnboardingMigration { let mut version_cards: Vec = Vec::new(); for instance in &detection.instances { let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str()); - let dedicated_available = - matching_new_cluster_id(instance, &new_clusters).is_some(); + let dedicated_available = matching_new_cluster_id(instance, &new_clusters).is_some(); version_cards.push(version_card( instance, @@ -259,26 +262,16 @@ fn version_card( .font_weight(FontWeight::MEDIUM) .color(colors::fg_primary()), ) - .child( - label() - .text(subtitle) - .font_size(11.) - .color(if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - }), - ) - .child( - label() - .text(categories) - .font_size(11.) - .color(if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - }), - ), + .child(label().text(subtitle).font_size(11.).color(if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + })) + .child(label().text(categories).font_size(11.).color(if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + })), ) // Selection affordance only when the instance actually has files. .maybe_child(importable.then(|| { @@ -471,7 +464,7 @@ fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl In .secondary() .width(Size::px(128.)) .on_press(move |_| { - let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); + let _ = RouterContext::get().replace(Route::OnboardingTerms {}); }) .text("Back"), ) diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index d9e14f98..b7403f1b 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -4,6 +4,7 @@ mod downloading; mod language; mod migration; mod preferences; +mod terms; mod welcome; pub use account::OnboardingAccount; @@ -13,6 +14,7 @@ pub use language::OnboardingLanguage; pub use migration::OnboardingMigration; pub(crate) use migration::matching_new_cluster_id; pub use preferences::OnboardingPreferences; +pub use terms::OnboardingTerms; pub use welcome::OnboardingWelcome; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -30,7 +32,7 @@ use crate::ui::{border_all_color, entrance_motion_layer}; /// Number of onboarding steps. The v1-migration step only exists (and is only /// counted) when old launcher data was detected. pub fn onboarding_total(has_migration: bool) -> usize { - if has_migration { 7 } else { 6 } + if has_migration { 8 } else { 7 } } pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { @@ -38,12 +40,13 @@ pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { match route { Route::OnboardingWelcome {} => 0, - Route::OnboardingMigration {} => 1, - Route::OnboardingLanguage {} => 1 + shift, - Route::OnboardingAccount {} => 2 + shift, - Route::OnboardingBundles {} => 3 + shift, - Route::OnboardingPreferences {} => 4 + shift, - Route::OnboardingDownloading {} => 5 + shift, + Route::OnboardingTerms {} => 1, + Route::OnboardingMigration {} => 2, + Route::OnboardingLanguage {} => 2 + shift, + Route::OnboardingAccount {} => 3 + shift, + Route::OnboardingBundles {} => 4 + shift, + Route::OnboardingPreferences {} => 5 + shift, + Route::OnboardingDownloading {} => 6 + shift, _ => 0, } } @@ -80,8 +83,16 @@ pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { } }); - let p = if anim_finished { 1.0 } else { anim.get().value() }; - let slide_x = if motion { direction * (1. - p) * SLIDE_DISTANCE } else { 0. }; + let p = if anim_finished { + 1.0 + } else { + anim.get().value() + }; + let slide_x = if motion { + direction * (1. - p) * SLIDE_DISTANCE + } else { + 0. + }; let opacity = if motion && !anim_finished { p } else { 1. }; rect() @@ -91,7 +102,11 @@ pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { .into_element() } -pub(crate) fn onboarding_page(illustration: impl IntoElement, content: impl IntoElement, nav: impl IntoElement) -> impl IntoElement { +pub(crate) fn onboarding_page( + illustration: impl IntoElement, + content: impl IntoElement, + nav: impl IntoElement, +) -> impl IntoElement { rect() .vertical() .width(Size::fill()) @@ -130,7 +145,11 @@ pub(crate) fn onboarding_illustration(icon: IconType) -> impl IntoElement { Icon::new(icon).size(240.).color(Color::WHITE) } -pub(crate) fn onboarding_nav(back: Option, next: Route, next_enabled: bool) -> impl IntoElement { +pub(crate) fn onboarding_nav( + back: Option, + next: Route, + next_enabled: bool, +) -> impl IntoElement { rect() .horizontal() .width(Size::fill()) diff --git a/packages/oneclient_app/src/view/onboarding/terms.rs b/packages/oneclient_app/src/view/onboarding/terms.rs new file mode 100644 index 00000000..7b4d7468 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/terms.rs @@ -0,0 +1,304 @@ +use freya::prelude::*; +use freya::query::UseQuery; +use freya::router::RouterContext; + +use crate::components::{Button, Icon, IconType, ScrollArea, Segment, SegmentedControl, toggle}; +use crate::hooks::{ + TermsQuery, has_migration_data, terms_document, terms_error, terms_is_loading, use_dispatch, + use_migration, use_settings_snapshot, use_terms, +}; +use crate::platform::open_url; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{onboarding_illustration, onboarding_page, step_heading}; + +#[derive(Clone, Copy, PartialEq)] +enum LegalTab { + Terms, + Privacy, +} + +#[derive(PartialEq)] +pub struct OnboardingTerms; + +impl Component for OnboardingTerms { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + let query = use_terms(); + let migration_query = use_migration(); + + let document = terms_document(&query); + let error = terms_error(&query); + let loading = terms_is_loading(&query); + + let accepted = use_state(|| false); + let tab = use_state(|| LegalTab::Terms); + + let returning = settings.seen_onboarding; + let next = if returning { + Route::Home {} + } else if has_migration_data(&migration_query) { + Route::OnboardingMigration {} + } else { + Route::OnboardingLanguage {} + }; + let back = (!returning).then_some(Route::OnboardingWelcome {}); + + let terms_version = document.as_ref().map(|doc| doc.version).unwrap_or(1); + let privacy_version = document + .as_ref() + .map(|doc| doc.privacy_version()) + .unwrap_or(1); + let terms_url = document + .as_ref() + .map(|doc| doc.terms_url().to_string()) + .unwrap_or_else(|| oneclient_core::constants::TOS_URL.to_string()); + let privacy_url = document + .as_ref() + .map(|doc| doc.privacy_url().to_string()) + .unwrap_or_else(|| oneclient_core::constants::PRIVACY_URL.to_string()); + + let privacy_body = document.as_ref().and_then(|doc| doc.privacy_body()); + let tabs = privacy_body.is_some().then(|| tab_switcher(tab)); + + let body = if loading { + loading_body() + } else if let Some(document) = document.as_ref() { + match (*tab.read(), privacy_body) { + (LegalTab::Privacy, Some(privacy)) => markdown_body(privacy), + _ => markdown_body(&document.terms), + } + } else { + fallback_body(error.as_deref(), query) + }; + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "Terms & Privacy", + "Please read and accept these before continuing.", + )) + .maybe_child(tabs) + .child(body) + .child(link_row(terms_url, privacy_url)) + .child(accept_row(accepted)) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::File02), + content, + terms_nav(back, *accepted.read() && !loading, move || { + dispatch.accept_tos(terms_version, privacy_version); + let _ = RouterContext::get().replace(next.clone()); + }), + ) + } +} + +fn panel(child: impl IntoElement) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .height(Size::px(220.)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(child) +} + +fn loading_body() -> Element { + panel( + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("Loading the Terms of Service and Privacy Policy...") + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .into_element() +} + +fn tab_switcher(tab: State) -> Element { + SegmentedControl::new(tab) + .segment(Segment::new(LegalTab::Terms).label("Terms")) + .segment(Segment::new(LegalTab::Privacy).label("Privacy")) + .into_element() +} + +fn markdown_body(markdown: &str) -> Element { + panel( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new_all(16.)) + .child( + MarkdownViewer::new(markdown.to_string()) + .width(Size::fill()) + .color(colors::fg_primary()) + .color_link(colors::code_info()) + .background_code(colors::component_bg()) + .color_code(colors::fg_primary()) + .background_blockquote(colors::component_bg()) + .border_blockquote(colors::brand()) + .background_divider(colors::component_border()) + .heading_h1(20.) + .heading_h2(17.) + .heading_h3(15.) + .heading_h4(14.) + .heading_h5(13.) + .heading_h6(12.) + .paragraph_size(13.) + .code_font_size(12.), + ), + ) + .into_element() +} + +fn fallback_body(error: Option<&str>, query: UseQuery) -> Element { + panel( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .center() + .spacing(10.) + .padding(Gaps::new_all(20.)) + .child( + Icon::new(IconType::AlertTriangle) + .size(22.) + .color(colors::code_warn()), + ) + .child( + label() + .text("Couldn't load the Terms of Service or Privacy Policy.") + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Open them in your browser using the links below, or try again once \ + you're back online.", + ) + .font_size(12.) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ) + .maybe_child(error.map(|err| { + label() + .text(err.to_string()) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary().with_a(160)) + })) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| { + query.invalidate(); + }) + .text("Retry") + .child(Icon::new(IconType::RefreshCw01).size(14.)), + ), + ) + .into_element() +} + +fn link_row(terms_url: String, privacy_url: String) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .spacing(10.) + .child(external_link_button("Terms of Service", terms_url)) + .child(external_link_button("Privacy Policy", privacy_url)) + .into_element() +} + +fn external_link_button(text: &'static str, url: String) -> impl IntoElement { + Button::new() + .secondary() + .small() + .on_press(move |_| open_url(&url)) + .text(text) + .child(Icon::new(IconType::LinkExternal01).size(14.)) +} + +fn accept_row(accepted: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(16.) + .content(Content::Flex) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text("I accept the Terms of Service and Privacy Policy") + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text("Required to use OneClient.") + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .child(toggle(accepted)) + .into_element() +} + +fn terms_nav( + back: Option, + next_enabled: bool, + on_next: impl FnMut() + 'static, +) -> impl IntoElement { + let mut on_next = on_next; + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .maybe_child(back.map(|route| { + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(route.clone()); + }) + .text("Back") + .into_element() + })) + .child( + Button::new() + .primary() + .width(Size::px(140.)) + .enabled(next_enabled) + .on_press(move |_| on_next()) + .text("Next") + .child(Icon::new(IconType::ArrowRight).size(16.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/welcome.rs b/packages/oneclient_app/src/view/onboarding/welcome.rs index 325c8834..37ad66e6 100644 --- a/packages/oneclient_app/src/view/onboarding/welcome.rs +++ b/packages/oneclient_app/src/view/onboarding/welcome.rs @@ -1,7 +1,6 @@ use freya::prelude::*; use crate::components::IconType; -use crate::hooks::{has_migration_data, use_migration}; use crate::routes::Route; use crate::theme::colors; use crate::view::onboarding::{ @@ -13,14 +12,6 @@ pub struct OnboardingWelcome; impl Component for OnboardingWelcome { fn render(&self) -> impl IntoElement { - let migration_query = use_migration(); - - let next = if has_migration_data(&migration_query) { - Route::OnboardingMigration {} - } else { - Route::OnboardingLanguage {} - }; - let content = rect() .vertical() .width(Size::fill()) @@ -40,7 +31,7 @@ impl Component for OnboardingWelcome { onboarding_page( onboarding_illustration(IconType::OnboardingWelcome), content, - onboarding_nav(None, next, true), + onboarding_nav(None, Route::OnboardingTerms {}, true), ) } } diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs index e5540bf3..db41121a 100644 --- a/packages/oneclient_app/src/view/startup.rs +++ b/packages/oneclient_app/src/view/startup.rs @@ -3,7 +3,10 @@ use freya::{prelude::*, router::*}; use crate::AppAssets; use crate::Route; -use crate::hooks::{use_launcher, use_notifications_snapshot, use_settings_snapshot}; +use crate::hooks::{ + terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, + use_settings_snapshot, use_terms, +}; use crate::theme::colors; #[derive(PartialEq)] @@ -14,6 +17,7 @@ impl Component for Startup { let launcher = use_launcher(); let settings = use_settings_snapshot(); let notifications = use_notifications_snapshot(); + let terms = use_terms(); let intro = use_animation(|conf| { conf.on_creation(OnCreation::Run); @@ -34,37 +38,49 @@ impl Component for Startup { let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); - if launcher.ready && !launcher.fetching { - let destination = if settings.settings.seen_onboarding { - Route::Home {} - } else { + if launcher.ready && !launcher.fetching && !terms_is_loading(&terms) { + let document = terms_document(&terms); + let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1); + let required_privacy = document + .as_ref() + .map(|doc| doc.privacy_version()) + .unwrap_or(1); + + let stale = settings.settings.accepted_tos_version < required_terms + || settings.settings.accepted_privacy_version < required_privacy; + + let destination = if !settings.settings.seen_onboarding { Route::OnboardingWelcome {} + } else if stale { + Route::OnboardingTerms {} + } else { + Route::Home {} }; let _ = RouterContext::get().replace(destination); return rect().into_element(); } - let active = notifications - .inbox - .iter() - .find(|entry| entry.is_loading); + let active = notifications.inbox.iter().find(|entry| entry.is_loading); - let (message, detail): (String, Option) = if let Some(err) = launcher.error.as_deref() { - ("Couldn't start OneClient".to_string(), Some(err.to_string())) - - } else if let Some(entry) = active { - let detail = entry - .tasks - .iter() - .find(|t| t.total == 0 || t.current < t.total) - .map(|t| t.label.clone()) - .or_else(|| entry.progress.map(|(c, t)| format!("{c} / {t}"))); - (entry.title.clone(), detail) - } else if launcher.ready { - ("Fetching versions and bundles...".to_string(), None) - } else { - ("Starting OneClient...".to_string(), None) - }; + let (message, detail): (String, Option) = + if let Some(err) = launcher.error.as_deref() { + ( + "Couldn't start OneClient".to_string(), + Some(err.to_string()), + ) + } else if let Some(entry) = active { + let detail = entry + .tasks + .iter() + .find(|t| t.total == 0 || t.current < t.total) + .map(|t| t.label.clone()) + .or_else(|| entry.progress.map(|(c, t)| format!("{c} / {t}"))); + (entry.title.clone(), detail) + } else if launcher.ready { + ("Fetching versions and bundles...".to_string(), None) + } else { + ("Starting OneClient...".to_string(), None) + }; let is_error = launcher.error.is_some(); diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index 89cfd4d3..e788dd26 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -56,9 +56,17 @@ path = "examples/launch_game.rs" name = "notifications" path = "examples/notifications.rs" +[[example]] +name = "discord_rpc" +path = "examples/discord_rpc.rs" + +[dev-dependencies] +async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } + [dependencies] # core (extra) # gamemode_rs.workspace = true +discord-rich-presence.workspace = true interfrost.workspace = true oneclient_db.workspace = true polyio.workspace = true @@ -68,6 +76,9 @@ tracing.workspace = true tracing-futures.workspace = true tracing-subscriber.workspace = true +sentry.workspace = true +sentry-tracing.workspace = true + # database sqlx.workspace = true @@ -76,9 +87,10 @@ async-trait.workspace = true tokio.workspace = true tokio-stream.workspace = true tokio-util.workspace = true -futures-util = { workspace = true, features = ["std"] } +futures-util = { workspace = true, features = ["std", "sink"] } futures-lite.workspace = true reqwest.workspace = true +reqwest-websocket.workspace = true async_zip.workspace = true async-tempfile.workspace = true async-stream.workspace = true diff --git a/packages/oneclient_core/examples/discord_rpc.rs b/packages/oneclient_core/examples/discord_rpc.rs new file mode 100644 index 00000000..b2fa7267 --- /dev/null +++ b/packages/oneclient_core/examples/discord_rpc.rs @@ -0,0 +1,33 @@ +use std::time::Duration; + +use oneclient_core::discord::{DiscordRpc, Presence}; + +fn main() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .init(); + + let rpc = DiscordRpc::spawn(true); + + println!("idle presence for 10s..."); + std::thread::sleep(Duration::from_secs(10)); + + println!("playing presence for 10s..."); + rpc.set_presence(Presence::Playing { + cluster: "Example Cluster".to_owned(), + mc_version: "1.8.9".to_owned(), + }); + std::thread::sleep(Duration::from_secs(10)); + + println!("disabling for 5s..."); + rpc.set_enabled(false); + std::thread::sleep(Duration::from_secs(5)); + + println!("re-enabling for 10s..."); + rpc.set_enabled(true); + std::thread::sleep(Duration::from_secs(10)); + + println!("shutting down"); + rpc.shutdown(); + std::thread::sleep(Duration::from_secs(1)); +} diff --git a/packages/oneclient_core/src/constants.rs b/packages/oneclient_core/src/constants.rs index ee2411db..6d01a600 100644 --- a/packages/oneclient_core/src/constants.rs +++ b/packages/oneclient_core/src/constants.rs @@ -16,6 +16,14 @@ pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; pub const SKYCLIENT_BASE_URL: &str = "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; pub const META_URL_BASE: &str = "https://data-v2.polyfrost.org"; +pub const TOS_URL: &str = "https://polyfrost.org/legal/terms"; +pub const PRIVACY_URL: &str = "https://polyfrost.org/legal/privacy"; +pub const PLUS_BACKEND_URL: &str = "https://plus.polyfrost.org"; + +pub const SENTRY_DSN: &str = match option_env!("ONECLIENT_SENTRY_DSN") { + Some(dsn) => dsn, + None => "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040", +}; pub const TARGET_OS: &str = cfg_select! { target_os = "windows" => "windows", diff --git a/packages/oneclient_core/src/crypto.rs b/packages/oneclient_core/src/crypto.rs index 52f04cdb..8bc68f5b 100644 --- a/packages/oneclient_core/src/crypto.rs +++ b/packages/oneclient_core/src/crypto.rs @@ -33,7 +33,7 @@ pub fn normalize_hash(hash: &str) -> String { hash.trim().to_ascii_lowercase() } -fn to_hex(data: &[u8]) -> String { +pub(crate) fn to_hex(data: &[u8]) -> String { data.iter().fold(String::new(), |mut out, b| { let _ = write!(out, "{b:02x}"); out diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 3e6595d1..97dbffa3 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -239,6 +239,7 @@ pub async fn ephemeral_state() -> LauncherResult> { versions: Arc::new(crate::versions::VersionsManager::new()), images: crate::images::ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), + discord: crate::discord::DiscordRpc::spawn(false), })) } diff --git a/packages/oneclient_core/src/discord.rs b/packages/oneclient_core/src/discord.rs new file mode 100644 index 00000000..ac187391 --- /dev/null +++ b/packages/oneclient_core/src/discord.rs @@ -0,0 +1,226 @@ +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::time::{Duration, Instant}; + +use discord_rich_presence::activity::{Activity, Assets, Button, Timestamps}; +use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; + +use crate::constants::DISCORD_CLIENT_ID; + +const RECONNECT_BACKOFF: Duration = Duration::from_secs(60); + +const IDLE_POLL: Duration = Duration::from_secs(3600); + +const LARGE_IMAGE: &str = "onelauncher_logo_512"; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Presence { + #[default] + Idle, + Playing { + cluster: String, + mc_version: String, + }, +} + +impl Presence { + fn details(&self) -> &'static str { + match self { + Self::Idle => "Idle", + Self::Playing { .. } => "Playing Minecraft", + } + } + + fn state(&self) -> String { + match self { + Self::Idle => "In the launcher".to_owned(), + Self::Playing { + cluster, + mc_version, + } => format!("{cluster} ({mc_version})"), + } + } +} + +enum Command { + SetPresence(Presence), + SetEnabled(bool), + Shutdown, +} + +#[derive(Clone)] +pub struct DiscordRpc { + tx: Sender, +} + +impl DiscordRpc { + pub fn spawn(enabled: bool) -> Self { + let (tx, rx) = mpsc::channel(); + + std::thread::Builder::new() + .name("discord-rpc".to_owned()) + .spawn(move || Worker::new(enabled).run(&rx)) + .expect("failed to spawn the Discord RPC thread"); + + Self { tx } + } + + pub fn set_presence(&self, presence: Presence) { + self.send(Command::SetPresence(presence)); + } + + pub fn set_enabled(&self, enabled: bool) { + self.send(Command::SetEnabled(enabled)); + } + + pub fn shutdown(&self) { + self.send(Command::Shutdown); + } + + fn send(&self, command: Command) { + if self.tx.send(command).is_err() { + tracing::debug!("Discord RPC worker is gone; dropping command"); + } + } +} + +struct Worker { + client: DiscordIpcClient, + enabled: bool, + connected: bool, + ever_connected: bool, + presence: Presence, + presence_since: i64, + next_attempt: Instant, +} + +impl Worker { + fn new(enabled: bool) -> Self { + Self { + client: DiscordIpcClient::new(DISCORD_CLIENT_ID), + enabled, + connected: false, + ever_connected: false, + presence: Presence::Idle, + presence_since: chrono::Utc::now().timestamp(), + next_attempt: Instant::now(), + } + } + + fn run(mut self, rx: &mpsc::Receiver) { + self.publish(); + + loop { + match rx.recv_timeout(self.poll_interval()) { + Ok(Command::SetPresence(presence)) => self.set_presence(presence), + Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), + Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => self.publish(), + } + } + + self.disconnect(); + } + + fn poll_interval(&self) -> Duration { + if !self.enabled || self.connected { + return IDLE_POLL; + } + + self.next_attempt + .saturating_duration_since(Instant::now()) + .max(Duration::from_millis(100)) + } + + fn set_presence(&mut self, presence: Presence) { + if self.presence == presence { + return; + } + + self.presence = presence; + self.presence_since = chrono::Utc::now().timestamp(); + self.publish(); + } + + fn set_enabled(&mut self, enabled: bool) { + if self.enabled == enabled { + return; + } + + self.enabled = enabled; + + if enabled { + self.next_attempt = Instant::now(); + self.publish(); + } else { + self.disconnect(); + } + } + + fn publish(&mut self) { + if !self.enabled || !self.ensure_connected() { + return; + } + + let state = self.presence.state(); + let activity = Activity::new() + .details(self.presence.details()) + .state(&state) + .timestamps(Timestamps::new().start(self.presence_since)) + .assets( + Assets::new() + .large_image(LARGE_IMAGE) + .large_text("OneClient"), + ) + .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); + + if let Err(err) = self.client.set_activity(activity) { + tracing::warn!("Discord RPC set_activity failed: {err}"); + self.drop_connection(); + } + } + + fn ensure_connected(&mut self) -> bool { + if self.connected { + return true; + } + + if Instant::now() < self.next_attempt { + return false; + } + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + + let result = if self.ever_connected { + self.client.reconnect() + } else { + self.client.connect() + }; + + match result { + Ok(()) => { + tracing::info!("Discord RPC connected"); + self.connected = true; + self.ever_connected = true; + true + } + Err(err) => { + tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); + false + } + } + } + + fn drop_connection(&mut self) { + self.connected = false; + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + } + + fn disconnect(&mut self) { + if !self.connected { + return; + } + + let _ = self.client.clear_activity(); + let _ = self.client.close(); + self.connected = false; + } +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 08cbe32d..cc0b801b 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -12,6 +12,7 @@ use tokio::sync::Mutex as AsyncMutex; use crate::auth::MinecraftAccount; use crate::ClusterStage; use crate::clusters::ClusterManager; +use crate::discord::Presence; use crate::game::{ GameError, arguments, download_minecraft, download_version_info, get_loader_version, libraries_missing, resolve_minecraft_version, @@ -298,6 +299,10 @@ pub async fn launch_cluster( stage(LaunchStage::Running); state.games.set_pid(cluster_id, pid); state.games.set_dir(cluster_id, cwd.clone()); + state.discord.set_presence(Presence::Playing { + cluster: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + }); let log_path = crate::logs::cluster_output_log(&cluster)?; if let Some(parent) = log_path.parent() { @@ -344,6 +349,10 @@ pub async fn launch_cluster( state.games.remove(cluster_id); state.services.notifier.game_stage(cluster_id, LaunchStage::Exited); + if state.games.running_ids().is_empty() { + state.discord.set_presence(Presence::Idle); + } + if played > Duration::from_secs(1) { let _ = ClusterManager::add_playtime(&state, cluster_id, played).await; } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 1cc5b30b..b90f3ba0 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -154,7 +154,7 @@ pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { Ok(()) } -const EMPTY_NOTE_NAME: &str = "PLEASE_READ_ME.txt"; +const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; async fn clear_content_files(dir: &Path) { let Ok(mut entries) = polyio::read_dir(dir).await else { diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index f01bb607..e10f5cb1 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -10,6 +10,7 @@ pub mod changelog; pub mod clusters; pub mod constants; pub mod crypto; +pub mod discord; mod error; pub mod game; pub mod http; @@ -25,10 +26,13 @@ pub mod os_ext; pub mod packages; pub mod patch; pub mod paths; +pub mod plus; pub mod recovery; +pub mod reporting; pub mod screenshots; pub mod settings; pub mod status; +pub mod tos; mod state; pub mod version; pub mod versions; @@ -42,6 +46,8 @@ pub use bundles::{ BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, }; pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup}; +pub use tos::{fetch_terms, TermsDocument}; +pub use discord::{DiscordRpc, Presence}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, ensure_from_bundles, ensure_from_versions, estimate_cluster_download, diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs index 94adf4d8..7f2a5545 100644 --- a/packages/oneclient_core/src/logger.rs +++ b/packages/oneclient_core/src/logger.rs @@ -22,11 +22,14 @@ pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout); + let sentry_layer = sentry_tracing::layer(); + #[cfg(debug_assertions)] { tracing_subscriber::registry() .with(filter) .with(stdout_layer) + .with(sentry_layer) .init(); } @@ -50,6 +53,7 @@ pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { .with(filter) .with(stdout_layer) .with(file_layer) + .with(sentry_layer) .init(); tracing::info!(path = %log_path.display(), "writing logs to file"); diff --git a/packages/oneclient_core/src/logs/manage.rs b/packages/oneclient_core/src/logs/manage.rs index 1e3ae036..2b41edf7 100644 --- a/packages/oneclient_core/src/logs/manage.rs +++ b/packages/oneclient_core/src/logs/manage.rs @@ -87,13 +87,22 @@ pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> push_file( cluster_output_log(cluster)?, - "Launcher output".to_string(), + "Game output".to_string(), LogKind::Game { cluster_id: cluster.id, }, &mut out, ); + if let Ok(logs_dir) = paths::logs_dir() { + collect_dir( + &logs_dir, + LogKind::Launcher, + |name| name.ends_with(".log") || name.ends_with(".log.gz"), + &mut out, + ); + } + out.sort_by_key(|info| std::cmp::Reverse(info.modified)); Ok(out) } diff --git a/packages/oneclient_core/src/logs/mod.rs b/packages/oneclient_core/src/logs/mod.rs index bd20e1dc..135bf75e 100644 --- a/packages/oneclient_core/src/logs/mod.rs +++ b/packages/oneclient_core/src/logs/mod.rs @@ -15,6 +15,7 @@ pub use parse::parse_level; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum LogKind { Game { cluster_id: i64 }, + Launcher, Minecraft, CrashReport, Other, diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index 93970eca..99561532 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -58,7 +58,7 @@ pub struct MigrationDetection { /// Where an imported game directory should land in *this* launcher. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ImportTarget { - /// Copy into the shared `/minecraft`. + /// Copy into the shared `/.minecraft`. Shared, /// Copy into a specific new cluster's own folder and mark it dedicated so /// `Cluster::game_dir()` resolves there instead of the shared dir. diff --git a/packages/oneclient_core/src/packages/store/mod.rs b/packages/oneclient_core/src/packages/store/mod.rs index 12911166..8e1ae8f1 100644 --- a/packages/oneclient_core/src/packages/store/mod.rs +++ b/packages/oneclient_core/src/packages/store/mod.rs @@ -346,16 +346,18 @@ fn ensure_compatible( return Ok(()); } - let cluster_loader = - GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); + if project.content_type == ContentType::Mod { + let cluster_loader = + GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); - if !version.loaders.is_empty() - && !version - .loaders - .iter() - .any(|l| cluster_loader.compatible_with(*l)) - { - return Err(PackageError::IncompatibleLoader.into()); + if !version.loaders.is_empty() + && !version + .loaders + .iter() + .any(|l| cluster_loader.compatible_with(*l)) + { + return Err(PackageError::IncompatibleLoader.into()); + } } if !version.game_versions.is_empty() @@ -369,3 +371,105 @@ fn ensure_compatible( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::packages::types::PackageBody; + use chrono::Utc; + + fn project(content_type: ContentType) -> ProjectDetail { + ProjectDetail { + id: "p".into(), + slug: "p".into(), + provider: ProviderId::Modrinth, + content_type, + name: "p".into(), + summary: String::new(), + author: String::new(), + members: Vec::new(), + gallery: Vec::new(), + body: PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: None, + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } + } + + fn version(loaders: Vec, game_versions: Vec<&str>) -> VersionDetail { + VersionDetail { + version_id: "v".into(), + project_id: "p".into(), + name: "v".into(), + version_number: "1".into(), + changelog: None, + game_versions: game_versions.into_iter().map(Into::into).collect(), + loaders, + published: Utc::now(), + downloads: 0, + files: Vec::new(), + } + } + + fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { + ClusterRow { + id: 1, + name: "c".into(), + folder_name: "c".into(), + setting_profile_name: None, + mc_version: mc_version.into(), + mc_loader: loader as i64, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + } + } + + #[test] + fn resource_pack_installs_into_modded_cluster() { + let result = ensure_compatible( + &project(ContentType::ResourcePack), + &version(vec![GameLoader::Vanilla], vec!["1.21.4"]), + &cluster(GameLoader::Fabric, "1.21.4"), + ); + + assert!(result.is_ok(), "resource packs are not bound to the loader"); + } + + #[test] + fn mod_still_rejects_incompatible_loader() { + let result = ensure_compatible( + &project(ContentType::Mod), + &version(vec![GameLoader::Forge], vec!["1.21.4"]), + &cluster(GameLoader::Fabric, "1.21.4"), + ); + + assert!(matches!( + result, + Err(LauncherError::PackageError(PackageError::IncompatibleLoader)) + )); + } + + #[test] + fn resource_pack_still_rejects_incompatible_mc_version() { + let result = ensure_compatible( + &project(ContentType::ResourcePack), + &version(vec![GameLoader::Vanilla], vec!["1.7.10"]), + &cluster(GameLoader::Fabric, "1.21.4"), + ); + + assert!(matches!( + result, + Err(LauncherError::PackageError(PackageError::IncompatibleMcVersion)) + )); + } +} diff --git a/packages/oneclient_core/src/plus.rs b/packages/oneclient_core/src/plus.rs new file mode 100644 index 00000000..7e3b4427 --- /dev/null +++ b/packages/oneclient_core/src/plus.rs @@ -0,0 +1,333 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use bytes::Bytes; +use futures_util::{SinkExt, TryStreamExt}; +use rand::Rng; +use reqwest_websocket::{Message, Upgrade}; +use uuid::Uuid; + +use crate::LauncherResult; +use crate::auth::MinecraftAccount; +use crate::constants; + +const MOJANG_JOIN_URL: &str = "https://sessionserver.mojang.com/session/minecraft/join"; + +const PING_INTERVAL: Duration = Duration::from_secs(30); + +const RECONNECT_DELAY: Duration = Duration::from_secs(15); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); + +static STARTED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, thiserror::Error)] +pub enum PlusError { + #[error("Mojang rejected the Poly+ session join (HTTP {status}): {body}")] + JoinRejected { status: u16, body: String }, + + #[error("Poly+ rejected the login for {username} (HTTP {status}): {body}")] + LoginRejected { + username: String, + status: u16, + body: String, + }, + + #[error(transparent)] + Request(#[from] reqwest::Error), + + #[error(transparent)] + Websocket(#[from] reqwest_websocket::Error), + + #[error(transparent)] + Url(#[from] url::ParseError), +} + +#[derive(serde::Deserialize)] +struct LoginResponse { + token: String, +} + +enum Outcome { + Connected, + NoAccount, + Failed, +} + +enum Event { + Ping, + Message(Option), +} + +pub fn start() { + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + + tokio::spawn(async move { + let client = match build_client() { + Ok(client) => client, + Err(err) => { + tracing::error!("[plus] could not build the playtime client: {err}"); + return; + } + }; + + let mut delay = RECONNECT_DELAY; + + loop { + delay = match session(&client).await { + Outcome::Connected | Outcome::NoAccount => RECONNECT_DELAY, + Outcome::Failed => (delay * 2).min(MAX_RECONNECT_DELAY), + }; + + tokio::time::sleep(delay).await; + } + }); +} + +fn build_client() -> Result { + reqwest::Client::builder() + .tcp_keepalive(Some(Duration::from_secs(15))) + .connect_timeout(Duration::from_secs(10)) + .tls_backend_rustls() + .user_agent(format!( + "OneClient {} ({})", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_HOMEPAGE") + )) + .build() +} + +fn base_url() -> &'static str { + constants::PLUS_BACKEND_URL.trim_end_matches('/') +} + +async fn session(client: &reqwest::Client) -> Outcome { + let account = match current_account().await { + Ok(Some(account)) => account, + Ok(None) => return Outcome::NoAccount, + Err(err) => { + tracing::warn!("[plus] no account to track playtime for: {err}"); + return Outcome::Failed; + } + }; + + let id = account.id; + let mut connected = false; + + match run_session(client, &account, &mut connected).await { + Ok(()) => tracing::info!("[plus] playtime session for {id} ended"), + Err(err) => tracing::warn!("[plus] playtime session for {id} failed: {err}"), + } + + if connected { + Outcome::Connected + } else { + Outcome::Failed + } +} + +async fn current_account() -> LauncherResult> { + let Some(account) = crate::auth::default_account_for_launch().await? else { + return Ok(None); + }; + + Ok(account.is_microsoft().then_some(account)) +} + +async fn run_session( + client: &reqwest::Client, + account: &MinecraftAccount, + connected: &mut bool, +) -> Result<(), PlusError> { + let token = login(client, account).await?; + + let mut websocket = client + .get(format!("{}/websocket", base_url())) + .bearer_auth(&token) + .upgrade() + .send() + .await? + .into_websocket() + .await?; + + *connected = true; + tracing::info!("[plus] playtime websocket connected for {}", account.id); + + pump(&mut websocket, PING_INTERVAL, account.id).await +} + +async fn pump( + websocket: &mut reqwest_websocket::WebSocket, + ping_interval: Duration, + account_id: Uuid, +) -> Result<(), PlusError> { + let mut ping = tokio::time::interval(ping_interval); + ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + ping.tick().await; + + loop { + let event = tokio::select! { + _ = ping.tick() => Event::Ping, + message = websocket.try_next() => Event::Message(message?), + }; + + match event { + Event::Ping => { + if account_changed(account_id).await { + tracing::info!( + "[plus] default account changed, ending playtime session for {account_id}" + ); + break; + } + + websocket.send(Message::Ping(Bytes::new())).await?; + } + Event::Message(Some(Message::Close { code, reason })) => { + tracing::info!("[plus] playtime websocket closed by the server: {code:?} {reason}"); + break; + } + Event::Message(Some(_)) => {} + Event::Message(None) => break, + } + } + + Ok(()) +} + +async fn login( + client: &reqwest::Client, + account: &MinecraftAccount, +) -> Result { + let server_id = generate_server_id(); + + let join = client + .post(MOJANG_JOIN_URL) + .header("Accept", "application/json") + .json(&serde_json::json!({ + "accessToken": account.access_token, + "selectedProfile": account.id.simple().to_string(), + "serverId": server_id, + })) + .send() + .await?; + + if !join.status().is_success() { + return Err(PlusError::JoinRejected { + status: join.status().as_u16(), + body: join.text().await.unwrap_or_default(), + }); + } + + let mut url = reqwest::Url::parse(&format!("{}/account/login", base_url()))?; + url.query_pairs_mut() + .append_pair("username", &account.username) + .append_pair("server_id", &server_id); + + let login = client + .post(url) + .header("Accept", "application/json") + .send() + .await?; + + if !login.status().is_success() { + return Err(PlusError::LoginRejected { + username: account.username.clone(), + status: login.status().as_u16(), + body: login.text().await.unwrap_or_default(), + }); + } + + Ok(login.json::().await?.token) +} + +async fn account_changed(id: Uuid) -> bool { + match crate::auth::get_default_account().await { + Ok(Some(account)) => account.id != id, + Ok(None) => true, + Err(_) => false, + } +} + +fn generate_server_id() -> String { + let mut bytes = [0u8; 20]; + rand::rng().fill_bytes(&mut bytes); + crate::crypto::to_hex(&bytes) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + use async_tungstenite::tokio::accept_async; + use async_tungstenite::tungstenite::Message as ServerMessage; + use futures_util::StreamExt; + use tokio::net::TcpListener; + + use super::*; + + async fn ping_counting_server(close_after_pings: usize) -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let pings = Arc::new(AtomicUsize::new(0)); + + tokio::spawn({ + let pings = Arc::clone(&pings); + async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut server = accept_async(stream).await.expect("handshake"); + + while let Some(Ok(message)) = server.next().await { + if let ServerMessage::Ping(_) = message + && pings.fetch_add(1, Ordering::SeqCst) + 1 >= close_after_pings + { + let _ = server.close(None).await; + break; + } + } + } + }); + + (format!("ws://127.0.0.1:{port}/websocket"), pings) + } + + async fn connect(url: &str) -> reqwest_websocket::WebSocket { + build_client() + .expect("client") + .get(url) + .upgrade() + .send() + .await + .expect("upgrade") + .into_websocket() + .await + .expect("websocket") + } + + #[tokio::test] + async fn pings_on_every_interval_and_stops_when_the_server_closes() { + assert!(!account_changed(Uuid::nil()).await); + + let (url, pings) = ping_counting_server(3).await; + let mut websocket = connect(&url).await; + + let pumped = tokio::time::timeout( + Duration::from_secs(10), + pump(&mut websocket, Duration::from_millis(50), Uuid::nil()), + ) + .await + .expect("pump should return once the server closes the socket"); + + pumped.expect("pump should treat a server close as a clean end"); + assert_eq!(pings.load(Ordering::SeqCst), 3); + } + + #[test] + fn server_ids_are_random_20_byte_hex() { + let id = generate_server_id(); + assert_eq!(id.len(), 40); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(id, generate_server_id()); + } +} diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs new file mode 100644 index 00000000..45b3c883 --- /dev/null +++ b/packages/oneclient_core/src/reporting.rs @@ -0,0 +1,76 @@ +use std::panic; +use std::time::Duration; + +use sentry::{ClientInitGuard, ClientOptions}; + +use crate::constants::SENTRY_DSN; + +const PANIC_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); + +const ENVIRONMENT: &str = if cfg!(debug_assertions) { + "development" +} else { + "production" +}; + +pub fn init(enabled: bool) -> Option { + if !enabled { + tracing::debug!("crash reporting disabled by settings"); + return None; + } + + if cfg!(debug_assertions) && option_env!("ONECLIENT_SENTRY_DSN").is_none() { + tracing::debug!("crash reporting skipped: debug build without an explicit DSN"); + return None; + } + + let guard = sentry::init(( + SENTRY_DSN, + ClientOptions { + release: Some(format!("oneclient@{}", env!("CARGO_PKG_VERSION")).into()), + environment: Some(ENVIRONMENT.into()), + attach_stacktrace: true, + send_default_pii: false, + ..Default::default() + }, + )); + + if !guard.is_enabled() { + tracing::warn!("sentry client failed to start; crash reports will not be sent"); + return None; + } + + install_panic_flush(); + + tracing::info!(environment = ENVIRONMENT, "crash reporting enabled"); + Some(guard) +} + +fn install_panic_flush() { + let capture = panic::take_hook(); + + panic::set_hook(Box::new(move |info| { + capture(info); + + if let Some(client) = sentry::Hub::current().client() { + client.flush(Some(PANIC_FLUSH_TIMEOUT)); + } + })); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_dsn_parses() { + SENTRY_DSN + .parse::() + .expect("bundled sentry DSN should be valid"); + } + + #[test] + fn opting_out_skips_the_client() { + assert!(init(false).is_none()); + } +} diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 9991984f..9de8c728 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -26,6 +26,7 @@ pub struct LauncherSettings { pub settings_version: u32, pub log_debug: bool, pub auto_update: bool, + pub crash_reporting: bool, pub enable_gamemode: bool, pub discord_enabled: bool, pub max_concurrent_requests: usize, @@ -34,6 +35,8 @@ pub struct LauncherSettings { pub dynamic_background_enabled: bool, pub view_states: BTreeMap, pub seen_onboarding: bool, + pub accepted_tos_version: u32, + pub accepted_privacy_version: u32, pub seen_versions: Vec, pub modrinth_api_key: Option, pub curseforge_api_key: Option, @@ -57,6 +60,7 @@ impl Default for LauncherSettings { settings_version: 1, log_debug: false, auto_update: true, + crash_reporting: true, discord_enabled: true, enable_gamemode: false, max_concurrent_requests: 25, @@ -65,6 +69,8 @@ impl Default for LauncherSettings { dynamic_background_enabled: true, view_states: BTreeMap::new(), seen_onboarding: false, + accepted_tos_version: 0, + accepted_privacy_version: 0, seen_versions: Vec::new(), modrinth_api_key: None, curseforge_api_key: None, diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 80a187e2..2fc0af22 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -6,6 +6,7 @@ use tokio::sync::{Mutex, OnceCell}; use crate::auth::{CredentialsStore, PendingBrowserLogin}; use crate::bundles::BundlesManager; +use crate::discord::DiscordRpc; use crate::http::RequestClient; use crate::images::ImageCacheStore; use crate::java::JavaManager; @@ -38,6 +39,7 @@ pub struct LauncherState { pub versions: Arc, pub images: ImageCacheStore, pub games: crate::game::GameProcessManager, + pub discord: DiscordRpc, } impl LauncherState { @@ -59,6 +61,7 @@ impl LauncherState { let settings = store::load_settings(Some(&services.notifier)).await; let auth = CredentialsStore::load().await?; let java = JavaManager; + let discord = DiscordRpc::spawn(settings.discord_enabled); let state = Arc::new(Self { services, @@ -71,6 +74,7 @@ impl LauncherState { versions: Arc::new(VersionsManager::new()), images: ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), + discord, }); STATE diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs new file mode 100644 index 00000000..c30bb5ee --- /dev/null +++ b/packages/oneclient_core/src/tos.rs @@ -0,0 +1,197 @@ +use std::path::Path; + +use reqwest::header; +use reqwest::{Method, StatusCode}; +use serde::{Deserialize, Serialize}; + +use crate::api_config::meta_url_base; +use crate::constants; +use crate::paths; +use crate::state::LauncherServices; +use crate::{LauncherError, LauncherResult}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TermsDocument { + pub version: u32, + #[serde(default)] + pub privacy_version: Option, + #[serde(default)] + pub updated_at: Option, + pub terms: String, + #[serde(default)] + pub privacy: Option, + #[serde(default)] + pub terms_url: Option, + #[serde(default)] + pub privacy_url: Option, +} + +impl TermsDocument { + pub fn terms_url(&self) -> &str { + self.terms_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + .unwrap_or(constants::TOS_URL) + } + + pub fn privacy_url(&self) -> &str { + self.privacy_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + .unwrap_or(constants::PRIVACY_URL) + } + + pub fn privacy_version(&self) -> u32 { + self.privacy_version.unwrap_or(self.version) + } + + pub fn privacy_body(&self) -> Option<&str> { + self.privacy + .as_deref() + .filter(|body| !body.trim().is_empty()) + } +} + +#[tracing::instrument(level = "debug", skip(services))] +pub async fn fetch_terms(services: &LauncherServices) -> LauncherResult { + let url = format!("{}/oneclient/tos.json", meta_url_base()); + let cache_path = paths::caches_dir()?.join("TERMS.json"); + let etag_path = paths::caches_dir()?.join("TERMS.json.etag"); + + let stored_etag = read_sidecar_etag(&etag_path).await; + let url_parsed = url.parse().map_err(LauncherError::UrlError)?; + + let mut request = reqwest::Request::new(Method::GET, url_parsed); + if let Some(etag) = &stored_etag { + insert_if_none_match(&mut request, etag); + } + + match services.requester.send(request).await { + Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { + tracing::debug!("terms cache hit (304)"); + read_cached_terms(&cache_path).await + } + Ok(res) if res.status().is_success() => { + let server_etag = etag_from_response(&res); + let bytes = res + .bytes() + .await + .map_err(|err| LauncherError::InvalidSettingsProfile { + reason: err.to_string(), + })?; + + let document: TermsDocument = serde_json::from_slice(&bytes)?; + + if let Some(parent) = cache_path.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::write(&cache_path, &bytes).await?; + if let Some(etag) = server_etag { + let _ = polyio::write(&etag_path, etag.as_bytes()).await; + } + + Ok(document) + } + Err(err) if cache_path.exists() => { + tracing::debug!("falling back to cached terms after remote fetch failed: {err}"); + read_cached_terms(&cache_path).await + } + Err(err) => { + tracing::error!("failed to fetch terms from remote: {err}"); + Err(LauncherError::InvalidSettingsProfile { + reason: err.to_string(), + }) + } + Ok(res) => { + tracing::warn!( + status = %res.status(), + "unexpected terms response; using cache when available" + ); + if cache_path.exists() { + read_cached_terms(&cache_path).await + } else { + Err(LauncherError::InvalidSettingsProfile { + reason: format!("terms request failed with HTTP {}", res.status()), + }) + } + } + } +} + +async fn read_cached_terms(path: &Path) -> LauncherResult { + let bytes = polyio::read(path).await?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn insert_if_none_match(request: &mut reqwest::Request, etag: &str) { + if let Ok(value) = header::HeaderValue::from_str(etag) { + request.headers_mut().insert(header::IF_NONE_MATCH, value); + } +} + +fn etag_from_response(res: &reqwest::Response) -> Option { + res.headers() + .get(header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +async fn read_sidecar_etag(path: &Path) -> Option { + polyio::read(path) + .await + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|etag| !etag.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_document() { + let raw = br###"{"version":2,"terms":"## Terms\n\nBe nice."}"###; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.version, 2); + assert_eq!(document.terms, "## Terms\n\nBe nice."); + assert_eq!(document.terms_url(), constants::TOS_URL); + assert_eq!(document.privacy_url(), constants::PRIVACY_URL); + } + + #[test] + fn privacy_version_falls_back_to_terms_version() { + let raw = br#"{"version":4,"terms":"x"}"#; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.privacy_version(), 4); + assert_eq!(document.privacy_body(), None); + } + + #[test] + fn privacy_version_is_independent_when_published() { + let raw = br###"{"version":4,"privacy_version":2,"terms":"x","privacy":"## Privacy"}"###; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.version, 4); + assert_eq!(document.privacy_version(), 2); + assert_eq!(document.privacy_body(), Some("## Privacy")); + } + + #[test] + fn blank_privacy_body_is_treated_as_absent() { + let raw = br#"{"version":1,"terms":"x","privacy":" "}"#; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.privacy_body(), None); + } + + #[test] + fn document_urls_override_constants() { + let raw = br#"{"version":1,"terms":"x","terms_url":"https://example.com/tos","privacy_url":" "}"#; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.terms_url(), "https://example.com/tos"); + assert_eq!(document.privacy_url(), constants::PRIVACY_URL); + } +} diff --git a/uncommitted.patch b/uncommitted.patch new file mode 100644 index 00000000..bcc84f80 --- /dev/null +++ b/uncommitted.patch @@ -0,0 +1,5975 @@ +diff --git a/Cargo.lock b/Cargo.lock +index 4bbfb520aaf2633fa51513fc3bd28d10a3c85a6b..1b2edf4f6723b081b4a862fdb9b66c6f3f3403fd 100644 +GIT binary patch +delta 6903 +zcmai3dyHL2ebyX%{aD+v7u&mw9XlIiJ0W2=^Eh*6CJJ5$wN+?K8z*T?5N4UDeUp7S +zyLW>fK}eJr6cS@j#Ha$bq_il1^g*&Jw#2dkCAP+`$*;|mvF@wR +zy2XJ(-)@~8_j4iod?pY(DU)*4a%Qyl%q6XqPok@ozl)4 +z6^yc6*X-uu{K?~FXZ_-n+wxUkBOCKoXNzrX7Fx6Q?|t=PK6GQTD}UipGB#?vD<5yS +zPwX4KJB}yivF}}WS|L`@v6}o5J_ol(IeZAN7?Y5a(OB$ElgQ`QEqUEEd3rQ}YY;)BikN?|*g2bvV-)o!}FU +zcfZn{(ataQ?r&ZM+wQ$+wdZX2mT5bCY|$Qz<86Bkt7Ee>t(lnFEr{0CbH%3okx!Gs +z`mR?;@-q*Sb@}N>$rS_L4ky>iiMWDU91 +zUOO!;0u@S=N^b?1lBKBX$9`q_n$<`3>DYKHojXGRk>=yEcyfHYwR{eQG32`mxopEq +zjO%;vbB7I=+9+0PENi8)s^V3&%qUSN8mY3C)k@-ZjMzGJP6m;TiB)Ng_LZoa*jc~p +zt3&mvKfh#i@5xQV;(R@QnVqziq*WPhX_5VC6IF>PX9abouZ(o56i#ub42?z#8hGVh +zl?-JjB_(N7B3G{Qpx6|YAv9}`HuN)YyU;E0)Nbh9tiEdTLmi5rt +zn%L`h&uipQkF3qiO5z=I3U8obqvc8qwXg@mxRU!w-=I`Sc59+rAk)J$-xh7t~rcQFh;H<3aWF +zPi(JG{mbr+HiUUT-I|U0j~^y(k~NJ)mEhC~rdgRMb_e=6+A0ySYMy``3?2IeR*_XY#-v6U%9S0uzq>rnmxLw{`OS^OCRFLHxKw*+w-=z +zzqXyIeCZR#^^<@SoLmZJ7`RO0)D;IZ1fWC+Q45)bW)@+JSlToj@&KK{y^DsskpK2F +zw7|Kj?t4@H*oz}?oN3Lr?wFc8*$T@kbhkv`W0U#Cg|;6LaRxX(9rI~Mu3UQMP_?tZ +z>xQ*!8}g{XpxvadWQBv93P4qYFagS?vLzsy@|1$B&@DixNIiEvDrV4_*pjm}qLqAo +z=E>oYZ6#}K`lC(tGtZ1H9lYtc*R2D?mlJI1xp(|<``|3Fu|4JOLad*7w?8BVP8Z79 +zFavsQq=O)kctnx_m$F2fb*da_IcQ@&kjG}OSiWu_*}m=6 +zY`3i!XIqV689!Oy#x7oZ`hknqz0S|ghw=HAKR$k9J}y9S#Qg5d3wy|b+hst_SjUuS +zrh-(9!pfwR^$qw@?O6->9Qvf4teCRUnMj$%5}HwHqsjzojX@Y8F;O*!xk@`gq|uC96mqcC +z17E2}lk6C-ulnq!`so)p=7$+Mxb*k`{uC+bL_YFBapPn~!MhIfL@EVAE73GA>ZqW> +z5_Bv0w@h4u?#oaq5oKu&`V%6q3XHN;-@PzWkN;?B)9mft>Sze2wi|aXo%zo{A#0n? +z?@IgQ|Mk5cJs9ZT)iCC`n+vB_h`T=hqdy(!>dt)TDY8Ak^Y6&@hXbm$1WR*kg0umm +zNmn>lDl3s7>zTpcqKFZv$5tHECF+|(gG=v9bU`<=*Z=qjWF@k1dG+dZ*YADpgDcm0 +zfDpS5DhbThNrYUwZNmy_<_Gt|aR_NO5D8J>GTA6Slz|mUDQcE`OPNYQZ{T1+CK{~b +z*qV?GA`w-f^4)(;MmzTpkxMqNtn$0N$XmB=>QmI&E@C0nNhwWLItSlUqAiSatYUy- +zbPW|2ABU)*R}Cu7tCB0!l&>Na;1L09Q;c^0>5b&JZR>ir}R-$1U +zv>-JO%m<;SDNsqoG19sDCh~#d`VZ#zcV4@NylXJiHW|q$ +z7Re27hHogLt;@(uoKA`4DUuhOLq&vFL1GIk@glIK@ey`V1;U><&e+}BvtgZWeUk)7SuCK23F2^|G^1cUUt@5A(fPI87aJ*6ajVC>jKYa%d@bUY}ru_18az}pYezGBld&y9} +z^-BZ!mKNEXKXErwI&&}CP)~koIN$RC8O>X#$-&MO4-i>2p~wiuo_zKLi_3atWYOid>RenSZ|1XAiGMCz`UpZQ`j);aL& +zWU4q{U->sbOzvH+>P8IAod6r>Be2eAf$zZhVM5w|dpV&XLpP?E}LLQ?1!Fx9Z$tg4N`T8zv#oco%mZ +zO3Eh|tm4o|uuqan>5X%ca}1~%9r8fS2B|?3x`fDr5{8-`$+HF7154A>;Iplh`H?%w +z?xys5$sS&8jo;d81Ej=6&$BmEe-;i&tU}&fHHL_*RGSFtc@SU%JlVq~>!X!NZufRD47Y8mjP$gC6wDe`f +zHHF&NO;gwg)xE>QzEtR98eM`@kKi7`ZR{H3`XdwTHua1A#xFJFoDGcyzGyl7h!+zR +zFr9+s7oj2s`&_}cZ;;D7C%#4gY~!vKS-dK{`050&AK}pIs&ukS$!doL!naUkrO?2B +z=PmjLV;vqG0M3>imeMsw#bFc$)d{H)>qm}^=9j-i)^@}{kdd8ho4U(qmyiZv2K2R3 +zfOw3D@E0Iz2DvP{#7%Z)o1S( +zd!zoFp%KP5%XQy%%KdLR+w~~Rqo(D%pak3TWP8;&(QrL?v3w7Vzgol`W(L!tRX3in +zkc$0FG`+SmrE1UJ+M27s^~4)C_iv~hmctA%4Br!lYDObyn232n2|43|TNunh(m~*A +z1Xs%n!X2|BBQ0{`6a0fzn92-~oFCqux8}ftuCG21`#4Nt|K +z=sJm?J3iMFlid}2Iu>Oxw~S{^aJt@bYI(%jw=qmzms7ns`_XImtOVnsMWPxw$#w0M#>7q*knzGgrQ3sZcG)&_y_u&m5qPfId5kJ+SDvh +z4VDu!{&liH-*aQJXZvzoP-W+ete2RCxj{p;9{vXIF$VZ3fkBdq(7?c8txAf%f!_xn +zBO+1(%piW?HDO_a+~dPL(>eRlckFIHR?4zl#Jyxt2S+RdOq+wP;aoB=AuAwO0P;|0 +zu#Ld4hVg-y6=MKa$~}?<$DU|yUmMwdVToP1>P5>pEO$!ppm4~%g1`fz04M>!%_vbb +zU=MJ*nb0LnFDfUj)fiR4d_WRM9Uyx+1iR}_%NljDW9+9a-_3Qt>z&2kyx}z2QYSL9 +z5`y-r6LF#0ia&(e88s$=GgPJFAt~l1C^*d6L*p+oK@9;k!6<|)4iD{CVQ^vLvXCeA +z!p*}lv0abeJ^AtFJ~&?k`Pzrb-h6h1Y`=JHUo%1K-nTeE-TS}aiFdC1F=_20vi>Bw +zVG^QSD2xnZ5RwM)T3|i^N8oHyf(Z<$%rZ(Xp}YcgbmZ{HFba`3iVgEhOcd)A4{ogI +zK6Oz(`;TOA=i|f0U#;(x0 +zAm}})NNJRT1J+g57*l|NLCeU>K#Rg(1iupd_4NxQBb|F~@k7|8?#QTvjcqH6ah+d! +zH?fDQK`?Me4x)w4g6P6uguCZ}Lu4MP3&AR#g8k#D#Dx6Bn++}vcEPwB>EokedH>qBRW!Rm2JDhVkoO51%bu6z{XPh>3-`;?X +z*e==3frM`6RaL%Lu2f2?ooJ9aqCwthOE{#_+2WIjE<91>x88NHTuF}9$^#E>LVlih +zDza|o%Pv`5KDS@`iW!yP11%0Ny6^%#>%lxk!G?es*?}rC>_tJuYp}^7KYpPEz1*as +z5qxSV?vx3!uA`GX0;qFyq#1^tyIaw|o#V&R=Vmj8RgbyxTji?>48j_qv#S>q3eWXp +z)Gy*=ILv*w(WY|F#6wSsFwAkKN?e~s(!7667okxo9DRybDv1YK%;PMHiL})+ya>Ap +zEnrw=7SJt%87zs#4~U3p7N52(Y+2%!{4m8#cFxoRGPYkyN7(d@efm01M7n4Y5D Option { +- AppAssets::get(path).map(|file| { +- match file.data { +- std::borrow::Cow::Borrowed(slice) => { +- bytes::Bytes::copy_from_slice(slice) +- }, +- std::borrow::Cow::Owned(vec) => { +- tracing::warn!("Cloning asset data for '{}'", path); +- bytes::Bytes::from(vec) +- }, ++ AppAssets::get(path).map(|file| match file.data { ++ std::borrow::Cow::Borrowed(slice) => bytes::Bytes::copy_from_slice(slice), ++ std::borrow::Cow::Owned(vec) => { ++ tracing::warn!("Cloning asset data for '{}'", path); ++ bytes::Bytes::from(vec) + } + }) + } + } +- +diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs +index 5e64e9f..63637f4 100644 +--- a/packages/oneclient_app/src/bridge/commands.rs ++++ b/packages/oneclient_app/src/bridge/commands.rs +@@ -1,4 +1,3 @@ +- + use std::path::PathBuf; + + use oneclient_core::notification::UserChoice; +@@ -12,25 +11,43 @@ use crate::notifications::{ClusterUpdateSummary, NotificationSpec}; + pub enum BridgeCommand { + ReloadSettings, + SaveSettings, +- SetSettings { settings: LauncherSettings }, +- RecordSeenVersion { version: String }, ++ SetSettings { ++ settings: LauncherSettings, ++ }, ++ RecordSeenVersion { ++ version: String, ++ }, + MarkOnboardingSeen, ++ AcceptTos { ++ terms_version: u32, ++ privacy_version: u32, ++ }, + +- SaveGlobalProfile { profile: GameSettingsProfile }, +- UpdateGlobalProfile { update: ProfileUpdate }, ++ SaveGlobalProfile { ++ profile: GameSettingsProfile, ++ }, ++ UpdateGlobalProfile { ++ update: ProfileUpdate, ++ }, + +- CreateSettingsProfile { name: String }, ++ CreateSettingsProfile { ++ name: String, ++ }, + CreateProfileFromGlobal { + name: String, + mem_max: Option, + force_fullscreen: Option, + }, +- UpsertNamedProfile { profile: GameSettingsProfile }, ++ UpsertNamedProfile { ++ profile: GameSettingsProfile, ++ }, + UpdateNamedProfile { + name: String, + update: ProfileUpdate, + }, +- DeleteNamedProfile { name: String }, ++ DeleteNamedProfile { ++ name: String, ++ }, + + UpdateClusterProfile { + cluster_id: ClusterId, +@@ -49,8 +66,12 @@ pub enum BridgeCommand { + vendor: oneclient_core::java::JavaVendor, + major: u32, + }, +- AddCustomJavaRuntime { path: PathBuf }, +- RemoveJavaRuntime { path: String }, ++ AddCustomJavaRuntime { ++ path: PathBuf, ++ }, ++ RemoveJavaRuntime { ++ path: String, ++ }, + + ToggleNotificationCenter, + CloseNotificationCenter, +@@ -64,14 +85,20 @@ pub enum BridgeCommand { + AnswerPrompt(UserChoice), + OpenClusterUpdate(ClusterUpdateSummary), + CloseClusterUpdate, +- SendNotification { spec: NotificationSpec }, ++ SendNotification { ++ spec: NotificationSpec, ++ }, + SendTestProgress { + current: u64, + total: u64, + }, + +- LaunchCluster { cluster_id: ClusterId }, +- KillCluster { cluster_id: ClusterId }, ++ LaunchCluster { ++ cluster_id: ClusterId, ++ }, ++ KillCluster { ++ cluster_id: ClusterId, ++ }, + DismissGameError, + + ImportLocalFile { +@@ -91,7 +118,9 @@ pub enum BridgeCommand { + bundle_name: String, + skip_compatibility: bool, + }, +- ApplyBundleUpdates { cluster_id: ClusterId }, ++ ApplyBundleUpdates { ++ cluster_id: ClusterId, ++ }, + SyncBundles, + + ImportLauncher { +diff --git a/packages/oneclient_app/src/bridge/mod.rs b/packages/oneclient_app/src/bridge/mod.rs +index 54cfff5..a6b5cce 100644 +--- a/packages/oneclient_app/src/bridge/mod.rs ++++ b/packages/oneclient_app/src/bridge/mod.rs +@@ -1,4 +1,3 @@ +- + mod commands; + mod runtime; + mod snapshot; +diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs +index 7f3f70b..919ee01 100644 +--- a/packages/oneclient_app/src/bridge/runtime.rs ++++ b/packages/oneclient_app/src/bridge/runtime.rs +@@ -9,8 +9,8 @@ use tokio::sync::{mpsc, watch}; + + use crate::components::IconType; + use crate::notifications::{ +- ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind, +- NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, ++ ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, ++ NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, + }; + + use super::commands::BridgeCommand; +@@ -243,6 +243,7 @@ impl CoreBridgeRuntime { + LauncherState::initialize(notifier).await?; + + oneclient_core::status::start(); ++ oneclient_core::plus::start(); + + let data_dir = oneclient_core::paths::launcher_dir() + .map(|p| p.display().to_string()) +@@ -277,10 +278,12 @@ impl CoreBridgeRuntime { + snapshots.settings.error = None; + let state = LauncherState::get()?; + let loaded = store::load_settings(Some(&state.services.notifier)).await; ++ let discord_enabled = loaded.discord_enabled; + { + let mut lock = state.settings.write(); + *lock = loaded; + } ++ state.discord.set_enabled(discord_enabled); + refresh_settings(snapshots)?; + } + BridgeCommand::SaveSettings => { +@@ -298,6 +301,7 @@ impl CoreBridgeRuntime { + mutate_settings(snapshots, |s| *s = settings)?; + let state = LauncherState::get()?; + let snapshot = state.settings.read().clone(); ++ state.discord.set_enabled(snapshot.discord_enabled); + if let Err(err) = save_settings(&snapshot).await { + snapshots.settings.error = Some(err.to_string()); + } +@@ -319,6 +323,20 @@ impl CoreBridgeRuntime { + snapshots.settings.error = Some(err.to_string()); + } + } ++ BridgeCommand::AcceptTos { ++ terms_version, ++ privacy_version, ++ } => { ++ mutate_settings(snapshots, |settings| { ++ settings.accepted_tos_version = terms_version; ++ settings.accepted_privacy_version = privacy_version; ++ })?; ++ let state = LauncherState::get()?; ++ let snapshot = state.settings.read().clone(); ++ if let Err(err) = save_settings(&snapshot).await { ++ snapshots.settings.error = Some(err.to_string()); ++ } ++ } + + BridgeCommand::SaveGlobalProfile { profile } => { + let state = LauncherState::get()?; +@@ -609,20 +627,18 @@ impl CoreBridgeRuntime { + version_id, + } => { + let state = LauncherState::get()?; +- match install_package(&state, provider, &project_id, &version_id, cluster_id).await +- { +- Ok(()) => { +- state +- .services +- .notifier +- .send_info("Installed", "Package added to cluster"); +- bump_clusters_generation(snapshots); ++ tokio::spawn(async move { ++ let notifier = state.services.notifier.clone(); ++ match install_package(&state, provider, &project_id, &version_id, cluster_id) ++ .await ++ { ++ Ok(name) => { ++ notifier.send_info("Installed", &format!("Added {name}")); ++ notifier.invalidate_clusters(); ++ } ++ Err(err) => notifier.send_error("Install failed", &err.to_string()), + } +- Err(err) => state +- .services +- .notifier +- .send_error("Install failed", &err.to_string()), +- } ++ }); + } + + BridgeCommand::InstallBundle { +@@ -631,15 +647,24 @@ impl CoreBridgeRuntime { + skip_compatibility, + } => { + let state = LauncherState::get()?; +- oneclient_core::install_bundle( +- cluster_id, +- &bundle_name, +- skip_compatibility, +- state.bundles.as_ref(), +- &state.services, +- ) +- .await?; +- bump_clusters_generation(snapshots); ++ tokio::spawn(async move { ++ let notifier = state.services.notifier.clone(); ++ match oneclient_core::install_bundle( ++ cluster_id, ++ &bundle_name, ++ skip_compatibility, ++ state.bundles.as_ref(), ++ &state.services, ++ ) ++ .await ++ { ++ Ok(_) => { ++ notifier.send_info("Installed", &format!("Added {bundle_name}")); ++ notifier.invalidate_clusters(); ++ } ++ Err(err) => notifier.send_error("Install failed", &err.to_string()), ++ } ++ }); + } + BridgeCommand::ApplyBundleUpdates { cluster_id } => { + let state = LauncherState::get()?; +@@ -792,7 +817,7 @@ async fn install_package( + project_id: &str, + version_id: &str, + cluster_id: i64, +-) -> anyhow::Result<()> { ++) -> anyhow::Result { + let provider_impl = state.services.packages.get(provider)?; + let project = provider_impl + .get_project(project_id, &state.services) +@@ -823,7 +848,7 @@ async fn install_package( + child.finish(); + session.finish(); + result?; +- Ok(()) ++ Ok(project.name) + } + + fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: LaunchStage) { +diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs +index baa7908..b878408 100644 +--- a/packages/oneclient_app/src/bridge/snapshot.rs ++++ b/packages/oneclient_app/src/bridge/snapshot.rs +@@ -32,10 +32,7 @@ impl GameSnapshot { + } + + pub fn logs_for(&self, cluster_id: i64) -> Arc>> { +- self.logs +- .get(&cluster_id) +- .cloned() +- .unwrap_or_default() ++ self.logs.get(&cluster_id).cloned().unwrap_or_default() + } + } + +diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs +index 773865f..9e703b5 100644 +--- a/packages/oneclient_app/src/components/active_cluster_panel.rs ++++ b/packages/oneclient_app/src/components/active_cluster_panel.rs +@@ -60,7 +60,9 @@ impl Component for ActiveClusterPanel { + let p = intro.get().value(); + let slide_x = (p - 1.0) * 48.0; + +- let parsed = active.as_ref().and_then(|c| parse_mc_version(&c.mc_version)); ++ let parsed = active ++ .as_ref() ++ .and_then(|c| parse_mc_version(&c.mc_version)); + let metadata = use_version_metadata( + parsed.as_ref().map(|p| p.major), + parsed.and_then(|p| p.minor), +@@ -114,7 +116,11 @@ impl Component for ActiveClusterPanel { + .spacing(8.) + .cross_align(Alignment::Center) + .margin(Gaps::new(8., 0., 0., 0.)) +- .child(launch_button(cluster_id, dispatch, launch_button_state(&game, cluster_id))) ++ .child(launch_button( ++ cluster_id, ++ dispatch, ++ launch_button_state(&game, cluster_id), ++ )) + .child(cluster_settings_button(cluster_id)), + ) + } +diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs +index cf9ac79..61a3d4e 100644 +--- a/packages/oneclient_app/src/components/button.rs ++++ b/packages/oneclient_app/src/components/button.rs +@@ -3,7 +3,10 @@ use std::borrow::Cow; + + use freya::prelude::*; + +-use crate::{components::{IconType, Icon}, theme::colors}; ++use crate::{ ++ components::{Icon, IconType}, ++ theme::colors, ++}; + + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] + pub enum ButtonVariant { +@@ -285,8 +288,7 @@ impl Component for Button { + + rect.get_style().clone_from(&self.style); + +- rect = rect.background(background) +- .color(foreground); ++ rect = rect.background(background).color(foreground); + + if let Some(border) = border_color { + rect = rect.border( +@@ -420,7 +422,11 @@ fn size_layout(size: ButtonSize) -> (Gaps, CornerRadius, f32, Size, Size) { + pub fn link_button() -> Button { + Button::new() + .primary() +- .child(Icon::new(IconType::LinkExternal01).size(14.).color(colors::fg_primary())) ++ .child( ++ Icon::new(IconType::LinkExternal01) ++ .size(14.) ++ .color(colors::fg_primary()), ++ ) + .child(label().text("Open")) + } + +@@ -433,4 +439,4 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { + crate::platform::open_url(&folder.to_string_lossy()); + }) + .child(Icon::new(IconType::Folder).size(16.)) +-} +\ No newline at end of file ++} +diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs +index 7880627..3c5f35a 100644 +--- a/packages/oneclient_app/src/components/cluster_update_popup.rs ++++ b/packages/oneclient_app/src/components/cluster_update_popup.rs +@@ -49,7 +49,13 @@ fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> im + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) +- .shadow(Shadow::from((0., 18., 52., 0., Color::from_argb(150, 0, 0, 0)))) ++ .shadow(Shadow::from(( ++ 0., ++ 18., ++ 52., ++ 0., ++ Color::from_argb(150, 0, 0, 0), ++ ))) + .child(banner(summary)) + .child(content(summary, dispatch)) + } +@@ -93,7 +99,11 @@ fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement { + .horizontal() + .cross_align(Alignment::Center) + .spacing(5.) +- .child(Icon::new(IconType::RefreshCw01).size(12.).color(Color::WHITE)) ++ .child( ++ Icon::new(IconType::RefreshCw01) ++ .size(12.) ++ .color(Color::WHITE), ++ ) + .child( + label() + .text("Updated") +diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs +index f40a0eb..5ebecd2 100644 +--- a/packages/oneclient_app/src/components/dropdown.rs ++++ b/packages/oneclient_app/src/components/dropdown.rs +@@ -81,8 +81,8 @@ impl Component for Dropdown { + }; + + let visible = options.len().min(MAX_VISIBLE_OPTIONS); +- let list_h = visible as f32 * OPTION_HEIGHT +- + visible.saturating_sub(1) as f32 * OPTION_SPACING; ++ let list_h = ++ visible as f32 * OPTION_HEIGHT + visible.saturating_sub(1) as f32 * OPTION_SPACING; + + let offset_y = match (button_area(), list_size()) { + (Some(button), Some(list)) => { +@@ -141,10 +141,10 @@ impl Component for Dropdown { + .font_size(12.) + .color(colors::fg_primary()), + ) +- .child(Icon::new( +- IconType::ChevronDown) +- .size(14.) +- .color(colors::fg_secondary()), ++ .child( ++ Icon::new(IconType::ChevronDown) ++ .size(14.) ++ .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_open.then(|| { +diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs +index 4c25af2..636734c 100644 +--- a/packages/oneclient_app/src/components/dynamic_art.rs ++++ b/packages/oneclient_app/src/components/dynamic_art.rs +@@ -83,9 +83,12 @@ pub fn use_art_bytes( + let state = reader.state(); + match (&art_url, &*state) { + (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) +- | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { +- (format!("{max_edge}|{url}"), bytes.clone()) +- } ++ | ( ++ Some(url), ++ QueryStateData::Loading { ++ res: Some(Ok(bytes)), ++ }, ++ ) => (format!("{max_edge}|{url}"), bytes.clone()), + _ => ( + format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), + fallback.read().clone(), +diff --git a/packages/oneclient_app/src/components/log_viewer.rs b/packages/oneclient_app/src/components/log_viewer.rs +index 80f531f..306991f 100644 +--- a/packages/oneclient_app/src/components/log_viewer.rs ++++ b/packages/oneclient_app/src/components/log_viewer.rs +@@ -1,4 +1,3 @@ +- + use std::sync::{Arc, LazyLock}; + + use freya::prelude::*; +@@ -289,12 +288,11 @@ fn auto_scroll_toggle(mut auto_scroll: State) -> impl IntoElement { + } else { + colors::fg_secondary() + })) +- .child( +- label() +- .text("Auto-scroll") +- .font_size(11.) +- .color(if on { colors::brand() } else { colors::fg_secondary() }), +- ) ++ .child(label().text("Auto-scroll").font_size(11.).color(if on { ++ colors::brand() ++ } else { ++ colors::fg_secondary() ++ })) + .into_element() + } + +@@ -308,11 +306,15 @@ struct PosGeom { + corrected_y: f32, + } + +-fn pos_at_screen(lines: &[Arc], geom: PosGeom, screen_x: f32, screen_y: f32) -> (usize, usize) { ++fn pos_at_screen( ++ lines: &[Arc], ++ geom: PosGeom, ++ screen_x: f32, ++ screen_y: f32, ++) -> (usize, usize) { + let region_y = screen_y - geom.viewport_top_px; + let band_y = region_y - geom.corrected_y - BODY_PAD_TOP; +- let line = +- ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize; ++ let line = ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize; + + let text_origin_x = ROW_PAD_X + geom.num_col_w + NUM_GAP; + let local_x = screen_x - geom.viewport_left_px - geom.corrected_x - text_origin_x; +diff --git a/packages/oneclient_app/src/components/major_version_card.rs b/packages/oneclient_app/src/components/major_version_card.rs +index b8c1600..641b6ff 100644 +--- a/packages/oneclient_app/src/components/major_version_card.rs ++++ b/packages/oneclient_app/src/components/major_version_card.rs +@@ -55,11 +55,7 @@ impl Component for MajorVersionCard { + let on_press = self.on_press.clone(); + + let opacity = if selected || hovered || focused { +- if selected { +- 1.0 +- } else { +- 0.85 +- } ++ if selected { 1.0 } else { 0.85 } + } else { + 0.6 + }; +@@ -108,7 +104,10 @@ impl Component for MajorVersionCard { + .main_align(Alignment::SpaceBetween) + .corner_radius(CornerRadius::new_all(12.)) + .cross_align(Alignment::Start) +- .border(border_all_color(1., border_color).alignment(BorderAlignment::Inner)) ++ .border( ++ border_all_color(1., border_color) ++ .alignment(BorderAlignment::Inner), ++ ) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() +diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs +index b84e4df..27309f4 100644 +--- a/packages/oneclient_app/src/components/microsoft_login.rs ++++ b/packages/oneclient_app/src/components/microsoft_login.rs +@@ -84,7 +84,7 @@ pub fn use_microsoft_login() -> MicrosoftLogin { + return; + } + +- // open the browser immediately ++ // open the browser immediately + platform::open_url(session.auth_url()); + finish.mutate(session.clone()); + pending_login.set(Some(session)); +@@ -100,7 +100,7 @@ pub fn use_microsoft_login() -> MicrosoftLogin { + } + }); + +- // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress ++ // is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress + let locked = status.as_ref().is_some_and(|s| s.current > 0); + let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading(); + let pending = in_flight && !*cancelled.read(); +diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs +index 898dfb7..d902086 100644 +--- a/packages/oneclient_app/src/components/navbar/app_navbar.rs ++++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs +@@ -37,7 +37,7 @@ impl Component for Navbar { + .window_drag() + .width(Size::window_percent(100.)) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) +- .position(Position::new_absolute().top(0.).left(0.).right(0.)) ++ .position(Position::new_absolute().top(0.).left(0.).right(0.)), + ) + } + } +@@ -95,8 +95,8 @@ struct NavLink { + impl Component for NavLink { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); +- let a11y_id = use_a11y(); +- let focused = use_focus(a11y_id); ++ let a11y_id = use_a11y(); ++ let focused = use_focus(a11y_id); + + let active = self.active; + let target = self.target.clone(); +@@ -125,12 +125,12 @@ impl Component for NavLink { + .cross_align(Alignment::Center) + .spacing(2.) + .width(Size::px(nav_label.len() as f32 * 10. + 10.)) +- // TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly. +- // This basically fools freya into thinking it indeed has a box and properly handles pointer events +- .background(Color::RED.with_a(0)) +- .a11y_id(a11y_id) +- .a11y_focusable(true) +- .a11y_role(AccessibilityRole::Button) ++ // TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly. ++ // This basically fools freya into thinking it indeed has a box and properly handles pointer events ++ .background(Color::RED.with_a(0)) ++ .a11y_id(a11y_id) ++ .a11y_focusable(true) ++ .a11y_role(AccessibilityRole::Button) + .on_all_press(move |e: Event| { + e.prevent_default(); + let _ = RouterContext::get().push(target.clone()); +diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs +index 4742b31..7e6d132 100644 +--- a/packages/oneclient_app/src/components/package_row.rs ++++ b/packages/oneclient_app/src/components/package_row.rs +@@ -391,9 +391,12 @@ fn remote_icon( + let reader = icon_query.read(); + let loaded = match (icon_url, &*reader.state()) { + (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) +- | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { +- Some((url.clone(), bytes.clone())) +- } ++ | ( ++ Some(url), ++ QueryStateData::Loading { ++ res: Some(Ok(bytes)), ++ }, ++ ) => Some((url.clone(), bytes.clone())), + _ => None, + }; + +@@ -420,7 +423,11 @@ fn icon_box(icon: IconType, size: f32) -> Element { + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) +- .child(Icon::new(icon).size(size * 0.4).color(colors::fg_secondary())) ++ .child( ++ Icon::new(icon) ++ .size(size * 0.4) ++ .color(colors::fg_secondary()), ++ ) + .into_element() + } + +@@ -436,7 +443,10 @@ fn meta_size(size: u64) -> impl IntoElement { + } + + fn provider_badge(provider: ProviderId) -> Element { +- badge(Icon::new(provider).size(12.).into_element(), provider.to_string()) ++ badge( ++ Icon::new(provider).size(12.).into_element(), ++ provider.to_string(), ++ ) + } + + fn local_badge() -> Element { +diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs +index 1f5054a..b7151b9 100644 +--- a/packages/oneclient_app/src/components/player_model.rs ++++ b/packages/oneclient_app/src/components/player_model.rs +@@ -128,7 +128,7 @@ impl Component for PlayerModel { + + let (w, h) = (ctx.size.width, ctx.size.height); + +- if w <= 0.0 || h <= 0.0 { ++ if w <= 0.0 || h <= 0.0 { + return; + } + +diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs +index 3a3c80b..93a9d85 100644 +--- a/packages/oneclient_app/src/components/recents_row.rs ++++ b/packages/oneclient_app/src/components/recents_row.rs +@@ -49,7 +49,9 @@ impl Component for RecentsRow { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + let total = CARD_MS + (count.saturating_sub(1) as u64) * STAGGER_MS; +- AnimNum::new(0., 1.).time(total.max(1)).function(Function::Linear) ++ AnimNum::new(0., 1.) ++ .time(total.max(1)) ++ .function(Function::Linear) + }); + let progress = intro.get().value(); + +@@ -112,11 +114,7 @@ impl Component for ClusterCard { + let eased = 1.0 - (1.0 - local).powi(3); + let rise = (1.0 - eased) * CARD_RISE_PX; + +- let title = format!( +- "{} {}", +- self.cluster.mc_version, +- self.cluster.mc_loader +- ); ++ let title = format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader); + + let cluster_id = self.cluster.id; + let on_press = move |_| { +diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs +index 1eb5a31..cdb6539 100644 +--- a/packages/oneclient_app/src/components/screenshot_viewer.rs ++++ b/packages/oneclient_app/src/components/screenshot_viewer.rs +@@ -102,11 +102,15 @@ impl Component for ScreenshotViewer { + .height(Size::fill()) + .child(preview), + ) +- .child(chevron_btn(IconType::ArrowRight, has_next, move |_| { +- if idx + 1 < len { +- index.set(idx + 1); +- } +- })), ++ .child(chevron_btn( ++ IconType::ArrowRight, ++ has_next, ++ move |_| { ++ if idx + 1 < len { ++ index.set(idx + 1); ++ } ++ }, ++ )), + ) + .child( + rect() +@@ -119,7 +123,9 @@ impl Component for ScreenshotViewer { + .secondary() + .on_press(move |_| { + if let Some(dir) = open_path.parent() { +- crate::platform::open_url(&dir.to_string_lossy()); ++ crate::platform::open_url( ++ &dir.to_string_lossy(), ++ ); + } + }) + .child(Icon::new(IconType::Folder).size(14.)) +@@ -198,8 +204,12 @@ fn header_row( + len: usize, + on_close: impl Into>>, + ) -> impl IntoElement { +- let mut meta = format!("{} • {}", format_size(info.size_bytes), fmt_date(info.created)); +- ++ let mut meta = format!( ++ "{} • {}", ++ format_size(info.size_bytes), ++ fmt_date(info.created) ++ ); ++ + if let Some(res) = info.resolution { + meta = format!("{} • {}", format_res(res), meta); + } +diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs +index ba49b6c..471e973 100644 +--- a/packages/oneclient_app/src/components/scrollview.rs ++++ b/packages/oneclient_app/src/components/scrollview.rs +@@ -314,7 +314,11 @@ impl ScrollArea { + let shift = *shift_held.read(); + + let h_delta = if horizontal { +- if shift && e.delta_x == 0.0 { e.delta_y } else { e.delta_x } ++ if shift && e.delta_x == 0.0 { ++ e.delta_y ++ } else { ++ e.delta_x ++ } + } else { + 0.0 + }; +diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs +index f79d340..1aa5d32 100644 +--- a/packages/oneclient_app/src/components/tab_bar.rs ++++ b/packages/oneclient_app/src/components/tab_bar.rs +@@ -19,7 +19,7 @@ impl TabItem { + } + } + +- #[allow(dead_code)] ++ #[allow(dead_code)] + pub fn count_text(mut self, text: impl Into) -> Self { + self.count = Some(text.into()); + self +diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs +index 2ea7c95..0976248 100644 +--- a/packages/oneclient_app/src/components/toasts.rs ++++ b/packages/oneclient_app/src/components/toasts.rs +@@ -32,7 +32,6 @@ impl Variant { + Self::Message + } + } +- + } + + fn entry_icon(entry: &InboxEntry) -> (IconType, Color) { +diff --git a/packages/oneclient_app/src/components/update_prompt.rs b/packages/oneclient_app/src/components/update_prompt.rs +index 2c3071f..3bb1a5b 100644 +--- a/packages/oneclient_app/src/components/update_prompt.rs ++++ b/packages/oneclient_app/src/components/update_prompt.rs +@@ -19,7 +19,7 @@ impl Component for UpdatePromptOverlay { + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; +- ++ + if prompt.kind != PromptKind::Update { + return rect().into_element(); + } +diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs +index 4b2b07e..23f6ae5 100644 +--- a/packages/oneclient_app/src/constants.rs ++++ b/packages/oneclient_app/src/constants.rs +@@ -1,2 +1,2 @@ + pub const APP_ID: &str = "org.polyfrost.OneClient"; +-pub const WINDOW_TITLE: &str = "OneClient"; +\ No newline at end of file ++pub const WINDOW_TITLE: &str = "OneClient"; +diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs +index 488dd8b..0b042bf 100644 +--- a/packages/oneclient_app/src/hooks/dispatch.rs ++++ b/packages/oneclient_app/src/hooks/dispatch.rs +@@ -42,6 +42,13 @@ impl BridgeDispatch { + self.send(BridgeCommand::MarkOnboardingSeen); + } + ++ pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { ++ self.send(BridgeCommand::AcceptTos { ++ terms_version, ++ privacy_version, ++ }); ++ } ++ + pub fn import_launcher( + &self, + source: oneclient_core::MigrationSource, +@@ -64,9 +71,7 @@ impl BridgeDispatch { + } + + pub fn create_settings_profile(&self, name: impl Into) { +- self.send(BridgeCommand::CreateSettingsProfile { +- name: name.into(), +- }); ++ self.send(BridgeCommand::CreateSettingsProfile { name: name.into() }); + } + + pub fn create_profile_from_global( +@@ -94,20 +99,11 @@ impl BridgeDispatch { + } + + pub fn delete_named_profile(&self, name: impl Into) { +- self.send(BridgeCommand::DeleteNamedProfile { +- name: name.into(), +- }); ++ self.send(BridgeCommand::DeleteNamedProfile { name: name.into() }); + } + +- pub fn update_cluster_profile( +- &self, +- cluster_id: ClusterId, +- update: ProfileUpdate, +- ) { +- self.send(BridgeCommand::UpdateClusterProfile { +- cluster_id, +- update, +- }); ++ pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) { ++ self.send(BridgeCommand::UpdateClusterProfile { cluster_id, update }); + } + + pub fn create_and_assign_cluster_profile( +@@ -121,11 +117,7 @@ impl BridgeDispatch { + }); + } + +- pub fn set_cluster_loader_version( +- &self, +- cluster_id: ClusterId, +- version: impl Into, +- ) { ++ pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into) { + self.send(BridgeCommand::SetClusterLoaderVersion { + cluster_id, + version: version.into(), +@@ -318,6 +310,7 @@ impl NotificationBuilder { + } + + pub fn send(self) { +- self.dispatch.send(BridgeCommand::SendNotification { spec: self.spec }); ++ self.dispatch ++ .send(BridgeCommand::SendNotification { spec: self.spec }); + } + } +diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs +index 9062362..87b90ee 100644 +--- a/packages/oneclient_app/src/hooks/mod.rs ++++ b/packages/oneclient_app/src/hooks/mod.rs +@@ -19,30 +19,30 @@ pub use queries::{ + CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, + ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, + OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, +- SetDefaultAccountKeys, UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, +- UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, +- accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list, +- changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, ++ SetDefaultAccountKeys, TermsQuery, UploadLogKeys, UploadLogMutation, UseLogAction, ++ UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, ++ VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, ++ category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, + content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries, + invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, + java_runtimes, loader_versions, login_code_already_handled, migration_detection, + mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch, + pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items, +- search_pending, search_total, try_account, try_accounts, try_cluster_analytics, +- try_cluster_logs, try_cluster_screenshots, try_default_account, try_game_profile, +- try_global_analytics, try_log_content, use_account, use_accounts, use_add_microsoft_account, +- use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, +- use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, +- use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_mutation, +- use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters, +- use_current_account, use_default_account, use_finish_microsoft_login, use_game_profile, +- use_global_analytics, use_java_runtimes, use_loader_versions, use_local_image, use_log_action, +- use_log_content, use_migration, use_named_profiles, use_onboarding_bundles, +- use_package_categories, use_package_meta_batch, use_package_project, use_package_search, +- use_package_versions, use_player_profile, use_player_skin, use_provider_versions, +- use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, +- use_set_default_account, use_upload_log, use_version_metadata, use_versions, version_list, +- versions_total, ++ search_pending, search_total, terms_document, terms_error, terms_is_loading, try_account, ++ try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, ++ try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account, ++ use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login, ++ use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image, ++ use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content, ++ use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, ++ use_cluster_settings, use_clusters, use_current_account, use_default_account, ++ use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes, ++ use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration, ++ use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, ++ use_package_project, use_package_search, use_package_versions, use_player_profile, ++ use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts, ++ use_remove_account, use_screenshot_action, use_set_default_account, use_terms, use_upload_log, ++ use_version_metadata, use_versions, version_list, versions_total, + }; + + use crate::{ +diff --git a/packages/oneclient_app/src/hooks/queries/analytics.rs b/packages/oneclient_app/src/hooks/queries/analytics.rs +index 1d433fa..9181aa5 100644 +--- a/packages/oneclient_app/src/hooks/queries/analytics.rs ++++ b/packages/oneclient_app/src/hooks/queries/analytics.rs +@@ -2,7 +2,6 @@ use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; + use oneclient_core::LauncherError; + use oneclient_core::game::{Analytics, cluster_analytics, global_analytics}; + +- + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct GlobalAnalyticsKeys; + +@@ -33,7 +32,6 @@ pub fn try_global_analytics(query: &UseQuery) -> Option Some(list.clone()), +- QueryStateData::Loading { res: Some(Ok(list)) } => Some(list.clone()), ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => Some(list.clone()), + _ => None, + } + } +@@ -75,12 +77,8 @@ impl QueryCapability for BundlesWithStatusQuery { + async fn run(&self, keys: &Self::Keys) -> Result { + let _ = keys; + let state = LauncherState::get()?; +- get_bundles_with_update_status( +- self.cluster_id, +- state.bundles.as_ref(), +- &state.services, +- ) +- .await ++ get_bundles_with_update_status(self.cluster_id, state.bundles.as_ref(), &state.services) ++ .await + } + } + +@@ -90,7 +88,9 @@ pub fn bundles_with_status_items( + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(list), .. } => list.clone(), +- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => list.clone(), + _ => Vec::new(), + } + } +diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs +index 04b3f89..c9ad5da 100644 +--- a/packages/oneclient_app/src/hooks/queries/changelog.rs ++++ b/packages/oneclient_app/src/hooks/queries/changelog.rs +@@ -1,5 +1,7 @@ + use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +-use oneclient_core::{fetch_changelog, parse_changelog, ChangelogGroup, LauncherError, LauncherState}; ++use oneclient_core::{ ++ ChangelogGroup, LauncherError, LauncherState, fetch_changelog, parse_changelog, ++}; + + use crate::hooks::use_settings_snapshot; + +@@ -32,15 +34,14 @@ pub fn use_changelog() -> UseQuery { + .trim() + .to_string(); + +- use_query(Query::new( +- ChangelogKeys { meta_url_base }, +- ChangelogQuery, +- )) ++ use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) + } + + pub fn changelog_groups(query: &UseQuery) -> Option> { + match &*query.read().state() { +- QueryStateData::Settled { res: Ok(groups), .. } => Some(groups.clone()), ++ QueryStateData::Settled { ++ res: Ok(groups), .. ++ } => Some(groups.clone()), + QueryStateData::Loading { + res: Some(Ok(groups)), + } => Some(groups.clone()), +diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs +index 4e72732..2159fcb 100644 +--- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs ++++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs +@@ -1,4 +1,3 @@ +- + use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; + use oneclient_core::packages::{ContentType, PackageStore}; + use oneclient_core::{LauncherError, LauncherState, LinkedArtifactInfo}; +@@ -51,7 +50,9 @@ pub fn cluster_content_items(query: &UseQuery) -> Vec list.clone(), +- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => list.clone(), + _ => Vec::new(), + } + } +diff --git a/packages/oneclient_app/src/hooks/queries/clusters.rs b/packages/oneclient_app/src/hooks/queries/clusters.rs +index 0f09711..61f487f 100644 +--- a/packages/oneclient_app/src/hooks/queries/clusters.rs ++++ b/packages/oneclient_app/src/hooks/queries/clusters.rs +@@ -1,7 +1,7 @@ + use freya::query::{Query, QueryCapability, UseQuery, use_query}; +-use oneclient_core::clusters::{Cluster, ClusterManager}; + use oneclient_core::LauncherError; + use oneclient_core::LauncherState; ++use oneclient_core::clusters::{Cluster, ClusterManager}; + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct ListClustersQuery; +@@ -23,5 +23,3 @@ impl QueryCapability for ListClustersQuery { + pub fn use_clusters() -> UseQuery { + use_query(Query::new(ListClustersKeys, ListClustersQuery)) + } +- +- +diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs +index e2c7b7e..df34a5c 100644 +--- a/packages/oneclient_app/src/hooks/queries/java.rs ++++ b/packages/oneclient_app/src/hooks/queries/java.rs +@@ -1,6 +1,4 @@ +-use freya::query::{ +- QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query, +-}; ++use freya::query::{QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query}; + use oneclient_core::LauncherState; + use oneclient_core::java::{AvailableJava, JavaManager, JavaRuntime, JavaVendor}; + +@@ -31,7 +29,9 @@ pub fn java_runtimes(query: &UseQuery) -> Vec list.clone(), +- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => list.clone(), + _ => Vec::new(), + } + } +@@ -68,14 +68,14 @@ pub fn use_provider_versions(vendor: JavaVendor) -> UseQuery, +-) -> (Vec, bool) { ++pub fn provider_versions(query: &UseQuery) -> (Vec, bool) { + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(list), .. } => (list.clone(), false), + QueryStateData::Settled { res: Err(_), .. } => (Vec::new(), false), +- QueryStateData::Loading { res: Some(Ok(list)) } => (list.clone(), true), ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => (list.clone(), true), + _ => (Vec::new(), true), + } + } +diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs +index eb1ba9f..4d51fdb 100644 +--- a/packages/oneclient_app/src/hooks/queries/logs.rs ++++ b/packages/oneclient_app/src/hooks/queries/logs.rs +@@ -89,7 +89,9 @@ where + { + match &*query.read().state() { + QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), +- QueryStateData::Loading { res: Some(Ok(value)) } => Some(value.clone()), ++ QueryStateData::Loading { ++ res: Some(Ok(value)), ++ } => Some(value.clone()), + _ => None, + } + } +diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs +index ac8cd27..84da2cc 100644 +--- a/packages/oneclient_app/src/hooks/queries/mod.rs ++++ b/packages/oneclient_app/src/hooks/queries/mod.rs +@@ -14,6 +14,7 @@ mod player_profile; + mod screenshots; + mod settings_profiles; + mod skin; ++mod tos; + mod version_metadata; + mod versions; + +@@ -69,5 +70,6 @@ pub use settings_profiles::{ + use_named_profiles, + }; + pub use skin::use_player_skin; ++pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; + pub use version_metadata::{pick_version_metadata, use_version_metadata}; + pub use versions::{loader_versions, use_loader_versions, use_versions}; +diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs +index b2f05da..54d59e0 100644 +--- a/packages/oneclient_app/src/hooks/queries/mutations.rs ++++ b/packages/oneclient_app/src/hooks/queries/mutations.rs +@@ -1,4 +1,3 @@ +- + use std::path::PathBuf; + + use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; +@@ -37,9 +36,18 @@ pub struct ClusterMutation; + + #[derive(Clone, PartialEq, Eq, Hash)] + pub enum ClusterAction { +- ToggleArtifact { cluster_id: ClusterId, hash: String }, +- RemoveArtifact { cluster_id: ClusterId, hash: String }, +- RemoveBundlePackageFromDisk { cluster_id: ClusterId, hash: String }, ++ ToggleArtifact { ++ cluster_id: ClusterId, ++ hash: String, ++ }, ++ RemoveArtifact { ++ cluster_id: ClusterId, ++ hash: String, ++ }, ++ RemoveBundlePackageFromDisk { ++ cluster_id: ClusterId, ++ hash: String, ++ }, + SetBundlePackageEnabled { + cluster_id: ClusterId, + bundle_name: String, +@@ -106,10 +114,14 @@ impl MutationCapability for ClusterMutation { + ClusterAction::SetDedicatedDir { + cluster_id, + dedicated, +- } => oneclient_core::clusters::ClusterManager::set_dedicated_dir( +- &state, *cluster_id, *dedicated, +- ) +- .await, ++ } => { ++ oneclient_core::clusters::ClusterManager::set_dedicated_dir( ++ &state, ++ *cluster_id, ++ *dedicated, ++ ) ++ .await ++ } + }; + result.map_err(|e| e.to_string()) + } +diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs +index 85c427a..1ca3c6b 100644 +--- a/packages/oneclient_app/src/hooks/queries/packages.rs ++++ b/packages/oneclient_app/src/hooks/queries/packages.rs +@@ -1,4 +1,3 @@ +- + use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; + use oneclient_core::packages::domain::GameLoader; + use oneclient_core::packages::types::{ +@@ -48,7 +47,8 @@ impl QueryCapability for PackageSearchQuery { + &SearchFilters { + query: (!keys.query.trim().is_empty()).then(|| keys.query.trim().to_string()), + content_type: Some(keys.content_type), +- game_versions: (!keys.game_versions.is_empty()).then(|| keys.game_versions.clone()), ++ game_versions: (!keys.game_versions.is_empty()) ++ .then(|| keys.game_versions.clone()), + loaders: (!keys.loaders.is_empty()).then(|| keys.loaders.clone()), + categories: (!keys.categories.is_empty()).then(|| keys.categories.clone()), + sort: Some(keys.sort), +@@ -91,7 +91,9 @@ pub fn search_items(query: &UseQuery) -> Vec + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(page), .. } => page.items.clone(), +- QueryStateData::Loading { res: Some(Ok(page)) } => page.items.clone(), ++ QueryStateData::Loading { ++ res: Some(Ok(page)), ++ } => page.items.clone(), + _ => Vec::new(), + } + } +@@ -100,13 +102,18 @@ pub fn search_total(query: &UseQuery) -> usize { + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(page), .. } => page.total, +- QueryStateData::Loading { res: Some(Ok(page)) } => page.total, ++ QueryStateData::Loading { ++ res: Some(Ok(page)), ++ } => page.total, + _ => 0, + } + } + + pub fn search_pending(query: &UseQuery) -> bool { +- matches!(&*query.read().state(), QueryStateData::Pending | QueryStateData::Loading { .. }) ++ matches!( ++ &*query.read().state(), ++ QueryStateData::Pending | QueryStateData::Loading { .. } ++ ) + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +@@ -126,7 +133,9 @@ impl QueryCapability for PackageProjectQuery { + async fn run(&self, keys: &Self::Keys) -> Result { + let state = LauncherState::get()?; + let provider = state.services.packages.get(keys.provider)?; +- provider.get_project(&keys.project_id, &state.services).await ++ provider ++ .get_project(&keys.project_id, &state.services) ++ .await + } + } + +@@ -146,8 +155,12 @@ pub fn use_package_project( + pub fn project_detail(query: &UseQuery) -> Option { + let reader = query.read(); + match &*reader.state() { +- QueryStateData::Settled { res: Ok(detail), .. } => Some(detail.clone()), +- QueryStateData::Loading { res: Some(Ok(detail)) } => Some(detail.clone()), ++ QueryStateData::Settled { ++ res: Ok(detail), .. ++ } => Some(detail.clone()), ++ QueryStateData::Loading { ++ res: Some(Ok(detail)), ++ } => Some(detail.clone()), + _ => None, + } + } +@@ -260,7 +273,9 @@ pub fn version_list(query: &UseQuery) -> Vec page.items.clone(), +- QueryStateData::Loading { res: Some(Ok(page)) } => page.items.clone(), ++ QueryStateData::Loading { ++ res: Some(Ok(page)), ++ } => page.items.clone(), + _ => Vec::new(), + } + } +@@ -269,7 +284,9 @@ pub fn versions_total(query: &UseQuery) -> usize { + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(page), .. } => page.total, +- QueryStateData::Loading { res: Some(Ok(page)) } => page.total, ++ QueryStateData::Loading { ++ res: Some(Ok(page)), ++ } => page.total, + _ => 0, + } + } +@@ -315,7 +332,9 @@ pub fn category_list(query: &UseQuery) -> Vec { + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(list), .. } => list.clone(), +- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => list.clone(), + _ => Vec::new(), + } + } +diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs +index 5bb3d3c..df42b8b 100644 +--- a/packages/oneclient_app/src/hooks/queries/player_profile.rs ++++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs +@@ -39,7 +39,10 @@ impl QueryCapability for FetchPlayerProfileQuery { + // } + } + +-pub fn use_player_profile(uuid: impl Into, access_token: Option>) -> UseQuery { ++pub fn use_player_profile( ++ uuid: impl Into, ++ access_token: Option>, ++) -> UseQuery { + use_query( + Query::new( + PlayerProfileQueryKeys { +diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs +index 3b01dd9..214aa56 100644 +--- a/packages/oneclient_app/src/hooks/queries/screenshots.rs ++++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs +@@ -12,7 +12,6 @@ use tokio::sync::Semaphore; + + static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); + +- + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct ClusterScreenshotsKeys { + pub cluster_id: i64, +@@ -96,7 +95,6 @@ pub fn try_cluster_screenshots( + } + } + +- + pub async fn invalidate_screenshots_queries() { + QueriesStorage::::try_invalidate_all().await; + QueriesStorage::::try_invalidate_all().await; +diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs +index 6d3b1a8..f0d5bdf 100644 +--- a/packages/oneclient_app/src/hooks/queries/skin.rs ++++ b/packages/oneclient_app/src/hooks/queries/skin.rs +@@ -42,4 +42,4 @@ fn java_string_hash(s: &str) -> i32 { + h = h.wrapping_mul(31).wrapping_add(c as i32); + } + h +-} +\ No newline at end of file ++} +diff --git a/packages/oneclient_app/src/hooks/queries/tos.rs b/packages/oneclient_app/src/hooks/queries/tos.rs +new file mode 100644 +index 0000000..bec73ab +--- /dev/null ++++ b/packages/oneclient_app/src/hooks/queries/tos.rs +@@ -0,0 +1,61 @@ ++use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; ++use oneclient_core::{LauncherError, LauncherState, TermsDocument, fetch_terms}; ++ ++use crate::hooks::use_settings_snapshot; ++ ++#[derive(Clone, Debug, PartialEq, Eq, Hash)] ++pub struct TermsKeys { ++ pub meta_url_base: String, ++} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] ++pub struct TermsQuery; ++ ++impl QueryCapability for TermsQuery { ++ type Ok = TermsDocument; ++ type Err = LauncherError; ++ type Keys = TermsKeys; ++ ++ async fn run(&self, _keys: &Self::Keys) -> Result { ++ let state = LauncherState::get()?; ++ fetch_terms(&state.services).await ++ } ++} ++ ++pub fn use_terms() -> UseQuery { ++ let settings = use_settings_snapshot().settings; ++ let meta_url_base = settings ++ .custom_meta_url_base ++ .clone() ++ .unwrap_or_default() ++ .trim() ++ .to_string(); ++ ++ use_query(Query::new(TermsKeys { meta_url_base }, TermsQuery)) ++} ++ ++pub fn terms_document(query: &UseQuery) -> Option { ++ match &*query.read().state() { ++ QueryStateData::Settled { ++ res: Ok(document), .. ++ } => Some(document.clone()), ++ QueryStateData::Loading { ++ res: Some(Ok(document)), ++ } => Some(document.clone()), ++ _ => None, ++ } ++} ++ ++pub fn terms_error(query: &UseQuery) -> Option { ++ match &*query.read().state() { ++ QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), ++ _ => None, ++ } ++} ++ ++pub fn terms_is_loading(query: &UseQuery) -> bool { ++ matches!( ++ &*query.read().state(), ++ QueryStateData::Pending | QueryStateData::Loading { res: None } ++ ) ++} +diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs +index c589718..f30f513 100644 +--- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs ++++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs +@@ -1,6 +1,6 @@ + use freya::query::QueryStateData; +-use oneclient_core::packages::domain::GameLoader; + use oneclient_core::VersionMetadata; ++use oneclient_core::packages::domain::GameLoader; + + use super::use_versions; + +@@ -46,7 +46,9 @@ pub fn use_version_metadata( + let state = reader.state(); + let list = match &*state { + QueryStateData::Settled { res: Ok(list), .. } => list, +- QueryStateData::Loading { res: Some(Ok(list)) } => list, ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => list, + _ => return None, + }; + +diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs +index 8f4d053..b48c48f 100644 +--- a/packages/oneclient_app/src/hooks/queries/versions.rs ++++ b/packages/oneclient_app/src/hooks/queries/versions.rs +@@ -69,7 +69,9 @@ pub fn loader_versions(query: &UseQuery) -> Vec { + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(list), .. } => list.clone(), +- QueryStateData::Loading { res: Some(Ok(list)) } => list.clone(), ++ QueryStateData::Loading { ++ res: Some(Ok(list)), ++ } => list.clone(), + _ => Vec::new(), + } + } +diff --git a/packages/oneclient_app/src/hooks/view_state.rs b/packages/oneclient_app/src/hooks/view_state.rs +index 0d8ec34..64696a3 100644 +--- a/packages/oneclient_app/src/hooks/view_state.rs ++++ b/packages/oneclient_app/src/hooks/view_state.rs +@@ -5,38 +5,38 @@ use super::{use_dispatch, use_settings_snapshot}; + + #[derive(Clone, Copy)] + pub struct PersistedView { +- pub layout: State, +- pub sort: State>, ++ pub layout: State, ++ pub sort: State>, + } + + pub fn use_view_state(key: &str) -> PersistedView { +- let key = key.to_string(); +- let settings = use_settings_snapshot().settings; +- let dispatch = use_dispatch(); ++ let key = key.to_string(); ++ let settings = use_settings_snapshot().settings; ++ let dispatch = use_dispatch(); + +- let initial = settings.view_state(&key); +- let init_layout = initial.layout; +- let init_sort = initial.sort; +- let layout = use_state(move || init_layout); +- let sort = use_state(move || init_sort.clone()); ++ let initial = settings.view_state(&key); ++ let init_layout = initial.layout; ++ let init_sort = initial.sort; ++ let layout = use_state(move || init_layout); ++ let sort = use_state(move || init_sort.clone()); + +- let mut first = use_state(|| true); +- use_side_effect(move || { +- let next = ViewState { +- layout: *layout.read(), +- sort: sort.read().clone(), +- }; +- if *first.peek() { +- first.set(false); +- return; +- } +- if settings.view_state(&key) == next { +- return; +- } +- let mut updated = settings.clone(); +- updated.set_view_state(key.clone(), next); +- dispatch.set_settings(updated); +- }); ++ let mut first = use_state(|| true); ++ use_side_effect(move || { ++ let next = ViewState { ++ layout: *layout.read(), ++ sort: sort.read().clone(), ++ }; ++ if *first.peek() { ++ first.set(false); ++ return; ++ } ++ if settings.view_state(&key) == next { ++ return; ++ } ++ let mut updated = settings.clone(); ++ updated.set_view_state(key.clone(), next); ++ dispatch.set_settings(updated); ++ }); + +- PersistedView { layout, sort } ++ PersistedView { layout, sort } + } +diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs +index e727b02..4b898b0 100644 +--- a/packages/oneclient_app/src/layout/app_shell.rs ++++ b/packages/oneclient_app/src/layout/app_shell.rs +@@ -9,8 +9,8 @@ use skia_safe::sampling_options::CubicResampler; + + use crate::Route; + use crate::components::Button; +-use crate::layout::AnimatedAppOutlet; + use crate::components::{AppNavbar, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea}; ++use crate::layout::AnimatedAppOutlet; + use crate::theme; + use crate::use_settings_snapshot; + use oneclient_db::models::ClusterId; +diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs +index 49b13df..2572ac0 100644 +--- a/packages/oneclient_app/src/layout/cluster_shell.rs ++++ b/packages/oneclient_app/src/layout/cluster_shell.rs +@@ -33,7 +33,7 @@ impl ClusterViewShellTab { + match self { + Self::Overview => "Overview", + Self::Logs => "Logs", +- Self::GameLog => "Game Log", ++ Self::GameLog => "Live Log", + Self::Screenshots => "Screenshots", + Self::Mods => "Mods", + Self::Shaders => "Shaders", +diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs +index bab99f5..9342e22 100644 +--- a/packages/oneclient_app/src/layout/mod.rs ++++ b/packages/oneclient_app/src/layout/mod.rs +@@ -6,8 +6,8 @@ mod root_layout; + mod settings_shell; + + pub use animated_outlet::AnimatedAppOutlet; +-pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET}; + pub(crate) use app_shell::gradient_overlay_radial; ++pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET}; + pub use cluster_shell::{ClusterShell, cluster_content}; + pub use onboarding_shell::OnboardingShell; + pub use root_layout::RootLayout; +diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs +index 773be6c..8fe9d55 100644 +--- a/packages/oneclient_app/src/layout/settings_shell.rs ++++ b/packages/oneclient_app/src/layout/settings_shell.rs +@@ -198,7 +198,10 @@ const SEARCH_INDEX: &[SearchItem] = &[ + icon: IconType::Globe01, + title: "Language", + description: "Choose the launcher language.", +- keywords: &["language", "locale", "english", "spanish", "french", "german", "russian", "japanese", "chinese"], ++ keywords: &[ ++ "language", "locale", "english", "spanish", "french", "german", "russian", "japanese", ++ "chinese", ++ ], + route: Route::SettingsLanguage {}, + }, + // Developer +@@ -332,10 +335,7 @@ const GROUPS: &[SettingsGroup] = &[ + }, + SettingsGroup { + label: "ABOUT", +- tabs: &[ +- SettingsTab::DeveloperOptions, +- SettingsTab::Changelog, +- ], ++ tabs: &[SettingsTab::DeveloperOptions, SettingsTab::Changelog], + }, + ]; + +@@ -490,18 +490,20 @@ fn search_results(query: String, mut search: State) -> Vec { + matches.truncate(SEARCH_RESULTS_MAX); + + if matches.is_empty() { +- return vec![rect() +- .width(Size::fill()) +- .padding(Gaps::new_symmetric(10., 14.)) +- .corner_radius(CornerRadius::new_all(12.)) +- .background(colors::page_elevated()) +- .child( +- label() +- .text("No settings matched your search.") +- .font_size(12.) +- .color(colors::fg_secondary()), +- ) +- .into_element()]; ++ return vec![ ++ rect() ++ .width(Size::fill()) ++ .padding(Gaps::new_symmetric(10., 14.)) ++ .corner_radius(CornerRadius::new_all(12.)) ++ .background(colors::page_elevated()) ++ .child( ++ label() ++ .text("No settings matched your search.") ++ .font_size(12.) ++ .color(colors::fg_secondary()), ++ ) ++ .into_element(), ++ ]; + } + + let mut out: Vec = Vec::with_capacity(matches.len() + 1); +@@ -696,11 +698,11 @@ impl Component for SidebarInfo { + .spacing(4.) + .font_size(12.) + .color(colors::fg_secondary()) +- .children(items.into_iter().map(|item| { +- label() +- .text(item) +- .into_element() +- })) ++ .children( ++ items ++ .into_iter() ++ .map(|item| label().text(item).into_element()), ++ ) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(copy_to_clipboard) +diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs +index 673e9f7..4f2b6b3 100644 +--- a/packages/oneclient_app/src/lib.rs ++++ b/packages/oneclient_app/src/lib.rs +@@ -12,8 +12,8 @@ mod notifications; + pub mod platform; + mod routes; + pub mod theme; +-pub mod updater; + mod ui; ++pub mod updater; + pub(crate) mod utils; + mod view; + +diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs +index 76bad9d..d5e7a08 100644 +--- a/packages/oneclient_app/src/main.rs ++++ b/packages/oneclient_app/src/main.rs +@@ -39,12 +39,15 @@ fn main() { + let _tokio_guard = rt.enter(); + + let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); +- ++ + if settings.log_debug { + oneclient_core::logger::init_debug() + } else { + oneclient_core::logger::init() +- }.expect("Failed to initialize logger"); ++ } ++ .expect("Failed to initialize logger"); ++ ++ let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); + + let (bridge, handle): (OneClientBridge, CoreBridgeHandle) = OneClientBridge::new(); + handle.spawn_runtime(); +@@ -67,7 +70,7 @@ fn main() { + std::env::var("ONECLIENT_GPU_CACHE_MB") + .ok() + .and_then(|v| v.parse::().ok()) +- .unwrap_or(32) ++ .unwrap_or(32), + ) + .with_default_font(theme::DEFAULT_FONT); + +diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs +index ba20fc9..09a4db7 100644 +--- a/packages/oneclient_app/src/routes.rs ++++ b/packages/oneclient_app/src/routes.rs +@@ -17,13 +17,13 @@ use crate::view::{ + ClusterShaders, ClusterTextures, ProcessLogs, + }, + settings::{ +- SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, +- SettingsJava, SettingsLanguage, SettingsLauncher, SettingsMinecraft, ++ SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, SettingsJava, ++ SettingsLanguage, SettingsLauncher, SettingsMinecraft, + }, + }, + onboarding::{ + OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, +- OnboardingMigration, OnboardingPreferences, OnboardingWelcome, ++ OnboardingMigration, OnboardingPreferences, OnboardingTerms, OnboardingWelcome, + }, + }; + +@@ -37,6 +37,8 @@ pub enum Route { + #[layout(OnboardingShell)] + #[route("/onboarding")] + OnboardingWelcome {}, ++ #[route("/onboarding/terms")] ++ OnboardingTerms {}, + #[route("/onboarding/migration")] + OnboardingMigration {}, + #[route("/onboarding/language")] +@@ -120,6 +122,7 @@ impl Route { + match self { + Route::Startup { .. } => "Startup".to_string(), + Route::OnboardingWelcome { .. } => "Welcome".to_string(), ++ Route::OnboardingTerms { .. } => "Terms & Privacy".to_string(), + Route::OnboardingMigration { .. } => "Migration".to_string(), + Route::OnboardingLanguage { .. } => "Language".to_string(), + Route::OnboardingAccount { .. } => "Account".to_string(), +diff --git a/packages/oneclient_app/src/theme/colors.rs b/packages/oneclient_app/src/theme/colors.rs +index 9dddbfa..0db8798 100644 +--- a/packages/oneclient_app/src/theme/colors.rs ++++ b/packages/oneclient_app/src/theme/colors.rs +@@ -149,4 +149,4 @@ pub fn selection_bg() -> Color { + + pub fn toast_action() -> Color { + Color::from_rgb(155, 161, 166) +-} +\ No newline at end of file ++} +diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs +index 5900812..33feece 100644 +--- a/packages/oneclient_app/src/ui.rs ++++ b/packages/oneclient_app/src/ui.rs +@@ -17,14 +17,12 @@ pub fn border_all(width: f32) -> Border { + } + + pub fn border_all_color(width: f32, color: Color) -> Border { +- Border::new() +- .fill(color) +- .width(BorderWidth { +- top: width, +- right: width, +- bottom: width, +- left: width, +- }) ++ Border::new().fill(color).width(BorderWidth { ++ top: width, ++ right: width, ++ bottom: width, ++ left: width, ++ }) + } + + pub fn border_left(width: f32) -> Border { +@@ -79,4 +77,4 @@ pub fn entrance_motion_layer( + .child(child), + ) + .into_element() +-} +\ No newline at end of file ++} +diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs +index ffc60e6..69c07c5 100644 +--- a/packages/oneclient_app/src/updater.rs ++++ b/packages/oneclient_app/src/updater.rs +@@ -55,10 +55,7 @@ fn check_for_update() -> anyhow::Result> { + Ok(check_update(current, config)?) + } + +-async fn download_and_install( +- update: Update, +- notifier: NotificationService, +-) -> anyhow::Result<()> { ++async fn download_and_install(update: Update, notifier: NotificationService) -> anyhow::Result<()> { + let progress_id = Uuid::new_v4(); + let version = update.version.clone(); + let label = format!("Downloading OneClient {version}"); +diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs +index daab86b..ab046e6 100644 +--- a/packages/oneclient_app/src/utils.rs ++++ b/packages/oneclient_app/src/utils.rs +@@ -16,7 +16,10 @@ pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups { + let Some(parsed) = parse_mc_version(&cluster.mc_version) else { + continue; + }; +- groups.entry(parsed.major).or_default().push(cluster.clone()); ++ groups ++ .entry(parsed.major) ++ .or_default() ++ .push(cluster.clone()); + } + + for list in groups.values_mut() { +@@ -82,9 +85,7 @@ pub fn resolve_cluster( + .iter() + .find(|cluster| { + let version_ok = minor.is_none_or(|minor| { +- parse_mc_version(&cluster.mc_version) +- .and_then(|parsed| parsed.minor) +- == Some(minor) ++ parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.minor) == Some(minor) + }); + let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader); + version_ok && loader_ok +@@ -241,21 +242,22 @@ fn version_sort_key(cluster: &Cluster) -> (u32, u32) { + .unwrap_or((0, 0)) + } + +- + #[cfg(not(target_os = "linux"))] + pub fn is_wayland() -> bool { +- false ++ false + } + + #[cfg(target_os = "linux")] + pub fn is_wayland() -> bool { +- static IS_WAYLAND: OnceLock = OnceLock::new(); +- +- *IS_WAYLAND.get_or_init(|| { +- if cfg!(target_os = "linux") { +- std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false) +- } else { +- false +- } +- }) +-} +\ No newline at end of file ++ static IS_WAYLAND: OnceLock = OnceLock::new(); ++ ++ *IS_WAYLAND.get_or_init(|| { ++ if cfg!(target_os = "linux") { ++ std::env::var("XDG_SESSION_TYPE") ++ .map(|v| v == "wayland") ++ .unwrap_or(false) ++ } else { ++ false ++ } ++ }) ++} +diff --git a/packages/oneclient_app/src/view/app/accounts.rs b/packages/oneclient_app/src/view/app/accounts.rs +index c94bc14..1148231 100644 +--- a/packages/oneclient_app/src/view/app/accounts.rs ++++ b/packages/oneclient_app/src/view/app/accounts.rs +@@ -1,7 +1,9 @@ +-use chrono::Utc; ++use chrono::{DateTime, Utc}; ++use freya::animation::{AnimNum, Function, OnFinish, use_animation}; + use freya::prelude::*; + use freya::query::{MutationCapability, MutationStateData, UseMutation}; + use oneclient_core::auth::{AccountKind, MinecraftAccount}; ++use uuid::Uuid; + + use crate::components::{ + Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, ScrollArea, TextInput, +@@ -74,13 +76,17 @@ impl Component for Accounts { + let mut rows: Vec = accounts + .iter() + .map(|account| { +- account_row( +- account, +- Some(account.id) == default_id, ++ AccountRow { ++ id: account.id, ++ username: account.username.clone(), ++ kind: account.kind, ++ expires: account.expires, ++ is_default: Some(account.id) == default_id, + set_default, + remove, + refresh, +- ) ++ } ++ .into_element() + }) + .collect(); + if rows.is_empty() { +@@ -339,113 +345,182 @@ fn hint_line(icon: IconType, text: String, color: Color) -> impl IntoElement { + .into_element() + } + +-fn account_row( +- account: &MinecraftAccount, ++const REFRESH_SPIN_TIME: u64 = 800; ++ ++const REFRESHING_OPACITY: f32 = 0.7; ++ ++struct AccountRow { ++ id: Uuid, ++ username: String, ++ kind: AccountKind, ++ expires: DateTime, + is_default: bool, + set_default: crate::hooks::UseSetDefaultAccount, + remove: crate::hooks::UseRemoveAccount, + refresh: crate::hooks::UseRefreshAccount, +-) -> Element { +- let id = account.id; +- let is_microsoft = account.is_microsoft(); +- let expired = is_microsoft && account.expires <= Utc::now(); +- +- let border_color = if expired { +- colors::danger() +- } else if is_default { +- colors::brand() +- } else { +- colors::component_border() +- }; ++} + +- rect() +- .horizontal() +- .width(Size::fill()) +- .cross_align(Alignment::Center) +- .content(Content::Flex) +- .spacing(16.) +- .padding(Gaps::new_all(12.)) +- .corner_radius(CornerRadius::new_all(12.)) +- .background(colors::page_elevated()) +- .border(border_all_color(1., border_color)) +- .a11y_role(AccessibilityRole::Button) +- .maybe(!is_default, |el| { +- el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) +- }) +- .child( +- Avatar::new(id.to_string()) +- .width(Size::px(40.)) +- .height(Size::px(40.)), +- ) +- .child( +- rect() +- .vertical() +- .width(Size::flex(1.0)) +- .spacing(4.) +- .child( +- rect() +- .horizontal() +- .cross_align(Alignment::Center) +- .spacing(8.) +- .child( +- label() +- .text(account.username.clone()) +- .font_size(16.) +- .font_weight(FontWeight::MEDIUM) +- .color(colors::fg_primary()), +- ) +- .child(kind_badge(account.kind)) +- .maybe_child(is_default.then(default_badge)) +- .maybe_child(expired.then(expired_badge)), +- ) +- .child( +- label() +- .text(id.to_string()) +- .font_size(11.) +- .color(colors::fg_secondary()), +- ), +- ) +- .maybe_child(is_microsoft.then(|| { +- Button::new() +- .ghost() +- .icon() +- .on_press(move |e: Event| { +- e.stop_propagation(); +- refresh.mutate(RefreshAccountKeys { id }); +- }) +- .child( +- Icon::new(IconType::RefreshCw01) +- .size(18.) +- .color(if expired { +- colors::danger() +- } else { +- colors::fg_secondary() +- }), +- ) +- .into_element() +- })) +- .child( +- Button::new().ghost().icon().enabled(false).child( +- Icon::new(IconType::Pencil01) +- .size(18.) +- .color(colors::fg_secondary()), +- ), +- ) +- .child( +- Button::new() +- .ghost() +- .icon() +- .on_press(move |e: Event| { +- e.stop_propagation(); +- remove.mutate(RemoveAccountKeys { id }); +- }) +- .child( +- Icon::new(IconType::Trash01) ++impl PartialEq for AccountRow { ++ fn eq(&self, other: &Self) -> bool { ++ self.id == other.id ++ && self.username == other.username ++ && self.kind == other.kind ++ && self.expires == other.expires ++ && self.is_default == other.is_default ++ } ++} ++ ++impl Component for AccountRow { ++ fn render_key(&self) -> DiffKey { ++ DiffKey::from(&self.id) ++ } ++ ++ fn render(&self) -> impl IntoElement { ++ let id = self.id; ++ let is_default = self.is_default; ++ let set_default = self.set_default; ++ let remove = self.remove; ++ let refresh = self.refresh; ++ ++ let is_microsoft = self.kind == AccountKind::Microsoft; ++ let expired = is_microsoft && self.expires <= Utc::now(); ++ ++ let mut refreshing = use_state(|| false); ++ let is_refreshing = *refreshing.read(); ++ ++ let spin = use_animation(|conf| { ++ conf.on_finish(OnFinish::restart()); ++ AnimNum::new(0., 360.) ++ .time(REFRESH_SPIN_TIME) ++ .function(Function::Linear) ++ }); ++ ++ use_side_effect_with_deps(&is_refreshing, move |&is_refreshing| { ++ let mut spin = spin; ++ if is_refreshing { ++ spin.start(); ++ } else { ++ spin.reset(); ++ } ++ }); ++ ++ let rotation = if is_refreshing { ++ spin.get().value() ++ } else { ++ 0. ++ }; ++ ++ let border_color = if expired { ++ colors::danger() ++ } else if is_default { ++ colors::brand() ++ } else { ++ colors::component_border() ++ }; ++ ++ rect() ++ .horizontal() ++ .width(Size::fill()) ++ .cross_align(Alignment::Center) ++ .content(Content::Flex) ++ .spacing(16.) ++ .padding(Gaps::new_all(12.)) ++ .corner_radius(CornerRadius::new_all(12.)) ++ .background(colors::page_elevated()) ++ .border(border_all_color(1., border_color)) ++ .opacity(if is_refreshing { ++ REFRESHING_OPACITY ++ } else { ++ 1. ++ }) ++ .a11y_role(AccessibilityRole::Button) ++ .maybe(!is_default, |el| { ++ el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) ++ }) ++ .child( ++ Avatar::new(id.to_string()) ++ .width(Size::px(40.)) ++ .height(Size::px(40.)), ++ ) ++ .child( ++ rect() ++ .vertical() ++ .width(Size::flex(1.0)) ++ .spacing(4.) ++ .child( ++ rect() ++ .horizontal() ++ .cross_align(Alignment::Center) ++ .spacing(8.) ++ .child( ++ label() ++ .text(self.username.clone()) ++ .font_size(16.) ++ .font_weight(FontWeight::MEDIUM) ++ .color(colors::fg_primary()), ++ ) ++ .child(kind_badge(self.kind)) ++ .maybe_child(is_default.then(default_badge)) ++ .maybe_child(expired.then(expired_badge)), ++ ) ++ .child( ++ label() ++ .text(id.to_string()) ++ .font_size(11.) ++ .color(colors::fg_secondary()), ++ ), ++ ) ++ .maybe_child(is_microsoft.then(|| { ++ Button::new() ++ .ghost() ++ .icon() ++ .on_press(move |e: Event| { ++ e.stop_propagation(); ++ if *refreshing.peek() { ++ return; ++ } ++ refreshing.set(true); ++ spawn(async move { ++ refresh.mutate_async(RefreshAccountKeys { id }).await; ++ refreshing.set(false); ++ }); ++ }) ++ .child( ++ rect().rotate(rotation).child( ++ Icon::new(IconType::RefreshCw01) ++ .size(18.) ++ .color(if expired { ++ colors::danger() ++ } else { ++ colors::fg_secondary() ++ }), ++ ), ++ ) ++ .into_element() ++ })) ++ .child( ++ Button::new().ghost().icon().enabled(false).child( ++ Icon::new(IconType::Pencil01) + .size(18.) + .color(colors::fg_secondary()), + ), +- ) +- .into_element() ++ ) ++ .child( ++ Button::new() ++ .ghost() ++ .icon() ++ .on_press(move |e: Event| { ++ e.stop_propagation(); ++ remove.mutate(RemoveAccountKeys { id }); ++ }) ++ .child( ++ Icon::new(IconType::Trash01) ++ .size(18.) ++ .color(colors::fg_secondary()), ++ ), ++ ) ++ .into_element() ++ } + } + + fn kind_badge(kind: AccountKind) -> impl IntoElement { +diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs +index b43f531..7aeb775 100644 +--- a/packages/oneclient_app/src/view/app/analytics/charts.rs ++++ b/packages/oneclient_app/src/view/app/analytics/charts.rs +@@ -62,7 +62,10 @@ impl Component for WhenChart { + ) + } + WhenMode::Hour => { +- let hour = self.peak_hour.map(format_hour).unwrap_or_else(|| "—".to_string()); ++ let hour = self ++ .peak_hour ++ .map(format_hour) ++ .unwrap_or_else(|| "—".to_string()); + ( + format!("Peak around {hour}"), + BarChart::new(self.per_hour.clone(), hour_labels()) +@@ -148,7 +151,12 @@ impl Component for DailyChart { + let win_total: i64 = values.iter().sum(); + let subtitle = match (slice.first(), slice.last()) { + (Some((a, _)), Some((b, _))) => { +- format!("{} · {} – {}", format_duration_hm(win_total), format_day(*a), format_day(*b)) ++ format!( ++ "{} · {} – {}", ++ format_duration_hm(win_total), ++ format_day(*a), ++ format_day(*b) ++ ) + } + _ => format_duration_hm(win_total), + }; +@@ -159,24 +167,16 @@ impl Component for DailyChart { + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) +- .child(nav_button( +- IconType::ChevronsLeft, +- can_older, +- move |_| { +- if can_older { +- *offset.write() = off + 1; +- } +- }, +- )) +- .child(nav_button( +- IconType::ChevronsRight, +- can_newer, +- move |_| { +- if can_newer { +- *offset.write() = off - 1; +- } +- }, +- )) ++ .child(nav_button(IconType::ChevronsLeft, can_older, move |_| { ++ if can_older { ++ *offset.write() = off + 1; ++ } ++ })) ++ .child(nav_button(IconType::ChevronsRight, can_newer, move |_| { ++ if can_newer { ++ *offset.write() = off - 1; ++ } ++ })) + .child( + SegmentedControl::new(range) + .height(30.) +@@ -272,6 +272,12 @@ fn weekday_labels() -> Vec { + + fn hour_labels() -> Vec { + (0..24) +- .map(|h| if h % 6 == 0 { format_hour(h) } else { String::new() }) ++ .map(|h| { ++ if h % 6 == 0 { ++ format_hour(h) ++ } else { ++ String::new() ++ } ++ }) + .collect() + } +diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs +index f09a358..2235de7 100644 +--- a/packages/oneclient_app/src/view/app/analytics/mod.rs ++++ b/packages/oneclient_app/src/view/app/analytics/mod.rs +@@ -45,11 +45,7 @@ fn charts_grid(cards: Vec) -> Element { + let mut grid = rect().vertical().width(Size::fill()).spacing(16.); + for pair in cards.chunks(2) { + if pair.len() == 1 { +- grid = grid.child( +- rect() +- .width(Size::fill()) +- .child(pair[0].clone()), +- ); ++ grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); + continue; + } + +diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs +index fe5743d..a8e3e7c 100644 +--- a/packages/oneclient_app/src/view/app/analytics/servers.rs ++++ b/packages/oneclient_app/src/view/app/analytics/servers.rs +@@ -12,7 +12,12 @@ use super::{card, card_header}; + + pub(super) fn servers_section(servers: &[ServerStat]) -> Element { + let total_joins: i64 = servers.iter().map(|s| s.joins).sum(); +- let max_secs = servers.iter().map(|s| s.total_secs).max().unwrap_or(0).max(1); ++ let max_secs = servers ++ .iter() ++ .map(|s| s.total_secs) ++ .max() ++ .unwrap_or(0) ++ .max(1); + + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + for server in servers.iter().take(10) { +@@ -155,9 +160,12 @@ impl Component for ServerIcon { + let reader = query.read(); + let loaded = match (&self.url, &*reader.state()) { + (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) +- | (Some(url), QueryStateData::Loading { res: Some(Ok(bytes)) }) => { +- Some((url.clone(), bytes.clone())) +- } ++ | ( ++ Some(url), ++ QueryStateData::Loading { ++ res: Some(Ok(bytes)), ++ }, ++ ) => Some((url.clone(), bytes.clone())), + _ => None, + }; + +@@ -185,7 +193,11 @@ fn server_icon_placeholder(is_ip: bool, size: f32) -> Element { + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) +- .child(Icon::new(icon).size(size * 0.5).color(colors::fg_secondary())) ++ .child( ++ Icon::new(icon) ++ .size(size * 0.5) ++ .color(colors::fg_secondary()), ++ ) + .into_element() + } + +diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs +index aaf072c..10bb6bd 100644 +--- a/packages/oneclient_app/src/view/app/analytics/tiles.rs ++++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs +@@ -3,9 +3,9 @@ use freya::prelude::*; + use oneclient_core::game::{Analytics, Persona}; + + use crate::components::{Icon, IconType}; +-use crate::utils::format_duration_hm; + use crate::theme::colors; + use crate::ui::border_all_color; ++use crate::utils::format_duration_hm; + + use super::card; + +@@ -19,10 +19,22 @@ pub(super) fn tiles_row(analytics: &Analytics) -> Element { + let total_joins: i64 = analytics.servers.iter().map(|s| s.joins).sum(); + + let mut tiles = vec![ +- stat_tile(IconType::ClockRewind, "Total playtime", format_duration_hm(stats.total_secs)), ++ stat_tile( ++ IconType::ClockRewind, ++ "Total playtime", ++ format_duration_hm(stats.total_secs), ++ ), + stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), +- stat_tile(IconType::Calendar, "Avg / session", format_duration_hm(avg_session)), +- stat_tile(IconType::Rocket02, "Active days", stats.active_days.to_string()), ++ stat_tile( ++ IconType::Calendar, ++ "Avg / session", ++ format_duration_hm(avg_session), ++ ), ++ stat_tile( ++ IconType::Rocket02, ++ "Active days", ++ stats.active_days.to_string(), ++ ), + ]; + if !analytics.servers.is_empty() { + tiles.push(stat_tile( +@@ -97,7 +109,11 @@ fn persona_card(persona: Persona) -> Element { + .corner_radius(CornerRadius::new_all(22.)) + .background(colors::brand().with_a(40)) + .center() +- .child(Icon::new(persona_icon(persona)).size(22.).color(colors::brand())), ++ .child( ++ Icon::new(persona_icon(persona)) ++ .size(22.) ++ .color(colors::brand()), ++ ), + ) + .child( + rect() +diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs +index bc9c10c..c919849 100644 +--- a/packages/oneclient_app/src/view/app/browser/index/cards.rs ++++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs +@@ -1,17 +1,19 @@ + use super::*; + + use freya::router::RouterContext; +-use oneclient_core::packages::types::ProjectSummary; + use oneclient_core::packages::ProviderId; ++use oneclient_core::packages::types::ProjectSummary; + +-use crate::components::{ +- Icon, IconType, +-}; ++use crate::components::{Icon, IconType}; + use crate::routes::Route; + use crate::theme::colors; + use crate::ui::border_all_color; + +-pub(super) fn grid_row(row: Vec, cluster_id: i64, package_type: &str) -> impl IntoElement { ++pub(super) fn grid_row( ++ row: Vec, ++ cluster_id: i64, ++ package_type: &str, ++) -> impl IntoElement { + let package_type = package_type.to_string(); + let fill = GRID_COLUMNS - row.len(); + +@@ -154,7 +156,11 @@ impl Component for PackageCard { + } + } + +-pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str) -> impl IntoElement { ++pub(super) fn list_row( ++ item: ProjectSummary, ++ cluster_id: i64, ++ package_type: &str, ++) -> impl IntoElement { + ListRow { + item, + cluster_id, +@@ -185,69 +191,69 @@ impl Component for ListRow { + Cursor::set(CursorIcon::default()); + }); + +- rect() +- .horizontal() +- .width(Size::fill()) +- .height(Size::px(LIST_ROW_H)) +- .cross_align(Alignment::Center) +- .spacing(12.) +- .padding(Gaps::new_all(16.)) +- .corner_radius(CornerRadius::new_all(10.)) +- .background(CARD_BG) +- .border(border_all_color( +- 1., +- if focused { +- colors::brand() +- } else { +- colors::component_border() +- }, +- )) +- .overflow(Overflow::Clip) +- .content(Content::Flex) +- .a11y_id(a11y_id) +- .a11y_focusable(true) +- .a11y_role(AccessibilityRole::Button) +- .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) +- .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) +- .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) +- .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) +- .child( +- rect() +- .vertical() +- .width(Size::flex(1.0)) +- .spacing(3.) +- .child( +- rect() +- .horizontal() +- .cross_align(Alignment::Center) +- .spacing(6.) +- .child( +- label() +- .text(item.name.clone()) +- .font_size(15.) +- .font_weight(FontWeight::MEDIUM) +- .max_lines(1) +- .color(CARD_NAME), +- ) +- .child( +- label() +- .text(format!("by {}", item.author)) +- .font_size(10.) +- .max_lines(1) +- .color(colors::fg_secondary()), +- ) +- .child(Icon::new(item.provider).size(12.)), +- ) +- .child( +- label() +- .text(item.summary.clone()) +- .font_size(11.) +- .max_lines(2) +- .width(Size::fill()) +- .color(colors::fg_secondary()), +- ), +- ) +- .child(downloads_row(item.downloads)) ++ rect() ++ .horizontal() ++ .width(Size::fill()) ++ .height(Size::px(LIST_ROW_H)) ++ .cross_align(Alignment::Center) ++ .spacing(12.) ++ .padding(Gaps::new_all(16.)) ++ .corner_radius(CornerRadius::new_all(10.)) ++ .background(CARD_BG) ++ .border(border_all_color( ++ 1., ++ if focused { ++ colors::brand() ++ } else { ++ colors::component_border() ++ }, ++ )) ++ .overflow(Overflow::Clip) ++ .content(Content::Flex) ++ .a11y_id(a11y_id) ++ .a11y_focusable(true) ++ .a11y_role(AccessibilityRole::Button) ++ .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) ++ .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) ++ .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) ++ .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) ++ .child( ++ rect() ++ .vertical() ++ .width(Size::flex(1.0)) ++ .spacing(3.) ++ .child( ++ rect() ++ .horizontal() ++ .cross_align(Alignment::Center) ++ .spacing(6.) ++ .child( ++ label() ++ .text(item.name.clone()) ++ .font_size(15.) ++ .font_weight(FontWeight::MEDIUM) ++ .max_lines(1) ++ .color(CARD_NAME), ++ ) ++ .child( ++ label() ++ .text(format!("by {}", item.author)) ++ .font_size(10.) ++ .max_lines(1) ++ .color(colors::fg_secondary()), ++ ) ++ .child(Icon::new(item.provider).size(12.)), ++ ) ++ .child( ++ label() ++ .text(item.summary.clone()) ++ .font_size(11.) ++ .max_lines(2) ++ .width(Size::fill()) ++ .color(colors::fg_secondary()), ++ ), ++ ) ++ .child(downloads_row(item.downloads)) + } + } + +diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs +index cb4c799..77df9ea 100644 +--- a/packages/oneclient_app/src/view/app/browser/index/mod.rs ++++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs +@@ -1,6 +1,5 @@ + use freya::animation::{ +- AnimNum, Ease, Function, OnChange, OnCreation, +- use_animation_with_dependencies, ++ AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, + }; + use freya::prelude::*; + use oneclient_core::packages::types::ProjectSummary; +@@ -174,7 +173,9 @@ impl Component for Browser { + match mode { + ViewLayout::Grid => { + let rows = BROWSE_PAGE_SIZE.div_ceil(GRID_COLUMNS); +- sa.lazy(rows, CARD_H, GRID_SPACING, |_| skeleton_grid_row().into_element()) ++ sa.lazy(rows, CARD_H, GRID_SPACING, |_| { ++ skeleton_grid_row().into_element() ++ }) + } + ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { + SkeletonListRow.into_element() +@@ -289,4 +290,3 @@ fn controls( + ), + ) + } +- +diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +index 1e03d38..3808083 100644 +--- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs ++++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +@@ -120,4 +120,3 @@ impl Component for CategoryRow { + ) + } + } +- +diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs +index d7e9aa4..30f6d2a 100644 +--- a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs ++++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs +@@ -1,8 +1,6 @@ + use super::*; + +-use freya::animation::{ +- AnimNum, Ease, Function, OnCreation, OnFinish, use_animation, +-}; ++use freya::animation::{AnimNum, Ease, Function, OnCreation, OnFinish, use_animation}; + + use crate::theme::colors; + use crate::ui::border_all_color; +diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs +index db7aadd..b3cdbce 100644 +--- a/packages/oneclient_app/src/view/app/browser/package/mod.rs ++++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs +@@ -3,8 +3,8 @@ use oneclient_core::packages::{ContentType, ProviderId}; + + use crate::components::ScrollArea; + use crate::hooks::{ +- content_type_for_slug, project_detail, use_browser_compat, use_dispatch, +- use_link_confirm, use_package_project, use_package_versions, version_list, versions_total, ++ content_type_for_slug, project_detail, use_browser_compat, use_dispatch, use_link_confirm, ++ use_package_project, use_package_versions, version_list, versions_total, + }; + use crate::theme::colors; + use crate::ui::border_all_color; +@@ -205,4 +205,3 @@ impl Component for BrowserPackage { + .into_element() + } + } +- +diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs +index c0e854d..42f88fc 100644 +--- a/packages/oneclient_app/src/view/app/browser/package/panels.rs ++++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs +@@ -1,14 +1,14 @@ + use super::*; + + use freya::query::QueryStateData; ++use oneclient_core::packages::ProviderId; + use oneclient_core::packages::types::{ + GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary, + }; +-use oneclient_core::packages::ProviderId; + + use crate::BridgeDispatch; + use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; +-use crate::hooks::{use_cached_image, VERSIONS_PAGE_SIZE}; ++use crate::hooks::{VERSIONS_PAGE_SIZE, use_cached_image}; + use crate::theme::colors; + use crate::ui::border_all_color; + use crate::utils::format_size; +@@ -16,7 +16,6 @@ use crate::utils::format_size; + const GALLERY_IMAGE_H: f32 = 360.; + const GALLERY_EDGE: u32 = 1024; + +- + pub(super) fn loading_body() -> impl IntoElement { + rect() + .width(Size::fill()) +@@ -356,4 +355,3 @@ fn release_badge(release_type: ReleaseType) -> impl IntoElement { + .color(color), + ) + } +- +diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +index 3c4e40f..2b3b017 100644 +--- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs ++++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +@@ -1,16 +1,13 @@ + use super::*; + +-use oneclient_core::packages::types::{ +- ProjectDetail, ProjectMember, +-}; + use oneclient_core::packages::ProviderId; ++use oneclient_core::packages::types::{ProjectDetail, ProjectMember}; + + use crate::BridgeDispatch; + use crate::components::{Button, Icon, IconType}; + use crate::theme::colors; + use crate::ui::border_all_color; + +- + pub(super) fn sidebar( + project: Option, + latest_version: Option, +diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +index 827836e..4d760ff 100644 +--- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs ++++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +@@ -1,4 +1,3 @@ +- + use freya::prelude::*; + use oneclient_core::Patch; + use oneclient_core::java::JavaRuntime; +@@ -6,7 +5,8 @@ use oneclient_core::packages::domain::GameLoader; + use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution}; + + use crate::components::{ +- Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number ++ Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, ++ validate_number, + }; + use crate::hooks::{ + ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation, +@@ -37,7 +37,10 @@ impl Component for ClusterSettings { + .as_ref() + .map(|c| c.mc_version.clone()) + .unwrap_or_default(); +- let loader = cluster.as_ref().map(|c| c.mc_loader).unwrap_or(GameLoader::Fabric); ++ let loader = cluster ++ .as_ref() ++ .map(|c| c.mc_loader) ++ .unwrap_or(GameLoader::Fabric); + let versions_query = use_loader_versions(mc_version, loader); + let runtimes_query = use_java_runtimes(); + +@@ -149,13 +152,11 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen + + Button::new() + .small() +- .ghost() +- .icon() ++ .ghost() ++ .icon() + .corner_radius(CornerRadius::new_all(7.)) +- .maybe(overridden, |el| { +- el.on_press(move |_| on_reset.call(())) +- }) +- .enabled(overridden) ++ .maybe(overridden, |el| el.on_press(move |_| on_reset.call(()))) ++ .enabled(overridden) + .child(Icon::new(IconType::RefreshCcw02).size(15.).color(color)) + .into_element() + } +@@ -394,7 +395,11 @@ impl Component for ResolutionRow { + .text_align(TextAlign::Center) + .width(Size::px(70.)), + ) +- .child(Icon::new(IconType::X).size(14.).color(colors::fg_secondary())) ++ .child( ++ Icon::new(IconType::X) ++ .size(14.) ++ .color(colors::fg_secondary()), ++ ) + .child( + TextInput::new(height) + .placeholder("480") +@@ -527,7 +532,12 @@ impl Component for TextRow { + .placeholder(placeholder) + .width(Size::px(220.)); + +- settings_row(icon, title, description, override_cell(control, overridden, on_reset)) ++ settings_row( ++ icon, ++ title, ++ description, ++ override_cell(control, overridden, on_reset), ++ ) + } + } + +@@ -564,8 +574,7 @@ impl Component for JavaRow { + .map(runtime_label) + .unwrap_or_else(|| "Automatic".into()); + +- let paths: Vec = +- runtimes.iter().map(|r| r.absolute_path.clone()).collect(); ++ let paths: Vec = runtimes.iter().map(|r| r.absolute_path.clone()).collect(); + + let dispatch = dispatch.clone(); + Dropdown::new(selected, options) +@@ -641,7 +650,10 @@ impl Component for LoaderRow { + settings_row( + IconType::Rocket02, + "Loader Version", +- format!("The {} loader version used to launch this cluster.", self.loader), ++ format!( ++ "The {} loader version used to launch this cluster.", ++ self.loader ++ ), + control, + ) + } +diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs +index fa61a34..e136905 100644 +--- a/packages/oneclient_app/src/view/app/cluster/logs.rs ++++ b/packages/oneclient_app/src/view/app/cluster/logs.rs +@@ -65,7 +65,8 @@ enum Confirm { + + fn kind_badge(kind: LogKind) -> &'static str { + match kind { +- LogKind::Game { .. } => "Launcher", ++ LogKind::Game { .. } => "Game output", ++ LogKind::Launcher => "Launcher logs", + LogKind::Minecraft => "Minecraft", + LogKind::CrashReport => "Crash report", + LogKind::Other => "Other", +@@ -151,7 +152,7 @@ impl Component for ClusterLogs { + let value = search.read().clone(); + (!value.trim().is_empty()).then_some(value) + }; +- ++ + let content_query = use_log_content( + selected_path.clone(), + level.read().to_level(), +@@ -162,7 +163,7 @@ impl Component for ClusterLogs { + let Some(cluster) = load_cluster(cluster_id) else { + return cluster_not_found(); + }; +- let folder = cluster.game_dir().ok().map(|d| d.join("logs")); ++ let folder = cluster.dir().ok().map(|d| d.join("logs")); + + let files = try_cluster_logs(&logs_query).unwrap_or_default(); + let has_log = !files.is_empty(); +diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs +index bc95a97..1cf1f0e 100644 +--- a/packages/oneclient_app/src/view/app/cluster/mod.rs ++++ b/packages/oneclient_app/src/view/app/cluster/mod.rs +@@ -1,23 +1,23 @@ +-mod overview; + mod logs; ++mod overview; + mod package_manager; + + mod cluster_settings; + mod mods; ++mod process_logs; + mod screenshots; + mod shaders; + mod textures; +-mod process_logs; + +-pub use overview::ClusterOverview; + pub use logs::ClusterLogs; ++pub use overview::ClusterOverview; + + pub use cluster_settings::ClusterSettings; + pub use mods::ClusterMods; ++pub use process_logs::ProcessLogs; + pub use screenshots::ClusterScreenshots; + pub use shaders::ClusterShaders; + pub use textures::ClusterTextures; +-pub use process_logs::ProcessLogs; + + use freya::prelude::*; + use freya::query::QueryStateData; +diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs +index 6387b54..9c8622e 100644 +--- a/packages/oneclient_app/src/view/app/cluster/mods.rs ++++ b/packages/oneclient_app/src/view/app/cluster/mods.rs +@@ -1,14 +1,14 @@ + use freya::prelude::*; +-use oneclient_core::packages::{ContentType, ProviderId}; ++use oneclient_core::packages::ContentType; + + use crate::hooks::{ +- bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, +- use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, ++ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, ++ use_bundles_with_status, use_cluster_content, + }; + use crate::layout::cluster_content; + + use super::package_manager::{ +- PackageManager, bundle_categories, bundle_packages, managed_project_ids, ++ PackageManager, bundle_categories, bundle_packages, use_content_meta, + }; + use super::{cluster_not_found, load_cluster}; + +@@ -23,8 +23,8 @@ impl Component for ClusterMods { + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); +- let project_ids = managed_project_ids(&bundle_items, ContentType::Mod); +- let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); ++ let content_items = cluster_content_items(&content); ++ let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); + + let Some(_cluster) = load_cluster(self.cluster_id) else { + return cluster_not_found(); +@@ -32,10 +32,10 @@ impl Component for ClusterMods { + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( +- cluster_content_items(&content), ++ content_items, + &bundle_items, + &bundle_overrides_map(&overrides), +- &package_meta_batch(&meta), ++ &meta, + ContentType::Mod, + ); + +diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +index 20fdcbc..3665b0a 100644 +--- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs ++++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +@@ -1,4 +1,3 @@ +- + use std::collections::{HashMap, HashSet}; + + use freya::prelude::*; +@@ -6,7 +5,7 @@ use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId}; + use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; + + use crate::components::{CardLayout, PackageEntry}; +-use crate::hooks::use_view_state; ++use crate::hooks::{package_meta_batch, use_package_meta_batch, use_view_state}; + + mod views; + use views::{ContentBox, ContentKind, EnabledFilter, SortMode, toolbar_bar}; +@@ -18,9 +17,13 @@ const GRID_GAP: f32 = 10.; + const GRID_MIN_W: f32 = 260.; + const LAZY_OVERSCAN: i64 = 2; + +-pub fn managed_project_ids( ++pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; ++ ++fn provider_project_ids( ++ content: &[LinkedArtifactInfo], + bundles: &[BundleWithUpdateStatus], + content_type: ContentType, ++ provider: ProviderId, + ) -> Vec { + let mut ids = Vec::new(); + for bundle in bundles { +@@ -28,19 +31,49 @@ pub fn managed_project_ids( + if file.content_type() != content_type { + continue; + } +- if let BundleFileKind::Managed { project_id, .. } = &file.kind { ++ if let BundleFileKind::Managed { ++ project_id, ++ provider: file_provider, ++ .. ++ } = &file.kind ++ && *file_provider == provider ++ { + ids.push(project_id.clone()); + } + } + } ++ for info in content { ++ if info.content_type != content_type || info.provider != Some(provider) { ++ continue; ++ } ++ if let Some(project_id) = &info.project_id { ++ ids.push(project_id.clone()); ++ } ++ } + ids + } + ++pub fn use_content_meta( ++ content: &[LinkedArtifactInfo], ++ bundles: &[BundleWithUpdateStatus], ++ content_type: ContentType, ++) -> PackageMetaMap { ++ let mut out = PackageMetaMap::new(); ++ for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { ++ let ids = provider_project_ids(content, bundles, content_type, provider); ++ let query = use_package_meta_batch(provider, ids); ++ for (project_id, meta) in package_meta_batch(&query) { ++ out.insert((provider, project_id), meta); ++ } ++ } ++ out ++} ++ + pub fn bundle_packages( + content: Vec, + bundles: &[BundleWithUpdateStatus], + overrides: &HashMap<(String, String), String>, +- meta: &HashMap, ++ meta: &PackageMetaMap, + content_type: ContentType, + ) -> Vec { + let mut by_project: HashMap<&str, &LinkedArtifactInfo> = HashMap::new(); +@@ -104,10 +137,7 @@ pub fn bundle_packages( + } + + for info in &content { +- let in_bundle = info +- .project_id +- .as_deref() +- .is_some_and(|p| seen.contains(p)) ++ let in_bundle = info.project_id.as_deref().is_some_and(|p| seen.contains(p)) + || seen.contains(&info.hash); + if in_bundle { + continue; +@@ -141,10 +171,10 @@ fn make_row( + categories: Vec, + enabled: bool, + installed_info: Option<&LinkedArtifactInfo>, +- meta: &HashMap, ++ meta: &PackageMetaMap, + fallback_name: String, + ) -> PackageEntry { +- let m = meta.get(&package_id); ++ let m = meta.get(&(provider, package_id.clone())); + let name = m + .map(|p| p.name.clone()) + .filter(|s| !s.is_empty()) +diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +index 529adc1..4b30c03 100644 +--- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs ++++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +@@ -264,7 +264,13 @@ impl Component for FilterPopover { + .background(colors::page_elevated().with_a(230)) + .blur(12.) + .border(crate::ui::border_all_color(1., colors::component_border())) +- .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(120, 0, 0, 0)))) ++ .shadow(Shadow::from(( ++ 0., ++ 8., ++ 32., ++ 0., ++ Color::from_argb(120, 0, 0, 0), ++ ))) + .a11y_id(a11y_id) + .a11y_role(AccessibilityRole::Dialog) + .on_global_key_down(move |e: Event| { +@@ -527,12 +533,16 @@ impl Component for ContentBox { + + let header = (count > 0) + .then(|| match kind { +- ContentKind::External => Some( +- action_header(browse_button(cluster_id, package_type)).into_element(), +- ), ++ ContentKind::External => { ++ Some(action_header(browse_button(cluster_id, package_type)).into_element()) ++ } + ContentKind::Local => Some( +- action_header(add_from_file_button(cluster_id, content_type, dispatch.clone())) +- .into_element(), ++ action_header(add_from_file_button( ++ cluster_id, ++ content_type, ++ dispatch.clone(), ++ )) ++ .into_element(), + ), + ContentKind::Other => None, + }) +diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs +index 8568ae6..df8b1f7 100644 +--- a/packages/oneclient_app/src/view/app/cluster/process_logs.rs ++++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs +@@ -1,4 +1,3 @@ +- + use freya::prelude::*; + + use crate::components::LogViewer; +@@ -30,9 +29,7 @@ impl Component for ProcessLogs { + not_running() + }; + +- cluster_content() +- .child(body) +- .into_element() ++ cluster_content().child(body).into_element() + } + } + +diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs +index ed13be2..c9fd68e 100644 +--- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs ++++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs +@@ -8,8 +8,7 @@ use oneclient_core::settings::ViewLayout; + + use crate::components::{ + Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, +- open_folder_button, +- Segment, SegmentedControl, ++ Segment, SegmentedControl, open_folder_button, + }; + use crate::hooks::{ + ScreenshotAction, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, +@@ -57,7 +56,14 @@ impl Component for ClusterScreenshots { + let grid_width = use_state(|| 0f32); + let mut menu = use_state(|| None::<(f32, f32, PathBuf)>); + +- let toolbar = toolbar_row(&shots, folder, view_mode, edit_mode, selected, confirm_delete); ++ let toolbar = toolbar_row( ++ &shots, ++ folder, ++ view_mode, ++ edit_mode, ++ selected, ++ confirm_delete, ++ ); + + let content: Element = if shots.is_empty() { + empty_state(matches!( +@@ -210,7 +216,12 @@ fn toolbar_row( + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) +- .maybe_child((!editing).then_some(folder).flatten().map(open_folder_button)); ++ .maybe_child( ++ (!editing) ++ .then_some(folder) ++ .flatten() ++ .map(open_folder_button), ++ ); + + if editing { + let select_all_paths = all_paths.clone(); +@@ -422,8 +433,6 @@ fn confirm_panel( + .into_element() + } + +- +- + fn res_badge(res: Option<(u32, u32)>) -> Option { + res.map(format_res).map(|text| { + rect() +diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs +index 61fde57..a69000b 100644 +--- a/packages/oneclient_app/src/view/app/cluster/shaders.rs ++++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs +@@ -1,14 +1,14 @@ + use freya::prelude::*; +-use oneclient_core::packages::{ContentType, ProviderId}; ++use oneclient_core::packages::ContentType; + + use crate::hooks::{ +- bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, +- use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, ++ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, ++ use_bundles_with_status, use_cluster_content, + }; + use crate::layout::cluster_content; + + use super::package_manager::{ +- PackageManager, bundle_categories, bundle_packages, managed_project_ids, ++ PackageManager, bundle_categories, bundle_packages, use_content_meta, + }; + use super::{cluster_not_found, load_cluster}; + +@@ -23,8 +23,8 @@ impl Component for ClusterShaders { + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); +- let project_ids = managed_project_ids(&bundle_items, ContentType::Shader); +- let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); ++ let content_items = cluster_content_items(&content); ++ let meta = use_content_meta(&content_items, &bundle_items, ContentType::Shader); + + let Some(_cluster) = load_cluster(self.cluster_id) else { + return cluster_not_found(); +@@ -32,10 +32,10 @@ impl Component for ClusterShaders { + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( +- cluster_content_items(&content), ++ content_items, + &bundle_items, + &bundle_overrides_map(&overrides), +- &package_meta_batch(&meta), ++ &meta, + ContentType::Shader, + ); + +diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs +index fc42cbd..9f08ec8 100644 +--- a/packages/oneclient_app/src/view/app/cluster/textures.rs ++++ b/packages/oneclient_app/src/view/app/cluster/textures.rs +@@ -1,14 +1,14 @@ + use freya::prelude::*; +-use oneclient_core::packages::{ContentType, ProviderId}; ++use oneclient_core::packages::ContentType; + + use crate::hooks::{ +- bundle_overrides_map, bundles_with_status_items, cluster_content_items, package_meta_batch, +- use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_meta_batch, ++ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, ++ use_bundles_with_status, use_cluster_content, + }; + use crate::layout::cluster_content; + + use super::package_manager::{ +- PackageManager, bundle_categories, bundle_packages, managed_project_ids, ++ PackageManager, bundle_categories, bundle_packages, use_content_meta, + }; + use super::{cluster_not_found, load_cluster}; + +@@ -23,8 +23,8 @@ impl Component for ClusterTextures { + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); +- let project_ids = managed_project_ids(&bundle_items, ContentType::ResourcePack); +- let meta = use_package_meta_batch(ProviderId::Modrinth, project_ids); ++ let content_items = cluster_content_items(&content); ++ let meta = use_content_meta(&content_items, &bundle_items, ContentType::ResourcePack); + + let Some(_cluster) = load_cluster(self.cluster_id) else { + return cluster_not_found(); +@@ -32,10 +32,10 @@ impl Component for ClusterTextures { + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( +- cluster_content_items(&content), ++ content_items, + &bundle_items, + &bundle_overrides_map(&overrides), +- &package_meta_batch(&meta), ++ &meta, + ContentType::ResourcePack, + ); + +diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs +index 14b4e41..3d416fe 100644 +--- a/packages/oneclient_app/src/view/app/clusters/page.rs ++++ b/packages/oneclient_app/src/view/app/clusters/page.rs +@@ -8,7 +8,6 @@ use crate::components::{Button, ClusterLandscapeArt, Dropdown, Icon, IconType, M + use crate::hooks::{ + use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_version_metadata, + }; +-use crate::view::app::launch_button_state; + use crate::routes::Route; + use crate::theme::colors; + use crate::ui::border_all_color; +@@ -16,6 +15,7 @@ use crate::utils::{ + default_loader, default_major, default_minor, group_clusters_by_major, loaders_for_major, + major_pretty_name, minor_label, minor_versions, resolve_cluster, + }; ++use crate::view::app::launch_button_state; + + const GRID_GAP_PX: f32 = 12.; + const MIN_CARD_WIDTH_PX: f32 = 280.; +@@ -212,7 +212,10 @@ fn detail_sidebar( + .as_ref() + .and_then(|m| m.long_description.clone()) + .unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string()); +- let tags = metadata.as_ref().map(|m| m.tags.clone()).unwrap_or_default(); ++ let tags = metadata ++ .as_ref() ++ .map(|m| m.tags.clone()) ++ .unwrap_or_default(); + + rect() + .width(Size::px(SIDEBAR_WIDTH_PX)) +@@ -225,17 +228,9 @@ fn detail_sidebar( + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .overflow(Overflow::Clip) +- .child( +- rect() +- .width(Size::fill()) +- .max_height(Size::px(140.)) +- .child(ClusterLandscapeArt::for_version( +- major, +- minor_value, +- loader_value, +- false, +- )), +- ) ++ .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( ++ ClusterLandscapeArt::for_version(major, minor_value, loader_value, false), ++ )) + .child( + rect() + .vertical() +@@ -274,7 +269,11 @@ fn detail_sidebar( + .content(Content::Flex) + .main_align(Alignment::Center) + .spacing(8.) +- .child(play_button(cluster_id, dispatch, launch_button_state(&game, cluster_id))) ++ .child(play_button( ++ cluster_id, ++ dispatch, ++ launch_button_state(&game, cluster_id), ++ )) + .child(view_button(cluster_id, active_id)), + ), + ) +diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs +index 9df3240..1cf9396 100644 +--- a/packages/oneclient_app/src/view/app/mod.rs ++++ b/packages/oneclient_app/src/view/app/mod.rs +@@ -1,14 +1,14 @@ +-mod analytics; +-mod home; + mod accounts; ++mod analytics; + mod clusters; + mod debug; ++mod home; + mod skins; + mod stats; + + pub mod browser; +-pub mod settings; + pub mod cluster; ++pub mod settings; + + use crate::bridge::GameSnapshot; + use oneclient_core::notification::LaunchStage; +@@ -23,10 +23,10 @@ pub fn launch_button_state(game: &GameSnapshot, cluster_id: i64) -> (&'static st + } + } + +-pub(crate) use analytics::analytics_body; + pub use accounts::Accounts; +-pub use home::Home; ++pub(crate) use analytics::analytics_body; + pub use clusters::Clusters; + pub use debug::Debug; ++pub use home::Home; + pub use skins::AccountSkins; + pub use stats::Stats; +diff --git a/packages/oneclient_app/src/view/app/settings/apis.rs b/packages/oneclient_app/src/view/app/settings/apis.rs +index ac42047..13dd672 100644 +--- a/packages/oneclient_app/src/view/app/settings/apis.rs ++++ b/packages/oneclient_app/src/view/app/settings/apis.rs +@@ -1,8 +1,8 @@ + use freya::prelude::*; + ++use super::settings_page; + use crate::components::{IconType, TextInput}; + use crate::hooks::{use_dispatch, use_settings_snapshot}; +-use super::settings_page; + use crate::view::app::settings::{section_header, settings_row}; + + fn normalize(value: &str) -> Option { +diff --git a/packages/oneclient_app/src/view/app/settings/appearance.rs b/packages/oneclient_app/src/view/app/settings/appearance.rs +index 7a43f38..3edc388 100644 +--- a/packages/oneclient_app/src/view/app/settings/appearance.rs ++++ b/packages/oneclient_app/src/view/app/settings/appearance.rs +@@ -1,8 +1,8 @@ + use freya::prelude::*; + ++use super::settings_page; + use crate::components::{Icon, IconType, toggle}; + use crate::hooks::{use_dispatch, use_settings_snapshot}; +-use super::settings_page; + use crate::theme::colors; + use crate::ui::border_all_color; + use crate::view::app::settings::settings_row; +diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs +index 8238e3f..791bf81 100644 +--- a/packages/oneclient_app/src/view/app/settings/changelog.rs ++++ b/packages/oneclient_app/src/view/app/settings/changelog.rs +@@ -1,8 +1,8 @@ + use freya::prelude::*; + ++use super::settings_page; + use crate::components::{Icon, IconType}; + use crate::hooks::{changelog_error, changelog_groups, changelog_is_loading, use_changelog}; +-use super::settings_page; + use crate::theme::colors; + + #[derive(PartialEq)] +@@ -119,14 +119,16 @@ impl Component for ReleaseCard { + .spacing(4.) + .padding(Gaps::new(0., 0., 0., 6.)) + .children(if changes.is_empty() { +- vec![rect() +- .child( +- label() +- .text("No changes recorded for this version.") +- .font_size(12.) +- .color(colors::fg_secondary()), +- ) +- .into_element()] ++ vec![ ++ rect() ++ .child( ++ label() ++ .text("No changes recorded for this version.") ++ .font_size(12.) ++ .color(colors::fg_secondary()), ++ ) ++ .into_element(), ++ ] + } else { + changes + .into_iter() +diff --git a/packages/oneclient_app/src/view/app/settings/developer.rs b/packages/oneclient_app/src/view/app/settings/developer.rs +index 4e5983c..3a35490 100644 +--- a/packages/oneclient_app/src/view/app/settings/developer.rs ++++ b/packages/oneclient_app/src/view/app/settings/developer.rs +@@ -1,10 +1,10 @@ + use freya::prelude::*; + use freya::router::RouterContext; + ++use super::settings_page; + use crate::Route; + use crate::components::{Button, IconType, link_button, toggle}; + use crate::hooks::{use_browser_compat, use_dispatch, use_settings_snapshot}; +-use super::settings_page; + use crate::view::app::settings::{section_header, settings_row}; + + #[derive(PartialEq)] +@@ -67,7 +67,7 @@ impl Component for SettingsDeveloper { + "Debug Page", + "View the debug page.", + link_button().on_press(move |_| { +- let _ = RouterContext::get().push(Route::Debug { }); ++ let _ = RouterContext::get().push(Route::Debug {}); + }), + )) + .into_element() +diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs +index 5a9c3e6..bc8cda8 100644 +--- a/packages/oneclient_app/src/view/app/settings/java.rs ++++ b/packages/oneclient_app/src/view/app/settings/java.rs +@@ -1,12 +1,12 @@ + use freya::prelude::*; + use oneclient_core::java::{AvailableJava, JavaRuntime, JavaVendor}; + ++use super::settings_page; + use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; + use crate::hooks::{ +- java_runtimes, provider_versions, use_dispatch, use_java_runtimes, use_provider_versions, +- BridgeDispatch, ++ BridgeDispatch, java_runtimes, provider_versions, use_dispatch, use_java_runtimes, ++ use_provider_versions, + }; +-use super::settings_page; + use crate::theme::colors; + use crate::ui::border_all_color; + use crate::view::app::settings::section_header; +@@ -151,15 +151,13 @@ fn table_header() -> impl IntoElement { + .child(head("VENDOR", Size::px(130.))) + .child(head("VERSION", Size::px(90.))) + .child( +- rect() +- .width(Size::flex(1.0)) +- .child( +- label() +- .text("PATH") +- .font_size(11.) +- .font_weight(FontWeight::MEDIUM) +- .color(colors::fg_secondary()), +- ), ++ rect().width(Size::flex(1.0)).child( ++ label() ++ .text("PATH") ++ .font_size(11.) ++ .font_weight(FontWeight::MEDIUM) ++ .color(colors::fg_secondary()), ++ ), + ) + .child(rect().width(Size::px(34.))) + .into_element() +@@ -206,7 +204,10 @@ impl Component for RuntimeRow { + .fill(colors::component_border()), + ) + }) +- .child(cell(format!("{} {}", runtime.vendor, runtime.major), Size::px(130.))) ++ .child(cell( ++ format!("{} {}", runtime.vendor, runtime.major), ++ Size::px(130.), ++ )) + .child(cell(runtime.version.clone(), Size::px(90.))) + .child( + rect() +@@ -241,7 +242,11 @@ fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement { + .ghost() + .small() + .on_press(move |_| dispatch.remove_java_runtime(path.clone())) +- .child(Icon::new(IconType::Trash01).size(14.).color(colors::danger())) ++ .child( ++ Icon::new(IconType::Trash01) ++ .size(14.) ++ .color(colors::danger()), ++ ) + .into_element() + } + +diff --git a/packages/oneclient_app/src/view/app/settings/language.rs b/packages/oneclient_app/src/view/app/settings/language.rs +index baba05a..0248396 100644 +--- a/packages/oneclient_app/src/view/app/settings/language.rs ++++ b/packages/oneclient_app/src/view/app/settings/language.rs +@@ -1,13 +1,11 @@ + use freya::prelude::*; + +-use crate::components::{IconType, Icon}; + use super::settings_page; ++use crate::components::{Icon, IconType}; + use crate::theme::colors; + use crate::view::app::settings::section_header; + +-const LANGUAGES: &[(&str, &str)] = &[ +- ("English", "English (US)"), +-]; ++const LANGUAGES: &[(&str, &str)] = &[("English", "English (US)")]; + + #[derive(PartialEq)] + pub struct SettingsLanguage; +@@ -63,8 +61,11 @@ fn language_row( + .color(colors::fg_secondary()), + ), + ) +- .maybe_child( +- is_selected.then(|| Icon::new(IconType::Check).size(20.).color(colors::brand()).into_element()), +- ) ++ .maybe_child(is_selected.then(|| { ++ Icon::new(IconType::Check) ++ .size(20.) ++ .color(colors::brand()) ++ .into_element() ++ })) + .into_element() + } +diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs +index cc518d2..c5420fb 100644 +--- a/packages/oneclient_app/src/view/app/settings/launcher.rs ++++ b/packages/oneclient_app/src/view/app/settings/launcher.rs +@@ -1,8 +1,8 @@ + use freya::prelude::*; + ++use super::settings_page; + use crate::components::{IconType, link_button, toggle}; + use crate::hooks::{use_dispatch, use_launcher, use_settings_snapshot}; +-use super::settings_page; + use crate::platform; + use crate::view::app::settings::{section_header, settings_row}; + +@@ -20,17 +20,24 @@ impl Component for SettingsLauncher { + move || v + }); + ++ let crash_reporting = use_state({ ++ let v = settings.crash_reporting; ++ move || v ++ }); ++ + let mut first = use_state(|| true); + { + let settings = settings.clone(); + use_side_effect(move || { + let discord = *discord_rpc.read(); ++ let crash = *crash_reporting.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.discord_enabled = discord; ++ next.crash_reporting = crash; + dispatch.set_settings(next); + }); + } +@@ -46,6 +53,12 @@ impl Component for SettingsLauncher { + "Enable Discord Rich Presence.", + toggle(discord_rpc), + )) ++ .child(settings_row( ++ IconType::AlertTriangle, ++ "Crash Reporting", ++ "Send anonymous crash and error reports to help fix bugs. Applies on restart.", ++ toggle(crash_reporting), ++ )) + .child(section_header("FOLDERS AND FILES")) + .child(settings_row( + IconType::Folder, +diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs +index 5b88f8d..bbd2041 100644 +--- a/packages/oneclient_app/src/view/app/settings/minecraft.rs ++++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs +@@ -2,9 +2,9 @@ use freya::prelude::*; + use oneclient_core::Patch; + use oneclient_core::settings::{ProfileUpdate, Resolution}; + ++use super::settings_page; + use crate::components::{Icon, IconType, TextInput, toggle, validate_number}; + use crate::hooks::{use_dispatch, use_settings_snapshot}; +-use super::settings_page; + use crate::theme::colors; + use crate::view::app::settings::{section_header, settings_row}; + +@@ -21,11 +21,17 @@ impl Component for SettingsMinecraft { + move || v + }); + let width = use_state({ +- let v = profile.resolution.map(|r| r.width.to_string()).unwrap_or_default(); ++ let v = profile ++ .resolution ++ .map(|r| r.width.to_string()) ++ .unwrap_or_default(); + move || v + }); + let height = use_state({ +- let v = profile.resolution.map(|r| r.height.to_string()).unwrap_or_default(); ++ let v = profile ++ .resolution ++ .map(|r| r.height.to_string()) ++ .unwrap_or_default(); + move || v + }); + let memory = use_state({ +@@ -165,7 +171,11 @@ fn resolution_field(width: State, height: State) -> impl IntoEle + .width(Size::px(70.)) + .text_align(TextAlign::Center), + ) +- .child(Icon::new(IconType::X).size(14.).color(colors::fg_secondary())) ++ .child( ++ Icon::new(IconType::X) ++ .size(14.) ++ .color(colors::fg_secondary()), ++ ) + .child( + TextInput::new(height) + .placeholder("480") +diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs +index 88e39f5..16a2434 100644 +--- a/packages/oneclient_app/src/view/app/settings/mod.rs ++++ b/packages/oneclient_app/src/view/app/settings/mod.rs +@@ -15,14 +15,14 @@ pub use apis::SettingsApis; + pub use changelog::SettingsChangelog; + pub use developer::SettingsDeveloper; + // pub use feedback::SettingsFeedback; +-pub use java::SettingsJava; + pub use appearance::SettingsAppearance; ++pub use java::SettingsJava; + pub use language::SettingsLanguage; + pub use launcher::SettingsLauncher; + pub use minecraft::SettingsMinecraft; + + use crate::{ +- components::{IconType, Icon}, ++ components::{Icon, IconType}, + theme::colors, + }; + +diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs +index 121faa4..b9f4b39 100644 +--- a/packages/oneclient_app/src/view/app/skins.rs ++++ b/packages/oneclient_app/src/view/app/skins.rs +@@ -1,4 +1,3 @@ +- + use freya::prelude::*; + + use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel}; +@@ -120,7 +119,11 @@ fn side_panel() -> impl IntoElement { + + fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement { + Button::new() +- .variant(if primary { ButtonVariant::Primary } else { ButtonVariant::Secondary }) ++ .variant(if primary { ++ ButtonVariant::Primary ++ } else { ++ ButtonVariant::Secondary ++ }) + .child(Icon::new(icon).size(16.).color(colors::fg_primary())) + .text(text) + .into_element() +diff --git a/packages/oneclient_app/src/view/mod.rs b/packages/oneclient_app/src/view/mod.rs +index cf49c6a..6714e42 100644 +--- a/packages/oneclient_app/src/view/mod.rs ++++ b/packages/oneclient_app/src/view/mod.rs +@@ -1,8 +1,8 @@ + mod not_found; + mod startup; + +-pub mod onboarding; + pub mod app; ++pub mod onboarding; + + pub use not_found::NotFound; +-pub use startup::Startup; +\ No newline at end of file ++pub use startup::Startup; +diff --git a/packages/oneclient_app/src/view/not_found.rs b/packages/oneclient_app/src/view/not_found.rs +index 4edd701..8dfac53 100644 +--- a/packages/oneclient_app/src/view/not_found.rs ++++ b/packages/oneclient_app/src/view/not_found.rs +@@ -32,4 +32,4 @@ impl Component for NotFound { + .color(theme::colors::fg_secondary()), + ) + } +-} +\ No newline at end of file ++} +diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs +index 707bd39..7298282 100644 +--- a/packages/oneclient_app/src/view/onboarding/account.rs ++++ b/packages/oneclient_app/src/view/onboarding/account.rs +@@ -125,12 +125,7 @@ fn sign_in_card( + .size(13.) + .color(colors::danger()), + ) +- .child( +- label() +- .text(message) +- .font_size(12.) +- .color(colors::danger()), +- ) ++ .child(label().text(message).font_size(12.).color(colors::danger())) + .into_element() + })) + .into_element() +diff --git a/packages/oneclient_app/src/view/onboarding/bundles/card.rs b/packages/oneclient_app/src/view/onboarding/bundles/card.rs +index 8398ecf..4263ad2 100644 +--- a/packages/oneclient_app/src/view/onboarding/bundles/card.rs ++++ b/packages/oneclient_app/src/view/onboarding/bundles/card.rs +@@ -2,9 +2,7 @@ use super::*; + + use oneclient_core::clusters::Cluster; + +-use crate::components::{ +- Button, DynamicArt, Icon, IconType, +-}; ++use crate::components::{Button, DynamicArt, Icon, IconType}; + use crate::theme::colors; + use crate::ui::border_all_color; + +diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +index dff095c..78a1643 100644 +--- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs ++++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +@@ -8,8 +8,7 @@ use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; + + use crate::components::ScrollArea; + use crate::hooks::{ +- ClusterBundles, onboarding_bundles_items, +- use_onboarding_bundles, use_onboarding_selection, ++ ClusterBundles, onboarding_bundles_items, use_onboarding_bundles, use_onboarding_selection, + }; + use crate::routes::Route; + use crate::theme::colors; +@@ -321,4 +320,3 @@ impl Component for OnboardingBundles { + .maybe_child(popup) + } + } +- +diff --git a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs b/packages/oneclient_app/src/view/onboarding/bundles/popup.rs +index 5259193..05d888d 100644 +--- a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs ++++ b/packages/oneclient_app/src/view/onboarding/bundles/popup.rs +@@ -6,12 +6,8 @@ use freya::query::QueryStateData; + use oneclient_core::packages::{CachedPackageMeta, ProviderId}; + use oneclient_core::{BundleArchive, BundleFileKind}; + +-use crate::components::{ +- Button, Icon, OverlayPopup, ScrollArea, TabBar, TabItem, +-}; +-use crate::hooks::{ +- package_meta_batch, use_cached_image, use_package_meta_batch, +-}; ++use crate::components::{Button, Icon, OverlayPopup, ScrollArea, TabBar, TabItem}; ++use crate::hooks::{package_meta_batch, use_cached_image, use_package_meta_batch}; + use crate::theme::colors; + use crate::ui::border_all_color; + use crate::utils::format_size; +diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +index 5e9dcc9..ff5d2c9 100644 +--- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs ++++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +@@ -46,7 +46,10 @@ pub(super) fn summary_view( + + let migration_section: Element = match migration { + Some((source, folder, target)) => { +- let mut rows = vec![summary_line("From", &source), summary_line("Files", &folder)]; ++ let mut rows = vec![ ++ summary_line("From", &source), ++ summary_line("Files", &folder), ++ ]; + if !target.is_empty() { + rows.push(summary_line("Destination", &target)); + } +diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs +index 8025652..75b15ad 100644 +--- a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs ++++ b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs +@@ -50,7 +50,14 @@ pub(super) fn use_onboarding_tip() -> String { + let Ok(state) = oneclient_core::LauncherState::get() else { + return; + }; +- match state.services.requester.http().get(FUNFACTS_URL).send().await { ++ match state ++ .services ++ .requester ++ .http() ++ .get(FUNFACTS_URL) ++ .send() ++ .await ++ { + Ok(res) if res.status().is_success() => match res.text().await { + Ok(text) => { + let parsed = parse_funfacts(&text); +diff --git a/packages/oneclient_app/src/view/onboarding/language.rs b/packages/oneclient_app/src/view/onboarding/language.rs +index c4bb80a..7166f53 100644 +--- a/packages/oneclient_app/src/view/onboarding/language.rs ++++ b/packages/oneclient_app/src/view/onboarding/language.rs +@@ -14,9 +14,10 @@ struct Language { + percentage: u32, + } + +-const LANGUAGES: [Language; 1] = [ +- Language { name: "English", percentage: 100 }, +-]; ++const LANGUAGES: [Language; 1] = [Language { ++ name: "English", ++ percentage: 100, ++}]; + + #[derive(PartialEq)] + pub struct OnboardingLanguage; +@@ -29,15 +30,13 @@ impl Component for OnboardingLanguage { + let back = if has_migration_data(&migration_query) { + Route::OnboardingMigration {} + } else { +- Route::OnboardingWelcome {} ++ Route::OnboardingTerms {} + }; + + let rows: Vec = LANGUAGES + .iter() + .enumerate() +- .map(|(index, lang)| { +- language_row(lang, index == *selected.read(), selected, language) +- }) ++ .map(|(index, lang)| language_row(lang, index == *selected.read(), selected, language)) + .collect(); + + let content = rect() +@@ -55,7 +54,13 @@ impl Component for OnboardingLanguage { + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) +- .child(rect().vertical().width(Size::fill()).spacing(4.).children(rows)) ++ .child( ++ rect() ++ .vertical() ++ .width(Size::fill()) ++ .spacing(4.) ++ .children(rows), ++ ) + .into_element(); + + onboarding_page( +@@ -92,7 +97,9 @@ fn language_row( + .padding(Gaps::new_symmetric(14., 20.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(background) +- .maybe(active, |el| el.border(border_all_color(1., colors::brand()))) ++ .maybe(active, |el| { ++ el.border(border_all_color(1., colors::brand())) ++ }) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) +diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs +index aca061c..f457a13 100644 +--- a/packages/oneclient_app/src/view/onboarding/migration.rs ++++ b/packages/oneclient_app/src/view/onboarding/migration.rs +@@ -27,9 +27,10 @@ fn build_migrated_selection(source: &[SourceInstance], new: &[ClusterBundles]) - + let mut selected = HashSet::new(); + + for cb in new { +- let Some(instance) = source.iter().find(|o| { +- o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader +- }) else { ++ let Some(instance) = source ++ .iter() ++ .find(|o| o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader) ++ else { + continue; + }; + +@@ -89,7 +90,10 @@ impl Component for OnboardingMigration { + // No detection yet -> render nothing meaningful; the shell only routes + // here when a source was detected, so this is a transient loading state. + let Some(detection) = migration_detection(&migration_query) else { +- return rect().width(Size::fill()).height(Size::fill()).into_element(); ++ return rect() ++ .width(Size::fill()) ++ .height(Size::fill()) ++ .into_element(); + }; + let source_name = detection.source.display_name(); + let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); +@@ -103,8 +107,7 @@ impl Component for OnboardingMigration { + let mut version_cards: Vec = Vec::new(); + for instance in &detection.instances { + let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str()); +- let dedicated_available = +- matching_new_cluster_id(instance, &new_clusters).is_some(); ++ let dedicated_available = matching_new_cluster_id(instance, &new_clusters).is_some(); + + version_cards.push(version_card( + instance, +@@ -259,26 +262,16 @@ fn version_card( + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) +- .child( +- label() +- .text(subtitle) +- .font_size(11.) +- .color(if selected { +- colors::fg_primary() +- } else { +- colors::fg_secondary() +- }), +- ) +- .child( +- label() +- .text(categories) +- .font_size(11.) +- .color(if selected { +- colors::fg_primary() +- } else { +- colors::fg_secondary() +- }), +- ), ++ .child(label().text(subtitle).font_size(11.).color(if selected { ++ colors::fg_primary() ++ } else { ++ colors::fg_secondary() ++ })) ++ .child(label().text(categories).font_size(11.).color(if selected { ++ colors::fg_primary() ++ } else { ++ colors::fg_secondary() ++ })), + ) + // Selection affordance only when the instance actually has files. + .maybe_child(importable.then(|| { +@@ -471,7 +464,7 @@ fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl In + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { +- let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); ++ let _ = RouterContext::get().replace(Route::OnboardingTerms {}); + }) + .text("Back"), + ) +diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs +index d9e14f9..b7403f1 100644 +--- a/packages/oneclient_app/src/view/onboarding/mod.rs ++++ b/packages/oneclient_app/src/view/onboarding/mod.rs +@@ -4,6 +4,7 @@ mod downloading; + mod language; + mod migration; + mod preferences; ++mod terms; + mod welcome; + + pub use account::OnboardingAccount; +@@ -13,6 +14,7 @@ pub use language::OnboardingLanguage; + pub use migration::OnboardingMigration; + pub(crate) use migration::matching_new_cluster_id; + pub use preferences::OnboardingPreferences; ++pub use terms::OnboardingTerms; + pub use welcome::OnboardingWelcome; + + use std::sync::atomic::{AtomicUsize, Ordering}; +@@ -30,7 +32,7 @@ use crate::ui::{border_all_color, entrance_motion_layer}; + /// Number of onboarding steps. The v1-migration step only exists (and is only + /// counted) when old launcher data was detected. + pub fn onboarding_total(has_migration: bool) -> usize { +- if has_migration { 7 } else { 6 } ++ if has_migration { 8 } else { 7 } + } + + pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { +@@ -38,12 +40,13 @@ pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { + + match route { + Route::OnboardingWelcome {} => 0, +- Route::OnboardingMigration {} => 1, +- Route::OnboardingLanguage {} => 1 + shift, +- Route::OnboardingAccount {} => 2 + shift, +- Route::OnboardingBundles {} => 3 + shift, +- Route::OnboardingPreferences {} => 4 + shift, +- Route::OnboardingDownloading {} => 5 + shift, ++ Route::OnboardingTerms {} => 1, ++ Route::OnboardingMigration {} => 2, ++ Route::OnboardingLanguage {} => 2 + shift, ++ Route::OnboardingAccount {} => 3 + shift, ++ Route::OnboardingBundles {} => 4 + shift, ++ Route::OnboardingPreferences {} => 5 + shift, ++ Route::OnboardingDownloading {} => 6 + shift, + _ => 0, + } + } +@@ -80,8 +83,16 @@ pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { + } + }); + +- let p = if anim_finished { 1.0 } else { anim.get().value() }; +- let slide_x = if motion { direction * (1. - p) * SLIDE_DISTANCE } else { 0. }; ++ let p = if anim_finished { ++ 1.0 ++ } else { ++ anim.get().value() ++ }; ++ let slide_x = if motion { ++ direction * (1. - p) * SLIDE_DISTANCE ++ } else { ++ 0. ++ }; + let opacity = if motion && !anim_finished { p } else { 1. }; + + rect() +@@ -91,7 +102,11 @@ pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { + .into_element() + } + +-pub(crate) fn onboarding_page(illustration: impl IntoElement, content: impl IntoElement, nav: impl IntoElement) -> impl IntoElement { ++pub(crate) fn onboarding_page( ++ illustration: impl IntoElement, ++ content: impl IntoElement, ++ nav: impl IntoElement, ++) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) +@@ -130,7 +145,11 @@ pub(crate) fn onboarding_illustration(icon: IconType) -> impl IntoElement { + Icon::new(icon).size(240.).color(Color::WHITE) + } + +-pub(crate) fn onboarding_nav(back: Option, next: Route, next_enabled: bool) -> impl IntoElement { ++pub(crate) fn onboarding_nav( ++ back: Option, ++ next: Route, ++ next_enabled: bool, ++) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) +diff --git a/packages/oneclient_app/src/view/onboarding/terms.rs b/packages/oneclient_app/src/view/onboarding/terms.rs +new file mode 100644 +index 0000000..7b4d746 +--- /dev/null ++++ b/packages/oneclient_app/src/view/onboarding/terms.rs +@@ -0,0 +1,304 @@ ++use freya::prelude::*; ++use freya::query::UseQuery; ++use freya::router::RouterContext; ++ ++use crate::components::{Button, Icon, IconType, ScrollArea, Segment, SegmentedControl, toggle}; ++use crate::hooks::{ ++ TermsQuery, has_migration_data, terms_document, terms_error, terms_is_loading, use_dispatch, ++ use_migration, use_settings_snapshot, use_terms, ++}; ++use crate::platform::open_url; ++use crate::routes::Route; ++use crate::theme::colors; ++use crate::ui::border_all_color; ++use crate::view::onboarding::{onboarding_illustration, onboarding_page, step_heading}; ++ ++#[derive(Clone, Copy, PartialEq)] ++enum LegalTab { ++ Terms, ++ Privacy, ++} ++ ++#[derive(PartialEq)] ++pub struct OnboardingTerms; ++ ++impl Component for OnboardingTerms { ++ fn render(&self) -> impl IntoElement { ++ let settings = use_settings_snapshot().settings; ++ let dispatch = use_dispatch(); ++ let query = use_terms(); ++ let migration_query = use_migration(); ++ ++ let document = terms_document(&query); ++ let error = terms_error(&query); ++ let loading = terms_is_loading(&query); ++ ++ let accepted = use_state(|| false); ++ let tab = use_state(|| LegalTab::Terms); ++ ++ let returning = settings.seen_onboarding; ++ let next = if returning { ++ Route::Home {} ++ } else if has_migration_data(&migration_query) { ++ Route::OnboardingMigration {} ++ } else { ++ Route::OnboardingLanguage {} ++ }; ++ let back = (!returning).then_some(Route::OnboardingWelcome {}); ++ ++ let terms_version = document.as_ref().map(|doc| doc.version).unwrap_or(1); ++ let privacy_version = document ++ .as_ref() ++ .map(|doc| doc.privacy_version()) ++ .unwrap_or(1); ++ let terms_url = document ++ .as_ref() ++ .map(|doc| doc.terms_url().to_string()) ++ .unwrap_or_else(|| oneclient_core::constants::TOS_URL.to_string()); ++ let privacy_url = document ++ .as_ref() ++ .map(|doc| doc.privacy_url().to_string()) ++ .unwrap_or_else(|| oneclient_core::constants::PRIVACY_URL.to_string()); ++ ++ let privacy_body = document.as_ref().and_then(|doc| doc.privacy_body()); ++ let tabs = privacy_body.is_some().then(|| tab_switcher(tab)); ++ ++ let body = if loading { ++ loading_body() ++ } else if let Some(document) = document.as_ref() { ++ match (*tab.read(), privacy_body) { ++ (LegalTab::Privacy, Some(privacy)) => markdown_body(privacy), ++ _ => markdown_body(&document.terms), ++ } ++ } else { ++ fallback_body(error.as_deref(), query) ++ }; ++ ++ let content = rect() ++ .vertical() ++ .width(Size::fill()) ++ .spacing(16.) ++ .child(step_heading( ++ "Terms & Privacy", ++ "Please read and accept these before continuing.", ++ )) ++ .maybe_child(tabs) ++ .child(body) ++ .child(link_row(terms_url, privacy_url)) ++ .child(accept_row(accepted)) ++ .into_element(); ++ ++ onboarding_page( ++ onboarding_illustration(IconType::File02), ++ content, ++ terms_nav(back, *accepted.read() && !loading, move || { ++ dispatch.accept_tos(terms_version, privacy_version); ++ let _ = RouterContext::get().replace(next.clone()); ++ }), ++ ) ++ } ++} ++ ++fn panel(child: impl IntoElement) -> impl IntoElement { ++ rect() ++ .vertical() ++ .width(Size::fill()) ++ .height(Size::px(220.)) ++ .content(Content::Flex) ++ .corner_radius(CornerRadius::new_all(12.)) ++ .background(colors::page_elevated()) ++ .border(border_all_color(1., colors::component_border())) ++ .child(child) ++} ++ ++fn loading_body() -> Element { ++ panel( ++ rect() ++ .width(Size::fill()) ++ .height(Size::fill()) ++ .center() ++ .child( ++ label() ++ .text("Loading the Terms of Service and Privacy Policy...") ++ .font_size(13.) ++ .color(colors::fg_secondary()), ++ ), ++ ) ++ .into_element() ++} ++ ++fn tab_switcher(tab: State) -> Element { ++ SegmentedControl::new(tab) ++ .segment(Segment::new(LegalTab::Terms).label("Terms")) ++ .segment(Segment::new(LegalTab::Privacy).label("Privacy")) ++ .into_element() ++} ++ ++fn markdown_body(markdown: &str) -> Element { ++ panel( ++ ScrollArea::new() ++ .width(Size::fill()) ++ .height(Size::flex(1.0)) ++ .padding(Gaps::new_all(16.)) ++ .child( ++ MarkdownViewer::new(markdown.to_string()) ++ .width(Size::fill()) ++ .color(colors::fg_primary()) ++ .color_link(colors::code_info()) ++ .background_code(colors::component_bg()) ++ .color_code(colors::fg_primary()) ++ .background_blockquote(colors::component_bg()) ++ .border_blockquote(colors::brand()) ++ .background_divider(colors::component_border()) ++ .heading_h1(20.) ++ .heading_h2(17.) ++ .heading_h3(15.) ++ .heading_h4(14.) ++ .heading_h5(13.) ++ .heading_h6(12.) ++ .paragraph_size(13.) ++ .code_font_size(12.), ++ ), ++ ) ++ .into_element() ++} ++ ++fn fallback_body(error: Option<&str>, query: UseQuery) -> Element { ++ panel( ++ rect() ++ .vertical() ++ .width(Size::fill()) ++ .height(Size::fill()) ++ .center() ++ .spacing(10.) ++ .padding(Gaps::new_all(20.)) ++ .child( ++ Icon::new(IconType::AlertTriangle) ++ .size(22.) ++ .color(colors::code_warn()), ++ ) ++ .child( ++ label() ++ .text("Couldn't load the Terms of Service or Privacy Policy.") ++ .font_size(14.) ++ .font_weight(FontWeight::MEDIUM) ++ .color(colors::fg_primary()), ++ ) ++ .child( ++ label() ++ .text( ++ "Open them in your browser using the links below, or try again once \ ++ you're back online.", ++ ) ++ .font_size(12.) ++ .text_align(TextAlign::Center) ++ .color(colors::fg_secondary()), ++ ) ++ .maybe_child(error.map(|err| { ++ label() ++ .text(err.to_string()) ++ .font_size(11.) ++ .max_lines(2) ++ .color(colors::fg_secondary().with_a(160)) ++ })) ++ .child( ++ Button::new() ++ .secondary() ++ .small() ++ .on_press(move |_| { ++ query.invalidate(); ++ }) ++ .text("Retry") ++ .child(Icon::new(IconType::RefreshCw01).size(14.)), ++ ), ++ ) ++ .into_element() ++} ++ ++fn link_row(terms_url: String, privacy_url: String) -> impl IntoElement { ++ rect() ++ .horizontal() ++ .width(Size::fill()) ++ .spacing(10.) ++ .child(external_link_button("Terms of Service", terms_url)) ++ .child(external_link_button("Privacy Policy", privacy_url)) ++ .into_element() ++} ++ ++fn external_link_button(text: &'static str, url: String) -> impl IntoElement { ++ Button::new() ++ .secondary() ++ .small() ++ .on_press(move |_| open_url(&url)) ++ .text(text) ++ .child(Icon::new(IconType::LinkExternal01).size(14.)) ++} ++ ++fn accept_row(accepted: State) -> impl IntoElement { ++ rect() ++ .horizontal() ++ .width(Size::fill()) ++ .cross_align(Alignment::Center) ++ .spacing(16.) ++ .content(Content::Flex) ++ .padding(Gaps::new_symmetric(12., 16.)) ++ .corner_radius(CornerRadius::new_all(12.)) ++ .background(colors::page_elevated()) ++ .border(border_all_color(1., colors::component_border())) ++ .child( ++ rect() ++ .vertical() ++ .width(Size::flex(1.0)) ++ .spacing(3.) ++ .child( ++ label() ++ .text("I accept the Terms of Service and Privacy Policy") ++ .font_size(14.) ++ .font_weight(FontWeight::MEDIUM) ++ .color(colors::fg_primary()), ++ ) ++ .child( ++ label() ++ .text("Required to use OneClient.") ++ .font_size(11.) ++ .color(colors::fg_secondary()), ++ ), ++ ) ++ .child(toggle(accepted)) ++ .into_element() ++} ++ ++fn terms_nav( ++ back: Option, ++ next_enabled: bool, ++ on_next: impl FnMut() + 'static, ++) -> impl IntoElement { ++ let mut on_next = on_next; ++ rect() ++ .horizontal() ++ .width(Size::fill()) ++ .main_align(Alignment::End) ++ .cross_align(Alignment::Center) ++ .spacing(12.) ++ .padding(Gaps::new(0., 40., 32., 40.)) ++ .maybe_child(back.map(|route| { ++ Button::new() ++ .secondary() ++ .width(Size::px(128.)) ++ .on_press(move |_| { ++ let _ = RouterContext::get().replace(route.clone()); ++ }) ++ .text("Back") ++ .into_element() ++ })) ++ .child( ++ Button::new() ++ .primary() ++ .width(Size::px(140.)) ++ .enabled(next_enabled) ++ .on_press(move |_| on_next()) ++ .text("Next") ++ .child(Icon::new(IconType::ArrowRight).size(16.)), ++ ) ++ .into_element() ++} +diff --git a/packages/oneclient_app/src/view/onboarding/welcome.rs b/packages/oneclient_app/src/view/onboarding/welcome.rs +index 325c883..37ad66e 100644 +--- a/packages/oneclient_app/src/view/onboarding/welcome.rs ++++ b/packages/oneclient_app/src/view/onboarding/welcome.rs +@@ -1,7 +1,6 @@ + use freya::prelude::*; + + use crate::components::IconType; +-use crate::hooks::{has_migration_data, use_migration}; + use crate::routes::Route; + use crate::theme::colors; + use crate::view::onboarding::{ +@@ -13,14 +12,6 @@ pub struct OnboardingWelcome; + + impl Component for OnboardingWelcome { + fn render(&self) -> impl IntoElement { +- let migration_query = use_migration(); +- +- let next = if has_migration_data(&migration_query) { +- Route::OnboardingMigration {} +- } else { +- Route::OnboardingLanguage {} +- }; +- + let content = rect() + .vertical() + .width(Size::fill()) +@@ -40,7 +31,7 @@ impl Component for OnboardingWelcome { + onboarding_page( + onboarding_illustration(IconType::OnboardingWelcome), + content, +- onboarding_nav(None, next, true), ++ onboarding_nav(None, Route::OnboardingTerms {}, true), + ) + } + } +diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs +index e5540bf..db41121 100644 +--- a/packages/oneclient_app/src/view/startup.rs ++++ b/packages/oneclient_app/src/view/startup.rs +@@ -3,7 +3,10 @@ use freya::{prelude::*, router::*}; + + use crate::AppAssets; + use crate::Route; +-use crate::hooks::{use_launcher, use_notifications_snapshot, use_settings_snapshot}; ++use crate::hooks::{ ++ terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, ++ use_settings_snapshot, use_terms, ++}; + use crate::theme::colors; + + #[derive(PartialEq)] +@@ -14,6 +17,7 @@ impl Component for Startup { + let launcher = use_launcher(); + let settings = use_settings_snapshot(); + let notifications = use_notifications_snapshot(); ++ let terms = use_terms(); + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); +@@ -34,37 +38,49 @@ impl Component for Startup { + + let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); + +- if launcher.ready && !launcher.fetching { +- let destination = if settings.settings.seen_onboarding { +- Route::Home {} +- } else { ++ if launcher.ready && !launcher.fetching && !terms_is_loading(&terms) { ++ let document = terms_document(&terms); ++ let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1); ++ let required_privacy = document ++ .as_ref() ++ .map(|doc| doc.privacy_version()) ++ .unwrap_or(1); ++ ++ let stale = settings.settings.accepted_tos_version < required_terms ++ || settings.settings.accepted_privacy_version < required_privacy; ++ ++ let destination = if !settings.settings.seen_onboarding { + Route::OnboardingWelcome {} ++ } else if stale { ++ Route::OnboardingTerms {} ++ } else { ++ Route::Home {} + }; + let _ = RouterContext::get().replace(destination); + return rect().into_element(); + } + +- let active = notifications +- .inbox +- .iter() +- .find(|entry| entry.is_loading); +- +- let (message, detail): (String, Option) = if let Some(err) = launcher.error.as_deref() { +- ("Couldn't start OneClient".to_string(), Some(err.to_string())) +- +- } else if let Some(entry) = active { +- let detail = entry +- .tasks +- .iter() +- .find(|t| t.total == 0 || t.current < t.total) +- .map(|t| t.label.clone()) +- .or_else(|| entry.progress.map(|(c, t)| format!("{c} / {t}"))); +- (entry.title.clone(), detail) +- } else if launcher.ready { +- ("Fetching versions and bundles...".to_string(), None) +- } else { +- ("Starting OneClient...".to_string(), None) +- }; ++ let active = notifications.inbox.iter().find(|entry| entry.is_loading); ++ ++ let (message, detail): (String, Option) = ++ if let Some(err) = launcher.error.as_deref() { ++ ( ++ "Couldn't start OneClient".to_string(), ++ Some(err.to_string()), ++ ) ++ } else if let Some(entry) = active { ++ let detail = entry ++ .tasks ++ .iter() ++ .find(|t| t.total == 0 || t.current < t.total) ++ .map(|t| t.label.clone()) ++ .or_else(|| entry.progress.map(|(c, t)| format!("{c} / {t}"))); ++ (entry.title.clone(), detail) ++ } else if launcher.ready { ++ ("Fetching versions and bundles...".to_string(), None) ++ } else { ++ ("Starting OneClient...".to_string(), None) ++ }; + + let is_error = launcher.error.is_some(); + +diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml +index 89cfd4d..e788dd2 100644 +--- a/packages/oneclient_core/Cargo.toml ++++ b/packages/oneclient_core/Cargo.toml +@@ -56,9 +56,17 @@ path = "examples/launch_game.rs" + name = "notifications" + path = "examples/notifications.rs" + ++[[example]] ++name = "discord_rpc" ++path = "examples/discord_rpc.rs" ++ ++[dev-dependencies] ++async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } ++ + [dependencies] + # core (extra) + # gamemode_rs.workspace = true ++discord-rich-presence.workspace = true + interfrost.workspace = true + oneclient_db.workspace = true + polyio.workspace = true +@@ -68,6 +76,9 @@ tracing.workspace = true + tracing-futures.workspace = true + tracing-subscriber.workspace = true + ++sentry.workspace = true ++sentry-tracing.workspace = true ++ + # database + sqlx.workspace = true + +@@ -76,9 +87,10 @@ async-trait.workspace = true + tokio.workspace = true + tokio-stream.workspace = true + tokio-util.workspace = true +-futures-util = { workspace = true, features = ["std"] } ++futures-util = { workspace = true, features = ["std", "sink"] } + futures-lite.workspace = true + reqwest.workspace = true ++reqwest-websocket.workspace = true + async_zip.workspace = true + async-tempfile.workspace = true + async-stream.workspace = true +diff --git a/packages/oneclient_core/examples/discord_rpc.rs b/packages/oneclient_core/examples/discord_rpc.rs +new file mode 100644 +index 0000000..b2fa726 +--- /dev/null ++++ b/packages/oneclient_core/examples/discord_rpc.rs +@@ -0,0 +1,33 @@ ++use std::time::Duration; ++ ++use oneclient_core::discord::{DiscordRpc, Presence}; ++ ++fn main() { ++ tracing_subscriber::fmt() ++ .with_max_level(tracing::Level::DEBUG) ++ .init(); ++ ++ let rpc = DiscordRpc::spawn(true); ++ ++ println!("idle presence for 10s..."); ++ std::thread::sleep(Duration::from_secs(10)); ++ ++ println!("playing presence for 10s..."); ++ rpc.set_presence(Presence::Playing { ++ cluster: "Example Cluster".to_owned(), ++ mc_version: "1.8.9".to_owned(), ++ }); ++ std::thread::sleep(Duration::from_secs(10)); ++ ++ println!("disabling for 5s..."); ++ rpc.set_enabled(false); ++ std::thread::sleep(Duration::from_secs(5)); ++ ++ println!("re-enabling for 10s..."); ++ rpc.set_enabled(true); ++ std::thread::sleep(Duration::from_secs(10)); ++ ++ println!("shutting down"); ++ rpc.shutdown(); ++ std::thread::sleep(Duration::from_secs(1)); ++} +diff --git a/packages/oneclient_core/src/constants.rs b/packages/oneclient_core/src/constants.rs +index ee2411d..6d01a60 100644 +--- a/packages/oneclient_core/src/constants.rs ++++ b/packages/oneclient_core/src/constants.rs +@@ -16,6 +16,14 @@ pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; + pub const SKYCLIENT_BASE_URL: &str = + "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; + pub const META_URL_BASE: &str = "https://data-v2.polyfrost.org"; ++pub const TOS_URL: &str = "https://polyfrost.org/legal/terms"; ++pub const PRIVACY_URL: &str = "https://polyfrost.org/legal/privacy"; ++pub const PLUS_BACKEND_URL: &str = "https://plus.polyfrost.org"; ++ ++pub const SENTRY_DSN: &str = match option_env!("ONECLIENT_SENTRY_DSN") { ++ Some(dsn) => dsn, ++ None => "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040", ++}; + + pub const TARGET_OS: &str = cfg_select! { + target_os = "windows" => "windows", +diff --git a/packages/oneclient_core/src/crypto.rs b/packages/oneclient_core/src/crypto.rs +index 52f04cd..8bc68f5 100644 +--- a/packages/oneclient_core/src/crypto.rs ++++ b/packages/oneclient_core/src/crypto.rs +@@ -33,7 +33,7 @@ pub fn normalize_hash(hash: &str) -> String { + hash.trim().to_ascii_lowercase() + } + +-fn to_hex(data: &[u8]) -> String { ++pub(crate) fn to_hex(data: &[u8]) -> String { + data.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out +diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs +index 3e6595d..97dbffa 100644 +--- a/packages/oneclient_core/src/dev.rs ++++ b/packages/oneclient_core/src/dev.rs +@@ -239,6 +239,7 @@ pub async fn ephemeral_state() -> LauncherResult> { + versions: Arc::new(crate::versions::VersionsManager::new()), + images: crate::images::ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), ++ discord: crate::discord::DiscordRpc::spawn(false), + })) + } + +diff --git a/packages/oneclient_core/src/discord.rs b/packages/oneclient_core/src/discord.rs +new file mode 100644 +index 0000000..ac18739 +--- /dev/null ++++ b/packages/oneclient_core/src/discord.rs +@@ -0,0 +1,226 @@ ++use std::sync::mpsc::{self, RecvTimeoutError, Sender}; ++use std::time::{Duration, Instant}; ++ ++use discord_rich_presence::activity::{Activity, Assets, Button, Timestamps}; ++use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; ++ ++use crate::constants::DISCORD_CLIENT_ID; ++ ++const RECONNECT_BACKOFF: Duration = Duration::from_secs(60); ++ ++const IDLE_POLL: Duration = Duration::from_secs(3600); ++ ++const LARGE_IMAGE: &str = "onelauncher_logo_512"; ++ ++#[derive(Debug, Clone, PartialEq, Eq, Default)] ++pub enum Presence { ++ #[default] ++ Idle, ++ Playing { ++ cluster: String, ++ mc_version: String, ++ }, ++} ++ ++impl Presence { ++ fn details(&self) -> &'static str { ++ match self { ++ Self::Idle => "Idle", ++ Self::Playing { .. } => "Playing Minecraft", ++ } ++ } ++ ++ fn state(&self) -> String { ++ match self { ++ Self::Idle => "In the launcher".to_owned(), ++ Self::Playing { ++ cluster, ++ mc_version, ++ } => format!("{cluster} ({mc_version})"), ++ } ++ } ++} ++ ++enum Command { ++ SetPresence(Presence), ++ SetEnabled(bool), ++ Shutdown, ++} ++ ++#[derive(Clone)] ++pub struct DiscordRpc { ++ tx: Sender, ++} ++ ++impl DiscordRpc { ++ pub fn spawn(enabled: bool) -> Self { ++ let (tx, rx) = mpsc::channel(); ++ ++ std::thread::Builder::new() ++ .name("discord-rpc".to_owned()) ++ .spawn(move || Worker::new(enabled).run(&rx)) ++ .expect("failed to spawn the Discord RPC thread"); ++ ++ Self { tx } ++ } ++ ++ pub fn set_presence(&self, presence: Presence) { ++ self.send(Command::SetPresence(presence)); ++ } ++ ++ pub fn set_enabled(&self, enabled: bool) { ++ self.send(Command::SetEnabled(enabled)); ++ } ++ ++ pub fn shutdown(&self) { ++ self.send(Command::Shutdown); ++ } ++ ++ fn send(&self, command: Command) { ++ if self.tx.send(command).is_err() { ++ tracing::debug!("Discord RPC worker is gone; dropping command"); ++ } ++ } ++} ++ ++struct Worker { ++ client: DiscordIpcClient, ++ enabled: bool, ++ connected: bool, ++ ever_connected: bool, ++ presence: Presence, ++ presence_since: i64, ++ next_attempt: Instant, ++} ++ ++impl Worker { ++ fn new(enabled: bool) -> Self { ++ Self { ++ client: DiscordIpcClient::new(DISCORD_CLIENT_ID), ++ enabled, ++ connected: false, ++ ever_connected: false, ++ presence: Presence::Idle, ++ presence_since: chrono::Utc::now().timestamp(), ++ next_attempt: Instant::now(), ++ } ++ } ++ ++ fn run(mut self, rx: &mpsc::Receiver) { ++ self.publish(); ++ ++ loop { ++ match rx.recv_timeout(self.poll_interval()) { ++ Ok(Command::SetPresence(presence)) => self.set_presence(presence), ++ Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), ++ Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, ++ Err(RecvTimeoutError::Timeout) => self.publish(), ++ } ++ } ++ ++ self.disconnect(); ++ } ++ ++ fn poll_interval(&self) -> Duration { ++ if !self.enabled || self.connected { ++ return IDLE_POLL; ++ } ++ ++ self.next_attempt ++ .saturating_duration_since(Instant::now()) ++ .max(Duration::from_millis(100)) ++ } ++ ++ fn set_presence(&mut self, presence: Presence) { ++ if self.presence == presence { ++ return; ++ } ++ ++ self.presence = presence; ++ self.presence_since = chrono::Utc::now().timestamp(); ++ self.publish(); ++ } ++ ++ fn set_enabled(&mut self, enabled: bool) { ++ if self.enabled == enabled { ++ return; ++ } ++ ++ self.enabled = enabled; ++ ++ if enabled { ++ self.next_attempt = Instant::now(); ++ self.publish(); ++ } else { ++ self.disconnect(); ++ } ++ } ++ ++ fn publish(&mut self) { ++ if !self.enabled || !self.ensure_connected() { ++ return; ++ } ++ ++ let state = self.presence.state(); ++ let activity = Activity::new() ++ .details(self.presence.details()) ++ .state(&state) ++ .timestamps(Timestamps::new().start(self.presence_since)) ++ .assets( ++ Assets::new() ++ .large_image(LARGE_IMAGE) ++ .large_text("OneClient"), ++ ) ++ .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); ++ ++ if let Err(err) = self.client.set_activity(activity) { ++ tracing::warn!("Discord RPC set_activity failed: {err}"); ++ self.drop_connection(); ++ } ++ } ++ ++ fn ensure_connected(&mut self) -> bool { ++ if self.connected { ++ return true; ++ } ++ ++ if Instant::now() < self.next_attempt { ++ return false; ++ } ++ self.next_attempt = Instant::now() + RECONNECT_BACKOFF; ++ ++ let result = if self.ever_connected { ++ self.client.reconnect() ++ } else { ++ self.client.connect() ++ }; ++ ++ match result { ++ Ok(()) => { ++ tracing::info!("Discord RPC connected"); ++ self.connected = true; ++ self.ever_connected = true; ++ true ++ } ++ Err(err) => { ++ tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); ++ false ++ } ++ } ++ } ++ ++ fn drop_connection(&mut self) { ++ self.connected = false; ++ self.next_attempt = Instant::now() + RECONNECT_BACKOFF; ++ } ++ ++ fn disconnect(&mut self) { ++ if !self.connected { ++ return; ++ } ++ ++ let _ = self.client.clear_activity(); ++ let _ = self.client.close(); ++ self.connected = false; ++ } ++} +diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs +index 08cbe32..cc0b801 100644 +--- a/packages/oneclient_core/src/game/launch.rs ++++ b/packages/oneclient_core/src/game/launch.rs +@@ -12,6 +12,7 @@ use tokio::sync::Mutex as AsyncMutex; + use crate::auth::MinecraftAccount; + use crate::ClusterStage; + use crate::clusters::ClusterManager; ++use crate::discord::Presence; + use crate::game::{ + GameError, arguments, download_minecraft, download_version_info, get_loader_version, + libraries_missing, resolve_minecraft_version, +@@ -298,6 +299,10 @@ pub async fn launch_cluster( + stage(LaunchStage::Running); + state.games.set_pid(cluster_id, pid); + state.games.set_dir(cluster_id, cwd.clone()); ++ state.discord.set_presence(Presence::Playing { ++ cluster: cluster.name.clone(), ++ mc_version: cluster.mc_version.clone(), ++ }); + + let log_path = crate::logs::cluster_output_log(&cluster)?; + if let Some(parent) = log_path.parent() { +@@ -344,6 +349,10 @@ pub async fn launch_cluster( + state.games.remove(cluster_id); + state.services.notifier.game_stage(cluster_id, LaunchStage::Exited); + ++ if state.games.running_ids().is_empty() { ++ state.discord.set_presence(Presence::Idle); ++ } ++ + if played > Duration::from_secs(1) { + let _ = ClusterManager::add_playtime(&state, cluster_id, played).await; + } +diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs +index 1cc5b30..b90f3ba 100644 +--- a/packages/oneclient_core/src/game/shared_dir.rs ++++ b/packages/oneclient_core/src/game/shared_dir.rs +@@ -154,7 +154,7 @@ pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { + Ok(()) + } + +-const EMPTY_NOTE_NAME: &str = "PLEASE_READ_ME.txt"; ++const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; + + async fn clear_content_files(dir: &Path) { + let Ok(mut entries) = polyio::read_dir(dir).await else { +diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs +index f01bb60..e10f5cb 100644 +--- a/packages/oneclient_core/src/lib.rs ++++ b/packages/oneclient_core/src/lib.rs +@@ -10,6 +10,7 @@ pub mod changelog; + pub mod clusters; + pub mod constants; + pub mod crypto; ++pub mod discord; + mod error; + pub mod game; + pub mod http; +@@ -25,10 +26,13 @@ pub mod os_ext; + pub mod packages; + pub mod patch; + pub mod paths; ++pub mod plus; + pub mod recovery; ++pub mod reporting; + pub mod screenshots; + pub mod settings; + pub mod status; ++pub mod tos; + mod state; + pub mod version; + pub mod versions; +@@ -42,6 +46,8 @@ pub use bundles::{ + BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, + }; + pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup}; ++pub use tos::{fetch_terms, TermsDocument}; ++pub use discord::{DiscordRpc, Presence}; + pub use clusters::{ + Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, + ensure_from_bundles, ensure_from_versions, estimate_cluster_download, +diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs +index 94adf4d..7f2a554 100644 +--- a/packages/oneclient_core/src/logger.rs ++++ b/packages/oneclient_core/src/logger.rs +@@ -22,11 +22,14 @@ pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { + + let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout); + ++ let sentry_layer = sentry_tracing::layer(); ++ + #[cfg(debug_assertions)] + { + tracing_subscriber::registry() + .with(filter) + .with(stdout_layer) ++ .with(sentry_layer) + .init(); + } + +@@ -50,6 +53,7 @@ pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { + .with(filter) + .with(stdout_layer) + .with(file_layer) ++ .with(sentry_layer) + .init(); + + tracing::info!(path = %log_path.display(), "writing logs to file"); +diff --git a/packages/oneclient_core/src/logs/manage.rs b/packages/oneclient_core/src/logs/manage.rs +index 1e3ae03..2b41edf 100644 +--- a/packages/oneclient_core/src/logs/manage.rs ++++ b/packages/oneclient_core/src/logs/manage.rs +@@ -87,13 +87,22 @@ pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> + + push_file( + cluster_output_log(cluster)?, +- "Launcher output".to_string(), ++ "Game output".to_string(), + LogKind::Game { + cluster_id: cluster.id, + }, + &mut out, + ); + ++ if let Ok(logs_dir) = paths::logs_dir() { ++ collect_dir( ++ &logs_dir, ++ LogKind::Launcher, ++ |name| name.ends_with(".log") || name.ends_with(".log.gz"), ++ &mut out, ++ ); ++ } ++ + out.sort_by_key(|info| std::cmp::Reverse(info.modified)); + Ok(out) + } +diff --git a/packages/oneclient_core/src/logs/mod.rs b/packages/oneclient_core/src/logs/mod.rs +index bd20e1d..135bf75 100644 +--- a/packages/oneclient_core/src/logs/mod.rs ++++ b/packages/oneclient_core/src/logs/mod.rs +@@ -15,6 +15,7 @@ pub use parse::parse_level; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum LogKind { + Game { cluster_id: i64 }, ++ Launcher, + Minecraft, + CrashReport, + Other, +diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs +index 93970ec..9956153 100644 +--- a/packages/oneclient_core/src/migration/mod.rs ++++ b/packages/oneclient_core/src/migration/mod.rs +@@ -58,7 +58,7 @@ pub struct MigrationDetection { + /// Where an imported game directory should land in *this* launcher. + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub enum ImportTarget { +- /// Copy into the shared `/minecraft`. ++ /// Copy into the shared `/.minecraft`. + Shared, + /// Copy into a specific new cluster's own folder and mark it dedicated so + /// `Cluster::game_dir()` resolves there instead of the shared dir. +diff --git a/packages/oneclient_core/src/packages/store/mod.rs b/packages/oneclient_core/src/packages/store/mod.rs +index 1291116..8e1ae8f 100644 +--- a/packages/oneclient_core/src/packages/store/mod.rs ++++ b/packages/oneclient_core/src/packages/store/mod.rs +@@ -346,16 +346,18 @@ fn ensure_compatible( + return Ok(()); + } + +- let cluster_loader = +- GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); +- +- if !version.loaders.is_empty() +- && !version +- .loaders +- .iter() +- .any(|l| cluster_loader.compatible_with(*l)) +- { +- return Err(PackageError::IncompatibleLoader.into()); ++ if project.content_type == ContentType::Mod { ++ let cluster_loader = ++ GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); ++ ++ if !version.loaders.is_empty() ++ && !version ++ .loaders ++ .iter() ++ .any(|l| cluster_loader.compatible_with(*l)) ++ { ++ return Err(PackageError::IncompatibleLoader.into()); ++ } + } + + if !version.game_versions.is_empty() +@@ -369,3 +371,105 @@ fn ensure_compatible( + + Ok(()) + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use crate::packages::types::PackageBody; ++ use chrono::Utc; ++ ++ fn project(content_type: ContentType) -> ProjectDetail { ++ ProjectDetail { ++ id: "p".into(), ++ slug: "p".into(), ++ provider: ProviderId::Modrinth, ++ content_type, ++ name: "p".into(), ++ summary: String::new(), ++ author: String::new(), ++ members: Vec::new(), ++ gallery: Vec::new(), ++ body: PackageBody::Raw(String::new()), ++ license: None, ++ links: Vec::new(), ++ version_ids: Vec::new(), ++ game_versions: Vec::new(), ++ loaders: Vec::new(), ++ icon_url: None, ++ created: Utc::now(), ++ updated: Utc::now(), ++ downloads: 0, ++ } ++ } ++ ++ fn version(loaders: Vec, game_versions: Vec<&str>) -> VersionDetail { ++ VersionDetail { ++ version_id: "v".into(), ++ project_id: "p".into(), ++ name: "v".into(), ++ version_number: "1".into(), ++ changelog: None, ++ game_versions: game_versions.into_iter().map(Into::into).collect(), ++ loaders, ++ published: Utc::now(), ++ downloads: 0, ++ files: Vec::new(), ++ } ++ } ++ ++ fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { ++ ClusterRow { ++ id: 1, ++ name: "c".into(), ++ folder_name: "c".into(), ++ setting_profile_name: None, ++ mc_version: mc_version.into(), ++ mc_loader: loader as i64, ++ stage: 0, ++ mc_loader_version: None, ++ created_at: None, ++ last_played: None, ++ overall_played: None, ++ linked_modpack_hash: None, ++ } ++ } ++ ++ #[test] ++ fn resource_pack_installs_into_modded_cluster() { ++ let result = ensure_compatible( ++ &project(ContentType::ResourcePack), ++ &version(vec![GameLoader::Vanilla], vec!["1.21.4"]), ++ &cluster(GameLoader::Fabric, "1.21.4"), ++ ); ++ ++ assert!(result.is_ok(), "resource packs are not bound to the loader"); ++ } ++ ++ #[test] ++ fn mod_still_rejects_incompatible_loader() { ++ let result = ensure_compatible( ++ &project(ContentType::Mod), ++ &version(vec![GameLoader::Forge], vec!["1.21.4"]), ++ &cluster(GameLoader::Fabric, "1.21.4"), ++ ); ++ ++ assert!(matches!( ++ result, ++ Err(LauncherError::PackageError(PackageError::IncompatibleLoader)) ++ )); ++ } ++ ++ #[test] ++ fn resource_pack_still_rejects_incompatible_mc_version() { ++ let result = ensure_compatible( ++ &project(ContentType::ResourcePack), ++ &version(vec![GameLoader::Vanilla], vec!["1.7.10"]), ++ &cluster(GameLoader::Fabric, "1.21.4"), ++ ); ++ ++ assert!(matches!( ++ result, ++ Err(LauncherError::PackageError(PackageError::IncompatibleMcVersion)) ++ )); ++ } ++} +diff --git a/packages/oneclient_core/src/plus.rs b/packages/oneclient_core/src/plus.rs +new file mode 100644 +index 0000000..7e3b442 +--- /dev/null ++++ b/packages/oneclient_core/src/plus.rs +@@ -0,0 +1,333 @@ ++use std::sync::atomic::{AtomicBool, Ordering}; ++use std::time::Duration; ++ ++use bytes::Bytes; ++use futures_util::{SinkExt, TryStreamExt}; ++use rand::Rng; ++use reqwest_websocket::{Message, Upgrade}; ++use uuid::Uuid; ++ ++use crate::LauncherResult; ++use crate::auth::MinecraftAccount; ++use crate::constants; ++ ++const MOJANG_JOIN_URL: &str = "https://sessionserver.mojang.com/session/minecraft/join"; ++ ++const PING_INTERVAL: Duration = Duration::from_secs(30); ++ ++const RECONNECT_DELAY: Duration = Duration::from_secs(15); ++const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); ++ ++static STARTED: AtomicBool = AtomicBool::new(false); ++ ++#[derive(Debug, thiserror::Error)] ++pub enum PlusError { ++ #[error("Mojang rejected the Poly+ session join (HTTP {status}): {body}")] ++ JoinRejected { status: u16, body: String }, ++ ++ #[error("Poly+ rejected the login for {username} (HTTP {status}): {body}")] ++ LoginRejected { ++ username: String, ++ status: u16, ++ body: String, ++ }, ++ ++ #[error(transparent)] ++ Request(#[from] reqwest::Error), ++ ++ #[error(transparent)] ++ Websocket(#[from] reqwest_websocket::Error), ++ ++ #[error(transparent)] ++ Url(#[from] url::ParseError), ++} ++ ++#[derive(serde::Deserialize)] ++struct LoginResponse { ++ token: String, ++} ++ ++enum Outcome { ++ Connected, ++ NoAccount, ++ Failed, ++} ++ ++enum Event { ++ Ping, ++ Message(Option), ++} ++ ++pub fn start() { ++ if STARTED.swap(true, Ordering::SeqCst) { ++ return; ++ } ++ ++ tokio::spawn(async move { ++ let client = match build_client() { ++ Ok(client) => client, ++ Err(err) => { ++ tracing::error!("[plus] could not build the playtime client: {err}"); ++ return; ++ } ++ }; ++ ++ let mut delay = RECONNECT_DELAY; ++ ++ loop { ++ delay = match session(&client).await { ++ Outcome::Connected | Outcome::NoAccount => RECONNECT_DELAY, ++ Outcome::Failed => (delay * 2).min(MAX_RECONNECT_DELAY), ++ }; ++ ++ tokio::time::sleep(delay).await; ++ } ++ }); ++} ++ ++fn build_client() -> Result { ++ reqwest::Client::builder() ++ .tcp_keepalive(Some(Duration::from_secs(15))) ++ .connect_timeout(Duration::from_secs(10)) ++ .tls_backend_rustls() ++ .user_agent(format!( ++ "OneClient {} ({})", ++ env!("CARGO_PKG_VERSION"), ++ env!("CARGO_PKG_HOMEPAGE") ++ )) ++ .build() ++} ++ ++fn base_url() -> &'static str { ++ constants::PLUS_BACKEND_URL.trim_end_matches('/') ++} ++ ++async fn session(client: &reqwest::Client) -> Outcome { ++ let account = match current_account().await { ++ Ok(Some(account)) => account, ++ Ok(None) => return Outcome::NoAccount, ++ Err(err) => { ++ tracing::warn!("[plus] no account to track playtime for: {err}"); ++ return Outcome::Failed; ++ } ++ }; ++ ++ let id = account.id; ++ let mut connected = false; ++ ++ match run_session(client, &account, &mut connected).await { ++ Ok(()) => tracing::info!("[plus] playtime session for {id} ended"), ++ Err(err) => tracing::warn!("[plus] playtime session for {id} failed: {err}"), ++ } ++ ++ if connected { ++ Outcome::Connected ++ } else { ++ Outcome::Failed ++ } ++} ++ ++async fn current_account() -> LauncherResult> { ++ let Some(account) = crate::auth::default_account_for_launch().await? else { ++ return Ok(None); ++ }; ++ ++ Ok(account.is_microsoft().then_some(account)) ++} ++ ++async fn run_session( ++ client: &reqwest::Client, ++ account: &MinecraftAccount, ++ connected: &mut bool, ++) -> Result<(), PlusError> { ++ let token = login(client, account).await?; ++ ++ let mut websocket = client ++ .get(format!("{}/websocket", base_url())) ++ .bearer_auth(&token) ++ .upgrade() ++ .send() ++ .await? ++ .into_websocket() ++ .await?; ++ ++ *connected = true; ++ tracing::info!("[plus] playtime websocket connected for {}", account.id); ++ ++ pump(&mut websocket, PING_INTERVAL, account.id).await ++} ++ ++async fn pump( ++ websocket: &mut reqwest_websocket::WebSocket, ++ ping_interval: Duration, ++ account_id: Uuid, ++) -> Result<(), PlusError> { ++ let mut ping = tokio::time::interval(ping_interval); ++ ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); ++ ping.tick().await; ++ ++ loop { ++ let event = tokio::select! { ++ _ = ping.tick() => Event::Ping, ++ message = websocket.try_next() => Event::Message(message?), ++ }; ++ ++ match event { ++ Event::Ping => { ++ if account_changed(account_id).await { ++ tracing::info!( ++ "[plus] default account changed, ending playtime session for {account_id}" ++ ); ++ break; ++ } ++ ++ websocket.send(Message::Ping(Bytes::new())).await?; ++ } ++ Event::Message(Some(Message::Close { code, reason })) => { ++ tracing::info!("[plus] playtime websocket closed by the server: {code:?} {reason}"); ++ break; ++ } ++ Event::Message(Some(_)) => {} ++ Event::Message(None) => break, ++ } ++ } ++ ++ Ok(()) ++} ++ ++async fn login( ++ client: &reqwest::Client, ++ account: &MinecraftAccount, ++) -> Result { ++ let server_id = generate_server_id(); ++ ++ let join = client ++ .post(MOJANG_JOIN_URL) ++ .header("Accept", "application/json") ++ .json(&serde_json::json!({ ++ "accessToken": account.access_token, ++ "selectedProfile": account.id.simple().to_string(), ++ "serverId": server_id, ++ })) ++ .send() ++ .await?; ++ ++ if !join.status().is_success() { ++ return Err(PlusError::JoinRejected { ++ status: join.status().as_u16(), ++ body: join.text().await.unwrap_or_default(), ++ }); ++ } ++ ++ let mut url = reqwest::Url::parse(&format!("{}/account/login", base_url()))?; ++ url.query_pairs_mut() ++ .append_pair("username", &account.username) ++ .append_pair("server_id", &server_id); ++ ++ let login = client ++ .post(url) ++ .header("Accept", "application/json") ++ .send() ++ .await?; ++ ++ if !login.status().is_success() { ++ return Err(PlusError::LoginRejected { ++ username: account.username.clone(), ++ status: login.status().as_u16(), ++ body: login.text().await.unwrap_or_default(), ++ }); ++ } ++ ++ Ok(login.json::().await?.token) ++} ++ ++async fn account_changed(id: Uuid) -> bool { ++ match crate::auth::get_default_account().await { ++ Ok(Some(account)) => account.id != id, ++ Ok(None) => true, ++ Err(_) => false, ++ } ++} ++ ++fn generate_server_id() -> String { ++ let mut bytes = [0u8; 20]; ++ rand::rng().fill_bytes(&mut bytes); ++ crate::crypto::to_hex(&bytes) ++} ++ ++#[cfg(test)] ++mod tests { ++ use std::sync::Arc; ++ use std::sync::atomic::AtomicUsize; ++ ++ use async_tungstenite::tokio::accept_async; ++ use async_tungstenite::tungstenite::Message as ServerMessage; ++ use futures_util::StreamExt; ++ use tokio::net::TcpListener; ++ ++ use super::*; ++ ++ async fn ping_counting_server(close_after_pings: usize) -> (String, Arc) { ++ let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); ++ let port = listener.local_addr().expect("addr").port(); ++ let pings = Arc::new(AtomicUsize::new(0)); ++ ++ tokio::spawn({ ++ let pings = Arc::clone(&pings); ++ async move { ++ let (stream, _) = listener.accept().await.expect("accept"); ++ let mut server = accept_async(stream).await.expect("handshake"); ++ ++ while let Some(Ok(message)) = server.next().await { ++ if let ServerMessage::Ping(_) = message ++ && pings.fetch_add(1, Ordering::SeqCst) + 1 >= close_after_pings ++ { ++ let _ = server.close(None).await; ++ break; ++ } ++ } ++ } ++ }); ++ ++ (format!("ws://127.0.0.1:{port}/websocket"), pings) ++ } ++ ++ async fn connect(url: &str) -> reqwest_websocket::WebSocket { ++ build_client() ++ .expect("client") ++ .get(url) ++ .upgrade() ++ .send() ++ .await ++ .expect("upgrade") ++ .into_websocket() ++ .await ++ .expect("websocket") ++ } ++ ++ #[tokio::test] ++ async fn pings_on_every_interval_and_stops_when_the_server_closes() { ++ assert!(!account_changed(Uuid::nil()).await); ++ ++ let (url, pings) = ping_counting_server(3).await; ++ let mut websocket = connect(&url).await; ++ ++ let pumped = tokio::time::timeout( ++ Duration::from_secs(10), ++ pump(&mut websocket, Duration::from_millis(50), Uuid::nil()), ++ ) ++ .await ++ .expect("pump should return once the server closes the socket"); ++ ++ pumped.expect("pump should treat a server close as a clean end"); ++ assert_eq!(pings.load(Ordering::SeqCst), 3); ++ } ++ ++ #[test] ++ fn server_ids_are_random_20_byte_hex() { ++ let id = generate_server_id(); ++ assert_eq!(id.len(), 40); ++ assert!(id.chars().all(|c| c.is_ascii_hexdigit())); ++ assert_ne!(id, generate_server_id()); ++ } ++} +diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs +new file mode 100644 +index 0000000..45b3c88 +--- /dev/null ++++ b/packages/oneclient_core/src/reporting.rs +@@ -0,0 +1,76 @@ ++use std::panic; ++use std::time::Duration; ++ ++use sentry::{ClientInitGuard, ClientOptions}; ++ ++use crate::constants::SENTRY_DSN; ++ ++const PANIC_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); ++ ++const ENVIRONMENT: &str = if cfg!(debug_assertions) { ++ "development" ++} else { ++ "production" ++}; ++ ++pub fn init(enabled: bool) -> Option { ++ if !enabled { ++ tracing::debug!("crash reporting disabled by settings"); ++ return None; ++ } ++ ++ if cfg!(debug_assertions) && option_env!("ONECLIENT_SENTRY_DSN").is_none() { ++ tracing::debug!("crash reporting skipped: debug build without an explicit DSN"); ++ return None; ++ } ++ ++ let guard = sentry::init(( ++ SENTRY_DSN, ++ ClientOptions { ++ release: Some(format!("oneclient@{}", env!("CARGO_PKG_VERSION")).into()), ++ environment: Some(ENVIRONMENT.into()), ++ attach_stacktrace: true, ++ send_default_pii: false, ++ ..Default::default() ++ }, ++ )); ++ ++ if !guard.is_enabled() { ++ tracing::warn!("sentry client failed to start; crash reports will not be sent"); ++ return None; ++ } ++ ++ install_panic_flush(); ++ ++ tracing::info!(environment = ENVIRONMENT, "crash reporting enabled"); ++ Some(guard) ++} ++ ++fn install_panic_flush() { ++ let capture = panic::take_hook(); ++ ++ panic::set_hook(Box::new(move |info| { ++ capture(info); ++ ++ if let Some(client) = sentry::Hub::current().client() { ++ client.flush(Some(PANIC_FLUSH_TIMEOUT)); ++ } ++ })); ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn bundled_dsn_parses() { ++ SENTRY_DSN ++ .parse::() ++ .expect("bundled sentry DSN should be valid"); ++ } ++ ++ #[test] ++ fn opting_out_skips_the_client() { ++ assert!(init(false).is_none()); ++ } ++} +diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs +index 9991984..9de8c72 100644 +--- a/packages/oneclient_core/src/settings/launcher.rs ++++ b/packages/oneclient_core/src/settings/launcher.rs +@@ -26,6 +26,7 @@ pub struct LauncherSettings { + pub settings_version: u32, + pub log_debug: bool, + pub auto_update: bool, ++ pub crash_reporting: bool, + pub enable_gamemode: bool, + pub discord_enabled: bool, + pub max_concurrent_requests: usize, +@@ -34,6 +35,8 @@ pub struct LauncherSettings { + pub dynamic_background_enabled: bool, + pub view_states: BTreeMap, + pub seen_onboarding: bool, ++ pub accepted_tos_version: u32, ++ pub accepted_privacy_version: u32, + pub seen_versions: Vec, + pub modrinth_api_key: Option, + pub curseforge_api_key: Option, +@@ -57,6 +60,7 @@ impl Default for LauncherSettings { + settings_version: 1, + log_debug: false, + auto_update: true, ++ crash_reporting: true, + discord_enabled: true, + enable_gamemode: false, + max_concurrent_requests: 25, +@@ -65,6 +69,8 @@ impl Default for LauncherSettings { + dynamic_background_enabled: true, + view_states: BTreeMap::new(), + seen_onboarding: false, ++ accepted_tos_version: 0, ++ accepted_privacy_version: 0, + seen_versions: Vec::new(), + modrinth_api_key: None, + curseforge_api_key: None, +diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs +index 80a187e..2fc0af2 100644 +--- a/packages/oneclient_core/src/state.rs ++++ b/packages/oneclient_core/src/state.rs +@@ -6,6 +6,7 @@ use tokio::sync::{Mutex, OnceCell}; + + use crate::auth::{CredentialsStore, PendingBrowserLogin}; + use crate::bundles::BundlesManager; ++use crate::discord::DiscordRpc; + use crate::http::RequestClient; + use crate::images::ImageCacheStore; + use crate::java::JavaManager; +@@ -38,6 +39,7 @@ pub struct LauncherState { + pub versions: Arc, + pub images: ImageCacheStore, + pub games: crate::game::GameProcessManager, ++ pub discord: DiscordRpc, + } + + impl LauncherState { +@@ -59,6 +61,7 @@ impl LauncherState { + let settings = store::load_settings(Some(&services.notifier)).await; + let auth = CredentialsStore::load().await?; + let java = JavaManager; ++ let discord = DiscordRpc::spawn(settings.discord_enabled); + + let state = Arc::new(Self { + services, +@@ -71,6 +74,7 @@ impl LauncherState { + versions: Arc::new(VersionsManager::new()), + images: ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), ++ discord, + }); + + STATE +diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs +new file mode 100644 +index 0000000..c30bb5e +--- /dev/null ++++ b/packages/oneclient_core/src/tos.rs +@@ -0,0 +1,197 @@ ++use std::path::Path; ++ ++use reqwest::header; ++use reqwest::{Method, StatusCode}; ++use serde::{Deserialize, Serialize}; ++ ++use crate::api_config::meta_url_base; ++use crate::constants; ++use crate::paths; ++use crate::state::LauncherServices; ++use crate::{LauncherError, LauncherResult}; ++ ++#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] ++pub struct TermsDocument { ++ pub version: u32, ++ #[serde(default)] ++ pub privacy_version: Option, ++ #[serde(default)] ++ pub updated_at: Option, ++ pub terms: String, ++ #[serde(default)] ++ pub privacy: Option, ++ #[serde(default)] ++ pub terms_url: Option, ++ #[serde(default)] ++ pub privacy_url: Option, ++} ++ ++impl TermsDocument { ++ pub fn terms_url(&self) -> &str { ++ self.terms_url ++ .as_deref() ++ .filter(|url| !url.trim().is_empty()) ++ .unwrap_or(constants::TOS_URL) ++ } ++ ++ pub fn privacy_url(&self) -> &str { ++ self.privacy_url ++ .as_deref() ++ .filter(|url| !url.trim().is_empty()) ++ .unwrap_or(constants::PRIVACY_URL) ++ } ++ ++ pub fn privacy_version(&self) -> u32 { ++ self.privacy_version.unwrap_or(self.version) ++ } ++ ++ pub fn privacy_body(&self) -> Option<&str> { ++ self.privacy ++ .as_deref() ++ .filter(|body| !body.trim().is_empty()) ++ } ++} ++ ++#[tracing::instrument(level = "debug", skip(services))] ++pub async fn fetch_terms(services: &LauncherServices) -> LauncherResult { ++ let url = format!("{}/oneclient/tos.json", meta_url_base()); ++ let cache_path = paths::caches_dir()?.join("TERMS.json"); ++ let etag_path = paths::caches_dir()?.join("TERMS.json.etag"); ++ ++ let stored_etag = read_sidecar_etag(&etag_path).await; ++ let url_parsed = url.parse().map_err(LauncherError::UrlError)?; ++ ++ let mut request = reqwest::Request::new(Method::GET, url_parsed); ++ if let Some(etag) = &stored_etag { ++ insert_if_none_match(&mut request, etag); ++ } ++ ++ match services.requester.send(request).await { ++ Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { ++ tracing::debug!("terms cache hit (304)"); ++ read_cached_terms(&cache_path).await ++ } ++ Ok(res) if res.status().is_success() => { ++ let server_etag = etag_from_response(&res); ++ let bytes = res ++ .bytes() ++ .await ++ .map_err(|err| LauncherError::InvalidSettingsProfile { ++ reason: err.to_string(), ++ })?; ++ ++ let document: TermsDocument = serde_json::from_slice(&bytes)?; ++ ++ if let Some(parent) = cache_path.parent() { ++ polyio::create_dir_all(parent).await?; ++ } ++ polyio::write(&cache_path, &bytes).await?; ++ if let Some(etag) = server_etag { ++ let _ = polyio::write(&etag_path, etag.as_bytes()).await; ++ } ++ ++ Ok(document) ++ } ++ Err(err) if cache_path.exists() => { ++ tracing::debug!("falling back to cached terms after remote fetch failed: {err}"); ++ read_cached_terms(&cache_path).await ++ } ++ Err(err) => { ++ tracing::error!("failed to fetch terms from remote: {err}"); ++ Err(LauncherError::InvalidSettingsProfile { ++ reason: err.to_string(), ++ }) ++ } ++ Ok(res) => { ++ tracing::warn!( ++ status = %res.status(), ++ "unexpected terms response; using cache when available" ++ ); ++ if cache_path.exists() { ++ read_cached_terms(&cache_path).await ++ } else { ++ Err(LauncherError::InvalidSettingsProfile { ++ reason: format!("terms request failed with HTTP {}", res.status()), ++ }) ++ } ++ } ++ } ++} ++ ++async fn read_cached_terms(path: &Path) -> LauncherResult { ++ let bytes = polyio::read(path).await?; ++ Ok(serde_json::from_slice(&bytes)?) ++} ++ ++fn insert_if_none_match(request: &mut reqwest::Request, etag: &str) { ++ if let Ok(value) = header::HeaderValue::from_str(etag) { ++ request.headers_mut().insert(header::IF_NONE_MATCH, value); ++ } ++} ++ ++fn etag_from_response(res: &reqwest::Response) -> Option { ++ res.headers() ++ .get(header::ETAG) ++ .and_then(|v| v.to_str().ok()) ++ .map(str::to_string) ++} ++ ++async fn read_sidecar_etag(path: &Path) -> Option { ++ polyio::read(path) ++ .await ++ .ok() ++ .and_then(|bytes| String::from_utf8(bytes).ok()) ++ .filter(|etag| !etag.is_empty()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn parses_minimal_document() { ++ let raw = br###"{"version":2,"terms":"## Terms\n\nBe nice."}"###; ++ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); ++ ++ assert_eq!(document.version, 2); ++ assert_eq!(document.terms, "## Terms\n\nBe nice."); ++ assert_eq!(document.terms_url(), constants::TOS_URL); ++ assert_eq!(document.privacy_url(), constants::PRIVACY_URL); ++ } ++ ++ #[test] ++ fn privacy_version_falls_back_to_terms_version() { ++ let raw = br#"{"version":4,"terms":"x"}"#; ++ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); ++ ++ assert_eq!(document.privacy_version(), 4); ++ assert_eq!(document.privacy_body(), None); ++ } ++ ++ #[test] ++ fn privacy_version_is_independent_when_published() { ++ let raw = br###"{"version":4,"privacy_version":2,"terms":"x","privacy":"## Privacy"}"###; ++ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); ++ ++ assert_eq!(document.version, 4); ++ assert_eq!(document.privacy_version(), 2); ++ assert_eq!(document.privacy_body(), Some("## Privacy")); ++ } ++ ++ #[test] ++ fn blank_privacy_body_is_treated_as_absent() { ++ let raw = br#"{"version":1,"terms":"x","privacy":" "}"#; ++ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); ++ ++ assert_eq!(document.privacy_body(), None); ++ } ++ ++ #[test] ++ fn document_urls_override_constants() { ++ let raw = br#"{"version":1,"terms":"x","terms_url":"https://example.com/tos","privacy_url":" "}"#; ++ let document: TermsDocument = serde_json::from_slice(raw).unwrap(); ++ ++ assert_eq!(document.terms_url(), "https://example.com/tos"); ++ assert_eq!(document.privacy_url(), constants::PRIVACY_URL); ++ } ++}