From c24425e06df7b2c65fae48574028d85b839bcaed Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Thu, 27 Aug 2026 15:05:07 +0530 Subject: [PATCH] feat(auth): enable offline mode accounts and inline username editing --- ATTRIBUTION.md | 108 + CODE_OF_CONDUCT.md | 136 + CONTRIBUTING.md | 122 + Cargo.lock | 8759 +++++++++++++++++ Cargo.toml | 156 + distribution/aur/oneclient-bin/PKGBUILD | 36 + distribution/aur/oneclient/PKGBUILD | 68 + distribution/windows/bundle-crt.py | 117 + packages/oneclient_app/Cargo.toml | 150 + packages/oneclient_app/assets/alex.png | Bin 0 -> 958 bytes .../assets/backgrounds/CavesAndCliffs.jpg | Bin 0 -> 633705 bytes .../assets/fonts/JetBrainsMono/AUTHORS.txt | 10 + .../JetBrainsMono/JetBrainsMonoVariable.ttf | Bin 0 -> 303144 bytes .../assets/fonts/JetBrainsMono/OFL.txt | 93 + .../assets/fonts/Poppins/OFL.txt | 93 + .../assets/fonts/Poppins/Poppins-Bold.ttf | Bin 0 -> 153944 bytes .../fonts/Poppins/Poppins-BoldItalic.ttf | Bin 0 -> 176588 bytes .../fonts/Poppins/Poppins-ExtraBold.ttf | Bin 0 -> 152764 bytes .../fonts/Poppins/Poppins-ExtraBoldItalic.ttf | Bin 0 -> 173916 bytes .../assets/fonts/Poppins/Poppins-Italic.ttf | Bin 0 -> 182012 bytes .../assets/fonts/Poppins/Poppins-Light.ttf | Bin 0 -> 159892 bytes .../fonts/Poppins/Poppins-LightItalic.ttf | Bin 0 -> 184460 bytes .../assets/fonts/Poppins/Poppins-Medium.ttf | Bin 0 -> 156520 bytes .../fonts/Poppins/Poppins-MediumItalic.ttf | Bin 0 -> 180444 bytes .../assets/fonts/Poppins/Poppins-Regular.ttf | Bin 0 -> 158240 bytes .../assets/fonts/Poppins/Poppins-SemiBold.ttf | Bin 0 -> 155232 bytes .../fonts/Poppins/Poppins-SemiBoldItalic.ttf | Bin 0 -> 178584 bytes .../assets/icons/alert-circle.svg | 3 + .../assets/icons/alert-triangle.svg | 3 + .../assets/icons/announcement-01.svg | 3 + .../oneclient_app/assets/icons/arrow-left.svg | 3 + .../assets/icons/arrow-right.svg | 3 + .../oneclient_app/assets/icons/bell-01.svg | 3 + .../oneclient_app/assets/icons/brush-01.svg | 3 + .../oneclient_app/assets/icons/calendar.svg | 3 + .../assets/icons/check-circle.svg | 3 + packages/oneclient_app/assets/icons/check.svg | 3 + .../assets/icons/chevron-down.svg | 3 + .../assets/icons/chevron-right.svg | 3 + .../assets/icons/chevrons-left.svg | 4 + .../assets/icons/chevrons-right.svg | 4 + .../assets/icons/clipboard-check.svg | 3 + .../assets/icons/clock-rewind.svg | 3 + .../assets/icons/code-snippet-02.svg | 3 + .../oneclient_app/assets/icons/colors.svg | 3 + .../oneclient_app/assets/icons/copy-01.svg | 3 + .../oneclient_app/assets/icons/curseforge.svg | 1 + .../assets/icons/database-01.svg | 3 + .../oneclient_app/assets/icons/dots-grid.svg | 11 + .../assets/icons/dots-vertical.svg | 5 + .../assets/icons/download-01.svg | 3 + .../assets/icons/download-cloud-02.svg | 5 + packages/oneclient_app/assets/icons/eye.svg | 6 + .../oneclient_app/assets/icons/file-02.svg | 3 + .../assets/icons/file-plus-02.svg | 3 + .../oneclient_app/assets/icons/file-x-02.svg | 3 + .../assets/icons/folder-check.svg | 5 + .../assets/icons/folder-download.svg | 5 + .../oneclient_app/assets/icons/folder.svg | 3 + .../oneclient_app/assets/icons/globe-01.svg | 3 + .../assets/icons/help-circle.svg | 3 + .../assets/icons/info-circle.svg | 3 + .../oneclient_app/assets/icons/key-01.svg | 3 + .../oneclient_app/assets/icons/layout-top.svg | 3 + .../oneclient_app/assets/icons/link-03.svg | 3 + .../assets/icons/link-external-01.svg | 3 + .../oneclient_app/assets/icons/loading-02.svg | 3 + .../assets/icons/maximize-01.svg | 3 + .../assets/icons/message-text-square-01.svg | 3 + packages/oneclient_app/assets/icons/minus.svg | 3 + .../oneclient_app/assets/icons/modrinth.svg | 1 + .../assets/icons/onboarding-account.svg | 38 + .../assets/icons/onboarding-complete.svg | 41 + .../assets/icons/onboarding-language.svg | 43 + .../assets/icons/onboarding-preferences.svg | 41 + .../assets/icons/onboarding-welcome.svg | 21 + .../oneclient_app/assets/icons/paint-pour.svg | 3 + .../assets/icons/paragraph-wrap.svg | 3 + .../oneclient_app/assets/icons/pencil-01.svg | 3 + packages/oneclient_app/assets/icons/play.svg | 3 + packages/oneclient_app/assets/icons/plus.svg | 3 + .../assets/icons/refresh-ccw-02.svg | 3 + .../assets/icons/refresh-cw-01.svg | 3 + .../oneclient_app/assets/icons/rocket-02.svg | 3 + .../oneclient_app/assets/icons/search-md.svg | 3 + .../assets/icons/settings-01.svg | 4 + .../assets/icons/settings-02.svg | 4 + .../assets/icons/settings-04.svg | 3 + .../oneclient_app/assets/icons/sliders-04.svg | 3 + .../oneclient_app/assets/icons/square.svg | 3 + .../oneclient_app/assets/icons/terminal.svg | 3 + .../oneclient_app/assets/icons/trash-01.svg | 3 + .../oneclient_app/assets/icons/users-01.svg | 3 + .../oneclient_app/assets/icons/x-close.svg | 3 + packages/oneclient_app/assets/icons/x.svg | 3 + packages/oneclient_app/assets/logo.svg | 4 + packages/oneclient_app/assets/steve.png | Bin 0 -> 848 bytes packages/oneclient_app/build.rs | 20 + .../distribution/App.entitlements | 18 + .../oneclient_app/distribution/Info.plist | 26 + .../distribution/dmg-background.png | Bin 0 -> 9383 bytes .../distribution/dmg-background.tiff | Bin 0 -> 62738 bytes .../distribution/dmg-background@2x.png | Bin 0 -> 20695 bytes .../linux/org.polyfrost.oneclient.template | 12 + packages/oneclient_app/icons/128x128.png | Bin 0 -> 5626 bytes packages/oneclient_app/icons/128x128@2x.png | Bin 0 -> 11645 bytes packages/oneclient_app/icons/32x32.png | Bin 0 -> 1438 bytes packages/oneclient_app/icons/512x512.png | Bin 0 -> 19730 bytes packages/oneclient_app/icons/64x64.png | Bin 0 -> 2762 bytes .../OneClient.icon/Assets/oneclient-mark.svg | 7 + .../icons/OneClient.icon/icon.json | 38 + packages/oneclient_app/icons/StoreLogo.png | Bin 0 -> 1716 bytes packages/oneclient_app/icons/icon.icns | Bin 0 -> 244223 bytes packages/oneclient_app/icons/icon.ico | Bin 0 -> 47387 bytes packages/oneclient_app/icons/icon.png | Bin 0 -> 19730 bytes packages/oneclient_app/src/assets.rs | 15 + .../src/components/account_switcher.rs | 244 + .../src/components/active_cluster_panel.rs | 151 + .../oneclient_app/src/components/avatar.rs | 86 + .../oneclient_app/src/components/button.rs | 452 + .../oneclient_app/src/components/charts.rs | 553 ++ .../src/components/cluster_landscape_art.rs | 45 + .../src/components/cluster_update_popup.rs | 383 + .../src/components/context_menu.rs | 212 + .../oneclient_app/src/components/dropdown.rs | 248 + .../src/components/dynamic_art.rs | 126 + .../oneclient_app/src/components/file_drop.rs | 489 + .../src/components/generic_prompt.rs | 175 + .../oneclient_app/src/components/icons.rs | 152 + .../src/components/java_install_manager.rs | 275 + .../src/components/java_prompt.rs | 148 + .../src/components/link_confirm.rs | 106 + .../src/components/local_image.rs | 129 + .../src/components/log_viewer.rs | 557 ++ .../src/components/memory_field.rs | 51 + .../src/components/microsoft_login.rs | 477 + packages/oneclient_app/src/components/mod.rs | 85 + .../src/components/navbar/app_navbar.rs | 312 + .../src/components/navbar/mod.rs | 60 + .../components/navbar/onboarding_navbar.rs | 44 + .../src/components/notifications.rs | 529 + .../src/components/overlay_popup.rs | 112 + .../src/components/package_row.rs | 534 + .../src/components/package_update_popup.rs | 388 + .../src/components/pagination.rs | 161 + .../src/components/player_model.rs | 325 + .../src/components/progress_track.rs | 20 + .../src/components/recents_row.rs | 318 + .../src/components/screenshot_viewer.rs | 252 + .../src/components/scrollview.rs | 617 ++ .../src/components/segmented_control.rs | 213 + .../src/components/splash_curtain.rs | 80 + .../src/components/status_bar.rs | 197 + .../oneclient_app/src/components/tab_bar.rs | 224 + .../src/components/text_input.rs | 178 + .../oneclient_app/src/components/toasts.rs | 477 + .../oneclient_app/src/components/toggle.rs | 147 + .../src/components/update_prompt.rs | 114 + .../src/components/upload_mclogs.rs | 67 + .../src/components/version_card.rs | 158 + packages/oneclient_app/src/constants.rs | 7 + packages/oneclient_app/src/events.rs | 379 + packages/oneclient_app/src/hooks/actions.rs | 1438 +++ .../oneclient_app/src/hooks/active_cluster.rs | 109 + packages/oneclient_app/src/hooks/debounce.rs | 28 + packages/oneclient_app/src/hooks/mod.rs | 116 + .../src/hooks/queries/analytics.rs | 58 + .../oneclient_app/src/hooks/queries/auth.rs | 409 + .../src/hooks/queries/bundles.rs | 171 + .../src/hooks/queries/changelog.rs | 44 + .../src/hooks/queries/cluster_content.rs | 52 + .../src/hooks/queries/clusters.rs | 31 + .../oneclient_app/src/hooks/queries/image.rs | 58 + .../oneclient_app/src/hooks/queries/java.rs | 68 + .../oneclient_app/src/hooks/queries/logs.rs | 152 + .../src/hooks/queries/migration.rs | 52 + .../oneclient_app/src/hooks/queries/mod.rs | 101 + .../src/hooks/queries/mutations.rs | 231 + .../src/hooks/queries/package_updates.rs | 50 + .../src/hooks/queries/packages.rs | 313 + .../src/hooks/queries/player_profile.rs | 57 + .../src/hooks/queries/screenshots.rs | 215 + .../src/hooks/queries/settings_profiles.rs | 136 + .../oneclient_app/src/hooks/queries/skin.rs | 32 + .../oneclient_app/src/hooks/queries/state.rs | 53 + .../src/hooks/queries/storage.rs | 84 + .../oneclient_app/src/hooks/queries/tos.rs | 39 + .../src/hooks/queries/version_metadata.rs | 113 + .../src/hooks/queries/versions.rs | 87 + .../oneclient_app/src/hooks/view_state.rs | 42 + packages/oneclient_app/src/install.rs | 342 + packages/oneclient_app/src/launcher.rs | 22 + .../src/layout/animated_outlet.rs | 176 + .../oneclient_app/src/layout/app_shell.rs | 613 ++ .../oneclient_app/src/layout/cluster_shell.rs | 343 + packages/oneclient_app/src/layout/mod.rs | 14 + .../src/layout/onboarding_shell.rs | 130 + .../oneclient_app/src/layout/root_layout.rs | 81 + .../src/layout/settings_shell.rs | 835 ++ packages/oneclient_app/src/lib.rs | 33 + packages/oneclient_app/src/main.rs | 135 + packages/oneclient_app/src/motion.rs | 29 + packages/oneclient_app/src/notifications.rs | 1009 ++ packages/oneclient_app/src/platform.rs | 56 + packages/oneclient_app/src/recovery.rs | 72 + packages/oneclient_app/src/routes.rs | 168 + packages/oneclient_app/src/state.rs | 207 + packages/oneclient_app/src/theme/colors.rs | 152 + packages/oneclient_app/src/theme/mod.rs | 38 + packages/oneclient_app/src/transfer.rs | 114 + packages/oneclient_app/src/ui.rs | 131 + packages/oneclient_app/src/updater.rs | 185 + packages/oneclient_app/src/utils.rs | 485 + .../src/view/app/analytics/charts.rs | 258 + .../src/view/app/analytics/mod.rs | 245 + .../src/view/app/analytics/servers.rs | 414 + .../src/view/app/analytics/tiles.rs | 137 + .../src/view/app/browser/index/cards.rs | 443 + .../src/view/app/browser/index/mod.rs | 418 + .../src/view/app/browser/index/sidebar.rs | 117 + .../src/view/app/browser/index/skeletons.rs | 100 + .../oneclient_app/src/view/app/browser/mod.rs | 552 ++ .../src/view/app/browser/package/gallery.rs | 325 + .../src/view/app/browser/package/mod.rs | 226 + .../src/view/app/browser/package/panels.rs | 339 + .../src/view/app/browser/package/sidebar.rs | 455 + .../src/view/app/cluster/cluster_settings.rs | 772 ++ .../src/view/app/cluster/logs.rs | 684 ++ .../oneclient_app/src/view/app/cluster/mod.rs | 38 + .../src/view/app/cluster/mods.rs | 60 + .../src/view/app/cluster/overview.rs | 73 + .../view/app/cluster/package_manager/mod.rs | 455 + .../view/app/cluster/package_manager/views.rs | 814 ++ .../src/view/app/cluster/process_logs.rs | 119 + .../src/view/app/cluster/screenshots.rs | 580 ++ .../src/view/app/cluster/shaders.rs | 60 + .../src/view/app/cluster/textures.rs | 60 + .../src/view/app/clusters/mod.rs | 3 + .../src/view/app/clusters/page.rs | 501 + packages/oneclient_app/src/view/app/debug.rs | 1177 +++ .../oneclient_app/src/view/app/home/index.rs | 29 + .../oneclient_app/src/view/app/home/mod.rs | 3 + packages/oneclient_app/src/view/app/mod.rs | 41 + .../src/view/app/settings/accounts.rs | 746 ++ .../src/view/app/settings/apis.rs | 97 + .../src/view/app/settings/appearance.rs | 264 + .../src/view/app/settings/changelog.rs | 192 + .../src/view/app/settings/developer.rs | 69 + .../src/view/app/settings/feedback.rs | 55 + .../src/view/app/settings/java.rs | 293 + .../src/view/app/settings/language.rs | 70 + .../src/view/app/settings/launcher.rs | 71 + .../src/view/app/settings/minecraft.rs | 237 + .../src/view/app/settings/mod.rs | 106 + .../src/view/app/settings/storage.rs | 325 + packages/oneclient_app/src/view/app/skins.rs | 169 + packages/oneclient_app/src/view/app/stats.rs | 69 + packages/oneclient_app/src/view/console.rs | 298 + packages/oneclient_app/src/view/mod.rs | 9 + packages/oneclient_app/src/view/not_found.rs | 35 + .../src/view/onboarding/account.rs | 159 + .../src/view/onboarding/bundles/mod.rs | 532 + .../src/view/onboarding/bundles/row.rs | 157 + .../view/onboarding/downloading/backdrop.rs | 187 + .../src/view/onboarding/downloading/mod.rs | 1146 +++ .../view/onboarding/downloading/progress.rs | 390 + .../view/onboarding/downloading/summary.rs | 254 + .../src/view/onboarding/downloading/tips.rs | 150 + .../src/view/onboarding/language.rs | 129 + .../src/view/onboarding/migration.rs | 432 + .../oneclient_app/src/view/onboarding/mod.rs | 349 + .../src/view/onboarding/preferences.rs | 85 + .../src/view/onboarding/selection.rs | 216 + .../src/view/onboarding/terms.rs | 304 + .../src/view/onboarding/test_support.rs | 66 + .../src/view/onboarding/welcome.rs | 37 + packages/oneclient_app/src/view/startup.rs | 257 + packages/oneclient_auth/Cargo.toml | 40 + packages/oneclient_auth/src/data.rs | 80 + packages/oneclient_auth/src/diagnostics.rs | 349 + packages/oneclient_auth/src/error.rs | 139 + packages/oneclient_auth/src/lib.rs | 21 + packages/oneclient_auth/src/msa.rs | 823 ++ packages/oneclient_auth/src/offline.rs | 52 + packages/oneclient_auth/src/service.rs | 428 + packages/oneclient_auth/src/store.rs | 191 + packages/oneclient_auth/tests/offline.rs | 107 + packages/oneclient_cluster/Cargo.toml | 35 + packages/oneclient_cluster/src/cluster.rs | 117 + packages/oneclient_cluster/src/error.rs | 75 + packages/oneclient_cluster/src/lib.rs | 22 + packages/oneclient_cluster/src/logs/error.rs | 19 + packages/oneclient_cluster/src/logs/manage.rs | 157 + packages/oneclient_cluster/src/logs/mclogs.rs | 80 + packages/oneclient_cluster/src/logs/mod.rs | 131 + packages/oneclient_cluster/src/logs/parse.rs | 25 + packages/oneclient_cluster/src/manager.rs | 347 + packages/oneclient_cluster/src/options.rs | 59 + packages/oneclient_cluster/src/profile.rs | 165 + packages/oneclient_cluster/src/profiles.rs | 166 + .../oneclient_cluster/src/screenshots/mod.rs | 126 + packages/oneclient_cluster/src/stage.rs | 30 + packages/oneclient_common/Cargo.toml | 23 + packages/oneclient_common/src/constants.rs | 49 + packages/oneclient_common/src/domain.rs | 289 + packages/oneclient_common/src/error.rs | 8 + packages/oneclient_common/src/lib.rs | 21 + packages/oneclient_common/src/os_ext.rs | 53 + packages/oneclient_common/src/patch.rs | 55 + packages/oneclient_common/src/paths.rs | 152 + packages/oneclient_common/src/search.rs | 352 + packages/oneclient_common/src/version.rs | 132 + packages/oneclient_content/Cargo.toml | 44 + .../oneclient_content/src/bundles/error.rs | 22 + .../oneclient_content/src/bundles/install.rs | 885 ++ .../oneclient_content/src/bundles/manager.rs | 318 + .../oneclient_content/src/bundles/manifest.rs | 43 + packages/oneclient_content/src/bundles/mod.rs | 29 + .../src/bundles/overrides.rs | 504 + .../src/bundles/polymrpack.rs | 176 + .../oneclient_content/src/bundles/runtime.rs | 33 + .../oneclient_content/src/bundles/types.rs | 161 + .../oneclient_content/src/bundles/updates.rs | 944 ++ packages/oneclient_content/src/ctx.rs | 33 + packages/oneclient_content/src/error.rs | 66 + packages/oneclient_content/src/lib.rs | 8 + .../src/packages/activity.rs | 249 + .../src/packages/dependencies.rs | 401 + .../oneclient_content/src/packages/error.rs | 38 + .../src/packages/file_identity.rs | 75 + .../src/packages/metadata_cache.rs | 236 + .../oneclient_content/src/packages/mod.rs | 32 + .../src/packages/modpack/mod.rs | 17 + .../src/packages/modpack/mrpack.rs | 200 + .../src/packages/provider/curseforge.rs | 746 ++ .../src/packages/provider/http.rs | 50 + .../src/packages/provider/mod.rs | 85 + .../src/packages/provider/modrinth.rs | 744 ++ .../src/packages/provider/registry.rs | 154 + .../src/packages/store/download.rs | 141 + .../src/packages/store/gc.rs | 284 + .../src/packages/store/link.rs | 114 + .../src/packages/store/manifest.rs | 156 + .../src/packages/store/mod.rs | 475 + .../src/packages/store/paths.rs | 31 + .../oneclient_content/src/packages/types.rs | 241 + .../oneclient_content/src/packages/updates.rs | 552 ++ .../oneclient_content/tests/file_identity.rs | 39 + packages/oneclient_core/.gitignore | 1 + packages/oneclient_core/Cargo.toml | 95 + packages/oneclient_core/examples/auth_msa.rs | 52 + .../oneclient_core/examples/bundles_sync.rs | 32 + .../oneclient_core/examples/cluster_manage.rs | 47 + packages/oneclient_core/examples/detect_v1.rs | 27 + .../oneclient_core/examples/download_game.rs | 48 + .../oneclient_core/examples/launch_game.rs | 48 + .../oneclient_core/examples/notifications.rs | 30 + .../examples/package_project.rs | 59 + .../oneclient_core/examples/package_search.rs | 56 + packages/oneclient_core/src/auth/mod.rs | 13 + packages/oneclient_core/src/auth/offline.rs | 52 + packages/oneclient_core/src/changelog.rs | 62 + .../oneclient_core/src/clusters/migrate.rs | 272 + packages/oneclient_core/src/clusters/mod.rs | 14 + .../oneclient_core/src/clusters/prepare.rs | 389 + .../oneclient_core/src/clusters/provision.rs | 140 + .../src/clusters/unlink_legacy.rs | 182 + packages/oneclient_core/src/dev.rs | 324 + packages/oneclient_core/src/error.rs | 272 + packages/oneclient_core/src/game/analytics.rs | 348 + packages/oneclient_core/src/game/diagnosis.rs | 204 + packages/oneclient_core/src/game/error.rs | 43 + packages/oneclient_core/src/game/launch.rs | 680 ++ .../oneclient_core/src/game/log_replay.rs | 288 + packages/oneclient_core/src/game/mod.rs | 38 + packages/oneclient_core/src/game/process.rs | 172 + packages/oneclient_core/src/game/reattach.rs | 410 + packages/oneclient_core/src/game/session.rs | 244 + .../oneclient_core/src/game/shared_dir.rs | 941 ++ packages/oneclient_core/src/game/tail.rs | 303 + packages/oneclient_core/src/images/mod.rs | 130 + packages/oneclient_core/src/java_store.rs | 79 + packages/oneclient_core/src/lib.rs | 68 + packages/oneclient_core/src/logger.rs | 219 + packages/oneclient_core/src/logger/console.rs | 121 + packages/oneclient_core/src/migration/mod.rs | 144 + .../src/migration/oneclient_v1.rs | 195 + .../oneclient_core/src/migration/vanilla.rs | 243 + packages/oneclient_core/src/recovery.rs | 426 + packages/oneclient_core/src/reporting.rs | 89 + .../oneclient_core/src/settings/launcher.rs | 85 + packages/oneclient_core/src/settings/mod.rs | 21 + packages/oneclient_core/src/settings/store.rs | 75 + packages/oneclient_core/src/simulate.rs | 365 + packages/oneclient_core/src/state.rs | 166 + packages/oneclient_core/src/storage.rs | 380 + packages/oneclient_core/src/tos.rs | 115 + packages/oneclient_core/src/verify.rs | 403 + .../oneclient_core/src/versions/manager.rs | 93 + .../oneclient_core/src/versions/manifest.rs | 347 + packages/oneclient_core/src/versions/mod.rs | 8 + .../oneclient_core/tests/bundle_updates.rs | 396 + packages/oneclient_core/tests/clusters.rs | 147 + .../oneclient_core/tests/download_verify.rs | 364 + packages/oneclient_core/tests/patch.rs | 33 + packages/oneclient_core/tests/providers.rs | 163 + packages/oneclient_db/.env | 2 + packages/oneclient_db/.env.template | 2 + packages/oneclient_db/.gitignore | 1 + ...9807f547a1376372c8b201653a36d6dd14cbc.json | 86 + ...8665f6be44e7937deffb921ddb6a5450805ab.json | 26 + ...7e23cd24ddea07917d5f77b7c0d01cc617ccb.json | 38 + ...2f62f4b794c0fdda3ccbde49384689911aeba.json | 86 + ...c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json | 158 + ...5a3417d56b455c6bb2a9ef3e027a3e1915813.json | 110 + ...3ddef96835a67aa8affe3453845a646e5ba61.json | 158 + ...e5b84ad8ece01b56b9e22d595271db2c5c202.json | 12 + ...1e6be22264a2ca3e91155cb01f2debd39b319.json | 146 + ...79a00a6002a35b47ec3723e3aa6a327cb7f51.json | 62 + ...459bb3294907c8fdf0727c0ef503e4b7bdf2c.json | 110 + ...9bb0c0dcb880fcff67a096fa94b236fbe6107.json | 146 + ...b3c7fd7f8d8b5f425e71989e98a2b27338b42.json | 158 + ...773afa568d626c60a37bbc32cc932746ad49b.json | 86 + ...c6ee2a9a36d8ae0948b71369897c014acae77.json | 74 + ...150d03ab2fa9b955ba223066edf210e3556a1.json | 158 + ...46218b16c849301fa3db250e24ec81d15d1c7.json | 74 + ...83b933c2f6788e2d2482ca9fd7d71ee67381a.json | 12 + ...b190781cf0aac8ae6b4e1a333608d29946919.json | 74 + ...223cac28f59431933ffefea04e59b62deab3a.json | 12 + ...af15fd91e22e080742f75a0a393fc45c59b22.json | 158 + ...f338462888518de3307bdab3db0b419a82be9.json | 12 + ...2c492521a693f48d07f9230cda440297ac02d.json | 158 + ...08f37b12f571608bbafc0a2e1a083ec73239f.json | 12 + ...f4fffe319a5de18e34f5f190366a113a8831c.json | 110 + ...7ce139b331506d1c53ed0f4011596c810252f.json | 62 + ...7ffe26960fdc25855f6cc993d6ab5deb84c02.json | 146 + ...53d367c52a142b7afdae2f1bf8c07444d9e30.json | 74 + ...cece742d6ce62a230335e56e987b6b1d0996a.json | 12 + ...bd20f083308b702a51c3e490409c37d0e8549.json | 12 + ...3a9b4f796753ab1fbb28efcbf71562b3fafa1.json | 158 + ...ecab5db70f3f37058b87fc9896028c1e8f644.json | 158 + ...7c8364fda672d92ba43b641edf8c028e25186.json | 158 + ...8202b0de13f77c7dc9ef0c4a967fab8135e02.json | 12 + ...4661d57dc172286b91235ab034a827fcc061e.json | 158 + ...a35888bb3b6a5a4fda5df3c662ca16b1d801e.json | 12 + ...5b33ced5913589907d80f4b20159150e1d1f1.json | 110 + ...efc092f51300c10ad8776815637fc4616d12b.json | 12 + ...3703ba78d72f39b3090734bb3e69c2b72d50c.json | 12 + ...850f3bf1f02bebabd8239716c2b3f2853dfcd.json | 12 + ...07be05abf1a8a20b730075288e20d6166044f.json | 122 + ...972d10b5ac1d6b0d4df02f42edbf250d81782.json | 158 + ...aaa4f0b079ccbd757e5db1a8229ea58d2e700.json | 158 + ...938a45c3a1dde7fe82f61aa78012f372a3263.json | 33 + ...a32dce67133c35f44119a81996f3fc9145a4e.json | 38 + ...cf6d83ed846b9b65132a46ccb14de5abe9391.json | 158 + ...613a4c41eb729ca22249f15eb45e6c6030094.json | 21 + ...758d1103037947f14aeff5794511f1c002d55.json | 12 + ...c489501629742786fd6e00d96650c9f2404b2.json | 74 + ...b55f9beddb045bc43eaef851da89b3e547933.json | 12 + ...e0f985a66434365f0939f7572434af40e43af.json | 62 + ...4adbc864909984313ea1c065ba226465b23ca.json | 62 + ...5a90e1e0902bde48eea1ced6d89ea6dbb4555.json | 12 + ...79235e2470eeec5b10f1e768fcf54d37d57fe.json | 62 + ...30ba45423c0116c911b9da16576290ed9f3c2.json | 158 + ...a110da6fb1628894dcecf5ec1c8f9e648cd38.json | 74 + ...a254b21d865175669c985322a7c773c8c202a.json | 110 + ...e8d96ea46be6570ea9c7bdb92cc7cd0274892.json | 12 + ...352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json | 38 + ...14caee3a8cb31d7bd5ba6faf7d081be94ce8c.json | 122 + ...af3f2c7b9b6cd5727816704793d3723195dee.json | 12 + ...dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json | 62 + ...944691cece457a493bd67c52f399e35bf1501.json | 122 + ...0c758fc8787e982a2587b0a6f3c91abdcb3ec.json | 12 + ...abe9cbe31fcfe919b841aa022a353e08cf1ed.json | 12 + ...63bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json | 86 + ...5960bf2968c4b75705e1a0e6800e97c3f4628.json | 158 + ...e9d689e971fbe525a5a66c7ba72b69c808073.json | 158 + packages/oneclient_db/Cargo.toml | 27 + .../20260525104022_initial_migration.sql | 153 + .../20260715120000_applied_migrations.sql | 4 + ...0260716120000_session_process_tracking.sql | 6 + ...20260801120000_browser_package_updates.sql | 37 + .../migrations/20260802120000_java_is_jdk.sql | 5 + ...120000_repair_orphaned_bundle_disables.sql | 44 + .../20260809120000_java_probe_version.sql | 1 + packages/oneclient_db/src/backup.rs | 271 + packages/oneclient_db/src/console.rs | 172 + .../oneclient_db/src/dao/applied_migration.rs | 24 + packages/oneclient_db/src/dao/artifact.rs | 321 + .../src/dao/browser_package_update.rs | 246 + packages/oneclient_db/src/dao/bundle.rs | 152 + packages/oneclient_db/src/dao/cluster.rs | 351 + .../oneclient_db/src/dao/cluster_bundle.rs | 226 + packages/oneclient_db/src/dao/game_session.rs | 300 + packages/oneclient_db/src/dao/java.rs | 110 + packages/oneclient_db/src/dao/mod.rs | 10 + .../oneclient_db/src/dao/package_metadata.rs | 63 + .../oneclient_db/src/dao/setting_profile.rs | 108 + packages/oneclient_db/src/error.rs | 21 + packages/oneclient_db/src/lib.rs | 11 + packages/oneclient_db/src/migrate.rs | 136 + packages/oneclient_db/src/models/artifact.rs | 31 + .../src/models/browser_package_update.rs | 18 + packages/oneclient_db/src/models/bundle.rs | 32 + packages/oneclient_db/src/models/cluster.rs | 37 + .../oneclient_db/src/models/cluster_bundle.rs | 49 + .../oneclient_db/src/models/game_session.rs | 53 + packages/oneclient_db/src/models/java.rs | 12 + packages/oneclient_db/src/models/mod.rs | 22 + .../src/models/package_metadata.rs | 11 + .../src/models/setting_profile.rs | 18 + packages/oneclient_db/src/pool.rs | 38 + packages/oneclient_discord/Cargo.toml | 25 + packages/oneclient_discord/examples/rpc.rs | 33 + packages/oneclient_discord/src/lib.rs | 226 + packages/oneclient_events/Cargo.toml | 20 + packages/oneclient_events/src/bus.rs | 301 + packages/oneclient_events/src/error.rs | 12 + packages/oneclient_events/src/event.rs | 131 + packages/oneclient_events/src/lib.rs | 23 + packages/oneclient_events/src/progress.rs | 398 + packages/oneclient_events/src/prompt.rs | 190 + packages/oneclient_java/Cargo.toml | 37 + .../oneclient_java/assets/java/JavaInfo.class | Bin 0 -> 3231 bytes .../oneclient_java/assets/java/JavaInfo.java | 98 + packages/oneclient_java/assets/java/README.md | 13 + .../examples/install_provider.rs | 66 + .../oneclient_java/examples/list_providers.rs | 55 + packages/oneclient_java/src/checker.rs | 182 + packages/oneclient_java/src/data.rs | 79 + packages/oneclient_java/src/error.rs | 119 + packages/oneclient_java/src/install.rs | 161 + packages/oneclient_java/src/lib.rs | 27 + packages/oneclient_java/src/locate.rs | 288 + packages/oneclient_java/src/platform.rs | 110 + packages/oneclient_java/src/resolve.rs | 63 + packages/oneclient_java/src/service.rs | 437 + packages/oneclient_java/src/store.rs | 88 + .../oneclient_java/src/vendors/adoptium.rs | 170 + .../oneclient_java/src/vendors/corretto.rs | 120 + .../oneclient_java/src/vendors/liberica.rs | 142 + packages/oneclient_java/src/vendors/mod.rs | 126 + packages/oneclient_java/src/vendors/zulu.rs | 122 + .../oneclient_java/tests/vendor_checksums.rs | 95 + packages/oneclient_macro/.gitignore | 1 + packages/oneclient_macro/Cargo.toml | 25 + packages/oneclient_macro/src/freya.rs | 64 + packages/oneclient_macro/src/lib.rs | 12 + packages/oneclient_mc/Cargo.toml | 39 + packages/oneclient_mc/src/arguments.rs | 780 ++ packages/oneclient_mc/src/download.rs | 57 + packages/oneclient_mc/src/error.rs | 83 + packages/oneclient_mc/src/install.rs | 1300 +++ packages/oneclient_mc/src/lib.rs | 37 + packages/oneclient_mc/src/manifest.rs | 264 + packages/oneclient_mc/src/profile.rs | 429 + packages/oneclient_mc/src/rules.rs | 69 + packages/oneclient_net/Cargo.toml | 36 + packages/oneclient_net/src/cache.rs | 138 + packages/oneclient_net/src/config.rs | 105 + packages/oneclient_net/src/download.rs | 235 + packages/oneclient_net/src/error.rs | 245 + packages/oneclient_net/src/lib.rs | 17 + packages/oneclient_net/src/request.rs | 41 + packages/oneclient_net/src/response.rs | 143 + packages/oneclient_net/src/service.rs | 354 + packages/oneclient_net/src/status.rs | 159 + packages/oneclient_polyplus/Cargo.toml | 38 + packages/oneclient_polyplus/src/lib.rs | 353 + packages/polyio/.gitignore | 1 + packages/polyio/Cargo.toml | 49 + packages/polyio/src/archive.rs | 291 + packages/polyio/src/error.rs | 42 + packages/polyio/src/file.rs | 879 ++ packages/polyio/src/hash.rs | 252 + packages/polyio/src/lib.rs | 15 + packages/polyio/src/system.rs | 17 + packages/polyio/src/testing.rs | 44 + 578 files changed, 91764 insertions(+) create mode 100644 ATTRIBUTION.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 distribution/aur/oneclient-bin/PKGBUILD create mode 100644 distribution/aur/oneclient/PKGBUILD create mode 100644 distribution/windows/bundle-crt.py create mode 100644 packages/oneclient_app/Cargo.toml create mode 100644 packages/oneclient_app/assets/alex.png create mode 100644 packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg create mode 100755 packages/oneclient_app/assets/fonts/JetBrainsMono/AUTHORS.txt create mode 100644 packages/oneclient_app/assets/fonts/JetBrainsMono/JetBrainsMonoVariable.ttf create mode 100644 packages/oneclient_app/assets/fonts/JetBrainsMono/OFL.txt create mode 100644 packages/oneclient_app/assets/fonts/Poppins/OFL.txt create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-Bold.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-BoldItalic.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBold.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-Italic.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-Light.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-LightItalic.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-Medium.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-MediumItalic.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-Regular.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBold.ttf create mode 100644 packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf create mode 100644 packages/oneclient_app/assets/icons/alert-circle.svg create mode 100644 packages/oneclient_app/assets/icons/alert-triangle.svg create mode 100644 packages/oneclient_app/assets/icons/announcement-01.svg create mode 100644 packages/oneclient_app/assets/icons/arrow-left.svg create mode 100644 packages/oneclient_app/assets/icons/arrow-right.svg create mode 100644 packages/oneclient_app/assets/icons/bell-01.svg create mode 100644 packages/oneclient_app/assets/icons/brush-01.svg create mode 100644 packages/oneclient_app/assets/icons/calendar.svg create mode 100644 packages/oneclient_app/assets/icons/check-circle.svg create mode 100644 packages/oneclient_app/assets/icons/check.svg create mode 100644 packages/oneclient_app/assets/icons/chevron-down.svg create mode 100644 packages/oneclient_app/assets/icons/chevron-right.svg create mode 100644 packages/oneclient_app/assets/icons/chevrons-left.svg create mode 100644 packages/oneclient_app/assets/icons/chevrons-right.svg create mode 100644 packages/oneclient_app/assets/icons/clipboard-check.svg create mode 100644 packages/oneclient_app/assets/icons/clock-rewind.svg create mode 100644 packages/oneclient_app/assets/icons/code-snippet-02.svg create mode 100644 packages/oneclient_app/assets/icons/colors.svg create mode 100644 packages/oneclient_app/assets/icons/copy-01.svg create mode 100644 packages/oneclient_app/assets/icons/curseforge.svg create mode 100644 packages/oneclient_app/assets/icons/database-01.svg create mode 100644 packages/oneclient_app/assets/icons/dots-grid.svg create mode 100644 packages/oneclient_app/assets/icons/dots-vertical.svg create mode 100644 packages/oneclient_app/assets/icons/download-01.svg create mode 100644 packages/oneclient_app/assets/icons/download-cloud-02.svg create mode 100644 packages/oneclient_app/assets/icons/eye.svg create mode 100644 packages/oneclient_app/assets/icons/file-02.svg create mode 100644 packages/oneclient_app/assets/icons/file-plus-02.svg create mode 100644 packages/oneclient_app/assets/icons/file-x-02.svg create mode 100644 packages/oneclient_app/assets/icons/folder-check.svg create mode 100644 packages/oneclient_app/assets/icons/folder-download.svg create mode 100644 packages/oneclient_app/assets/icons/folder.svg create mode 100644 packages/oneclient_app/assets/icons/globe-01.svg create mode 100644 packages/oneclient_app/assets/icons/help-circle.svg create mode 100644 packages/oneclient_app/assets/icons/info-circle.svg create mode 100644 packages/oneclient_app/assets/icons/key-01.svg create mode 100644 packages/oneclient_app/assets/icons/layout-top.svg create mode 100644 packages/oneclient_app/assets/icons/link-03.svg create mode 100644 packages/oneclient_app/assets/icons/link-external-01.svg create mode 100644 packages/oneclient_app/assets/icons/loading-02.svg create mode 100644 packages/oneclient_app/assets/icons/maximize-01.svg create mode 100644 packages/oneclient_app/assets/icons/message-text-square-01.svg create mode 100644 packages/oneclient_app/assets/icons/minus.svg create mode 100644 packages/oneclient_app/assets/icons/modrinth.svg create mode 100644 packages/oneclient_app/assets/icons/onboarding-account.svg create mode 100644 packages/oneclient_app/assets/icons/onboarding-complete.svg create mode 100644 packages/oneclient_app/assets/icons/onboarding-language.svg create mode 100644 packages/oneclient_app/assets/icons/onboarding-preferences.svg create mode 100644 packages/oneclient_app/assets/icons/onboarding-welcome.svg create mode 100644 packages/oneclient_app/assets/icons/paint-pour.svg create mode 100644 packages/oneclient_app/assets/icons/paragraph-wrap.svg create mode 100644 packages/oneclient_app/assets/icons/pencil-01.svg create mode 100644 packages/oneclient_app/assets/icons/play.svg create mode 100644 packages/oneclient_app/assets/icons/plus.svg create mode 100644 packages/oneclient_app/assets/icons/refresh-ccw-02.svg create mode 100644 packages/oneclient_app/assets/icons/refresh-cw-01.svg create mode 100644 packages/oneclient_app/assets/icons/rocket-02.svg create mode 100644 packages/oneclient_app/assets/icons/search-md.svg create mode 100644 packages/oneclient_app/assets/icons/settings-01.svg create mode 100644 packages/oneclient_app/assets/icons/settings-02.svg create mode 100644 packages/oneclient_app/assets/icons/settings-04.svg create mode 100644 packages/oneclient_app/assets/icons/sliders-04.svg create mode 100644 packages/oneclient_app/assets/icons/square.svg create mode 100644 packages/oneclient_app/assets/icons/terminal.svg create mode 100644 packages/oneclient_app/assets/icons/trash-01.svg create mode 100644 packages/oneclient_app/assets/icons/users-01.svg create mode 100644 packages/oneclient_app/assets/icons/x-close.svg create mode 100644 packages/oneclient_app/assets/icons/x.svg create mode 100644 packages/oneclient_app/assets/logo.svg create mode 100644 packages/oneclient_app/assets/steve.png create mode 100644 packages/oneclient_app/build.rs create mode 100644 packages/oneclient_app/distribution/App.entitlements create mode 100644 packages/oneclient_app/distribution/Info.plist create mode 100644 packages/oneclient_app/distribution/dmg-background.png create mode 100644 packages/oneclient_app/distribution/dmg-background.tiff create mode 100644 packages/oneclient_app/distribution/dmg-background@2x.png create mode 100644 packages/oneclient_app/distribution/linux/org.polyfrost.oneclient.template create mode 100644 packages/oneclient_app/icons/128x128.png create mode 100644 packages/oneclient_app/icons/128x128@2x.png create mode 100644 packages/oneclient_app/icons/32x32.png create mode 100644 packages/oneclient_app/icons/512x512.png create mode 100644 packages/oneclient_app/icons/64x64.png create mode 100644 packages/oneclient_app/icons/OneClient.icon/Assets/oneclient-mark.svg create mode 100644 packages/oneclient_app/icons/OneClient.icon/icon.json create mode 100644 packages/oneclient_app/icons/StoreLogo.png create mode 100644 packages/oneclient_app/icons/icon.icns create mode 100644 packages/oneclient_app/icons/icon.ico create mode 100644 packages/oneclient_app/icons/icon.png create mode 100644 packages/oneclient_app/src/assets.rs create mode 100644 packages/oneclient_app/src/components/account_switcher.rs create mode 100644 packages/oneclient_app/src/components/active_cluster_panel.rs create mode 100644 packages/oneclient_app/src/components/avatar.rs create mode 100644 packages/oneclient_app/src/components/button.rs create mode 100644 packages/oneclient_app/src/components/charts.rs create mode 100644 packages/oneclient_app/src/components/cluster_landscape_art.rs create mode 100644 packages/oneclient_app/src/components/cluster_update_popup.rs create mode 100644 packages/oneclient_app/src/components/context_menu.rs create mode 100644 packages/oneclient_app/src/components/dropdown.rs create mode 100644 packages/oneclient_app/src/components/dynamic_art.rs create mode 100644 packages/oneclient_app/src/components/file_drop.rs create mode 100644 packages/oneclient_app/src/components/generic_prompt.rs create mode 100644 packages/oneclient_app/src/components/icons.rs create mode 100644 packages/oneclient_app/src/components/java_install_manager.rs create mode 100644 packages/oneclient_app/src/components/java_prompt.rs create mode 100644 packages/oneclient_app/src/components/link_confirm.rs create mode 100644 packages/oneclient_app/src/components/local_image.rs create mode 100644 packages/oneclient_app/src/components/log_viewer.rs create mode 100644 packages/oneclient_app/src/components/memory_field.rs create mode 100644 packages/oneclient_app/src/components/microsoft_login.rs create mode 100644 packages/oneclient_app/src/components/mod.rs create mode 100644 packages/oneclient_app/src/components/navbar/app_navbar.rs create mode 100644 packages/oneclient_app/src/components/navbar/mod.rs create mode 100644 packages/oneclient_app/src/components/navbar/onboarding_navbar.rs create mode 100644 packages/oneclient_app/src/components/notifications.rs create mode 100644 packages/oneclient_app/src/components/overlay_popup.rs create mode 100644 packages/oneclient_app/src/components/package_row.rs create mode 100644 packages/oneclient_app/src/components/package_update_popup.rs create mode 100644 packages/oneclient_app/src/components/pagination.rs create mode 100644 packages/oneclient_app/src/components/player_model.rs create mode 100644 packages/oneclient_app/src/components/progress_track.rs create mode 100644 packages/oneclient_app/src/components/recents_row.rs create mode 100644 packages/oneclient_app/src/components/screenshot_viewer.rs create mode 100644 packages/oneclient_app/src/components/scrollview.rs create mode 100644 packages/oneclient_app/src/components/segmented_control.rs create mode 100644 packages/oneclient_app/src/components/splash_curtain.rs create mode 100644 packages/oneclient_app/src/components/status_bar.rs create mode 100644 packages/oneclient_app/src/components/tab_bar.rs create mode 100644 packages/oneclient_app/src/components/text_input.rs create mode 100644 packages/oneclient_app/src/components/toasts.rs create mode 100644 packages/oneclient_app/src/components/toggle.rs create mode 100644 packages/oneclient_app/src/components/update_prompt.rs create mode 100644 packages/oneclient_app/src/components/upload_mclogs.rs create mode 100644 packages/oneclient_app/src/components/version_card.rs create mode 100644 packages/oneclient_app/src/constants.rs create mode 100644 packages/oneclient_app/src/events.rs create mode 100644 packages/oneclient_app/src/hooks/actions.rs create mode 100644 packages/oneclient_app/src/hooks/active_cluster.rs create mode 100644 packages/oneclient_app/src/hooks/debounce.rs create mode 100644 packages/oneclient_app/src/hooks/mod.rs create mode 100644 packages/oneclient_app/src/hooks/queries/analytics.rs create mode 100644 packages/oneclient_app/src/hooks/queries/auth.rs create mode 100644 packages/oneclient_app/src/hooks/queries/bundles.rs create mode 100644 packages/oneclient_app/src/hooks/queries/changelog.rs create mode 100644 packages/oneclient_app/src/hooks/queries/cluster_content.rs create mode 100644 packages/oneclient_app/src/hooks/queries/clusters.rs create mode 100644 packages/oneclient_app/src/hooks/queries/image.rs create mode 100644 packages/oneclient_app/src/hooks/queries/java.rs create mode 100644 packages/oneclient_app/src/hooks/queries/logs.rs create mode 100644 packages/oneclient_app/src/hooks/queries/migration.rs create mode 100644 packages/oneclient_app/src/hooks/queries/mod.rs create mode 100644 packages/oneclient_app/src/hooks/queries/mutations.rs create mode 100644 packages/oneclient_app/src/hooks/queries/package_updates.rs create mode 100644 packages/oneclient_app/src/hooks/queries/packages.rs create mode 100644 packages/oneclient_app/src/hooks/queries/player_profile.rs create mode 100644 packages/oneclient_app/src/hooks/queries/screenshots.rs create mode 100644 packages/oneclient_app/src/hooks/queries/settings_profiles.rs create mode 100644 packages/oneclient_app/src/hooks/queries/skin.rs create mode 100644 packages/oneclient_app/src/hooks/queries/state.rs create mode 100644 packages/oneclient_app/src/hooks/queries/storage.rs create mode 100644 packages/oneclient_app/src/hooks/queries/tos.rs create mode 100644 packages/oneclient_app/src/hooks/queries/version_metadata.rs create mode 100644 packages/oneclient_app/src/hooks/queries/versions.rs create mode 100644 packages/oneclient_app/src/hooks/view_state.rs create mode 100644 packages/oneclient_app/src/install.rs create mode 100644 packages/oneclient_app/src/launcher.rs create mode 100644 packages/oneclient_app/src/layout/animated_outlet.rs create mode 100644 packages/oneclient_app/src/layout/app_shell.rs create mode 100644 packages/oneclient_app/src/layout/cluster_shell.rs create mode 100644 packages/oneclient_app/src/layout/mod.rs create mode 100644 packages/oneclient_app/src/layout/onboarding_shell.rs create mode 100644 packages/oneclient_app/src/layout/root_layout.rs create mode 100644 packages/oneclient_app/src/layout/settings_shell.rs create mode 100644 packages/oneclient_app/src/lib.rs create mode 100644 packages/oneclient_app/src/main.rs create mode 100644 packages/oneclient_app/src/motion.rs create mode 100644 packages/oneclient_app/src/notifications.rs create mode 100644 packages/oneclient_app/src/platform.rs create mode 100644 packages/oneclient_app/src/recovery.rs create mode 100644 packages/oneclient_app/src/routes.rs create mode 100644 packages/oneclient_app/src/state.rs create mode 100644 packages/oneclient_app/src/theme/colors.rs create mode 100644 packages/oneclient_app/src/theme/mod.rs create mode 100644 packages/oneclient_app/src/transfer.rs create mode 100644 packages/oneclient_app/src/ui.rs create mode 100644 packages/oneclient_app/src/updater.rs create mode 100644 packages/oneclient_app/src/utils.rs create mode 100644 packages/oneclient_app/src/view/app/analytics/charts.rs create mode 100644 packages/oneclient_app/src/view/app/analytics/mod.rs create mode 100644 packages/oneclient_app/src/view/app/analytics/servers.rs create mode 100644 packages/oneclient_app/src/view/app/analytics/tiles.rs create mode 100644 packages/oneclient_app/src/view/app/browser/index/cards.rs create mode 100644 packages/oneclient_app/src/view/app/browser/index/mod.rs create mode 100644 packages/oneclient_app/src/view/app/browser/index/sidebar.rs create mode 100644 packages/oneclient_app/src/view/app/browser/index/skeletons.rs create mode 100644 packages/oneclient_app/src/view/app/browser/mod.rs create mode 100644 packages/oneclient_app/src/view/app/browser/package/gallery.rs create mode 100644 packages/oneclient_app/src/view/app/browser/package/mod.rs create mode 100644 packages/oneclient_app/src/view/app/browser/package/panels.rs create mode 100644 packages/oneclient_app/src/view/app/browser/package/sidebar.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/cluster_settings.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/logs.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/mod.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/mods.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/overview.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/package_manager/views.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/process_logs.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/screenshots.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/shaders.rs create mode 100644 packages/oneclient_app/src/view/app/cluster/textures.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/mod.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/page.rs create mode 100644 packages/oneclient_app/src/view/app/debug.rs create mode 100644 packages/oneclient_app/src/view/app/home/index.rs create mode 100644 packages/oneclient_app/src/view/app/home/mod.rs create mode 100644 packages/oneclient_app/src/view/app/mod.rs create mode 100644 packages/oneclient_app/src/view/app/settings/accounts.rs create mode 100644 packages/oneclient_app/src/view/app/settings/apis.rs create mode 100644 packages/oneclient_app/src/view/app/settings/appearance.rs create mode 100644 packages/oneclient_app/src/view/app/settings/changelog.rs create mode 100644 packages/oneclient_app/src/view/app/settings/developer.rs create mode 100644 packages/oneclient_app/src/view/app/settings/feedback.rs create mode 100644 packages/oneclient_app/src/view/app/settings/java.rs create mode 100644 packages/oneclient_app/src/view/app/settings/language.rs create mode 100644 packages/oneclient_app/src/view/app/settings/launcher.rs create mode 100644 packages/oneclient_app/src/view/app/settings/minecraft.rs create mode 100644 packages/oneclient_app/src/view/app/settings/mod.rs create mode 100644 packages/oneclient_app/src/view/app/settings/storage.rs create mode 100644 packages/oneclient_app/src/view/app/skins.rs create mode 100644 packages/oneclient_app/src/view/app/stats.rs create mode 100644 packages/oneclient_app/src/view/console.rs create mode 100644 packages/oneclient_app/src/view/mod.rs create mode 100644 packages/oneclient_app/src/view/not_found.rs create mode 100644 packages/oneclient_app/src/view/onboarding/account.rs create mode 100644 packages/oneclient_app/src/view/onboarding/bundles/mod.rs create mode 100644 packages/oneclient_app/src/view/onboarding/bundles/row.rs create mode 100644 packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs create mode 100644 packages/oneclient_app/src/view/onboarding/downloading/mod.rs create mode 100644 packages/oneclient_app/src/view/onboarding/downloading/progress.rs create mode 100644 packages/oneclient_app/src/view/onboarding/downloading/summary.rs create mode 100644 packages/oneclient_app/src/view/onboarding/downloading/tips.rs create mode 100644 packages/oneclient_app/src/view/onboarding/language.rs create mode 100644 packages/oneclient_app/src/view/onboarding/migration.rs create mode 100644 packages/oneclient_app/src/view/onboarding/mod.rs create mode 100644 packages/oneclient_app/src/view/onboarding/preferences.rs create mode 100644 packages/oneclient_app/src/view/onboarding/selection.rs create mode 100644 packages/oneclient_app/src/view/onboarding/terms.rs create mode 100644 packages/oneclient_app/src/view/onboarding/test_support.rs create mode 100644 packages/oneclient_app/src/view/onboarding/welcome.rs create mode 100644 packages/oneclient_app/src/view/startup.rs create mode 100644 packages/oneclient_auth/Cargo.toml create mode 100644 packages/oneclient_auth/src/data.rs create mode 100644 packages/oneclient_auth/src/diagnostics.rs create mode 100644 packages/oneclient_auth/src/error.rs create mode 100644 packages/oneclient_auth/src/lib.rs create mode 100644 packages/oneclient_auth/src/msa.rs create mode 100644 packages/oneclient_auth/src/offline.rs create mode 100644 packages/oneclient_auth/src/service.rs create mode 100644 packages/oneclient_auth/src/store.rs create mode 100644 packages/oneclient_auth/tests/offline.rs create mode 100644 packages/oneclient_cluster/Cargo.toml create mode 100644 packages/oneclient_cluster/src/cluster.rs create mode 100644 packages/oneclient_cluster/src/error.rs create mode 100644 packages/oneclient_cluster/src/lib.rs create mode 100644 packages/oneclient_cluster/src/logs/error.rs create mode 100644 packages/oneclient_cluster/src/logs/manage.rs create mode 100644 packages/oneclient_cluster/src/logs/mclogs.rs create mode 100644 packages/oneclient_cluster/src/logs/mod.rs create mode 100644 packages/oneclient_cluster/src/logs/parse.rs create mode 100644 packages/oneclient_cluster/src/manager.rs create mode 100644 packages/oneclient_cluster/src/options.rs create mode 100644 packages/oneclient_cluster/src/profile.rs create mode 100644 packages/oneclient_cluster/src/profiles.rs create mode 100644 packages/oneclient_cluster/src/screenshots/mod.rs create mode 100644 packages/oneclient_cluster/src/stage.rs create mode 100644 packages/oneclient_common/Cargo.toml create mode 100644 packages/oneclient_common/src/constants.rs create mode 100644 packages/oneclient_common/src/domain.rs create mode 100644 packages/oneclient_common/src/error.rs create mode 100644 packages/oneclient_common/src/lib.rs create mode 100644 packages/oneclient_common/src/os_ext.rs create mode 100644 packages/oneclient_common/src/patch.rs create mode 100644 packages/oneclient_common/src/paths.rs create mode 100644 packages/oneclient_common/src/search.rs create mode 100644 packages/oneclient_common/src/version.rs create mode 100644 packages/oneclient_content/Cargo.toml create mode 100644 packages/oneclient_content/src/bundles/error.rs create mode 100644 packages/oneclient_content/src/bundles/install.rs create mode 100644 packages/oneclient_content/src/bundles/manager.rs create mode 100644 packages/oneclient_content/src/bundles/manifest.rs create mode 100644 packages/oneclient_content/src/bundles/mod.rs create mode 100644 packages/oneclient_content/src/bundles/overrides.rs create mode 100644 packages/oneclient_content/src/bundles/polymrpack.rs create mode 100644 packages/oneclient_content/src/bundles/runtime.rs create mode 100644 packages/oneclient_content/src/bundles/types.rs create mode 100644 packages/oneclient_content/src/bundles/updates.rs create mode 100644 packages/oneclient_content/src/ctx.rs create mode 100644 packages/oneclient_content/src/error.rs create mode 100644 packages/oneclient_content/src/lib.rs create mode 100644 packages/oneclient_content/src/packages/activity.rs create mode 100644 packages/oneclient_content/src/packages/dependencies.rs create mode 100644 packages/oneclient_content/src/packages/error.rs create mode 100644 packages/oneclient_content/src/packages/file_identity.rs create mode 100644 packages/oneclient_content/src/packages/metadata_cache.rs create mode 100644 packages/oneclient_content/src/packages/mod.rs create mode 100644 packages/oneclient_content/src/packages/modpack/mod.rs create mode 100644 packages/oneclient_content/src/packages/modpack/mrpack.rs create mode 100644 packages/oneclient_content/src/packages/provider/curseforge.rs create mode 100644 packages/oneclient_content/src/packages/provider/http.rs create mode 100644 packages/oneclient_content/src/packages/provider/mod.rs create mode 100644 packages/oneclient_content/src/packages/provider/modrinth.rs create mode 100644 packages/oneclient_content/src/packages/provider/registry.rs create mode 100644 packages/oneclient_content/src/packages/store/download.rs create mode 100644 packages/oneclient_content/src/packages/store/gc.rs create mode 100644 packages/oneclient_content/src/packages/store/link.rs create mode 100644 packages/oneclient_content/src/packages/store/manifest.rs create mode 100644 packages/oneclient_content/src/packages/store/mod.rs create mode 100644 packages/oneclient_content/src/packages/store/paths.rs create mode 100644 packages/oneclient_content/src/packages/types.rs create mode 100644 packages/oneclient_content/src/packages/updates.rs create mode 100644 packages/oneclient_content/tests/file_identity.rs create mode 100644 packages/oneclient_core/.gitignore create mode 100644 packages/oneclient_core/Cargo.toml create mode 100644 packages/oneclient_core/examples/auth_msa.rs create mode 100644 packages/oneclient_core/examples/bundles_sync.rs create mode 100644 packages/oneclient_core/examples/cluster_manage.rs create mode 100644 packages/oneclient_core/examples/detect_v1.rs create mode 100644 packages/oneclient_core/examples/download_game.rs create mode 100644 packages/oneclient_core/examples/launch_game.rs create mode 100644 packages/oneclient_core/examples/notifications.rs create mode 100644 packages/oneclient_core/examples/package_project.rs create mode 100644 packages/oneclient_core/examples/package_search.rs create mode 100644 packages/oneclient_core/src/auth/mod.rs create mode 100644 packages/oneclient_core/src/auth/offline.rs create mode 100644 packages/oneclient_core/src/changelog.rs create mode 100644 packages/oneclient_core/src/clusters/migrate.rs create mode 100644 packages/oneclient_core/src/clusters/mod.rs create mode 100644 packages/oneclient_core/src/clusters/prepare.rs create mode 100644 packages/oneclient_core/src/clusters/provision.rs create mode 100644 packages/oneclient_core/src/clusters/unlink_legacy.rs create mode 100644 packages/oneclient_core/src/dev.rs create mode 100644 packages/oneclient_core/src/error.rs create mode 100644 packages/oneclient_core/src/game/analytics.rs create mode 100644 packages/oneclient_core/src/game/diagnosis.rs create mode 100644 packages/oneclient_core/src/game/error.rs create mode 100644 packages/oneclient_core/src/game/launch.rs create mode 100644 packages/oneclient_core/src/game/log_replay.rs create mode 100644 packages/oneclient_core/src/game/mod.rs create mode 100644 packages/oneclient_core/src/game/process.rs create mode 100644 packages/oneclient_core/src/game/reattach.rs create mode 100644 packages/oneclient_core/src/game/session.rs create mode 100644 packages/oneclient_core/src/game/shared_dir.rs create mode 100644 packages/oneclient_core/src/game/tail.rs create mode 100644 packages/oneclient_core/src/images/mod.rs create mode 100644 packages/oneclient_core/src/java_store.rs create mode 100644 packages/oneclient_core/src/lib.rs create mode 100644 packages/oneclient_core/src/logger.rs create mode 100644 packages/oneclient_core/src/logger/console.rs create mode 100644 packages/oneclient_core/src/migration/mod.rs create mode 100644 packages/oneclient_core/src/migration/oneclient_v1.rs create mode 100644 packages/oneclient_core/src/migration/vanilla.rs create mode 100644 packages/oneclient_core/src/recovery.rs create mode 100644 packages/oneclient_core/src/reporting.rs create mode 100644 packages/oneclient_core/src/settings/launcher.rs create mode 100644 packages/oneclient_core/src/settings/mod.rs create mode 100644 packages/oneclient_core/src/settings/store.rs create mode 100644 packages/oneclient_core/src/simulate.rs create mode 100644 packages/oneclient_core/src/state.rs create mode 100644 packages/oneclient_core/src/storage.rs create mode 100644 packages/oneclient_core/src/tos.rs create mode 100644 packages/oneclient_core/src/verify.rs create mode 100644 packages/oneclient_core/src/versions/manager.rs create mode 100644 packages/oneclient_core/src/versions/manifest.rs create mode 100644 packages/oneclient_core/src/versions/mod.rs create mode 100644 packages/oneclient_core/tests/bundle_updates.rs create mode 100644 packages/oneclient_core/tests/clusters.rs create mode 100644 packages/oneclient_core/tests/download_verify.rs create mode 100644 packages/oneclient_core/tests/patch.rs create mode 100644 packages/oneclient_core/tests/providers.rs create mode 100644 packages/oneclient_db/.env create mode 100644 packages/oneclient_db/.env.template create mode 100644 packages/oneclient_db/.gitignore create mode 100644 packages/oneclient_db/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json create mode 100644 packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json create mode 100644 packages/oneclient_db/.sqlx/query-0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb.json create mode 100644 packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json create mode 100644 packages/oneclient_db/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json create mode 100644 packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json create mode 100644 packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json create mode 100644 packages/oneclient_db/.sqlx/query-1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202.json create mode 100644 packages/oneclient_db/.sqlx/query-2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319.json create mode 100644 packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json create mode 100644 packages/oneclient_db/.sqlx/query-26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c.json create mode 100644 packages/oneclient_db/.sqlx/query-271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107.json create mode 100644 packages/oneclient_db/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json create mode 100644 packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json create mode 100644 packages/oneclient_db/.sqlx/query-3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77.json create mode 100644 packages/oneclient_db/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json create mode 100644 packages/oneclient_db/.sqlx/query-42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7.json create mode 100644 packages/oneclient_db/.sqlx/query-43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a.json create mode 100644 packages/oneclient_db/.sqlx/query-439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919.json create mode 100644 packages/oneclient_db/.sqlx/query-44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a.json create mode 100644 packages/oneclient_db/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json create mode 100644 packages/oneclient_db/.sqlx/query-472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9.json create mode 100644 packages/oneclient_db/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json create mode 100644 packages/oneclient_db/.sqlx/query-4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f.json create mode 100644 packages/oneclient_db/.sqlx/query-4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c.json create mode 100644 packages/oneclient_db/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json create mode 100644 packages/oneclient_db/.sqlx/query-5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02.json create mode 100644 packages/oneclient_db/.sqlx/query-53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30.json create mode 100644 packages/oneclient_db/.sqlx/query-54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a.json create mode 100644 packages/oneclient_db/.sqlx/query-56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549.json create mode 100644 packages/oneclient_db/.sqlx/query-5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1.json create mode 100644 packages/oneclient_db/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json create mode 100644 packages/oneclient_db/.sqlx/query-628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186.json create mode 100644 packages/oneclient_db/.sqlx/query-6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02.json create mode 100644 packages/oneclient_db/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json create mode 100644 packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json create mode 100644 packages/oneclient_db/.sqlx/query-7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1.json create mode 100644 packages/oneclient_db/.sqlx/query-80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b.json create mode 100644 packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json create mode 100644 packages/oneclient_db/.sqlx/query-8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd.json create mode 100644 packages/oneclient_db/.sqlx/query-968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f.json create mode 100644 packages/oneclient_db/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json create mode 100644 packages/oneclient_db/.sqlx/query-98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700.json create mode 100644 packages/oneclient_db/.sqlx/query-9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263.json create mode 100644 packages/oneclient_db/.sqlx/query-a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e.json create mode 100644 packages/oneclient_db/.sqlx/query-a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391.json create mode 100644 packages/oneclient_db/.sqlx/query-ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094.json create mode 100644 packages/oneclient_db/.sqlx/query-ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55.json create mode 100644 packages/oneclient_db/.sqlx/query-b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2.json create mode 100644 packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json create mode 100644 packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json create mode 100644 packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json create mode 100644 packages/oneclient_db/.sqlx/query-c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555.json create mode 100644 packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json create mode 100644 packages/oneclient_db/.sqlx/query-c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2.json create mode 100644 packages/oneclient_db/.sqlx/query-c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38.json create mode 100644 packages/oneclient_db/.sqlx/query-caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a.json create mode 100644 packages/oneclient_db/.sqlx/query-caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892.json create mode 100644 packages/oneclient_db/.sqlx/query-d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json create mode 100644 packages/oneclient_db/.sqlx/query-da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c.json create mode 100644 packages/oneclient_db/.sqlx/query-e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee.json create mode 100644 packages/oneclient_db/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json create mode 100644 packages/oneclient_db/.sqlx/query-e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501.json create mode 100644 packages/oneclient_db/.sqlx/query-f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec.json create mode 100644 packages/oneclient_db/.sqlx/query-f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed.json create mode 100644 packages/oneclient_db/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json create mode 100644 packages/oneclient_db/.sqlx/query-f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628.json create mode 100644 packages/oneclient_db/.sqlx/query-f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073.json create mode 100644 packages/oneclient_db/Cargo.toml create mode 100644 packages/oneclient_db/migrations/20260525104022_initial_migration.sql create mode 100644 packages/oneclient_db/migrations/20260715120000_applied_migrations.sql create mode 100644 packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql create mode 100644 packages/oneclient_db/migrations/20260801120000_browser_package_updates.sql create mode 100644 packages/oneclient_db/migrations/20260802120000_java_is_jdk.sql create mode 100644 packages/oneclient_db/migrations/20260806120000_repair_orphaned_bundle_disables.sql create mode 100644 packages/oneclient_db/migrations/20260809120000_java_probe_version.sql create mode 100644 packages/oneclient_db/src/backup.rs create mode 100644 packages/oneclient_db/src/console.rs create mode 100644 packages/oneclient_db/src/dao/applied_migration.rs create mode 100644 packages/oneclient_db/src/dao/artifact.rs create mode 100644 packages/oneclient_db/src/dao/browser_package_update.rs create mode 100644 packages/oneclient_db/src/dao/bundle.rs create mode 100644 packages/oneclient_db/src/dao/cluster.rs create mode 100644 packages/oneclient_db/src/dao/cluster_bundle.rs create mode 100644 packages/oneclient_db/src/dao/game_session.rs create mode 100644 packages/oneclient_db/src/dao/java.rs create mode 100644 packages/oneclient_db/src/dao/mod.rs create mode 100644 packages/oneclient_db/src/dao/package_metadata.rs create mode 100644 packages/oneclient_db/src/dao/setting_profile.rs create mode 100644 packages/oneclient_db/src/error.rs create mode 100644 packages/oneclient_db/src/lib.rs create mode 100644 packages/oneclient_db/src/migrate.rs create mode 100644 packages/oneclient_db/src/models/artifact.rs create mode 100644 packages/oneclient_db/src/models/browser_package_update.rs create mode 100644 packages/oneclient_db/src/models/bundle.rs create mode 100644 packages/oneclient_db/src/models/cluster.rs create mode 100644 packages/oneclient_db/src/models/cluster_bundle.rs create mode 100644 packages/oneclient_db/src/models/game_session.rs create mode 100644 packages/oneclient_db/src/models/java.rs create mode 100644 packages/oneclient_db/src/models/mod.rs create mode 100644 packages/oneclient_db/src/models/package_metadata.rs create mode 100644 packages/oneclient_db/src/models/setting_profile.rs create mode 100644 packages/oneclient_db/src/pool.rs create mode 100644 packages/oneclient_discord/Cargo.toml create mode 100644 packages/oneclient_discord/examples/rpc.rs create mode 100644 packages/oneclient_discord/src/lib.rs create mode 100644 packages/oneclient_events/Cargo.toml create mode 100644 packages/oneclient_events/src/bus.rs create mode 100644 packages/oneclient_events/src/error.rs create mode 100644 packages/oneclient_events/src/event.rs create mode 100644 packages/oneclient_events/src/lib.rs create mode 100644 packages/oneclient_events/src/progress.rs create mode 100644 packages/oneclient_events/src/prompt.rs create mode 100644 packages/oneclient_java/Cargo.toml create mode 100644 packages/oneclient_java/assets/java/JavaInfo.class create mode 100644 packages/oneclient_java/assets/java/JavaInfo.java create mode 100644 packages/oneclient_java/assets/java/README.md create mode 100644 packages/oneclient_java/examples/install_provider.rs create mode 100644 packages/oneclient_java/examples/list_providers.rs create mode 100644 packages/oneclient_java/src/checker.rs create mode 100644 packages/oneclient_java/src/data.rs create mode 100644 packages/oneclient_java/src/error.rs create mode 100644 packages/oneclient_java/src/install.rs create mode 100644 packages/oneclient_java/src/lib.rs create mode 100644 packages/oneclient_java/src/locate.rs create mode 100644 packages/oneclient_java/src/platform.rs create mode 100644 packages/oneclient_java/src/resolve.rs create mode 100644 packages/oneclient_java/src/service.rs create mode 100644 packages/oneclient_java/src/store.rs create mode 100644 packages/oneclient_java/src/vendors/adoptium.rs create mode 100644 packages/oneclient_java/src/vendors/corretto.rs create mode 100644 packages/oneclient_java/src/vendors/liberica.rs create mode 100644 packages/oneclient_java/src/vendors/mod.rs create mode 100644 packages/oneclient_java/src/vendors/zulu.rs create mode 100644 packages/oneclient_java/tests/vendor_checksums.rs create mode 100644 packages/oneclient_macro/.gitignore create mode 100644 packages/oneclient_macro/Cargo.toml create mode 100644 packages/oneclient_macro/src/freya.rs create mode 100644 packages/oneclient_macro/src/lib.rs create mode 100644 packages/oneclient_mc/Cargo.toml create mode 100644 packages/oneclient_mc/src/arguments.rs create mode 100644 packages/oneclient_mc/src/download.rs create mode 100644 packages/oneclient_mc/src/error.rs create mode 100644 packages/oneclient_mc/src/install.rs create mode 100644 packages/oneclient_mc/src/lib.rs create mode 100644 packages/oneclient_mc/src/manifest.rs create mode 100644 packages/oneclient_mc/src/profile.rs create mode 100644 packages/oneclient_mc/src/rules.rs create mode 100644 packages/oneclient_net/Cargo.toml create mode 100644 packages/oneclient_net/src/cache.rs create mode 100644 packages/oneclient_net/src/config.rs create mode 100644 packages/oneclient_net/src/download.rs create mode 100644 packages/oneclient_net/src/error.rs create mode 100644 packages/oneclient_net/src/lib.rs create mode 100644 packages/oneclient_net/src/request.rs create mode 100644 packages/oneclient_net/src/response.rs create mode 100644 packages/oneclient_net/src/service.rs create mode 100644 packages/oneclient_net/src/status.rs create mode 100644 packages/oneclient_polyplus/Cargo.toml create mode 100644 packages/oneclient_polyplus/src/lib.rs create mode 100644 packages/polyio/.gitignore create mode 100644 packages/polyio/Cargo.toml create mode 100644 packages/polyio/src/archive.rs create mode 100644 packages/polyio/src/error.rs create mode 100644 packages/polyio/src/file.rs create mode 100644 packages/polyio/src/hash.rs create mode 100644 packages/polyio/src/lib.rs create mode 100644 packages/polyio/src/system.rs create mode 100644 packages/polyio/src/testing.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 00000000..1c162ee9 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,108 @@ +# Attribution + +The source code of this repository is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE), with a header provided below. Certain files in our library wrappers may be under a different license or [require credit](#additional-libraries), with their specific headers outlined in the below non-exhaustive list. + +## OneLauncher + +```txt +OneLauncher - Open source Rust-based Minecraft Launcher +Copyright (C) 2023~CURRENT Polyfrost and Contributors + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program. If not, see . +``` + +## OneClient + +```txt +OneClient - A Minecraft Launcher +Copyright (C) 2023~CURRENT Polyfrost and Contributors + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program. If not, see . +``` + +## Polyfrost branding + +Any files depicting [Polyfrost branding](https://polyfrost.org/legal/branding), including the Polyfrost logo, any landing and background images, the OneConfig logo, the OneLauncher logo, and variations thereof, are licensed under the following: + +```txt +All rights reserved. Copyright (C) 2022~CURRENT Polyfrost +``` + +This includes, but may not be limited to, the following files: + +* [`packages/distribution/icons/*`](./packages/distribution/icons/) +* [`apps/frontend/src/assets/licensed/*`](./apps/frontend/src/assets/licensed/) + +## Additional Libraries + +The following are libraries, projects, and codebases which we (Polyfrost and Contributors) copied or modified, with additional attribution inline (THIS IS A NON-EXHAUSTIVE LIST, IF WE ARE MISSING A LICENSE, PLEASE LEAVE A TICKET IN [our Discord server](https://polyfrost.org/discord)): + +## [Gamemode] + +```txt +Copyright (c) 2017-2025, Feral Interactive and the GameMode contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Feral Interactive nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +``` + +## [Rust Gamemode Internal Linking] + +```txt +MIT License + +Copyright (c) 2024 Sophie Leochina + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +[Gamemode]: https://github.com/FeralInteractive/gamemode +[Rust Gamemode Internal Linking]: https://github.com/SophieSilver/linking-gamemode diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..7fb65515 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ +# Contributor Covenant Code of Conduct + +This is a modified version of the Contributor Covenant to clarify trolling (line 35), +and add contact information (lines 67-68). + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling (excessive antagonistic, inflammatory, insincere behaviour), insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via +[our Discord server](https://polyfrost.org/discord), or via +[email](mailto:coc@polyfrost.org). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4d0b3af4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,122 @@ +# Contributing to OneLauncher/OneClient + +Welcome! + +Please read our [Code of Conduct] to keep our community approachable and respectable. + +## New Contributor Guide + +To familiarize yourself with the project, please read the [README]. Here are some resources to help you get started with open-source contributions: + +- [Finding ways to contribute to open-source on GitHub] +- [Setting up Git] +- [GitHub flow] +- [Collaborating with pull requests] +- [The Rust Book] +- [The `cargo` CLI] + +## Getting Started + +### Issues + +#### Creating a New Issue + +If you come across an issue or have a feature request for OneLauncher/OneClient, please [search if a related issue has already been reported]. If no relevant issue exists, you can open a new issue using the appropriate [issue form]. + +#### Solving an Issue + +To find an issue that interests you, you can browse through our [existing issues] and use the available `labels` to narrow down your search (See [Labels] for more information). As a general rule, if you find an issue you want to work on, you are welcome to open a PR with a fix. + +### Making Changes + +#### Making Changes Locally + +This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a +recent Rust toolchain (`rustc` **1.85+**) installed before proceeding — the easiest way +is via [rustup]. + +To make changes locally, follow these steps: + +1. Clone the repository: `git clone https://github.com/Polyfrost/OneLauncher` +2. Navigate to the project directory: `cd OneLauncher` +3. Configure your system environment for OneLauncher/OneClient development: + 1. Install a Rust toolchain that supports edition 2024: `rustup toolchain install stable` + 2. Freya renders with [Skia]; on Linux you may also need system libraries such as a C + compiler, `pkg-config`, and the usual GUI/graphics dev packages. See the + [Freya setup guide] for platform-specific prerequisites. + +### Running + +The app crate is `oneclient_app`: + +- `cargo run -p oneclient_app` - Runs the **OneClient** desktop application. +- `cargo build --release -p oneclient_app` - Builds an optimized release binary. + +After you finish making your changes and committed them to your branch, run +`cargo fmt` and `cargo clippy` to fix style inconsistencies and catch lints. + +### Packaging + +Installers are built with [cargo-packager] (config in +`packages/oneclient_app/Cargo.toml` under `[package.metadata.packager]`): + +- `cargo install cargo-packager --locked` +- `cargo build --release -p oneclient_app` +- `cargo packager --release -p oneclient_app --formats ` + +Releases are cut by dispatching the `OneClient Release Build` GitHub Actions +workflow. See the **Packaging / Releasing** section of the [README] for details +and per-OS prerequisites. + +### Pull Request + +Once you have finished making your changes, create a pull request (PR) to submit them. + +- Fill out the template to help reviewers understand your changes and the purpose of your PR. +- If you are addressing an existing issue, don't forget to [link your PR to the issue]. +- Enable the checkbox to [allow maintainer edits] so that the branch can be updated for merging. +- Once you submit your PR, a team member will review your proposal. They may ask questions or request additional information. +- You may be asked to make changes before the PR can be merged, either through [suggested changes] or pull request comments. You can apply suggested changes directly through the UI. For other changes, you can make them in your fork and commit them to your branch. +- As you update your PR and apply changes, [mark each conversation as resolved]. +- If you run into any merge issues, refer to this [git tutorial] to help you resolve merge conflicts and other issues. + +### Your PR is Merged! + +Congratulations! 🎉🎉 Polyfrost thanks you for your contribution! ✨ +Once your PR is merged, your changes will be included in the next release. + +### Common Errors + +#### macOS errors with Xcode command line tools + +Build errors can occur when the Xcode command line tools are not installed or not in your `PATH`. + +Run `xcode-select --install` in the terminal to install them. If they are already installed, ensure that macOS is updated to the latest version available. + +### Credits + +This CONTRIBUTING.md file was inspired by the [`github/docs` CONTRIBUTING.md] file, and we extend our gratitude to the original authors. + +[cargo-packager]: https://github.com/crabnebula-dev/cargo-packager +[Skia]: https://skia.org/ +[Freya setup guide]: https://book.freyaui.dev/setup.html +[`cargo`]: https://doc.rust-lang.org/cargo/getting-started/installation.html +[rustup]: https://rustup.rs/ +[Labels]: https://github.com/Polyfrost/OneLauncher/labels +[The Rust Book]: https://doc.rust-lang.org/book/ +[The `cargo` CLI]: https://doc.rust-lang.org/cargo/ +[Collaborating with pull requests]: https://docs.github.com/en/github/collaborating-with-pull-requests +[GitHub flow]: https://docs.github.com/en/get-started/quickstart/github-flow +[Setting up Git]: https://docs.github.com/en/get-started/quickstart/set-up-git +[Finding ways to contribute to open-source on GitHub]: https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github +[Code of Conduct]: ./CODE_OF_CONDUCT.md +[README]: ./README.md +[search if a related issue has already been reported]: https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments +[issue form]: https://github.com/Polyfrost/OneLauncher/issues/new/choose +[existing issues]: https://github.com/Polyfrost/OneLauncher/issues +[`github/docs` CONTRIBUTING.md]: https://github.com/github/docs/blob/main/CONTRIBUTING.md +[link your PR to the issue]: https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue +[allow maintainer edits]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork +[suggested changes]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request +[mark each conversation as resolved]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations +[git tutorial]: https://lab.github.com/githubtraining/managing-merge-conflicts diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..9293654f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8759 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid 1.23.1", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.13.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.13.1", + "bytes 1.11.1", + "bytestring", + "derive_more", + "encoding_rs", + "foldhash", + "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", + "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.5", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ash-window" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" +dependencies = [ + "ash", + "raw-window-handle", + "raw-window-metal", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-tempfile" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8a57b75c36e16f4d015e60e6a177552976a99b6947724403c551bcfa7cb1207" +dependencies = [ + "tokio", + "uuid 1.23.1", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +dependencies = [ + "async-compression", + "chrono", + "crc32fast", + "futures-lite", + "pin-project", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "beul" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3c3baedf037b161e69da9200947329f52afa3b6e1cdbe6f344d6aa350fedd2f" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.117", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + +[[package]] +name = "bytes" +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 = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cargo-packager-updater" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec09acab5c2227aba2e592d431708305bdeb6d507703f6cd8983fb57b6c5ef7" +dependencies = [ + "base64 0.22.1", + "cargo-packager-utils", + "ctor 0.2.9", + "dirs", + "dunce", + "flate2", + "http 1.5.0", + "log", + "minisign-verify", + "percent-encoding", + "reqwest 0.12.28", + "semver", + "serde", + "serde_json", + "tar", + "tempfile", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "cargo-packager-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b43458dd2ee3cdab3f5b105acd80791383b730380c929018701313d7d299d4e8" +dependencies = [ + "ctor 0.2.9", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes 1.11.1", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "deflate64", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "smithay-clipboard", + "x11-clipboard", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + +[[package]] +name = "data-encoding" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +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 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "easer" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba524f8b83c9c5bde02c2bb1627de9d1f81980489a6d54168cdfd08c258f917" +dependencies = [ + "num-traits", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freya" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0902ffd592df176d3088ad001788f9a9438c21c40b4cbdec4dc3ef8bd16247" +dependencies = [ + "cfg-if", + "freya-android", + "freya-animation", + "freya-clipboard", + "freya-components", + "freya-core", + "freya-edit", + "freya-engine", + "freya-markdown", + "freya-performance-plugin", + "freya-query", + "freya-radio", + "freya-router", + "freya-sdk", + "freya-winit", + "torin", +] + +[[package]] +name = "freya-android" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3befea9d845539f4428f232ec6b1661a54e974a6e7b852f82fef90d994ccba" +dependencies = [ + "freya-components", + "freya-core", + "freya-winit", + "jni", + "tracing", + "winit", +] + +[[package]] +name = "freya-animation" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f9429ff1e0a793068ebdd4be84c03e722fdfb3d20c8bf3a63a7a875ba0a6de" +dependencies = [ + "async-io", + "easer", + "freya-core", +] + +[[package]] +name = "freya-clipboard" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8b2076b0a2d040ef2153cdd624dba28a6f4fabd64ca6182b128f642e2423b32" +dependencies = [ + "copypasta", + "freya-core", +] + +[[package]] +name = "freya-components" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd20c885e7e29f43e0279affea98ae3e1392c9d9f07a09b0ce0fc65d46fc09dc" +dependencies = [ + "accesskit", + "anyhow", + "async-io", + "async-lock", + "blocking", + "bytes 1.11.1", + "cfg-if", + "euclid", + "freya-animation", + "freya-core", + "freya-edit", + "freya-engine", + "freya-router", + "freya-sdk", + "open", + "paste", + "reqwest 0.12.28", + "thiserror 2.0.18", + "torin", + "tracing", +] + +[[package]] +name = "freya-core" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee644e4c5b4686b4cf59463347df7b64d9eb057c4a251e4528e90a1ecbf707d" +dependencies = [ + "accesskit", + "bitflags 2.13.1", + "bytes 1.11.1", + "cursor-icon", + "freya-engine", + "futures-channel", + "futures-lite", + "futures-util", + "generational-box", + "hotpath", + "itertools 0.14.0", + "keyboard-types", + "mundy", + "paste", + "pathgraph", + "ragnarok", + "rustc-hash", + "smallvec", + "torin", + "tracing", + "unicode-segmentation", +] + +[[package]] +name = "freya-edit" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e20601f2d927591cace6bdaf731397908105a572eeaa934b387471bc6cd3b1" +dependencies = [ + "bitflags 2.13.1", + "freya-clipboard", + "freya-core", + "keyboard-types", + "ropey", + "torin", + "unicode-segmentation", +] + +[[package]] +name = "freya-engine" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c5ba0dae520d78222758cffdcb335ba9cac6f846721b544fe216d894866b8c" +dependencies = [ + "freya-skia-safe", +] + +[[package]] +name = "freya-markdown" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94eb6ca3e8f9f18654ff14589c94155d7fa60c697dcbbbf0f02cb0a9e72e538c" +dependencies = [ + "freya-components", + "freya-core", + "pulldown-cmark", + "torin", +] + +[[package]] +name = "freya-performance-plugin" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c69c000dd001e2df79a8b9ad0977cc0f835c494d2e17a2848b80495c293fce" +dependencies = [ + "freya-core", + "freya-engine", + "freya-winit", +] + +[[package]] +name = "freya-query" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db86c4a0dea3ce04df0defa4ce62dad8e7c32bad05090e3644ff6d2f2e0655e" +dependencies = [ + "async-io", + "freya-core", + "futures-util", +] + +[[package]] +name = "freya-radio" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a2458e07d312a2365951a216a8fa14a604159f8ae7702a7ccb2ffbc3fccf0a" +dependencies = [ + "freya-core", +] + +[[package]] +name = "freya-router" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26a8c7b3b3c6432de8d90c01821f5206ab6d6c2c8417fe7b873d7a6586e7580" +dependencies = [ + "freya-core", + "freya-router-macro", + "rustversion", + "url", + "urlencoding", +] + +[[package]] +name = "freya-router-macro" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105a54bccebe377d4ce12066813737a0f86e1cc587cdc98c4df92fe48c800be4" +dependencies = [ + "proc-macro2", + "quote", + "slab", + "syn 2.0.117", +] + +[[package]] +name = "freya-sdk" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe2c1e80db9c6c782536bf1e8f8e4859ed43359e7c0775c619be8826e16dd18" +dependencies = [ + "async-io", + "freya-core", +] + +[[package]] +name = "freya-skia-bindings" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e65f9ff56a7d08451c839386a86842f065552c561041d0cdcc213390a2361a2" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "freya-skia-safe" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567c808eff01a4ad7d9d3dc42ce0fb4335dce76a2398d0cd18f1357bf2450c81" +dependencies = [ + "base64 0.23.1", + "bitflags 2.13.1", + "freya-skia-bindings", + "freya-skia-svg-macros", + "percent-encoding", +] + +[[package]] +name = "freya-skia-svg-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c622cedc8d1cdaf144bc779734e7f6c5c29876a63e114bd0424d47a0d1aad18e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "freya-winit" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f3e4172e15aa5bd524db527733fec0ba9ac3e4191388064c9ff0d4f5a23e091" +dependencies = [ + "accesskit", + "accesskit_winit", + "ash", + "ash-window", + "bytes 1.11.1", + "freya-clipboard", + "freya-components", + "freya-core", + "freya-engine", + "futures-channel", + "futures-lite", + "futures-util", + "gl", + "glutin", + "glutin-winit", + "hotpath", + "image", + "keyboard-types", + "mundy", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "ragnarok", + "raw-window-handle", + "rfd", + "rustc-hash", + "softbuffer", + "torin", + "tracing", + "winit", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", + "tokio-io", +] + +[[package]] +name = "generational-box" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b2dc9b873cd1a8adb80aa5dcef9a8205f781a8a313d5ffb867248cb3bcb764" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes 1.11.1", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +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.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66750a77f4f6b408a148be5102ef1f3ba7172def7ee92b1cfc75d9f7a3870453" +dependencies = [ + "hotpath-macros", +] + +[[package]] +name = "hotpath-macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afe0e1900d2dbe2e2df8e9522b97ebd7a5598ba18478f57e247957022dffedbe" + +[[package]] +name = "htmd" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eee9b00ee2e599b4f86507157e3db786e7a3319fc225f0e9584151dbea2291d" +dependencies = [ + "html5ever", + "markup5ever_rcdom", + "phf", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes 1.11.1", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes 1.11.1", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes 1.11.1", + "futures-core", + "http 1.5.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes 1.11.1", + "futures-channel", + "futures-core", + "h2", + "http 1.5.0", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.5.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "impl-more" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" + +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "tokio", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "interfrost" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3449eb9b8a587b832db2eb3a45776928ce0927c0efa7d0be9a7f4c619140a12a" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "junction" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.1", + "serde", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libflate" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.38.0+unofficial" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "beul", + "cfg-if", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +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.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "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-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "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 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.13.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]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneclient_app" +version = "2.2.3" +dependencies = [ + "anyhow", + "arboard", + "bytes 1.11.1", + "cargo-packager-updater", + "chrono", + "freya", + "freya-skia-safe", + "image", + "mimalloc", + "notify", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", + "oneclient_core", + "oneclient_db", + "oneclient_events", + "oneclient_java", + "oneclient_macro", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", + "open", + "polyio", + "regex", + "rfd", + "rust-embed", + "sysinfo", + "tokio", + "tracing", + "tracing-subscriber", + "uuid 1.23.1", + "winresource", +] + +[[package]] +name = "oneclient_auth" +version = "2.2.3" +dependencies = [ + "base64 0.22.1", + "chrono", + "md-5 0.10.6", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "rand 0.10.1", + "reqwest 0.13.3", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_cluster" +version = "2.2.3" +dependencies = [ + "bytes 1.11.1", + "chrono", + "image", + "oneclient_common", + "oneclient_db", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_repr", + "sqlx", + "strum", + "thiserror 2.0.18", + "tokio", + "tracing", + "trash", +] + +[[package]] +name = "oneclient_common" +version = "2.2.3" +dependencies = [ + "directories", + "interfrost", + "serde", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "oneclient_content" +version = "2.2.3" +dependencies = [ + "async-trait", + "async_zip", + "chrono", + "futures-lite", + "futures-util", + "htmd", + "oneclient_common", + "oneclient_db", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_core" +version = "2.2.3" +dependencies = [ + "async-compression", + "async-stream", + "async-tempfile", + "async-trait", + "async-tungstenite", + "async_zip", + "base64 0.22.1", + "bytes 1.11.1", + "chrono", + "digest 0.10.7", + "directories", + "discord-rich-presence", + "dunce", + "futures-lite", + "futures-util", + "htmd", + "image", + "indicatif", + "interfrost", + "md-5 0.10.6", + "md5", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", + "oneclient_db", + "oneclient_discord", + "oneclient_events", + "oneclient_java", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", + "parking_lot", + "polyio", + "rand 0.10.1", + "regex", + "reqwest 0.13.3", + "reqwest-websocket", + "sanitize-filename", + "sentry", + "sentry-tracing", + "serde", + "serde_json", + "serde_repr", + "sha1 0.10.6", + "sha2 0.10.9", + "sqlx", + "strum", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-futures", + "tracing-subscriber", + "trash", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_db" +version = "2.2.3" +dependencies = [ + "async-tempfile", + "chrono", + "dunce", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "oneclient_discord" +version = "2.2.3" +dependencies = [ + "chrono", + "discord-rich-presence", + "oneclient_common", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "oneclient_events" +version = "2.2.3" +dependencies = [ + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_java" +version = "2.2.3" +dependencies = [ + "astral-tokio-tar", + "async-tempfile", + "async-trait", + "async_zip", + "futures-lite", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "oneclient_macro" +version = "2.2.3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "oneclient_mc" +version = "2.2.3" +dependencies = [ + "base64 0.22.1", + "chrono", + "futures-util", + "interfrost", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_net" +version = "2.2.3" +dependencies = [ + "arc-swap", + "async-trait", + "bytes 1.11.1", + "futures-lite", + "futures-util", + "oneclient_common", + "oneclient_events", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_polyplus" +version = "2.2.3" +dependencies = [ + "async-tungstenite", + "bytes 1.11.1", + "futures-util", + "oneclient_auth", + "oneclient_common", + "polyio", + "rand 0.10.1", + "reqwest 0.13.3", + "reqwest-websocket", + "serde", + "serde_json", + "sha1 0.10.6", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathgraph" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f33d657119e71a8b28da5c8d52ade41672d873c292ae5bf4833b8fa658c86a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polyio" +version = "2.2.3" +dependencies = [ + "astral-tokio-tar", + "async-compression", + "async-stream", + "async-tempfile", + "async_zip", + "bytes 1.11.1", + "digest 0.10.7", + "dunce", + "futures-lite", + "futures-util", + "junction", + "sanitize-filename", + "serde", + "serde_json", + "sha1 0.10.6", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes 1.11.1", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes 1.11.1", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ragnarok" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5bbbde00035bdece3362a320eb982f23a57f82bf5f480a74f6aec4aaa01203" +dependencies = [ + "euclid", + "hotpath", + "itertools 0.14.0", + "rustc-hash", + "tracing", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +dependencies = [ + "cocoa", + "core-graphics", + "objc", + "raw-window-handle", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "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 = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "include-flate", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "include-flate", + "sha2 0.11.0", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +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.5", + "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.13.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.5", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "drm", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "rustls-native-certs", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes 1.11.1", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "uuid 1.23.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid 1.23.1", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.39.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8bd2130a9b60bee2581bf82cfe89ee836424d1f37dcfa4ce21509611684673" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows 0.62.2", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor 0.10.1", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes 1.11.1", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" +dependencies = [ + "bytes 0.4.12", + "futures", + "log", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes 1.11.1", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "hashbrown 0.15.5", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "torin" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4fb54c965056694c8616b9e0a6dfa151207c3599cd0c18f93acdb7172949bee" +dependencies = [ + "euclid", + "itertools 0.14.0", + "rustc-hash", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.1", + "bytes 1.11.1", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "parking_lot", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "chrono", + "libc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +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.5.0", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.6", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.5.0", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "md-5 0.10.6", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xml5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid 1.23.1", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..ab0d1c44 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,156 @@ +[workspace] +members = [ + "packages/*", +] +resolver = "3" + +[workspace.package] +authors = [ + "Polyfrost", + "Polyfrost Contributors", +] +documentation = "https://github.com/Polyfrost/OneLauncher" +edition = "2024" +homepage = "https://polyfrost.org/projects/oneclient" +license = "GPL-3.0-only" +readme = "README.md" +repository = "https://github.com/Polyfrost/OneLauncher" +version = "2.2.3" + +[workspace.dependencies] +# core +oneclient_auth = {path = "./packages/oneclient_auth"} +oneclient_cluster = {path = "./packages/oneclient_cluster"} +oneclient_common = {path = "./packages/oneclient_common"} +oneclient_content = {path = "./packages/oneclient_content"} +oneclient_core = {path = "./packages/oneclient_core"} +oneclient_db = {path = "./packages/oneclient_db"} +oneclient_discord = {path = "./packages/oneclient_discord"} +oneclient_events = {path = "./packages/oneclient_events"} +oneclient_java = {path = "./packages/oneclient_java"} +oneclient_macro = {path = "./packages/oneclient_macro"} +oneclient_mc = {path = "./packages/oneclient_mc"} +oneclient_net = {path = "./packages/oneclient_net"} +oneclient_polyplus = {path = "./packages/oneclient_polyplus"} +polyio = {path = "./packages/polyio"} + +# core (extra) +discord-rich-presence = {version = "=1.1.0"} +interfrost = {version = "=1.3.2"} + +# logging +sentry = {version = "=0.48.5", default-features = false, features = [ + "backtrace", + "contexts", + "debug-images", + "panic", + "release-health", + "reqwest", + "rustls", +]} +sentry-tracing = {version = "=0.48.5"} +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"]} + +# database +sqlx = {version = "=0.9.0", default-features = false, features = [ + "runtime-tokio", + "tls-rustls-ring-native-roots", + "sqlite", + "derive", + "macros", + "migrate", + "json", + "uuid", + "chrono", +]} + +# async +astral-tokio-tar = {version = "=0.6.2"} +async-compression = {version = "=0.4.42", features = ["tokio", "gzip"]} +async-stream = {version = "=0.3.6"} +async-tempfile = {version = "=0.7.0", features = ["uuid"]} +async-trait = {version = "=0.1.89"} +async_zip = {version = "=0.0.18", features = ["chrono", "tokio", "tokio-fs", "deflate", "deflate64"]} +futures-lite = {version = "=2.6.1"} +futures-util = {version = "=0.3.32", features = ["tokio-io"]} +reqwest = {version = "=0.13.3", default-features = false, features = [ + "json", + "stream", + "multipart", + "blocking", + "rustls", + "charset", + "http2", + "deflate", + "gzip", + "brotli", + "form", + "system-proxy", +]} +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"]} + +# crypto (content hashing) +byteorder = {version = "=1.5.0"} +digest = {version = "=0.10.7", features = ["std"]} +md-5 = {version = "=0.10.6", default-features = false} +p256 = {version = "=0.13.2", features = ["ecdsa", "pkcs8"]} +sha1 = {version = "=0.10.6", default-features = false} +sha2 = {version = "=0.10.9", default-features = false} + +# util +arboard = {version = "=3.6.1"} +arc-swap = {version = "=1.7.1"} +base64 = {version = "=0.22.1"} +bytes = {version = "=1.11.1"} +chrono = {version = "=0.4.44", features = ["serde"]} +directories = {version = "=6.0.0"} +dunce = {version = "=1.0.5"} +htmd = {version = "=0.5.4"} +indicatif = {version = "=0.18.4", features = ["tokio"]} +junction = {version = "=2.0.0"} +notify = {version = "=8.2.0"} +parking_lot = {version = "=0.12.5"} +rand = {version = "=0.10.1"} +regex = {version = "=1.12.3"} +rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]} +sanitize-filename = {version = "=0.6.0"} +serde = {version = "=1.0.228"} +serde_json = {version = "=1.0.150"} +serde_repr = {version = "=0.1.20"} +sysinfo = {version = "=0.39.5"} +trash = {version = "=5.2.6"} +url = {version = "=2.5.8", features = ["serde"]} +uuid = {version = "=1.23.1", features = ["serde", "v4"]} + +# util (codegen/macro) +anyhow = {version = "=1.0.102"} +proc-macro2 = {version = "=1.0.106"} +quote = {version = "=1.0.45"} +strum = {version = "=0.28.0", features = ["derive"]} +syn = {version = "=2.0.117"} +thiserror = {version = "=2.0.18"} + +# ui +freya = {version = "=0.5.0-rc.4", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "radio", "titlebar"]} +freya-skia-safe = "=0.99.1" # required for the SKSL player renderer +image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]} + +[profile.release] +codegen-units = 1 +debug = "line-tables-only" +lto = "fat" +opt-level = 3 +panic = "abort" +split-debuginfo = "packed" +strip = true + +[profile.release-profiling] +inherits = "release" + +debug = true +strip = false diff --git a/distribution/aur/oneclient-bin/PKGBUILD b/distribution/aur/oneclient-bin/PKGBUILD new file mode 100644 index 00000000..93a4c87c --- /dev/null +++ b/distribution/aur/oneclient-bin/PKGBUILD @@ -0,0 +1,36 @@ +# Maintainer: Polyfrost +pkgname=oneclient-bin +_appname=OneClient +pkgver=2.0.1 +pkgrel=1 +pkgdesc="Next-generation open source Minecraft launcher (prebuilt)" +arch=('x86_64') +url="https://github.com/Polyfrost/OneLauncher" +license=('GPL-3.0-only') +depends=( + 'fontconfig' + 'freetype2' + 'libglvnd' + 'libxcursor' + 'libxrandr' + 'libxi' + 'libxkbcommon' + 'wayland' + 'dbus' + 'gtk3' +) +provides=('oneclient') +conflicts=('oneclient') +options=('!strip') +_debfile="${_appname}_${pkgver}_linux_x86_64.deb" +source=("${_debfile}::${url}/releases/download/oneclient-${pkgver}/${_debfile}") +sha256sums=('SKIP') + +package() { + cd "$pkgdir" + bsdtar -xf "$srcdir/$_debfile" + bsdtar -xf data.tar.* + rm -f debian-binary control.tar.* data.tar.* + + ln -s oneclient_app "$pkgdir/usr/bin/oneclient" +} diff --git a/distribution/aur/oneclient/PKGBUILD b/distribution/aur/oneclient/PKGBUILD new file mode 100644 index 00000000..13315293 --- /dev/null +++ b/distribution/aur/oneclient/PKGBUILD @@ -0,0 +1,68 @@ +# Maintainer: Polyfrost +pkgname=oneclient +pkgver=2.0.1 +pkgrel=1 +pkgdesc="Next-generation open source Minecraft launcher (built from source)" +arch=('x86_64') +url="https://github.com/Polyfrost/OneLauncher" +license=('GPL-3.0-only') +depends=( + 'fontconfig' + 'freetype2' + 'libglvnd' + 'libxcursor' + 'libxrandr' + 'libxi' + 'libxkbcommon' + 'wayland' + 'dbus' + 'gtk3' +) +makedepends=( + 'rust' + 'cargo' + 'cmake' + 'clang' + 'pkgconf' + 'python' +) +provides=('oneclient') +conflicts=('oneclient-bin') +options=('!lto') +source=("$pkgname-$pkgver.tar.gz::${url}/archive/refs/tags/oneclient-${pkgver}.tar.gz") +sha256sums=('SKIP') + +_srcdir="OneLauncher-oneclient-${pkgver}" + +prepare() { + cd "$srcdir/$_srcdir" + export CARGO_HOME="$srcdir/cargo-home" + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "$srcdir/$_srcdir" + export CARGO_HOME="$srcdir/cargo-home" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --release --frozen --bin oneclient_app +} + +package() { + cd "$srcdir/$_srcdir" + local appdir="packages/oneclient_app" + + install -Dm755 "target/release/oneclient_app" "$pkgdir/usr/bin/oneclient_app" + ln -s oneclient_app "$pkgdir/usr/bin/oneclient" + + install -dm755 "$pkgdir/usr/share/applications" + sed -e 's|{{exec}}|oneclient_app|g' \ + -e 's|{{icon}}|org.polyfrost.OneClient|g' \ + "$appdir/distribution/linux/org.polyfrost.oneclient.template" \ + > "$pkgdir/usr/share/applications/org.polyfrost.OneClient.desktop" + + install -Dm644 "$appdir/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/org.polyfrost.OneClient.png" + install -Dm644 "$appdir/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/org.polyfrost.OneClient.png" + install -Dm644 "$appdir/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/org.polyfrost.OneClient.png" + install -Dm644 "$appdir/icons/512x512.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/org.polyfrost.OneClient.png" +} diff --git a/distribution/windows/bundle-crt.py b/distribution/windows/bundle-crt.py new file mode 100644 index 00000000..09f5b8d4 --- /dev/null +++ b/distribution/windows/bundle-crt.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +BEGIN_RES = "# >>> CRT_RESOURCES >>>" +END_RES = "# <<< CRT_RESOURCES <<<" +BEGIN_SEC = "; >>> VC_REDIST_SECTION >>>" +END_SEC = "; <<< VC_REDIST_SECTION <<<" + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CARGO_TOML = REPO_ROOT / "packages" / "oneclient_app" / "Cargo.toml" +DEFAULT_OUT = REPO_ROOT / "target" / "crt" + +VSWHERE = Path( + r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" +) +CRT_GLOB = r"VC\Redist\MSVC\**\x64\Microsoft.VC*.CRT\*.dll" + +SECTION_REPLACEMENT = """; >>> VC_REDIST_SECTION >>> +; <<< VC_REDIST_SECTION <<<""" + + +def region(begin: str, end: str) -> re.Pattern: + return re.compile( + r"^[ \t]*" + re.escape(begin) + r".*?^[ \t]*" + re.escape(end) + r"[ \t]*$", + re.DOTALL | re.MULTILINE, + ) + + +REGION_RES = region(BEGIN_RES, END_RES) +REGION_SEC = region(BEGIN_SEC, END_SEC) + + +def find_crt_dir() -> Path: + """Locate the newest x64 Microsoft.VC*.CRT redist directory via vswhere.""" + if not VSWHERE.is_file(): + sys.exit(f"error: vswhere not found at {VSWHERE}; pass --crt-dir explicitly") + + out = subprocess.run( + [str(VSWHERE), "-latest", "-products", "*", "-find", CRT_GLOB], + capture_output=True, + text=True, + check=True, + ).stdout + dirs = {Path(line).parent for line in out.splitlines() if line.strip()} + if not dirs: + sys.exit(f"error: vswhere found no DLLs matching {CRT_GLOB}") + + newest = sorted(dirs, key=lambda p: p.parts)[-1] + print(f"CRT redist dir: {newest}") + return newest + + +def collect(crt_dir: Path, out_dir: Path) -> list[Path]: + dlls = sorted(crt_dir.glob("*.dll")) + if not dlls: + sys.exit(f"error: no DLLs in {crt_dir}") + + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + + copied = [] + for dll in dlls: + dest = out_dir / dll.name + shutil.copy2(dll, dest) + copied.append(dest) + print(f" {dll.name} ({dest.stat().st_size} bytes)") + return copied + + +def patch(cargo_toml: Path, dlls: list[Path]) -> None: + text = cargo_toml.read_text(encoding="utf-8") + for pat, name in ((REGION_RES, "CRT_RESOURCES"), (REGION_SEC, "VC_REDIST_SECTION")): + if not pat.search(text): + sys.exit(f"error: {name} region not found in {cargo_toml}") + + entries = "\n".join( + f' {{ src = "{str(d).replace(chr(92), chr(92) * 2)}", target = "{d.name}" }},' + for d in dlls + ) + block = ( + f" {BEGIN_RES}\n" + f" # Generated by distribution/windows/bundle-crt.py -- do not edit by hand.\n" + f"{entries}\n" + f" {END_RES}" + ) + + text = REGION_RES.sub(lambda _: block, text, count=1) + text = REGION_SEC.sub(lambda _: SECTION_REPLACEMENT, text, count=1) + cargo_toml.write_text(text, encoding="utf-8") + print(f"patched {cargo_toml} with {len(dlls)} app-local CRT DLLs") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cargo-toml", type=Path, default=DEFAULT_CARGO_TOML) + ap.add_argument("--out-dir", type=Path, default=DEFAULT_OUT) + ap.add_argument( + "--crt-dir", + type=Path, + help="Microsoft.VC*.CRT directory to copy from (default: ask vswhere)", + ) + args = ap.parse_args() + + crt_dir = args.crt_dir.resolve() if args.crt_dir else find_crt_dir() + dlls = collect(crt_dir, args.out_dir.resolve()) + patch(args.cargo_toml.resolve(), dlls) + + +if __name__ == "__main__": + main() diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml new file mode 100644 index 00000000..1c5c9b86 --- /dev/null +++ b/packages/oneclient_app/Cargo.toml @@ -0,0 +1,150 @@ +[package] +name = "oneclient_app" +description = "OneClient Freya App" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +documentation = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } + +[features] +default = [] + +[dependencies] +oneclient_auth.workspace = true +oneclient_cluster.workspace = true +oneclient_common.workspace = true +oneclient_content.workspace = true +oneclient_events.workspace = true +oneclient_java.workspace = true +oneclient_mc.workspace = true +oneclient_polyplus.workspace = true +oneclient_net.workspace = true +oneclient_core.workspace = true +oneclient_db.workspace = true +chrono.workspace = true +oneclient_macro = { workspace = true, features = ["freya"] } + +polyio.workspace = true +rust-embed.workspace = true + +tracing.workspace = true +tracing-subscriber.workspace = true +tokio.workspace = true +uuid.workspace = true +bytes.workspace = true +sysinfo.workspace = true + +notify.workspace = true + +anyhow.workspace = true +regex.workspace = true + +cargo-packager-updater = "=0.2.3" + +rfd = "=0.17.2" +open = "=5.4.0" + +# clipboard image copy + decode for it +arboard.workspace = true +image.workspace = true + +freya-skia-safe.workspace = true +freya.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +mimalloc = "=0.1.52" + +[target.'cfg(windows)'.build-dependencies] +winresource = "=0.1.31" + +[package.metadata.packager] +product-name = "OneClient" +identifier = "org.polyfrost.OneClient" +category = "Game" +publisher = "Polyfrost Inc." +copyright = "© 2026 Polyfrost Inc." +homepage = "https://polyfrost.org/projects/oneclient" +long-description = "Next-generation open source Minecraft launcher" +formats = ["nsis", "app", "dmg", "deb", "appimage"] +binaries = [{ path = "oneclient_app", main = true }] +icons = [ + "icons/32x32.png", + "icons/64x64.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", +] +resources = [ + { src = "icons/generated/Assets.car", target = "Assets.car" }, + # >>> CRT_RESOURCES >>> + # <<< CRT_RESOURCES <<< +] + +[package.metadata.packager.macos] +minimum-system-version = "10.14" +entitlements = "distribution/App.entitlements" +info-plist-path = "distribution/Info.plist" + +[package.metadata.packager.dmg] +background = "distribution/dmg-background.tiff" +window-size = { width = 600, height = 400 } +app-position = { x = 160, y = 190 } +application-folder-position = { x = 440, y = 190 } + +[package.metadata.packager.windows] +digest-algorithm = "sha256" +timestamp-url = "https://timestamp.digicert.com" + +[package.metadata.packager.nsis] +preinstall-section = """ +Section "-OneClientTauriMigration" + ; Stale Tauri root start-menu shortcut (this build only writes the sub-folder one). + Delete "$SMPROGRAMS\\${PRODUCTNAME}.lnk" + ; Old Tauri main binary (renamed OneClient.exe -> oneclient_app.exe). Locked while + ; the outdated app is running during an auto-update, so fall back to reboot removal. + Delete /REBOOTOK "$INSTDIR\\OneClient.exe" +SectionEnd + +; >>> VC_REDIST_SECTION >>> +Section "-VCRedist" + SetRegView 64 + ReadRegDWORD $R0 HKLM "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\X64" "Installed" + ReadRegDWORD $R1 HKLM "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\X64" "Minor" + SetRegView lastused + + ${If} $R0 == 1 + ${AndIf} $R1 >= 40 + Goto vcredist_done + ${EndIf} + + ${If} $PassiveMode != 1 + MessageBox MB_OK|MB_ICONEXCLAMATION "This build of OneClient needs the Microsoft Visual C++ Redistributable (x64) 14.40 or newer. Install it from https://aka.ms/vs/17/release/vc_redist.x64.exe, then run this installer again." + ${EndIf} + + vcredist_done: +SectionEnd +; <<< VC_REDIST_SECTION <<< +""" + +[package.metadata.packager.deb] +desktop-template = "distribution/linux/org.polyfrost.oneclient.template" + +[package.metadata.packager.appimage] +# desktop entry generated from the deb template above via the linux config + +[package.metadata.generate-rpm] +auto-req = "builtin" +assets = [ + { source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" }, + { source = "target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop", dest = "/usr/share/applications/", mode = "644" }, + { source = "icons/32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/oneclient_app.png", mode = "644" }, + { source = "icons/64x64.png", dest = "/usr/share/icons/hicolor/64x64/apps/oneclient_app.png", mode = "644" }, + { source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/oneclient_app.png", mode = "644" }, + { source = "icons/512x512.png", dest = "/usr/share/icons/hicolor/512x512/apps/oneclient_app.png", mode = "644" }, +] + diff --git a/packages/oneclient_app/assets/alex.png b/packages/oneclient_app/assets/alex.png new file mode 100644 index 0000000000000000000000000000000000000000..99af19334ecf2f9160e4c28b2f5b47ed04df4599 GIT binary patch literal 958 zcmV;v13~8)W)E){ zFoKTR*{Xr#0fECNFbvk(s)A&D1*l*4(OLtn%@3k*T^e!5iy0zJ__++uSXq;-lg?5H zV>ta`&hFoqV{0YuFti|OG6Z!kQ8k#-7&Wjiu9lxSlX+g+JiV6X>yzPH4H`xzKMQ1O zYfO2=^2eSuFlAKW8czA==dv`$)?7EPkpeI>Hj$gEvL-ozhQkUYq!0Kc`W;9#qYnUmoA z3OrGWLlAD(!!kf{ z!azn7l7&E*+ySFl#h7BD@B26*9(!;+_NZ9`Vdu$&NPZ>I;EQJa^EjRnLWEigQ5%R5r|$$3>Qi{^`|OMus%-hR*!q(kl1Hc zQ2g?rgVStv*gB%+pU5K(hhwVD7QqSJS5Px5*ebZMpr#EKM4=nz<314Z>?pz6f^GoQ zgi5?82t1{}AWJX}QCZUki}cwnMOjN!a2?KKBdb z+z=#8hCq+GrC;dvDEuI&0!iKV3!4QJ8t?iAYs?10x?fNr*surdenHXJEE%#s1??BW gtzVFi;JXU`0{B{0H2iXh{r~^~07*qoM6N<$g84wO3IG5A literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg b/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a951f7b299be26ef13bd4e835d58e8fe0534467 GIT binary patch literal 633705 zcmbTd2Ut@}*FU=HNEHoIq=-nD-fKXlmr$fg6{!J{CcQ*JQ9|zu2uKwWQRz~mR1pyf zNbkLOFtl)Y^qlja_j~Sr{{Q<8%lqv%{HyOr@QO?KzzmN1=c;7qcHjP*o|D@nH!mkQ_iG~J0+QFb zP2dmh+tKfku0gZH1O z&cXltm&t$}g1_rTIbgs48dXR}Oj1Tn2sqyW?Edo{q5?vI)GZAiLfq2>z)dDRGl1YK zhZ++&OMC#I310yq#XXylxUdKlJ`^Aq5)zXX6XFvR7Zv*5kK=d0i^*9lcgqLE3pvlA zrH~Cn>GRJ34JlC$89;z{9l)o-BcQ=MZ^bPe9ueN}^t&5a;NcSx5)qS-ULYd}8&q8c z@bL%;@CgZsh;WaD7Yu$65YiA`ViQ&(zN~LUa@_+W5}NRul>OF+FSG`I+c!k-dxl*g zyFy3Lz{tVL#m&PjCN3c0L8hJ9`I5C%CiMBX1vHKmUO6 zCr=}uJ&!~tCMBn&roDKXo|~6nP+0V)xa4C+WmR=eZQZBlme#iRj<22H(ES5H2Zx47 zMrUT{<`)*1mRDAHcK7xV4u4^ej&bt>_v6pB!1B+`{>Qv%zl5)u%S;O2#g?~9u_ z4IvSmF!3cNeG(gw%hyFhNg=lqUVr#-4$Zi8lM*=c4<`fFJGEd%hiW`n(C|AAd?ng(6A#Y8r3F zMeq$Rdzr9RH>Px?xX8W4uIo;HH8v7{l-H0pX(=bS@R5x@p1aJxPkgDWu1UsdbZnFx zZs)|rTgrs5!G!Pk_Whl0XlK#0)?2v^H(ou@w6IL47mf_!;y z-ckI6ccouKga=PaW?if=JGn((e+pyC zPGZIF-ZB{@T6I~m&A6X1N=&x;&by~>vzGYQB7<`ghQDUwgO|sB?jaQNki+xte0v$w zTW#b$Pbb;ksv_!ot8QJ|#rx6Ms~vu_avU9}dB%L!TD?7WSG&eIjr*grm?O`Pha#j< zO|1K@c7(qV4?5t>8r@XaPsA6fVdtLI9QTP%eiO%7Pv1H4nf+>o)}BGq*Mf4w{aZ-x z9Q?mBoCs2vpA+<21)|6FPoWO(JS`2J2hM#N-11N&2)N^avXTHig6}S#~e`PZpup$KZ0sdrBCCY#Gn+55sfItjLHatX1Ac-UN zVWZrm+<3T;+GKKd}B>lew59eyh(d&;<>a#rs9{S%Qx6``Kw%v z@euk>686{vp}8Y^fcKXfIu>WS~-C-Aj>Ng-t4lsTaQwWE>JP zppvRGA~iHT6F<20K>5(JT7tDgy51(#@0WnW(?f*jX-Tbn{0DiUd?+sbFi-m|O^iN( z!8IH!qNx)iIVZODq{RU19JLKQ?fE#(h5cz3{Q3xmAyn7xr7n;|0|0$Nc^AXstJ}Fq z9;%stwt<_W$Q5#7*dB70n}i=~Ntv5nX>d?Rft3X z>Q8qi5y{Q=uU-3xl5m^-KQkOh8$`|NlUbGP`|sS^nm8-BeIvF`VSzQSi4J1W4&fQP zy|oP^`Z}@1dPLN6^n4KNL<=)+VQCg5-%l@cSE#nZ<{XfJ;f~s-l{?9HK61b0w_hrm z{%Wxz(7Vi8z-n>VIAQpJBi6Zwp?u{XQ|BPb1q$fs#8D0ENRWN^`;d?yax-_#ex?7k zIT6V>E_D@0yZG_we$h2W8kA;qe-z7pe>ZU$PS*XMi^@14dIYJj)QNeV7;)`e$|Q@w z6etXo4yD_D_^uhfPrJXh-mRL1&ziTXL-*aDWkKF=XFX-{ZKt zn8zAn%A=Sby}!dkLUs-WikJVgo%*@nw6}>7Wjj1j_+S*6FxGtzB+7i9GflCLcD)S% zQUDb|E7c=r+sNrLh)OZ2ezs0w#Un(?FE;rGzfeBZuUZd2+~V)Ost_P@CvFH=9W?>80UD2WKi|2IH7 z13>YT1COO#Q`ZG6O23Moj!nd8r|6bL2zwWHNS+t$PQy~A+T}X)*Pl}x9#a**6G|R^ zLOa&SF%YS7W6bAv6ZejB=yVyQ+eJ+3w2})oS>BmlT8<^}c@jP__~hk91Eeg|qL z(^PyCCF^cmQDvAMCWn1H)f#w4q`$KidvFd!2236Ps1C|dzi_3xBt+ft_`_!45Npr4 zm9X_EcLY%f(F0v}Gt?6kH><7rhI2rnM|A0VBd;~d_*8kC{a%RHKmht+ML!MLID@yu z^6g(m3WZdD@urbAR)poAcISZ?5zU)<0D`2jZBpDaEYe;)evD zN~|Zl=fGp}E<^d2j*YARUvx6#2^lS;{qgIKwyDb5zNe+Wk2^FPKL^&U^5|N|43z2y zCSs*&S|NzJ)*(t=o|dwhywslJC8>2)9+B`w53365$EOnEzX}%}e#LX2CX=C~niU|C zp)wYp36sXYSl2jooqgN>Igo%Qo*GH4Ufy2JB3ec08?fg$u#F=|j!-GVBv%q%-%^Uw zs?tXEb9_DZFxwGn3B`Y_d=^i#OWSD@wTLARUX1TDI>4d`mek*hUwF^|_F*B{NxVA3 zvbIdBfp4}x+CRYJtGxAn#IiPJ;2w-^zD_J-I9A8K2k8N`OQ<Yzd@t%#0SaR`e z%(6o4^g7QYD8#Gp35Z{HN;`Y%4}EEN(AGI8hJD4C7^EEKb{9(=MWn+YCl)p1`hsAC zu^~Ee{i)g>XO#`cV`dmN*yRQN`4gd{Z~2*F1=j6xr(5_Erm&kWB5Z`I4HX*1N&+2O zG&~1%I1<3(=LhO%r{WKRD2-}X(-y??-7=0f-3~8{q*h(?PE>}3yX6xkxw=$!O)ML> znfDxEGj3SHpOz-*y+hxjyU+6CZ2{!q-fNbQ>X4ofO2Io+OX>sq4<8)ph|FGi9sf|{ zvZpXbQ8>Cm`7r7%a|=s5r8OHznK$9*uxTYkE}tQ(J`Wwou3<^CU~hxSKe<>(rC%h$ z_wZmirhjn`2wUfwWu+CZNzgG`l|D@KNmK9Zf*=`~>F$yxouG9t)-cL-m=Y^$67;g zCt){XI7Jch%Y$+P4E2sV_OhjT5JR~-Rehl{HinNieRYClqVoi;3cb{DD87|*jIBBy zi}_;IVw}qIsWK09RL&Xl;Z~!}z)v&X9mLN@y<~&gXcOUbC2kOO;N#>5Ij#WymGS>R z_LXrGg^atW>h~#!{~nww)iT(S;01S{n4ZEtPz;gd4)qK&?rz8>oQQ(xYavN&z$W-# zz@z^P?ieh~0lQuN^W|@OPQ=L%N!F7k0y{c`xAARMAeEOmej`PL#mASB7QIf4w+uTp z?d1dX@caG<3ESOZ^*>9V)bnMld)w8mI`goPF*4aE%V>-&Zgef zK}GZDvF`3b-XH*a1n=ISsWmlJxrjv8D6V)XZKJD*zovD9OHZkLGQGa(!`H6Um>?^E zCX;3BjithD+2-TkmB@{r#kbJ1kN}^r<;#L(9xVMg@l9Tn7aCAV7JCAXv4gR0K{|)NG-hnp=aGUwye|+;F z$OnEI26olJZR^o3u;v4{4;6#xt7(!xkbNx8*~D{}ALQ;G>_ANb|L8EJ&9OS6aK8t|MM8woAc*;~AWsH_Qgl~2`7isIej^M+&|tf&{=NYcvO zv#vA65DNI^gD0gDIAz6W%+6d7f3h=?z0=hpPzQl{&U=uc|57si=yM3iG(jkr1L`{Z zN=k~0q3T|vPcBiN4s5sZfB!Hx_Bs)6%F7#_YWo@RUCbqjt_t2bxfVph*tm=-n9T?t z^eKyx;bPL@J$MoR(=hiOsOoV(vJ#vPU@)fgOD{M2xH@$kRaPY*6vdq-(f}{})b#b( z{LlfzPS+eqMyYHPW8NzBk#D?O`YAOZsltT$)H2^nA36gWuwOo_-62#KRW4`DHtFe$ zSXi!B+j{ZfCU<`fuX884MmN9Y_1f+yKlpwG1fk{;y;ilS+bOj z6hDDc!^?v`_N0$&m41%z6C59t9es4kC><-VB5xP5&>ee`HHnl5mieZ>?)JDs;o_U+ zZCMUby03Nj5BPGATo!(DovOhdS%4n4y+)fLe@mC`g%&<74&VN}Pm0^i$q@*4B>DBeG9-3Jf$MKj-%~ z9*&5nM_c5trK19pp8Kx)`J}L{(AN z^wsujO^xzN?PYo3;6PrZq)Bw=UE|JWi(nV?Af8$JJJ?P7y=HE#W3gQNlJ&29*JE>0 zelt{*u|$CZs|VQ=v?Kn%5FSUHURe!lbudkrorB%=rgMPvYwL`frh__@cfr>+tu5<7 z+od}1+GdhDU4|_c`kaECysYH2X!z=rpyo`ee6B_MGwwEs{=M3E%g?rDqP9M?;>4k* z;aSPAb_{7d8CLX#fZ5PuVeb4MvC3}`Kt3c}7XQ=_hnZ=T{>_J=nAC#_fjmrtD=2Y* znBlx{0#mkK-i9C%AFgzRv@N#zC%Jgx%Oz;jF_4F5M(>gmH_XZg>)K-DiYBC&p?%{7 zTd6=u)p5s(-I&ffK*|#Srdj<&p!}kz?9C7As8kPYJh_h&T9*)v*uohr*>+nYp)#`{ zW`!qXWpQnLn7gzcp4x;vZc%oXG*fUZqgdMQ~5bISP%X8p~nw3r1;D>Bqhck#t z?!y~%VjCTefwZFQcd|a7#EH;ry4bmI`?1Vzjjtk~^|A3Er(#nASc%}=zA%3;>3t$w z)^DG_>&Lnz7AjOW@h~<=U-{Y^*WARSob+Hf`xwqwvyzUw5|?{>gwgW7LZQ&oIl$8G z)Ys;z`8L~7HSNBi0XLm%gT}>uBZ4ENaQ0057~=_;uBwYj`no!~qx9*@5m%Xzceg@@ zeaC%^1V#DcoW;}xlN-cY7ur;*oK;p7+2+cijY5VU76!FP8qgH&7j3tsi!aOb+I4(0 zwW?f=(I{GKv=h)@mF`w}LB%tYBdt>8__`2{B)b7=*v}_{1oM=O=nl=q^nS#o%w!Zw zvAV`WPfn7tRmbtvv`6f@7FVH{oP0Gs7M!n?W#@;X%s&&F%wwHpif1S`5L3-_={A>9r*Wn`+28Q5@43cA9YOZ;R z@9kLWa}K0>vaFpAZuRaB9bTnXKZ`xn`+`@o62)XL?$aI+=}&r$3rge z){=e8SuFRy%juBX?(m^kW_qef+Ks_RRyPdE-XQ5cp4i%XlL15}Y<;ifl=r9o4ofHw zU9Fr0KA*3+sJb0F3Z6j!z$>dC*p#!EEvHHm-{1GO&J_5e8Wp-9xg7VNii&pe&XjyI zqRn1feE#NP@+=SEnB2UW!z-PYHkrqaEsYZSg_34~CJtmI<`r`T%-lJT@^cD7cu+}r z3ltQtxWlBCJ+nV>e!r+!4e~NM7iRy)YDMzB^C!D*SAU0D(|qL!6T^6wkd#wlOkB~a z-ZpwQ0gtNOfmWQ{<5Vz~L;tK}Rh~-C`Skm9d@7f+ZPu6rw`U<7;nwIlSfW$Ucq;p^ zgvPjK|MG}AU3$}11$5%sywBb)0z{RfqhCdzJuU8%+|=?O`E?FJ1rv^=m9{i`oPTU% zQF)m9Th%)Pp@JEXJ~FJ0knk1^H@1 z-&r+@;R8N7r7?@qjNTBU_R|3`-7mSXo_-f4j%ExNpkzzf5WCmEOJb%SJKn!wH}PCa z?1eZNSB}_g(|tqrQVEsHzHq}A1yGL*Vkn1(Eppox&9zdZ3-Srp#4&{hP9ww)eDvo4 z{4v(sz*1Qw{4AsZ&H`WX3!y7B(DsjonC$KL zF$DzjW;^uL2`bgaX=YVEXR0$Mzs6-tR*q4~rMnQ*za2}lxpPwoyDE)MiSas74B~iv z`YFHS92k;{J81JpfAcWFz_rc+JjA@$omO=00sF7K;p%jWY7Z-N$ql~kZ-NIJfs$x( z*6vafx6!1}o!BC+MnrKm!qD==OmwrkUfTG*J#Ai0NynB#6mNimtN89Xsw(37le3JL ziYA$dFV%L zxnVgA*SONeTJ=2Z>7*)a{Pm_VjO>nl6UU0A%bPal>au$yiQE+*Ub}r-y}%XT62ejy zVcX(2&H;6KUgUI&Qr6~#N0bX_it1#Qho=iUYkMIzw_&#(DCxykt(zY+gsb+$PPw}Y z-cQ76gu}WSa&C&+W*3H(&kSAtwlcAfRb4ydFJ=O+~VMS*Y zKAZZ2#?{%f9M(qvD~I&3>Z)0OMx<|M|&&h`R z+dNoJ^4RJXI^Y~Ase6i|Y<}O`zCYg(CV+;0pFam4>d09J$@G=`?7`oJXDi*XL&%&~ zE^)=CoY0(D!|$I1(;;Q<#AfY*{_XPlWC+#rj>J1()xQ!Hgj7uzvS-|M$KFM?aR#Na zqN{}!cC0h5=OXzI4QKI@`dSGqij=vp5?0h8z$lN`exAYrkZiCt8hxUNudkKuEV`k1 zK}7)0jR)@n0X7vkUaLbAJkO&5#MY1RztX7HqjJJ?0+pJFlq3aLW7s%DB`o^*N6&#a znJcg8yT+%+uCZ>Z6NL<)YA9$Q>t-pedq{WmlUv7~10?IJuXn<~ROb-$zqzF@w_TQg z=7&iUI0tA(JI(>QvN){V(kb83R$D;BQ9EPWZZmA_9EgD4ns)M>E*;pq(KL_xzIVzf zk)XTONW0^Rw!jh0v9Myqy}rtRaMxYMT)}Ax>Rzx&9>jKXoGNcs;Qfg``kAqJ%n(i;MdUsmP8 zjR>{{FMoPczzG>8L_g+6SGWV4Iy~>lXJIk6+jQYu-tUJSlj(1EAf!7cK^RU^GerAn zSvNmL#(CG3WA*U57gDFoc7b}7t9@k_1`&L+KjLfZboVY^H<6^hn}eHL?t0N;4VRD>~B+%!zDg!S*G8;q=hgkg47 zg(p7;ho$gTq^Qljfot>yf3#mpd?a@_zRiF`RxJTnmADeC=1^}XvtgUeLRv(nliBQv zUc&uI63@w|$G-B<#A7)dc$5Y*KAKfSrk6PAOREF2Kf65{eb!EWwWWyAuJ5gS>hV1( z({rGf`d4<&>50ikL`tElQ@U$)QsJ%Y!_pVr83P|mmSG*^Lqdw^Yc{)(@?nIUhCADq@&sg#6F+H*&0>i zSJ}W4Y|)D4%MfByWC_h!d7|`=_yD_)ml?L+W7blA^^!gR+_x_bSX%53c&OB4jpF?a zrUMPg&aS7>_amRsCMp=0nebS~e93RQll~g$creNjRpyMz^3GPS4z+@c^?Tl5vLI+ z8}t(R=aG!Q!sshfF2=&!6aPrw{6Z~Tcc(Um+H4lTbKn-nH}{qKU4)3gXZ2tS!e1`@ z{l5OG&e&S0iDq&U2Pt38M^&#kiLQ+_T(PHKIYCmrKWfS~K52+l4}Me2kbCS7@zFok z_9K|WM@cr=%uymbj^~@M4K0bt27PF++ntGKdfpevy)~W%6Kmq%v8tKt_H+$wz3g8B zCCz(28_hyP_c3m$c(HCLboWj9WUcZO+sDtUO3O@!j!f*2o0^_2)lGWt2#&O9x@@`C zpV6&eZ<_ifV|exENCA^W9J*~d%=N6~0nyYK*Zb4a89e+&EN%8iJy?0`7oQV}T@Etl z79t0lC^6ODsv7STl@6jVQJ)c`IM_UHIL%=mLkmK>X4=eWxTw&m$+RQZR~|toHPQ9u z124Rb`c2AYP)cf@`5G!>jNUrmSU}1oG4rh=(68fHFT&3&D%T`qt-X_1a66B)Fo(#h zic?ev@Ghn+0D$}X%se>`*@{$59G9s_O@D(#kX&y)*+U6ZvJ*D_6fJ zDxouZg<%k)XK17nY`;)=xPT$QU`r9r?yu0ZFu{6sZ-wh@+v|+)VDX!MU1h|K|6P5H zV0s7Cb%8YR%zdWg5ZZLy3$fl{^7#U$(%4OD9iMj~)H4~_Kkd`X>+%}zoc)|N&4)XZj#o2XL z{m8=s?=K z41e^lle;oU_-$CHdAQ*Zv>BU5Gbd-a1z+A9L-foC>ngYEzb4VTlz|)_ty)+)3b8KK zVv759G2NrO{v62CIWk!!T59WwlFSL84Xh0XZ@JbS;hA2>EY0$xH$Lsi?>|2H{@v?% zs0Y2>#fasNw+=j`S%F2r?;J76I8r<9`RU7|_Z@x1dh5OQ(jA9EF?0P-OLr>bSSPcD z&s=r`bkxkUvCt}prJi4z=RnLIVY%$J8xE&B0e4OXix1bzldX?HOZPkB6Nq(p!4YRj z(fFz2Hf{0srB)xl{a4hx;t7InQ%_T;kD9ou=1%fKhsqwknaV(Pc7U=gxwn$#qf^1W z!%|eDs_2|d^`{)vHWyZH`Jkr*^~C3M%HZkV3ey9`3)&voiT0^b_;pA_UB@q50%(Q_ zs16Tr96z!C_BL0Aahu4urA6-2WPbJpR*ma;Dk^n0h8v8se?Uo7= zLCheO= zp$3++FWN3NT7N+#Y#LzuP_#uW{uI`mN}$3r;&NqeF0Cyp4|GP<#9`}7%6H9nqiqWW z#fPXMU3pdXXYGbRggwoEA~=0bSZSMhV6P@Q8W^x^38x=SlQ3Jew1X-g&&#vdWV6>y z8TJ>G7{sTvIKH&Z^1WZW>-+pk zx^Q^9GzoG&mN5}?Kke(cJp?4|m6qX^i1n;<|S2ViWb+s|_ksv^#{ zS3+Wg_Hq1pP&wPP{ypmZ!F((CrMnTr4pJ9p_9<5RlC)}j0bM7^sGf%+nQkEZUe>-HI?M@g>M7)Vk}d?Cn53qYR1OK6CCA{Qm|cS6 z%MC<@Zn`&XT-N2d@&vJ@5SrKG$8K|V>98vn#48Z}H>VY6E_+*tQXlD{%>0}-_3No7 zmx9SD^N!L_=Gx*G{fZ3SHb0fm=c3Z(R7)0G`Joj$A^a8llb4?f7^kmEoCC1lrRBod z%GINRKs*(Q;a|pVZaf}%?kl~|;O;6y2G?{8Ys>&|$jgGVe|vrYclG?mRL}>I-%aF2 zt|I1LbXiG~$T;B%{MU=8S`Fxhea>`)9z zXb`RUo7OS61Bx*-s+ku-J)r%-Dr??kiBzDqKJ)9+jjZ|Op_R$2c3J3#5B+A!>Y(p^ z6RR<{*po0Dgy7&%apOOuJ3@W+l{z-)>%EUWdxN?27KA(mO5ou|8M=~Q#!hiEuVAuUX)oujo0ZL$nboDa;~L9-a$V4#^`%^vvmgy7$!aIHLh^Y7%;PYs&QFi{l2pgU zZL4qcd~{s&Rw*Z-DSSIf2y#|nu30*9xNBXywjAP->FC+2l}M*|YMpU*aqpzEP25ur z^4M-RdD>(&OGHt&PjN*}o`^{D9;@)@HN)$+5i7UeFd=={l&*tbOI%zKtvhI>Cx1#` zz{F@HuR=J^@OVvMxbrZl+AlxnO->uQpoL#>gc>am=lPeZKg=8ywP82|D5#SD&eZ4q zXWcv9*@w@<#n!8Pp6HMgZ=U@EZ7;>@EvWWhr9KG6OA;mLgE`d$~(C-)p;X;(RpmlGwJN$q(gje%SWi!Y- z42J`iY`O+tsRL_Tik&=b=$E=?#G2Uw1#ckJ1N+3`X=UoX;%hL-i@xgz#YDmZ{T*hB zy~ldgkaaS5{A>|!Bc_3_o+@F*Vio0h3;oWbr79s?vAW`mGnxHwtl+JN@9)NfXq_c_ zQ;jp$SU0b@Ye=knPwDF<5#$Zx!qRP- z@N*!@9-M^p7uRAd`v;HK>T@ULGW>6_Vph}7D9*By48muOlN?J%yB)nA^tan)NyIcq znih|KW}>~0g6^=So!sE~sS%=mtN=+nQ9cLAPy041U-X!XYFWpzzPE*Q?xJFJ`J`yfn-WJys94?k~-0$>f1--NqwsdkIGbe0mfd484wRQV> zV##`JBan#1{|d4AzdCH)iwr?!`adpq7w;lN^TEeg{|Mnf@v%x$5dG*KWk&g)&+0W< z7h-!(3jN}GKVTzJlyX{jkESj=AA8VC)OfXloKr`8g4nK}J_l^46k*4usq9$75|^E! zIjgKkkd=?6da$I1ni%fspQV!6=UiTr?dp z5LSa+9!-2qW~ON|`qM}Q z81~hXw5C1KxgCeu>o>iOJt;vRZyCKT8uaFg3oKSVCWu?Hmcf28emN*-UXooK-*P2b z;r(&HG61Pe)+31Q z2IGU%@er!PLgea%`9?s|de# z&5!d_x~$7>kseXP+xn>vasY`}*3T2>6S31P&t^=*6W8uUvtD%mtmV_{bt%jEVlW-1 z5gUe*+F_5eO$Leh(Ux(5N=Q@G-baPJry?XVWdTv+vDgr`N(|qZW68%2qP8=x#5A-8 ziA1cEKC3@E>V@ZrjElqylyWFccHS27X#CLKSe^0-SxhuzgUh1(8Qya<=pG-B=~kv5 zZYnVe6+74tlO2_s4{io2ziBG)N{-uXVf}TvI=IKsk~LON+qv$e#dP7A4LGT zzIA_IqhJc?hpPbiS;T&Yu`)xiPBk$Zxj|xR%C;3c#)9VTkD~-#EI-bg2?l;G%h$ZV zRp6XZpekSxaO2ZUqhk8DZ@QfiWoKcnjnp%R(Lw!)E23tr~jOUVz96hn^^jK)SL}`rH#> zCOh4)Md6`H+MdUi*=z1goETa#0Fof(Es!B~o`TIHYRV+CCU}xb|6*KHoXzvMCVLHx zV}A^e_V|3@>_ARX95c3aSx3gCHJi>fOJa4%fclrcn-F5z=!7FPj50(&q*vm8*PLVI z{q`%~Rhhw7v^}eQ`>a$YV{}r!QKyUw%29AHz36&qUzQ9>$VidFy*JnTtl#snlGr$F zIzq58B@_4edEv0a&~7=NRVQ*XO1V1<4|slMQn#lF*d$cn)-E~=tv}Lx_Vwh7B3FSf zuS>Ax-Fi0HM;Rk~XI=i(`|nD6W_nt$t_Nqd)ZAm)4W+|AQL#0@tef3&qw#LO`>xAv zPyBq^Rbvz(_B!nQw%G{vv8y|()5|;+g?BJJwIjSkoc*vV;YMO+ur86imP~Wms$eQA z;jIv@wQ}no%z$sX-tF-uD@d=GsV|MI2eZ`>fcA6FD%TK-Q~7YLg2RAYTtgISl<82p zhlwIbZ-IV3b7tW#XKw|^aiCKQk`+Y$E8plio^LJCEn(xvBR@=RV%SDf7f+8cAP3q>L((;u(QNjWRIgOyZwDOP0u>o zTJjtK`s}qzPpK1Ko0v{-gQh?InXY(wJ04c-ljcvd)?rf@-`P@&`552bkVnr4`w*As zn$0?%>b{;%z+vsry=|FW%6#8CN+x@Wq9TUlBXAE&-G(4<@j;9%3$SLK%3h-t7jGij3Q>j)aQCN-$EIyX;ZJD>gyt=C4AG!BYXYf zW18W^^4v%K0WGap_=4{pF*8$B;aWp`s)8I`QnaWAoYv^hoP#Aum`X~a&&P9<$~B>k zySh70Z6Knij+B$k^Q%y)2bI3({06(t2Na&|bJxPh184?lzKUa(c9y0U%D27Dr{GLA zP0S7VT_1Y9dUaYyIxTdj#@-n6(?ot2LE+RS(D$%GewN(6{zJp9*qE)}@R4WtWlVR=5u z8~W07F-0Y?hk7#c!DjV571rX3I}>%!o5Sr?DLFE1N2gbV0+gCbm(e5|Rf>bulpXh+ z^@@4DY16NDi)ErS`&~w)nsLe!F)pt1x544}t56Dhxkzp*z5Jdkeh67J7$ywFzbpXb zx!!}V$Z*=8tWbVIZYVDD70&^3U=Tp&{LbtxH+uo6RQqPjPf!2Zx1>FWy0HqXyrV5W zoP|q`;ax{n_BQmyS-oMRR^ihqf+U(H|1RUO}e=+t<~`?HJBz5PY8V{I6Hw*PKuWv#x(F4%cSPVHw%^~|Q#)ON6p zb-fztPWV`XqDgd?uz3vW7h0oh6za=-LyKxXL!DE%!ro!S3NCJ*@~)Ys5D;gw-?&E= zo}R`}F_&DGr)m=?U9v7cTP2s<7+@rl;k9MP=|B9IAXKS0Hqg*{?48MI&V?*^p+6O+ zhz!qy-|S2D3k&XiNG1NoI88-a(C^H2o7ulqg6`KR-2&HA`0fjV2lIE?y;t8BUBh}? z^ux;R&5|AI82A~oy2V1?`Wd!eVUgZ$e1=?CCtmUC0Yz1+VG&hPf0o(@+G>|s){**- zt{hB+HK}vrdjG*kLi^Y&vaeemu_V7>Gu9pr52B6QNvDVQNG{s#e!jo(EYOV=hPldG zwlKU9nN!Mt7?OYce&Q>nwn?lH7-AsL{Uzs?zE-#@CBOlx&4YkZ2-AJxVDN#gD<>r# z-e0H9|M4jQyB)6*I%DR*KDz(C)0dpvcm53=?`0+H_ov_F z3@B;4sYp$YJ)fY`M%83cS_>`R%#WA@x+(A2}7 zoB9|KRiZB#ck0X`?B-$eQitnsk}PknOFAA}<6*k)vXgUsR*}c5N3kyQ4Y<|>r;1Fd zcb>XLoh9CvfCAzZiWL68ObA(XONJgrJ&zB-v+iUlQ!|uk2j5wewf1?oE=B4&?cQho z4FzGFM;pv0euxA$Ln9na01QIYH9dO?-V}hL523@FJnrC9Ub>W5Obz))h_mzIV8s7E z8_eQD(n0$#9_}^6oO`{TZ5EfqZiRMo1rb67=*mJWX0p`$wS?09tb`9h zO$*j=M8(rlKy*vC(Yi=wZ)%gd6H;qtrg;{-)M_fd1`3bgnCOYPl^RK?LH#|WhetVZ z3t~9qD~+KtqUhXLm3=ih{ZN3%Cmnneb!9I#&~kh?ee zn1I5twk;5}(LPC=x^n75oK%&EE)(WopLYKcH$zgmw2aDj-mSgyd?+m)l5YEQA@O!r+XiedKnM!Ko2pT`zb(}N)?1;fh$ z-hYKVc!1#!d1Pzh``HOjIoaNM4J(>$>n7}_ zA`yE*GTCsYNBzQVL&Yp>WICGd^~zTDtjEa^vw`SvV9hdL$&U5&*V~)vjx(F~vTSiD z-K)XR46S88q{Hn#)p_0Q7xr~n)c}KbO#1Bn9V^qS(|?)Ff*lk!YF96^_T(I}W*#qAy~UK9)$UL~7traR z_|j?Hr)Ceh1Ixg)!CJs#*=&qeu$TF(cLGb&4sN=isXjjf-1+2d>1od{G|O>Oe4_Fi z1dW=OX=%Pg+fdmbUcS$R5-=untE#Z=SR&@SB3t~VvfzRqDq)K7Hm0O>>0^UHJ!ul@ z&&7lf+z7?v9f3VzIf9!n*uJ;;lupfn`92cDATAXh5=ZqIuD^DJ;Jk z9gWU`rWbgYD&@_HNZ%LauQQCGZE}4@%NN8C^DM`qlb#61xbBeo^kaq{TF31+!A9_{ zviT0)XU07#{7v|nO;3fs;G&V(lUMW5#CK4$mAB`BW1SSXe}~)f9MI-J2QY=@Ii2I4 zI+Dn_;#afwxhaj8&U9>34a^!t9OFh$@wXq5NPcQOn|eP&e+EgPtj5T0w~vIn3YdSC z?eSnIz~GatSVi!7S@f_z4;(-Qqu6t$Wcb#xsF&gJ8CC-Lu(=?mDL3=z#ouAjkFIPH zy%@7`bbHYF#JvRcqiS=BIn;61HE~XrI(*AC{#xrzsZ_f+#u6U4V3Y-)+EVY>5&OQF zSYU`8fiQ4IEJh#VhdFhYsQXeR;1ty6*(GnD&O&sr!acF%28ZZlJvQ~iKCL!Nuu zer7KoD2x8i_;H$z1LxPUkB%|k{7SURom0sam}Y7KD8XN{M=W2 z7Nll7%g5#o-&N1lqB-fSd40Gqzc#(6U2=z`oB4iE(dW6JMvgno%#iSOzD{`>IF)d^ zneu27LHdsM(?^1g%O)XLcQ{HWhOKx^R9EF0U=GYm%zQg7b7^0gy{3nrHxx@wI?+AJ z(8?*I$Ic~u_TH2|U^3Y#H(O}1nV}@;P++a!?ujfj-fZ7-{}xcZz8p|0sHYN~P2`ah z1nMG`a1>JhbSQhNV)y9u9Jp2#Q{Gop6e!IWnXS^lb+XXrsTrplHq#*RM8S zIf(q&NapkL9Pl`~iIj;wQ=^@ILNRS}XsnZ|*I_dk*`m3f+H<@b!VvT2&TD;iN!bn2 zYM%XG={A{0<@B4}g$Zp^f8M>~odI`20LMvG{}kXoZb-co=bTdk7$!*h;cqnJ0RgR{l=^Nx?gXsPJm#`IY87hdv?pMx_iNNn75%t z>gH7`i^FBlK(grpLD+)Tt5VZd7Bckf?WwFQbEXlp8J}O>@>*!iF4PLlH0g1yT9vxV ze_?kZtLLNfB7&plRosx27JuQ=##%>urljSR${r&0C#Jr`CyK&~W*c_Q@``#hzcy-t z@DU$%5W>HQtELRv`YS!8bh@+Z4L{+1X`B8BUAtX`>|R2@ygH#z{jhzE;k{)0Jr|Q) zwzpasLxhI-#k&HR@DR$@!sgdoW_WFElw|juC%1gYg3YTMnd8uGFN|7wcS~JBYkpL& zhQAf|Q`Vh6d|s*bC8zm1)8Q5xy!h3 zV+kvkUa=|4;bykTLjSK^A__mrn9qU9PK61+8)a=S>-E)>oeStt6;5T1dYjYHdrBhX7jb|v( zPrukOCi+7?#c!|SW|V)fW1G} zl_$2hbPr12WjpX&o{oM3&4s8i4C;1NOZrB8B-1(I(1ZCnWw5h6%Ljp#%RqMIOk8H12$6G61-y%Q})?+Kz$5MlJr=q*Z&-h1zTFyuS( zJiqsSzxDqAYkh0hoHH}$%rWPj+r9U`_O-9Al~kL9I&KVlaMxaCk^>z^X^e_*^-rLf zbMh+twS2<=)=pH*%NREDVj>7`-1YOsU?+H9Q`>yL-G~`q)0DG&Pf=4zq-jGvH9+M^ z7(F2M#dC7zSv&AK*HIwvNmoT<=jwtcI#?<4pbL^Va*zMg|eC# zf7#_z6?i&4vIc&=T8r4q-`(#w+vdDvM>=?TNJbrlq`NxFwZ`s$VKJYokO_eLbto2O zAAgnWZ-FR>L)sxvARd z17n}Y#R(^5`;P4|hX?HX|F;C+(qzlH(@9DkBwE2*U2vnj320yBH0rmGydQ6N#hZP+s&SAR58+$?^1gdDpnVo@ z-vk|zIcR4OLc&Yd@B~13$WHTRV!sKyz@+JwlqA**QT)+=uVWh}`MaBt>zHPULyP8v zeOCKlkPT zWqA{_{Cz4P=g=J!E+~nYBqn@%iqqSffg1}hCa*q0;9q56EWLQvGOLQ&#ou0PhU@W;;| zhHX5c!9B69_o-ORSYCr;dsUwjUv0Fm03Nue={TXAzBuo3QXPfqwXeiLx4m1wb|FU` zFpFuQ<6Br@wR5>V*8eAP&2pI<(0rNl)a92kDh0^+r=Q#dH7D#h&vW zZORyV<}XXfn_weoC186RDK_Trcu`m{Oj(!Cg7$1#ald|$aNnwszq@mS_y#foeFE)A zZYUq{v{ha@OUT|XiRk2=D*l7@_LH!Mm~Yt$qQ?C~=MW4_H9sPfAI?)g)0(|usPYUf z`%JZnJQ~UOv<#a~nh#=yH68GTmV*e^bsAX?1eZO0zUIC{4Ty&j!jQdjO}Mdk1WelS znc;^oP_u>##mzWnUAxjho`xsBcf%r9M0Czg@-G#RbSf9uM(P3 zf6!W7{yR7aTG{-9O2o6B_wuJi9q;ZvuTJmWISbvuix1hPpLV-@6hekw>DnAf;9lI& z4!8W=-jOTPeF3++N7iH0KFy$=^!=oc$)CxEGbbY<`?B}i%hPMaeQWAX($pls737s( zWnL3QOWWyTKeIlpvwrXcGTqkFV~h2g^){ufx9EFa@oPUt&rOjVw-S>$3!aQa$@zw} zGCAxl02?IcgHPcWTydU^9Jt25v@ks&YnqIdY)6c%rteP6r>_~jq6+#pxmWp>1+4Y1 z^%&6vA&A}~`f)cmZ);Dx5W(|ouCKH@cBE4-bb@3xN2dhbKIZF7)Tr?XYezZiet{L) z*;X$Yk}SmIfe|c4haFIZIVOE!&I*Lb?aMhnDs4Kh@ax}mR6w$Ou^?3bN=RY#57zI* z22n%K`Sb&JRgbsVHq1p&;Ul2xtEgL4Vqu=iiMN%A=mX74bG#= zulfA~h0)TSEoO6fM)*;OyQ`f^O|U&xbI(7SF+;WXH&05B2vCw{&YXtP4XgGxnLH zoo|zW=_Y zvFbB<@(1H&{>g#Kcgfnx?>zxF3akQbvei$rz)P%upPU(i^C+9+44Cz;Fd&Kc@9gQfM^ltiOfO^jQwRF0NC9CAjN;tJKW-bfV;q~_;8K?80G(3|J5S+^zRh! z0^FbnsWB<~BDXO^FORlr8}(r< zKQf|w-R7lyM?C+8?uRGUMl*h+Mp<3`kWDtSIKEw6{>iCiM&$e%#qpzHafsQXn1;@o zj-CH%8~Diu;zwFXBmIjW1A>8j#^>~^pL!{vF3?A-&fnNAGFHDC@@?EXfo~~JCi;nx zP`mN8&Vu$==7Q=WmQ~u1!akAA%3VK_yMCzI#dv;$addzJ44Ke^i!wQzo<+w6S$)P{ zKcp4>uEw@Y*)D&fBRS6m!jl&Ij zam(Lru~GGNV`(-I40P#m1dSZXhVr+0i4Zx96>@N;uVav0gXqV_sE>l1a}Rw0n$`Rs zOz-9Bz~r+cTt!p2AJVG#g`2l&d>^buW#ZR2Cn8Hqd;24whuc%SJ4!2Uq<>y4F3T2D z%PLP?N0g&>1X=Ly)etXNR6;D^<0AkyJ>-|bM?pbyY~ohAR!zvuZJFZ1^5J3iTVkqW zM)^(TnU?Oba2sB3PtDUyt)8#>ubdbM;epncnp4~Cgr2VtNX3W2bj0R0&b+FeUsuG2 zT;jCf+OgMbsvKEL_l6i{8wm=L+pF~YG2{jcKG{gNqut0-0qpe2S}_A$a>8&zG4IsjfAIC1&>Rjt5$Csd(QIrLi2M$F+A}H6-hq<6Wpy zn;=@;I-IfsSxrjWIp;w;4phX~58K$}hImc-M9ThjpwkeJsmvHqshWM_U^OQB;8pZ` zRn<0MO29z5B~X>x$e_2;NVgx96mq=Jz(0NGF(?#MA(msa{80GTHHDS*@XvaleI!75 z_h%Y@xUAP968qMKncV+s1eXc$V^{n6_&ZE_;YuJ{S;{jDE7#2-<0fh0kInr#Ni_tl z6HZmr1L-z!A+iC7&aWn}7rRU&I#MfZND6+I-vsYK34#YzxRc0txzx-QzcIQELLx%k z*~XvCq1$8m<8Jt~Q_cM7#AAt-oym?$?Li!+`kjh9xUp@fKw7-}^vFD38TVy_UV|rn z2*zg4R(ZxjyJGNI`C%}%r%$O$4bv{c= z_HFWg-TKytRPon+gtO^{WZ^MT=>}#xzz@hn+gx5g(qglT6^I!jp0Dee&-sJpM%;MP z+miwb@z})3|I`PVjtyhGjHJ}L&q%?y8z|&U!wFYdjN%3;jzv5Qw;4{t948$0tWK(W zF-M^cim%g4(Kb6iGej7VxyPpE~YnE!)Xop zL%aO54eydMW57T;L)$NjSz821rh|5Ejp^HQW2!F`E?cK{HoV*HaZ0DEWAA8*+lW5r z?TI;|z>Kw@(+~5Z0$*ox8DAihmIp=-#k&{n2ICvq)`7I%5vGekWpD>zVKOG4!wX6_ zm1XVSIxI5BMjJusrOQ|7umvP+d4Dz3*i3*|uk~5kbop1E6c;Ql&QvV1GeilncxE#oAMZbpBjTR+k z*tL4Ew1@p$Py(E2E!{|8mr8%hU z6dHY*e}d3$_uhqkX?+|V%#wzJI&LwXK**!~ z5?FBJ0TwW~=f)N)$3Fu;ka%^ocnO36_kqt;aQ;UK1O&4;##0lg9Vpc=x~V#QbYrcn zNxO#*@-}s7w6p+i8Q637SYQhlx#ytl6Fgsz#o486w?67WSaZ+rsa}5AKz>A1=g(nR zkArQ6JB{j$0Qc-i&eDrR|Ka?iy`_c}bjYdi0nf+u;NUulR{oiVzCFDxImDqQ00oml zh56qR5r~JV^!^jx{TDMag}52m>BB?714_%faf8O$;O)%3H>vCY5NrN-s`G!Q-J~^O zN)W|jTqaVcxS49$>@5o>$3Y1GoAeAs@4)9qp6Y|M2%dT=PEt$io(h@4Cu;Y77~^(T zy^Vl>)`@!1f?GYi5zEw$Ea$1Nd|Sn1d){1xd3Jybt|Y-V z2dR*VL3KmKF%&V)NK?TX*^*>?@IDemc;&Fx$zjer8*eQc-@um76R#pj`7~)uwVyqP zN#M9o#}1m1KY%rary=L9aO)%6xTObsc!<$)w$X8ZUIw>J@dc##LTgxEEP7iq{-b1q zAUWZ(*gfids=WzGWMmer* zvd$Q*Qh>5+7gC_;tDP$FA6Z8~!pAM)B~9c@LohV`X*Bw9GGb({1&KXV%EZ+9bCZ;a&aPI=rd>~@rfF6?4 zBuV})sU`6NMMK(itxf(8_fUnck0E=$19J2;G?;9}#`kNIa_Q>oN@zUEqE!Fj@eX=) zGsirPoa2E2_G@`8Y*b0bH}E#ZI}IOyRSKY^l0Xr;Yta7l@3v6dQ0GmH2`A-?X^f6w zAIJMG(KrvML)m)-@(*IX-O^bcsdzWN6(kxfy1WecMGhWD&OSqFidIHFessIR#+ra> zsjhu&#&Ml{bOSoVIfWSTEM{)}SU%~!vn zytZ=0X)3sExcvO#8&g97KPw;Ki~ zV2v>i20~X@pHgMk&-n5W)4`~O(YQe3v zOz#S3>)diCC)XI{!7re%2r@Q1{W}!K?gbSX=9mxOq*0+`ix-S(73R$%suK)zSYa0s|Y1P*B$GGV>PdCE48B-HkkFkJsDgjQJ6i=rS^!GGFi`LBipnGq!bA^`OB>`h8b(jjfJ?*{&HFazbx89U3DT$kn z`y~jX&C9XMBuSjH*rMF*$f4P{m>njMo@pHRqx1fur1HTJ(B_ob==l1Vws1p-yc6`Q zt9OEy1K{3&_NqL|HU=iB=OkQthm*O0rt;?(64Z3LS$?Ze#*f`?H3whr%9P}dUsnhM z@sly6czF1|AH$^ixHpF-8*?db2r423^*syt$l?W6I)Fc81sDOrmLNG^mw1_62Q7yO z+ui7owz@Bb3c<3tPsV4adB9|PF`32&W6T39XnR1 z(tXk$OVz?TxUzhO`nJqT&f;IYT0*j>s5uYmgk!%a>4+)>0|+bF4O=)bA@EBe1Ay=IB}`k}XR|n={B+JZ&<^Bh&%{GLN5}m$~kKtJ?Fp{WG7tp}I>|&;O zQ5g6@A45aOc@VYe+cH3gVLA{#jx?__{C4xk#4=z2N*O{Dy=&ALn8stVg3}11E zRJ%~74F5Q{Xqg^BWEtqKyXV5{S)CWG=8Dy3J;D*GKeS&scbrJOzYCmC`&z^|Mgu!@ zulOnm4!%g}yDF`(^=P-CZ40a~I_h3kP?Qih99bxA`srp%YgouGPj=9JST!>tc5Wb; zfi8R2I+&M6Js(?ZP^Eo2#BV~GN1|K8CJ3b&L(wNLF6G5TP%yK5tpMTOgP4FkK`e3)$ml!nIAl_9Le7B*g3yeClOe zT9Zp}7ICa`cQ&k4;w*}o;L_QS>*dvKmMic%*5NSp9YxyrXLj|yDU%Ugsb|fJ&AK*u zec#SrdplRpbIM|hGV-;9gngg4i}mZF*4L@7isJ{o4>SwNPiwY8_icR!GbUTpOd8_> zC3&L=`6G`bPEJJ0oZehV7~_;^ZSZAIW*5O^-Hy_Ppz;;9bb}a^BELh;dA3#lqubt; zPw+z2)kvOeFE7x&}4N!YPRG_{-*czB=eCV10j8nY(Y-eRmx2QUUcX*1*=SbdmBzwaHrHUTQe-)4Go!jh* zRGE_g)IgTmyqNE$lp;4IFeBD1$`&c|%q-C}UZ209m@te#l1jDa8NuCD=ROCQS(a*4 z`D%##935OtOxcss#z}j#IBMa82oJ+A7s@qp?OP@#%wJ*^#bd!ttBs6L7V6&@5?0q1 zcBI(wU0aCFf`92)ZB-Q7dafV+Jp2S_8w_dSnlqj7K^BbHx1Qst$ReuR3s_zLuT zV=n;8(BV!nR_qY2gQPXyto1sTpb6o-sAN_G&v<~qXoAg$(^|kS82PgM%$ZuxTT>;j zue2e;EU44T9*|KU%i$|9p}MvUyl5a9wUo&`m%3C`1|d}YPYZsABN#)thzRFW~&2IpkFw~i&WVXzs$A$LLk^V*2O zC6aiC(R~t^8JXNEX<~}YaMGoZyEWQ4)k4SMGg_plQ{WJVn1s3@-OmSL7)iKNq_L_M zc{b1BwFM?hcU-YgAV0rpF$G$m9P{-y+vS)5X8@fY?3B%56H%#zm#`-iK(;dqDg*l;)*m!TuX^1n@^VgdAQZO28M6 zu<%Tg7CnJe-E0F@fkRP1Y6T_)<1t+*$%WMO?&3pIsF zP^QtR5c&i)Vg1)BzZ{JVHzyr&s3v#K&;MWOg1hxK!IEk zdoe6-lxcy+`!LKUfBlMLfR3QS!?oFyeupG1jjY`0+u7iKf*(hQLZqWw`>%gU#`wm} zBx$U*X@vG|@R*g~D*BVc^V#xoL}gO7%11ZU7#oA$6W_R{M5{?Sm8AHi$MDHnAZW)X9zD*s z^3WRA#JQJ?3a1Cgl*(;hZ?@*+{Pq0mV}?lpAFx$QhIuu~R5Y;t{ffQ-;3ZF5TCzvB ziK$Yns><@-yub09N&j;&^{2Fj8*-yJNWpEFDmb;?KaTXj$I`(eB2%tt;IIS$1wj4^ z*hzq)89oW*F|Z%rK7+5JanMI}0YWcs@-KVxsNg5WZ2WNytl}uLIH}_tT22`T^M6Yrfw}o#@F&*4 zcq7@#UQr;7-16=8Ja#wJrKVKBgVoO7#H0E6G?vk%MV-&{_i{E0!-S`O5jD`IPU)EI z!ehZBeZ97FhkEh@LBlETy{zj7FqpAtExb)GUeZV5BH+{zP(N-ZA>^+3*#cT%7*$t% zA1g(W1tjM3|t_2ZtDukvR) z-@-7~ohmUH&h#}@fkqLCn#-{8I-M)#a{Dz6EgnVA(@4;1Bzl7tP~-;ah@XlGFib*_ z0B6yo)4T3sYuBE_mjP&>^PThowoKTpV3ohitFKK!GKM)+LQy6%*4f$| z=sfCOb?Zr5uaq6ZyZ!Dtr{BmkI@Ea;UyRKiCzop;$O$dyV1nF8wAWyDPt5Ott|Hrx zT4b;n*Mi=_19Xc^)zHV!Y-IQrxgrxpjCDxx%jaOMv8Z4TXY!{A$1b>>rX{Ivn)M?1 z(FT%O!0aF`Pmz=lh8j_0YOFD8W54d&Vsv_a_Jkg?pC0NoK;xzdKJW?cgI{=cW7JjZ z74Kd=7ouAt$VBuaDAAp74|XfP!ltD)?L`8erKTRiE<}844PyMMH_FGi1RiU?qbZ= zxptFswk8eMf=*6mzFUTt%HeX|jnpt1kdygDvVdxQct9Uq;frromG)?i{O+#@yjy21 zp~#Wm^zG`k@nsi8V{QeXNNB&px;#V3dlEaVKy=EyJOK6&zWCyYPLT>t>pd3IZe5L? zXi&Huyr2^s>o9c#*ty(q0mr)_ok7l8QqcxkKu@D5<^1H)i)!oKGAya0VC68?th)p8 zwV!@<)B98OZB%`jNHk@VWjG%`(fl3m5n#YIl35?%ke;%~uq}_Kx(MCe?b|ZEpxJ%o z{#pcU-&<^VO2BB`2+@UPwfs zx2!=`wUx!It#oM#dAQ5%Urz``2r_rHH7T3bJ$1x?Ol&(`%9ojCcyXg6ocAx=DrZ?s zqWg^5-XL~{V(hI`j{#k)nDnnC!%i$nO{WN`>R5T5ft7rz{@Ydymyb~-c4lxgkeenFwJs!K;t@%C@Br>IDbRE1D(aR zxcyEgdY3)DIt^=UPojg>U+aF?&)jIcrtSsj|4)F2=Hy2QDIf%5AcxTc#B{v`hHxHK ziO!(NtgKUa2%=tqUlAM_H|@%=xfB#w`Y?@{jX6&R*VWZn_wEf}V2@+}#%?07tt|QB z7WSrQ>||qd^K_+3tjhN#!Oa89>F1(@J@j`fdyCWIrdQu=f$k00;pj3JvnJ9H|Lj$a zVqZtWZ1?(*zzPG44zBx^rMUy?yjYTxsnT6f_ z88BtuA|E<|@4@7@06kNfzJ2noHXu-@xS4QufY{m32mrx=Ov&HynS%j%qyCo~1~g2# z+2y|>vcKt;zkCI;^gq9^z?~w1wCu3XMwR4?UZA5uHM!qv{Av&b{-E5TVw_?e_-jz7 zHTf6XjdDnDbBWtYP8LjL{D#cswFvyxB>tOdef1siyJ>oHfTT|^&OB;I;H!YiU@y*2 zwl!9q8oXDm|Cu}6gB2{AAs)3QS$(1N8gd)O0_5FFI3UE4Oiw#l>$X-@qV+a*7>F8%4Pb)iAPL5oyal zn;=?tb8;QX&6vTKmx_~R*yJ#=x}_Wbq5QoREE8*IvclN#Q<+WYsHqlrw7ij1=&Ma3pS9k|$CfMcDTGeqka}Zic zn6mO9ZEKjKQU*S330yKE5h;8oSaIqU1x5A4D)sh;U(fsb?-uraNYUW!REh+o!4%#G zam%r@eP>d*Ls?+oh5hro{Ny855?R^3FgsVdQsqwFXf-@j_dp_4sD!2a+Z|M&s&i6Y z@;D%>UdTOf`1Y0_fJ^8wvV90oUsPGfr;UP4C9fnx(mm>kS_Oj{?@>kDCD(eUjlNBF z=oaW-QysBB{-r2Lp0F~q>@xYrltJ?h-YpWV&9o%WGrLj}3Fpb#-^sdL-8@_waIfI7 za-rsdO2gf>g2x*pot8&;EtY{{{nQ#kjjy>b)g@S5+HJQZzH5>w$KBsn#?RNyl%~kq z;Ppbx-)`i`V}TmE2Zpl>L(?kCij1jcCA+HH zvCPr;TVcl_tO-4R%QGF<--T^d-aq5Wqiy7j17G^&j49f~-RLzE(yWvtxTYEkLZT$= znAZ03OZtT`v6J3k!`}*;R4BfMnp(}-bY0vrDJ~$kP~|)ZWF#0{C3_vjl8vfji1qCa zk@owi>`OeETQAO3C)t}TlK}Poo;dUve;%5rU7JvKHswY8nv>P)cL`UPg^RI@q=R6+ zFjVkUu2Y*ERWBD`|M^8XHAFjw^X&S3PbI(BvP`#xm3sy*UraC!>9n`(>(~i}3!Ncp zZ*WrQGKGc*&2miXz8v0b^L|N)0ojBY?|{r}riyVpPwo1x%zgAOeO`rm6Wg!RPkg!> zQW&}X+NE5N@`3oamEE)z5B4|K8;V-)&vkFI4tH*Fg}cVuasg1<_`n2u* z+P99V-Gi=3fJhzkztEKsc0!-x-&dz)NR~yAVEMCFSt_K25csfrWLe@#t*(#E$kwV-3 z%!elC=@$KCGQspg=AA}%OOkrl7xw34tF8)HkhWb(iY>+5xmmY|E1xDd;d@SpVA7e4 z-UAW~a$DRHz0t-z^vfJ8It~Zkjzuir+aVPD+?l+=t0LA#MrE;W@ZLxKs#F~M(o^y8 zDwR^-U3{|IgZkCxNHPvq(34Ne4Sp{W&{&8sqI~sZ-h{}nY5ojRw7*682dk1nn_3r| zfYhAFD5YvcG*eub&eJYL?WN$fY;-4ARI9tb0&lDVZo`-88{_xa0{-s>`wuZ)Mz!>_*fO-a%)x4*haDw3_r*{haHx|`$CesHk;~Rhob*r z9cXk&@GPdowiofVEOnSn-eLMx?fmV0j15;VRVSw2U|bHAralSvkr$@#DN>dBe6JTy zT@$>rZgHN`N8E*+8*ma>?0{Z8VDd9?Vqu0B z`ZzE(^EFg3&frNF%oVsA>{t*53riMkl|Arx*IZgZb$-K>ORkMSSd&mX@#kNW!&xs58w{H5gE=24Beie%{EOsv8TE46`{=DQ^^V-CF zX|ZE&u|c)D3ttcuZFW9-zuj&;z%R}EBWqr%VN8}j_vFOpv*10K)wpF$QnotEA#VuQUb!KAY61ckWC@aOIx(fTu! z8>dNB2}d6*$|AleG?3zh0p*NYB#)QrVgZr{T?ILi*p;4^e5%%{uiV0RK{DEyd-WY- zbD%KU_@Id_DU|HI3ic9+iB#DJ2+3#(PSf*Z>74_{lh+IRw$3?6=Yt3qX z$f*xn!fZdOIeJnJHaUWRFsCFUHlQ#Jh@OjjrY3JUa(Joj&$U2rPooiS4t`{3F28Za zrwW1zO!y*K>e67(JN>NCRc%EA*Td~qcYIdAoqI|io*y5cfSeb8#y<9;PWDhO=)MkN zKNwna3EH=6Rd%&|c%(P17zHlvCyJU?#b@q< z#;sH%^)4uji-3=BwCdH|=N*8`&`?alxmegyjd5Sfr$ky)!fi^suhktaKNXaL)b zK~1Yt1m1IXvZp$x9lszv()(3&LY-KXjwH;^LcHVXD99i9(a!{^K8gpbHF;clTgDdQ zc{LDna2!sb`^w}Eipv;fZ@8k+u%+#b4@pEx;1{C~F>*q}gf##y(#azk&A1rV)?mC? z+3>K??OsuYag9?Q(E*Y5O^$qfs7vx-ouEgU;`A!DR8M*BRTvMRA-kM$0;N$*r}H}n z$&N;LboTzHEjIcaK!J64lK3caVVwFc6>}Qt3DhHae81-%a#t?ivb$nW0;#zztW?M> zA_sjW^D9xvrG{FNE^!zD&E`H93p zh0VHd^47zJSkidD}}IIqF;h zrqfqtNr!Kc-Qj{uzpB#AH3x`g(d&J4yOGP@ahw8Y55TO?mD;~VX+k6IiHQUG9Gucq z4AM{X&xw8I;_XQ$(u59dn-BC<|6tuY4WgS7(}?EId=SZn&_nytGN$Ame~etLpHi+j z!x^{A0GCo8OQsUOKwH>-lJ5}wE~h8(6ME^7B$FoK{4J(J26DWkst(mRNpL| zZe(a&pKF>`Slny=Y~b-9EULEpaw~RBS!_fP*}88_oySD#k+!SQv1e)D>04s{GsWxI z2zTbbpFUo~?~raZ`y?Jq?`NthJ_@OObJh&)YVXe3V}BEl0ehWYP_5>rty1I{%sei! zJW3;E$82hknyL?$E0;mU=8#bCK)uOOeYl?QCq;Gov$;PA{PY9n=H`5TXJ7-^ksJ4Tdmp7d{;B z33R)5O!*OsimUO*M&=Zd$UuvHNieqGy=3+)(nHJJ54{#D7jYa;j}Q-D&CVZ^I6pZ< zZ19j&HyGz#>6TqB>&ybOKwmGo<&%FLpPL@=Y_6FDUARa*uBg9UFmX@57|P53l38ay zr3Os)#WUqYYQ~e%Fp$_w4!K!kkG%mVi z-jy-?t~A^uCR6tX^FyiD7>0c7IVUz7g7a~A(s z4udV%Z1k%vmZGe@(sMSBr9<}T>^IseG_C77kF%43PPu>(F(9e~2vz?TsWs<~Jf>qm zM)9FOQBl8q&Qvn3S0^LR>g2#fl;cXjFq^ccD5Ep@`HX}B-6LU-pPrWxq8VPUNqlReP+k3lwq()PJb$i4f9k(+xLXFJrrAK0BdpOgMa8 z1lb09E-No!qB5G6&ab-n&eMYW07xdWo^hBAe(OVGl%)#EL-feLUaRk8aRH}3$U|DS z-~8jV*hkIW!UQz&#%DuYRadpb-=R|>E#H@%ssOhTrqetY7JfJ8Y;`3y<$MOfoloQj5 z-eKd`uiEoMSM)XOf}8Q9%A7)ok0kimmn*6bW1OrqCbj5$cWajD;ND&kM%nVTY7|`J zPpM7&jpTsS`Ia`eKGk)7%VPd$E>(O^s&1{-hp*_Or-Aw19ZG$8(2!njp|Eq~%5&;R1^B?z6@ja2RBtx^EO0w~qZ-vZ7r< zSa<`ficYqI0P&aR0YyEXY=70VJMzWq6a(JBq6`1xF^( z&PFZjk)Y3qSZ|Bgem}tsxo&CgAV?3XKZyo(aPING?I5F|`zZLP&s|{PslX47bS{Nz zq(Fb{#95^Tr_V%5xDLO$j@{JnG5L=f^l+!X>=Pfd)zV#0`QXBcr~q!nz=O>y)xQr@5f9wy*@JM*rbybJfIj_Z`smdr+a)-A{adN{=ErTO8K=*#;73Mfu;W z8X$uE-uFyF2UOcbUeQP8rbhw9CL{%(CeScP8F=XHgi}zpy;+t!#W5G zHe6Hs8tp*$ghg7yRql;W5${9s zhM~8eq$6}ln4|0)TTfP*#fv)0&ZT<2q?QClwz`~2q$7Jfe}nRq9d+1t&AEZ|9uSCW zIZCtuab|&kKQB9_orHyX6AkKBb-4W@1tU&l`BjqFM~0euw0BX&d&DFRX1!l;qsZE>!`U&NvR6?xetEe=&_Nyt8n?TRV{p6b}1$4!4d( zGTs%FjOtG3v`8=AO(1_S0d7m${mRs78Lf*DPm|`SIa{q$CYcymaL&ecHnPtH+%%A@ zrV0t>RxuRSFh!d`eTERb*ZPH!`#4YPrjv$*xDMH>;Mkr$nePiEqd%(gG5W4dv|`Ts zL+v8b=XxEKzD*^`yYE98UdH69*JI&hQ817@&yN*n6&g2NSGilZ;ig;aYb^R}Nd^FM z>z)4?-~Z>e!-b}Wye`n1_+ND_hR5(MK+fyG>nm4^WBydWyWi`2@CFS@NOBwz zazN|b!orGSZD`p%52DxHwqQE4;kF^bRHSKR?j{p7$k>4FA4{f>2}<~i2oBQc9U0sJ zLMHz@J}-%Nodqq1|2jvSdVqSG8lV;SU&-?SB+Y>}oCdX%K>+wsRKSV-mn7LeTU~4R z?wr5GM@1Wlz){RCcOJnjXi$#_&cCIHb9}HKK!%XT6F8}u%2l}xT-+N zRs7@5;bRJuRGpr=@_~qmiiC8(k4$Z2Z>sC3hMxfC{2^OspD%Rs<4hep-W!F4=pao; zQr)I?vsL>IND{?gFLKUNx+Pw=Q^kGIt5L=qApCYyG=}}A^BLck0%_bbEjwnO@H)F? zVL>k5d`LU|by60H74ddB6)u_W)-j|;7XW7w8Su~_#dJt(3@WQFl2r(%;BF`{P`06E_kAc}o7zX2W!1E3-Yfr%_8G~+QWL*^#q!->lcY>4z1yj6Wd zuRGIkO$vfx*USM{l`BKWKdwI45~cDp2ny- zRmeelc)bKr5({-hw4FU2QX5)&uCNyC$&qzpX6*MvglQ2;(a7Va zIqLJ=Z36SQpg?a@w?O9mgbg#-JJv+T%tWooU=Z2r$Wv0M=mH3tu^ z6COTCJyUw#`x48UrGZ~@f2E-!yYMhLdBv{t+8tqy+%aqLs`)>py?0m>-Pr> z(rctw0qH#;FI~Fy5)lFEy_X0`l_FhG=|!YOdhbmTq)M-$_k{6CYHA|Ld z3)ioJi)(`I;A`yKws9gJP#2|bY<2zmZ(60M>>GC~B(eXml zNF^<|G4aP6u)nsKRai-*BrFeX3k}VJ^#uoGG{31{L&HQGo-LVIDz2zXqD5Fyp%Tk0 z-LfOW(#Y^N?VgU<5J3cwUyBJ7Xr_+PbjX!2l)s>)m(XDMJH!(sBt^LsF5CfnR*xGzeoyBX%Ose^Ap{?iynfp--?ZM#wHjQ=8 z_v?Sk0c-NNZvCmt5bcW66`+R&-%ZEeVQ$Fi0&@x`f zl>T<})Gsp=y7rfJ5kI1(4&BpS_Lds4-ot9Hd_p36#6k>AfaC1}(PwM$C1yPatrUKnxI%s7}Zgncnc49T+h*w&d`ca9OG9>Doeqr%M&Ixgr} z(hTLAsqDd9`1~teRS!Z&ddx%b7ynq}`!Ti{q8O$t$saK&F|t!%UeS#AYm*une3-on zh&6U$PJk~frPHRpiJ#iEps_J#T1`-rVRBpt&2_*bvll_4GyH^+&&|}+pL>*)%_j!{ zi4aI#3`_ej*uD$XD7USID%?J3#_rFpHz~@f_`AZ+;M?L@Lcq0AR%Ae5BS5b~-3Hf* z+h10)_;l9jYmheTYavEpTy92gq3!jq_8i~oWg_M*r7>Pxe-R-)UCX6ap*m3r<9?d~ z-EaTdU7^|!cn@m$s2~;Lkx7Ui)zi;5%S~P-2kGxONS2)nzR5$WWJoqbyc`1nRgAOY zdM^`mlVi(%@$%8q4Gtr2XeZt~35q3%8Gu!knFG$@)c5wV$V+LacRmJ;xZhUqj2XJI z$SX+TcI2Zp-rH~1UX|Dx9P_U%C1z}$AG(gzlph&q3^G2chm@USI>>TR0dvpCe+dw|z?y>hcCI!C=A*n^ zV7ZiJev5oC+r!0;%=qT!t!18Nj*TXPe?dLvtD;-7AN zWfc+p#rXzU}1rOH9yYq3qd_8b=4^I)vH{CC(>@`-jID{p{S)cca}I6!;OL zsXq9!7hQ#cT!Da?#}|Ml##s-%`?lxv|M~dW z!QXgxOo3P=3Vh(xr|bd&ytwdjCH6b+Zvm6_+v6sfpCSUyPQ@vN7nF?QilmYS+J(!!5sjd8`EK}Bf#9y&e*q|c1;^eqvv|6Ki>d1 z6|1rEJ>Gc??Yj2it7gOUe*(f1)0+8)q!X3TLD3gi3=3QrzGZ6me*jALyTDoEo}5c> z@#Dp_0IvL2@)a(~q@2^p?<%CcY&e?aM4wYYw#AuDL3K%hSAxBnTnuE^z)!Qlh~)bp!qBv4G8(H zvQq27rpcJ+#!xURq5+Zf+VLDhBBPjXBpT7%GUf`HlDmFCIcj!kURiI^(RtupQWrn;>rDL;$W$gu!+mkgStIRA}I32Hh4qf62c zZw>^-zs!X%{k+K1?UTw1n2t}O+0&+9T{Ud0iCeFDEx<6H*6x`#3D$wO#!mW|cM%OTnu854}>hl)*mVGdZ$!~seOAmOZi0|Au=&{C%hT#*^dEu5xu-}KRXY;QUYq`f< z)YXQetj7iZT-qIHQYoq2QJI_k(`L4}a^<7a)JYh54~u!b^64H-x%m6;O6#Q48|-;2 z^!h$!$z!A86~*stN$HSgO|gCT2~mTE(%Bvl_qb-19EbCy~xFKjVwzz#j5GgG9OX!Ohk6+ZU`{KtYXVrg-FRK@?`9VY9 zq{iMqNn6nb-!-4X((Z}Rr(Q>9mMI?!Q*pb~TDFA)-feEp8SgQ=`SO>hMTysZeoG>J zY;m}5gk3*rWjkR7vr;~ifDr(aRW}eq+G9nJxjc=qIZt@j)L~JF3GT$QZ%ce}W}w)l zOG?o&-plY>clkOAmjCU+C&R5(?d-jXj4;jAfhS23eO0+@bQY$vJ(L^$J}XT>!CwBX zG@U*pW;=n}Z(YLHfRB1l*Q!g7K9oel%~= z(EUXA9=figc|}2@=f8fh94U(lY^jN!e_>AhMV>jH$6j=PX4=uk*hnZbGUcdRjJ3B% z%_0`^>V*_GW}dnAxQmW0|4QRE?>*dy%uy_jqjIQ>%oo2Eai|p^in4leXa8xo+YO1b1=6gtyb3Jwf@Ch&fMAnhIad> zl&Y<5xB39I)OS9TyIO^PPeUAnoqPKz+|Tnv%}teeH8fpPDP{pfAlk7B?d^8bbzb( zE%uN5KYiJ6?Uw=nZwA1b-B_L5T^TSWzjY;e$CCf0I~lO=HvO-+Q#A4_jc>0j8o62c z*1;ixt<(Bg6;trZJN6$adNZ{2i)8-#VjZu@I6~#npaV;N979HWb#Y^*j?gUh21*Qv zRqz_rFEpb-#dhwxGwl=!+fBF!MZ^R8Vwl@soC)14%Me&13@aI8eVduSbX5fVZkO^? z*uDs$YU=&)cz7GwKBfEW;@=;i>Au?duSEC!uAeI5%99g0p&DA(#*+75O;|;#C&${) zO5XqJa`pE?hwwi)E`Pm8s| z86`Uo&<7%*#B7D+SpkQx@V}X-Ncjo;q5>KU9s5_nf!ARu&{nrmdm%#3#!itNF!_Uv zojDbd-U1?A-4;MGAr*_4YDGhl#tsZ$LMky3cQ5%59^hK}ujBK*|8#l&^@7AHWEH8^ z!6T(e<7}fK8)u|w%nv10zh{r4&@ti~d#05idz-4jFh)&t&t9#M>#m-7#a;0?b`H&L zk>N#_`|~Nzb(8t_IV&J;vRrVdr=F6 zx)$Q+9K>R0+eJzV-8tip1N?xwANU*x!m(uh8Km`>-RRIKxdk1s$Dg2AQk$~=l6we003x&Xb~CS4~y zymKJHe^=A0aokN;RY}iZ#&9&MRkqBp%%U1`5k=V8)}CY?Payepe}_OXki?Nr`Bl!7 z!2|q|eC$sEk?&Ze8lQv z7O=bdRdyn~%s6N%PL_9JSdpU?1VULC2XP}TN+Qea2i+6Jj@$%ozeWv1mq6aZI2DkX zkb8i&-s*@-x9e=_GDDPvu}xvbBHpxo zGFh?|Sosla09QJ*C;bT<7v56}vSDmC%^x6IGb;IuV-CZlE_ALDd>pspoPehSux>?>w?XQ6z2Z<&QFtKF(obtM$@|_R*=fQ{1t4+E@Ijb4R z)H~sl8^oAjaOh*l5V4tJBxm%-y(5KcE`2%8a=ar&vV*e4bL(5@;Ulx2I!1ft_M{Ir zr(n6EMu)Nr_GpwfDwA%jwA?SpupJ4C=6`WrnL9KpglX5#74yDyb-KqtwbSb$w{@L<4wDe9M50)twQEYjbY1oGCrQ+ zU8l?%C0nBM_qNUIr}OlxjE{_2BsS@fsQ@=)>=&c?*C-bn6qkmUMaQqL;3B8kY<`*Q zdGMdaTr3NKR`){4F*;bT8R%GRUX5l=%xFf5Jjv$$z#&!Q6Yr-7I3V*NUgRz+bggY> z7+imr`(x?bKF|8b7gA-sturUIQFoQLGnAOR-nE@Kw2|rRh_vz8#MHOX@h?skR$%gc z^dU%6WbRo|E~Y^zo9&%XzwoJ#)1!$W!+zeczoF?Ie$)H=2VGz7dDu1)yHi+5IgB`k z6vBp|eF06sBOfX zN;O?+H!doQ*2?q*fI1Z6t*C^&F3K{hE%=@mb<&6(aCi(515qK57tJwR(W6%0o-GP> z(eP`t4H}hce z6LKWpZVn3tPrP*BD_NOnyaB@@Jyo9MeQRH}IJMjAH%Wp6;LU+KF*y$_SN+(=L|+nn zUcx2ha*tpEehkmkzH*yQVhLyX5~&^0VunMBU&xa^&qOD8edcY>R6Jh4#Uci;qP~HC zH&G%xdVY9_EWL!T6x=ujLK#tv{MLbH7P>b2AM;q964vk3=+=1vp`?)>A7t!G|A%}fPL&xn6RecY2*$~BQKB$4xXeV%#e zFV1T=wh%rjQBT9fJO{PT;>g96bn@D#J<`{tPhxLCD0-x+KukOB+&9WZ$V3`zTeN!z>*iM#fD|JapEOB% z6KNaEthoIPD7RP0L)h#g z56;L-Ex20$@GF=}Kbf@$p+}aiA#m4DPgA`Dsg!sghZC1UUt4b;t`d$jvm}frDx)w+ za9nQAi)!1j`L3y{UAnAz;~uL%kXpxk`oUtlE{DpInu!I;*kf*KA!eM?$(TwKs`5(e zW%G1LIjX_52`LbLBsU55nX}GGqX?c-!0;5j!?$S|S&0SsFiek2GW54|+6*cnuI!uJ zGe-@8bjOuXV>9yOztSDSXZ-||u`jF{zs=tP{G0qnf;*nAdc1w|@T}mW*2aXj2nWCX z{1iUhs@{oTNoXuRH5V6;AouKfA#h(pf5wF9xJDT|<$FBF-YfvK_=j z4`gNDQjpBUeX=Zy(__uMhBzjbdo+}&8N+QjZnqB}&fEK-^#IzcM?7HHlI1>7!MUxq zArS*`SEVWLs0dSYEfr_jm%RR%qb?Z*R0IX~1#FW|MZ&N!IEgy4H?0hm~opC#yFxp(I<0y+2#eha1PzA0VEhZ7B-8&Z|0 zLuIc^29?F3Vdrvw2yo9a4MuI6bv%fimDru9uq0Yr2P}g%;5}DcJ z5i^vl1!9qbM&bX4ZeM=6ue|Kw~e*5gU^6-5j~kAU(5t^cjiAo7uX$-2lVib3}~8cGzgKRzqr zyiWfw$O}(3mt<;>i!$=CfTl$6QP!Qj)ESEf^(vL;v?f}##$Hb@SaQ@fX=g7W_fWsk zI{ay6)y28tn;{>B64uAtRN%4%1$bFk>PmKZ(qg5^hKAe>qMn4$#sa(hO`>%wfyBN< z9lQ;f4}whD@q6G$hbNNl`nN2j{{?(O#gFz}0nTWbEJt#B>=in@G4)ZFsf*j&*LFkD zTHgbtBT}mqmC@snzRXjVNTf~ZOLw}dhMl>jZ%`c29{kWEJtB4DEUw=WsL|s4b|cn+ zYiG|*fgmXyGM2eJ5)_Ko2>A^(Y#$v!`j#KZSRqBTY?9pY;R1-9Yx|qYmApepCCVAj zP<=IkAD3}tHV!C8plH|4ogh1M#87XEW#YgeKt&RMm6OV@DNZ9A0$+yD8`ZZ)tY%Yj z?W~sZa(CTjQn;7aIxO;x|*t=Y}m%a^bI{FHEURI4Bv#;YB9 zOC5-Rqzd{Pc-b3s7f2L!>go(kZoG(m0={rSK72fWd~Qasn&F=8*Do7l1yORK3UH}| z^U@S&9ExQy<6m;z))4xyjAq)RbUC7MdaZpr=b{MAYyhz!#xzIpph&VK(zs$i*oq|tH$FwNtSQNP2N zd@SwPD;URvZQoulc$$TkS!WXL-KNQLG*T6al+D;%F$~%M_=dvcTJCptHI)3e9I2+W z{~FH?|2)Ieu9UqeJK0k*ia5#?D3^vFCo8$;dzF(CJj9Yt8bcpwMIk=^#fkSqR=V;0 zrr+Z!vXqyn`S!WtC~Gs_sgw0gdM+7}|cH$O5O?XA(~gV+kZD*SqN%u0o= zYYQlyyl{PaiETgKsg_F29I=jpzWaxp()JJ6nGy6~=c>M;!CTd-%I@xmN8-ksJ|A~Vn-nf5wl6wAJjXd*_S z%B$m;qd)s3$D;HuE9-rmjU3Rk=tX?V_0I*CnmyP1P3zPlWJs4!MMomrjq%Wg*_ezI zkRZnJNAj9D%@+|5lJBc!q~By_Cch6CJoz%^t7E*U=u+ZHIonT=6}v(DT3N}T7Mw^4 zPaarHZC$;BEFMGKXyATQ;vqv2PAn8b`niV#$Vvo?si^4*6I9?K*c)c!|14(>#D!J< z?ht~9aH{tej{s?!`OVw2g+0eDd{x6((G)7cXi` zWBl~Ss|*QTgv0z{x$tb3AdS?abZupi+6*asj`Ru>i{(SF1Dn)0$Ar<_y><0biw)Px zF{K-ORAes(P_a0FaW2QYZX7oSVlL|sE9HPLg_$u+cPxqg0Ov|641%y507pO`aZp)O zOix=pmzejdO=F_%bw3%Y>io(0lg(&Yj;ka6RHw1o`c#AU8AdbY_)KXn&W5M4Tmsh|vH59(XocULWVzkOR!8e@1?eU~b8pkj zH%{DP*`Aseq#pq^gW~aR4@Lp#mf$ATE_!B9<)<>>N{w*{$M+hwgp68BGd)5!?__;x zw@icKhdp2Mk*q#h_i)Bj#;+$nFQ_(K_)i)3_;%85wV>yqcS zjw)V2z;Z~tCV7|e^YtyoaNTo&y{xdF3NXr_Rvxh}mF@;QyL5qtok1-r*n4fkT`&y> zPslI7I8k*`cIEGv`3Ix3+*9$Yr9F1vNY%}4Lw^?S@Uk4x$W2XfpG{7@dkaWy{w3Dl z0sj8m2X1JVMeQEfQ)XbQ1R@-L=z$f9R4i@}5WWWl!yCWQt<@WsH+Gp9+sa^x?+zub zj}ME>(yArQ?W9O;EX>V*1sK4^TDmJmxxo{p_4LLb#>#R}EqZEgrNvUEWN{S(c}4kY z@19E(!?R}xOcP_@;8VQ+w)b|iB(KD3(UjCwT%h7^tl=F=iouuFLJ9D~MQNQv@`Tze zV^0tscH$%x`)n_Rad~<4EEM1GQ5VedCb#&soo8Ak06i=ipNbXDJ&gd z{7tS?Uf*zSrMMM%T#wso_EG4$n3~6$=eb>vYqabtD@jvCg=TT#2iq&?FQ%Aj~1YOc-=I4yRk>c;AD-NjcZcw4kh z@%bCyvs-wTfa_Ck+{C#20mUbP^hHec;&!lJ=mK)@2ZXWDG?eaG{TH5hcWjtW(0-P} znc_jJW_zvkfcn3$1%4Oc4QsWprXDasbf20sv%--INc4aODSTtBzYfym#l!p){DHh*bceED`F zP0dTr0?=k0lkZm&hd!^i?l>m z%ag0GKq-I9c#^k1M$wlB=s~G8r782c36&()Mdt7HYRwi)Tk)7yFpvH+AeZD17tT@_ z#DT}oj{Rpn_}^&sJ?5=c9a((uc6@|w6+myJH1^Z}CvMO)U`@l@coOv$u+!#fdWM?! z3(%`DnCG@gi|iSyP`b}sADDR1hOov`K|b`iRGQVGpvqQkpQ8q43Q|8T`ntME&+KE| z3w6ug-+eE@A;AH7{QVd?gmNO{xZ8(`e4L!j~mvL32Kstk`q&a^oHx7s?=cNTr_2GHHyM{Y;|E^wP?OtGCOM_+*Y z*HLoe9i`Vs84k?`#Q;qrZP;qOUr^Z}2vB^qYqs@;u4%``s8azk=Ru&OF8s=R;xnw+N{_x^Av?W_7=a_jF{pu1%hYDRaKyF8V z)~*L3r6zqfiwD2BaNgLokYj{Mvg3giCL80uPkjIUK&9D!%{s+4Ux4KX-6uO=+eB6% zI(9)M7W4BfCL_fw#0!hWkZ+PtBf3sg7^x049 zCSUpB(?&qH)J)CJyjB@(rK`|htU@f$V++iNDHhX@Iu*XeeRafH%zGq&hZ1;!P)_hd zJDEFB-_O4|bz!n7Orm4TXJb2Lyv{-s_r$P=7Oy{ia{)NrZUwhn_2%gQ`_Ge8J4Q!@ zbXR>2sY;Ipi%8g6k~``P%{}B8O3^6k`>M2kgH)dS z$v+*!-9c(_TJK#^(N4EpXnkHW>5qrFhUZt9dcilJ67OkIz&Fn10B&RZlSP!PP7A`YbwNhB;)UD@ zF9>zfA%W)t4bzI>nU$-%To2h(j#P+L43phIr9I3u)|;jv>mpmAgWZuoKEH(3W2rF4 zi0Qo5z&0p6c(*R6JGSa)Ok%CWhdBBA)tg=O_{6yom6B z%3`ZC4NK|HSj&LgCUc)|x@j#3*U!h5(QkLw-mM|S(UG?II&E609)b2vx@JLtB;8&~ zknx2l#v7i^@v27cJQxj0OM|H zA1-(r`B*%o_fqU>Un}8zNzT&J={?*9%GLoa+yi>O6947_i^{dx_DzHYr1xC3#EC^| z?s2)gG>Ncfg~^4TB`Z@wRax=3tV8AEt>w8!T;cRl=aJcf^;4McT??n1#Ew{gb-)nPh3!PdW)^y(_C%dZ>pLwS_pOB4}^4c%IsIPZQ@Kn5VVS z=n(3#fo@z)uShF&wranQ#uw>|EvS4##de~q|2WrigZ)8*9rE$Jp#V)ccmPciLhUj%= z%l@)UlWU)63HCl)RTwaAmx*V*qV9%h)!fvAV-wt4(){CK$d9$E+?C#MT@%6aQQ&Cu z&?w_>QBnREnF;8@w>Fa&(jdGWU1f)Sz2f8Gim4m1$6DfMWT@i-DQKujmrT+POt0x@ zZ7sn=R#Le2pMQZg4@bBFnY||b#?+MzmAx1ERej?Q>j1OrpF7gl|40&jm^IwI!IoC+ zHArDw!1Lbp9qm2u;|Z2xnK4{#Q6VKMI(Rdpmy%kLbLet#$FISVawIbC>ZY%%U;JHd z!LU(H58#LeW!43Ul3N*zlIIw`1Kr^F`M!#K`R-9Pn6f|jL6kp4%U?l1t z?BpNcA7ZiwlYenyzbE_An_E80OSzd7gzE54<2}+^3@L6%B(v6U`S#O~NUVWJ##Woa zFP%K;%|zl$FV~x_Z-aS`NVjX7QN~`Is4Zd3lbG-k6Z7zQs+BwLw%o3^o-)O0XL3Gb z0bw?_b!pFTc4ndKR(esF;jS~W*9LDgS{k-h_p0O6^9IoJf;W!EIW&e4ibvYqCF3BG zt0bn#kEsK7!Bp3V@%%5Ey~}d+iR{iOl_7+Fme%PLa6M>K6Zq^gZ?%kVI>Jum7QAF*(7AkFR_>=4S!eMo?x83RM3GRQ`BewVBf~ zPz8tiU9PEFmI?GIfsx1GA7L?)*cUu07?G)g>$c}h*gQb8%!=8sXE1mVbwt>L=R`@x zb~U?XW<0??D`fp(-%X9gqrt?R`$WSjUCVq?SgUGP0sw;Uu63wawdq1O{ zmHWr?;Bfqc_Ta0VuQ_HgTGW0v<{oMNs(Xl)4O#mt`TyLLuD zm9_TREY9}_dJ~Vrj;BqGp%jUK5_hC=c#%hd(DPwNlhu#(QlnpMiDi43^tOZG22!_R zbItG5tyZXxlXJO16fc?+WxaM44newWdP4LVJ;37?3-x_q|ESB)2;BBoJUra=Sgh@c zXONauP4DZiO-__Qd$ncMzMl*wpRA}>oKsk=5Eo2h?^X-}{yPPLgsJ1PH-f0D+YIl+ z{Ln(7*bu-fN!}waKc`l=7U*B!QVGR?{a8z?=) zYm$v{ra2RRFOV;%b5q!1_o>yMpS#6^qm`l_H^rqi%1ks zkdhM+28G*85?|g*7WmS}&GA}JxEaiS-SWQD z*?wlm@bQDsYNoVC(iS0zpIu#teLl+|Br*Jf>%#>p(}6%A_SWW)$-|i-ZrRjVDT^~- z`nPFHQ?F;fezNg$f976F2?P^5@7sOX*2u9-*{*ZDaEH!7G_u3jU1>Go_kth15TJ@D z--wh*-pwc&Ok4wXe#O~1wG3nn^(eR3c>jy3E|V{llcS917uOos0I9+)JFG0k6?<=x zHAN0i_bE2HLKi)X0P?vpa`55phJZZ2JdfXg6qg?NcxA1VuMbR>-QQ_EmLQVKffdOt zh!%wQXj4Y}!~q2*`_^dW%RP7*kBGr03w!*z8nNy(qC%I~ayq0?&mA!NfMxt$A4Em# zgF5O{&L+7t78Pf*usacunZ>C$r;q`Rb-!`k`lCZ0do3_=ApdbounAh6bVK9^g+FU~ zPA|ISheg~+Vjjq?t>Fu>3nl)(%r*C5rG2?AJ_AQ@zKk-tm2yM4fcj+uOGvpnJ+lQon) zSt!tl!Km#!{OfO%{@=p}`i400BRQ{%A5SHiXu|xY~H;k zK_%FmQc!$|)uF*16(t%>**t3J{XN`s=cG|$!ZHBR(eqL>ryH}!2KqD6=DpfMR;egkD)JJODeS1Qw86SNYNH0A z1Z2&SI2YH|yC6mWuADnc5Jo~Nl(jmrDPe9d`RrwZ=*>pXcEit$Z?7FDeM`jJ zXs;<(R~9aN(~GQ-fM?iRdtzRWW@<0+hWvT}F{3>qSaJ=#@L&}jE2iQLm^~e_Nu1Pi z4dZb(WDY|=6JcIIS14n3j8DetykKSNkR8T=2HmN>a(6q9+T^wXGW$Xgl8Qr0tuz33 z(y$RS7Zi+2E3gOS1X;mBL=N>cCrk4Wc>SMlssRj7V?Qo!Y1+g!NaFNo=ZgFodHR>6 zLrrGJhlkZqAbij8)6|Gmchh#>MgK-gQG8v>&k8;F?TYTzc-Zlg}UMA9h zLi=;GJ4>%%5*xraBhm+1kOVBV>i3{%=UP9~;8T#4E5?g+%Q1h-#ngnJ@l+opK55ns ztr?9>8te0fXI6S=Dtf<38`c4pWY8s@mvh??m-n9VqV>C58|G}5r_BKD6cRr=h+2{P zkX1sR581vp9{qZ9pEF1Ox2W(Br;iRzl9ICf=Y9o>Tom-&MPD!UWBP?EqoGHVb<3?ZM!QCdXa2Mc@n1eu=|# zY6F7BwE-Gu(2~XA20~jSz3yM$-*1fe0#zCGCf)15Lehi}#(?)+AgIKa)SJjxQAb9_IYy%C z(SgYEd-ATwG&Bm$)^KvKxn6+Q#CmNk5~Mp4@N~Ap8vn3dm9-^KwNbY}AmMAg`)g$O z(7feVE%&MAcBBT9w9{u?v~TOjkLPV~-WJUYPk#MW-h=d9Q*os*Tc3Lai1m19TlMoe zKVVoZ{Pflg`tuz2Y33!;?(G1lE-r2d{gKG-Q~r{wPfp1MHdZ?dTkXF>Pt+w_N@+Fw zb?dXFOJ-b?*6rp}5wZLtlUnHZZ#P`7aXEyOu$b3i)eSJ3-5_N>s{73@j5nACh!2f{ zjkwkn!<%+BF5dnS5^_9?_OpoGH-&h}L2jHY{^H0LT-KbqV`YQ56oC}`NNAxRG(tZG zm@o{Za4&qX|4})G{a$w@E#hhj4Y*@A(0u@`+A^v!gh6H|$7;dHn8Y#Ln*uCNi5kDz z&O4~E!RL!lD2KKutwDi+5^k<+Rl6ov68Gz79K04#hS-E*{Q;MZ3SbG71oF$7hpw$| zdhKt4j0dnIir6V896Y!2<53LJv%fesM%{Ngc!rI6CnXB7%*Buv4|EsOkT)OVesLNy zm3^O~I4WSpg!TI|`lX+V*`+0@#p+mybQeN}X%9<{;H)!6tUom?4PI@`p}K}Oq6M*k zvYD_T5_1sRB?qHv1f6eC!IBaWhco~V7`;XVCJ=R@>@+Bn>@$PMi53x2Rgq@!NwUuEq4i+yBwsQxUn|wzntYi?K2OO0n(he7Xah^%sA9% z`Uwrdf=y+51u^pipf1EJ3rRx@#evqI&R?7!i^Xh}_pd+~03L!-3d(+-J%!I0;nXTK z1ly4w`!Z#2_MJqbA|px3k-c+@ZPtn8SYA)D z|AyfI8^-=Wwraoa-{#`AJJumUy^Mit^<|#rem*oD*?Y_nV!C*1835hrx5^hj_tH61 z3QQ~oyqur6?rOdauTdHQfYuLsPOvrUbqXA7D3+%X=$Cy*Uf$r=o}$12ln47Z2F(3NN4r3(z=1LF1DI>WoZ6Tij6>&5S&(xaBdIp$%w_oX%<$C9 zcKo3gOcNxK_vY1qUZcf%Xa+}OYkIB&p`C@Gsi_#WG3qP5z*-p<=zK!`DkRRgdTmFkV(m*oj)dovH`5N0$j+Y)E8AbVDX6Dfg4{}EJwR0fauo% z7gcZ?bhH6TmDj+GWIP5i)NO!()~S2mjh)6rwF-dNq@hSrPK;`Zmg`wyIHL^|_-qIcb@&(G)7vxdE$k4o_7`UzvDta81rYUY${wH%Q7;mg zcc`s*d7u$N8h}|m^+XC{oeydYL6NOcT2mVXYh^G(yUF_Iy9|*sM7a%uqOiM;{Sq>j zLo#@E_UrsFPKVuJ99vj~oWlIa=!GAcGS0PZi}7E_04gKi=T|w7?pV$77dTWz{7D?K z{zjS`2V9%yQrJl-G28yb>ve2q9#$KI>;INO;Y#m&Nu46%*B#6RdgcljK5TR{0JKHJ zLf&y6awCcog)EC@Ul9OrR=LmvA@>rXnI8#W3l@JlRZ+31!uHWrIKo3Xyf)i?B*cnl zJoTfT^+bmW*befLlrV^X!ib)8`s=aHfvR;K7&*p!`Ww7?z^gJvVo(&(n4!10F z^_hOYt&q31F<6r#!B0)nKXd|aUzPx(o@%{7L>vAQoK6HKp{!f+ZyW8#3WKxKJVjrO67jK9L-oA>7%_D`0ZaGsHVwN%${u);1MAVgxQQN}12j{YX?S*62{Z&hJZXQd?W zp{fKSwR<<*M?tl$xnI^P^v-ihxT%N4lkFvcKja>8ctO&(Tc}7gQfoI|Mx5=SCZr|H zBQ?P_>I3kbO%#Cj$nB@a0UqS^qn1;4vi7Qy0}gI@cl85UoQPJX)J4rO1wu@3VfwZs2VUKi-^{~^}& zxw{4=k}V{X`LdF9fQem7DR=VVGj+qOcPhq{c~F?u@mXbt>Gabx3povR_H(zgrB7!- z|L`mSQKk2T!MX?20wUYMN@Pej%HiU~sbzjN8%l(MoHunP^m(YHxeWabrG8sL0bH7D z@1$nt1{bf^qmV11PlG&j&F%GGOT*6VtJiPL_edtRFU^h`8f4{RVWvI$_3h3as!WW^ z(~9)HeqLS@lGg!e1ESZjNhDw(ROaeVkuS1169=vOGU4>?W`QM@Hi}gFVxTC5623wt zIgXv|8CZ_$+h)R6VPJDi9D(VRQ_)pjwVH*uwnIJjHz$#B?F;5!-X$(ZEOWW&L?kbEy&)Nls~bklvJLzp}Bb?;vH^EPfbs| zsmmIAsf3(9Mo3c$38hr}S@C=vzc>nb-J!SUqz)ss)-=^Xo`RLuWFQSC=K%pC{^01^ zeP>>y?4cp0WPUaxk{3!Ho0-=>7yXW8#6SXq0^fk=GrrzzHCZ*;L6|(s3Nh0QqHHzJ zrWsRIxa+_^CE)()@M+E9s3NbI()o6-=(UYncRZ(Fd98+FNTOxA;MwAy#y{pLHAIOC zss_$E>9kZKqJc}VKZOHl+uu~yzGuitU^TdEw0qbQ-pY|p>Kf@&|9qyqrt>K#Vl(B- zhq&w-f~(7H-#~X0G0sj9QP4BzHCgH#koM#x%QF6HWr^oy(I)|9)kgZ05SmI>Jya5$ zw{3h!ha=Htkr3o``48x~$X}d+=yO=VaO(f`afpK&-~t4WOb-Daz%zK^+l|Rt=d_mqG9|y*C&Jp81QdH-8%$|g^8zvF z=xDo!@oaiyIn}sNi4IJK_qsTzE@A!)d?_Ewnl@XT@R+=NGT(GbcG~_X_|bV$8maNP zH|rWdYlPh!wi@8=ITX*9F}z+2d!O}TfN}e)Q%fgvp$`<}tt=!fY|+-xz0aBWYwFyD zUH=bryftL_NVK%b0k;p+M0&&q7DscUSJo}^A5D3Xx)(tIP$$^aC^$&uk&BKpKUZbrBSA5q2> zltFEhrNYVU7oHFhwkS*unB>naO0kc3Z9lPUxqV4$t$NrK9pwtY zP{de%4F*cp`>paN;V^~{dW44RuPcb%2Kwx5VP7a{&%BIO_Zsv(SW~rxCHKYQ-H)C> zpKOo?kU4m{=rK>$v)G6?h7v$!`XD)voaO> zRI)x=xA7*d_XAp$vB1etipe`Y6;henjQBe9?Dx_?k&0lDI%5w~v>rP_ za|Cls*~U#+KW|&CM`#PF8V$v-z%E~AU5PDlJQ-*QG>h7M6ZdPKwID}k8rPNJil6~1 z1DpfiBNRtkk)EZ-xeVV?f%=0HY|fVEd~!w|doXhK2Qb61{lB``|J}vD-4gu(CR~#F z`=0U~VZ!Rd3h})-+Z08r8)K@L#^?&KF<1wMWaQLU0NRF7elJfiP?bHv&90|t$p zzC1ajsqH7t+w zuGdw4iylq*i}TZ{8P(pSfowaFdn~SaL!#8wY(UG5^MoS+R}F*jlTPQI zp5e%tkVDkvO4bdGRpP!m%CxG8=}p^q&EXBy5^A8(|3$#nKU*438aeGYzbhtX`m{c? zknrf}jT0?g| zBLX7bAUUKoBi&sB(%mq0Hw-X?cfaSHweGp=-uvNv*dI2qSZnXu^X&ig%N7Ck86l+) z@k@)Bk|f&l#02eDtDt$p)o0!g-S-kF7IZtVd_IGU$;`~=@r<8f4?s{Dp?V*94lQ_!JK-|u zayDVDbpH~Td*dNRCa?z8jxSQRoANJ2h4;53Q?)~6jDN+iW#}5i4{nW126U6%&icsj z&zn90k<+9VYx^y)F01!XaK1j$k!=)~-}$pd0WL%=CfS9!x0V;Ph?~qzFNcsgzAVG{ zpGXGpXLm}ux@j#G{VD(M;~EsGH=eDzV>b@2xVd)=NE9y0U?Co20`M9uF#t#59NCdm zWnbMYoPCvA0a}*7Pdt%g?Mi!t*mT0MQB$+AP)~>X83L|E{o1~w{f}C~jg$IYZPC$7 zoPraoT)d+U@KaFeMfBrc!m@o=;$X%1aui+`k}vUGVf3Oxr=SR5^HS)F_WjrtI1z_2 z+)i6G3P8c<(*I!*`5Wnu!)VO(eIHjQj?6hCW2nC@QUGgKVE#KGueInfDLcJP68ER)A1b*u4(0J`ITH^fKpHe?U+zi*G;?zh{tI@3X$tis?gT+Ka$TK zp~*Uang-2{v6bfoNB-rmFioTVU=ta{krdrvYe`Un+)+|%3ykO{UU+E9yE4_Ak1FZc z)y1!kr0p`iG9QKEVNZas`eoMnzXXT>+Zxs)ht=yjVrmk8QqsqDa^5@by%?1C#0?io za3Up4?TuKbMKQ;6B`&W=Vy!k4^AA(3);TbLS!=1>7aju^T z@zxZClr$T+_<&MDrd?*{2I;n4Rv10eUlY;!ugd|i&J!SvwEkPz|49A%U%m1GqbWDA z39xzLA*||hBwUfeCX4OAvht4~V&Db`)k@EMBN=fvOY?_8v%r@FVdTM)bu!c*u6-vM za_w1Bt$;Zyxt$@Ef(x;17(X$v-9qE$)GO&0$Jm|qT?L;soJ98+tFh`j)aI?&hK!cB z@u8#S{7byV^|lLzPTLM+*Gyei72WRoFC88NZRdgcO|js-E=7#VqMg0lsybK^fK zJy8&>&!rwEePJb9l=F#zo!&)FL*~&h6&X?)tTQ&hT?J4u_k(@C)Sl;P*1j@v>%4i^ zM*^wew8g7*PSw>wp0V@A;xS_%Ei}fXQu4|o4v15LioBi&13>1gN`O3D1TYCm1NRQ= z|M>X14H$I!yr+K1f5h0<7=;AgPHV{E7S;Ahx2fECUG$va{_)MPy4MMP zf|QS3QaN0?vVghcIek0OPBJW}p8BYFo7gu?W85X3l8UdkF}0C2#ibk?s5a_A_NDH; z4?keB=>f*>YU&H>ONj~>>Ffj23@4JtRhJ)=_~=1QoG-B6J>lt@b=RM8uMH$J=41HU z6@;<7xYZawe7zaUatahEu5&vax{;fjT@+BEfzRfFKg%BV)4B=D+&`bIbWJmh%i~2mBQra>((l^p|dp0i`Y&b3_Z#my1 z=!rkFmQM%*PhytGe?;!sN$NV!c8hqY6DdHKSPM{@vhW6apP>e^T&WFd7f) z-`C|${pKmh<8JHRqCOobAaYBxzdS6A19hg6khd-Dap}|qa*EKHAYS9Vp?HbeK7qu zUK!dTO`G-U$}&}DE?(7NzAfu?&s5gREEc#`)7SsjnPZms{ALO!@LIg?tA25p*lf~w zpQiWv-Ie@cTt8`s)7Yw9YfeiUub=I1?u>+voJ}d4j6YpUUL7Gf{c0BTqJ8z7=%ZFwOuiJk z>M3@o5)wmzEgUAnMD(gy%4dn!GLzF2s>GeylRVzqbSj@x(OY8M#u3Ae^~G@rbs>c+ z_vql+Xbak95N3BJ{3M=qb&23%A7Xl!XE(6~Q>A@m_*1(bNn+~ zTBh=NMh{Hn@LdD9w#ML0A$j9!8>WOVYEKU}v7WQiB^t(-E~iDP_dd6s+p8nh*_zXa z(Cy7to!yzY)qFmrxGZ?V|J+<7H7NpFn#=TA%7&W<^0z55?*cEFPG# zLu#i1bs1CscLFGl&ATfhdNpW)eFsTk?8sYy%3_as%_0hvV6yJ3J&kbKYA9d`YdXSz zLeVF<%62C$-eM~qnD45Z`Q_(tLsi!Wt_dIaz8XhEs0B^sit0o${U3(Wczx%l-i|eK zeXl>|1HA{v-Fl|R{(x#p-9%n2Z>Fna8&H?C5EYyp6{fNV%4O8<4h7YDn0@%*E#JMp z3F_Lt_BN-Dpd&$^<+(9*iG@U|*97dy92&!eFJm5B-v$NeW0Rf;x{Khdgbw`#mQq(y zf{s42%G*j*EQctZMhBy1T$nP$OAJA|&VIXMwvWo2Qgc zJe~XsqeCvQ{9NKO99=I=v7{1j=^jngumR~a0p|@H7?hkx8^F6@#n&+L*ec%%^qK+- z{QrX`|DTKfe|S7iAy!U&s)zXKLyheS4f8MpTy6`d*H6vMjK(j9bPXr7#syWBoDhzB zj4to;mqITdwwSLew2x4s4O@C6@#X~oED%A|9tZoqyY>L+71%3|OQ$t(2ME`fX+npF zU>021i1gXjqS%mZ2emiczw9dq3*EpdQ5k`QMA<6WiEW9JWGFZo=uZ&XcjbPC2(MAP z`+Yoh%diFh2C3LAPiB;2K^0YAZR4NX6wAs|>`83x9Do|7AR0>?$96tYE>jgd>uJ`U zkA*)T78kB!;bGyYVQI{qZFzN*{~f5S>jkG=7?Z_8Cj1OR#)FjuA_o4$LH2EAd&~kV zN^o1TnMm_y)*dcqIbPB*o9)!lJT2Uxs|0!JW~uKR^kV9$r=?2Dc5ek~{m@Mh?utb{ zKnGF3>RWvJM%xW0WFI0OznckAVnEF><+Fy(`lgKX&JK_%xI2PM2DvOS_ z#{5~*e)O^K(hR4WoI2*0e}1e~T?J|{*mk*JAXx5fkkk6%AdOY91pE*uQCv*8lLgu6F`+$HV`FgZsZ3#Tl4U{-YxPzYa>-CJox^-0^R?*!B)Q z#7)*urDM$E%M|g-glP)pf9smFH~09Tx(0hK65y}5`W3si3vJ%>P9707VIDXiV%O0OW zXDA}0mNWJ%)UpG|<(t?GyYXf|WUgnO$`lnDy_qPXlJo&={@#6c%gY=*Ke14Bah{gm z0M`Kjp!CQtMcM8vPXJ5KSd>(jsmiy%ZnFe6K3xWSKu(HfZm7gaWiN5tby^W9rOa@! zzAV?KoKbIGJ?`(jfJ^3PvOW;CAef+c)TWa2Jhtm4u#;^}C%UvJEb~gpTD12ZFJMty zQ1HoIE86$=h;?+Vp$h)Sw-n8IizUi3tNR!B5@enBT#T)H=;c$zj@XJoHXQS%k959u zStP*P8t%`GO70=!mq_M$B_%KGd*>%Grcn8nV@RUhRg|U7RL3PMTyq$aGX4wLJCxtG zv;y4d5~L@!wu*3;%poe1K(3mO)9cdl$ZyDYCl3l&0Y6z*Eb_9~zu2{qc;#j1AsKjS z($QM;2a{PUA5A;`PJ49YB>*42scGM;IYIqI?kZ2HU7w#H`@v(k#)sJ1%gYpo{3!i& zFY7*Am>5d9HWL9Qz)bpz9XK9Be~sjoq^1sTH+Z3NC8w7L>2{=m=78vNV@fXd(_q@% zdjo&}nvG<9Anye->&wxdP$j|XLVV#PGKv_xA4DgD2mV!Ba*)G;CGT zKXBIr#0Z3#bNH^I=VJlAk~2vA$|2p9K|NZwU?>i$dkxd_H;>oc_`A~v8cXH$0vSZ4 zr+x{+nN*-Z?}1SoVTz8}geWLPsDXGvz56%keIf3e4h*uq)#D{`-l(Rw~WqcX^>qdtOh2_Y764sxo=2)z=^u1zrEJP_n-2 z2xHr$KAoux!yo!D1EN@C&ms^I4I^t>1eO-tEGuf8*^cgTecg6Fdb&`d+XuI} zxS&)eI;~*rOm=Mzn=Jf9Iqdg=qgr9l1zj}CUT3@X(Ucs4=dk4|b-((vqcuNyCRUsr z7WL6J!>Wx|@4klj0qul;3!n0J7Ba$3Aqe`Zuj5uJ)(=+bbv`w81M?RT+_IB`Q zR$A}&M!7A~$5;6AAYa!zL(v|8(571tlD%9+tXzEV9-uWdqEkPM9~sDC4kn$F>}lnM z&JcB?crD_Emx!Zqy?cGl`aln_U1ggr^knN~clVzx>T~tmiJ5$yC<#qZ{3m_us{5+g zj5%>hF^|<)7ul%De@#4nrLHuqv7v#rtGdYQn6PYWYC0q=6 zDWZD!0X!z$UP9d=Ns-Ea2eg?-*o8of`K-?DeIR?A8l2HuP*9&Ve#V$eMngeu3EqUn zlnQhA9YdV%|AJnCY6T6WL&Si|Gk-UrpyD&gdojJ)9MkDZXy(Up#zW*P%&I`AWJkla zJBEgD`9(k3V&{kIyH5f9Q-@S0aYz}r$El*-X&T)ZrvG86WR{}}7RRdAa3ppl*5l+N zUIN~@zi;Gn;sMj-w*xNXlw3jJw^peJk7DY9*ocIW)H&J1(#`iC z&_#Nfkk{Xt1YdbKjfbIzoh4)cfq9;#j|WmLrlcq);=WDsvE>ixWtdh$~?ZS z{duI+vpLQH_KvWHcagiPqo#joC&z1G}K5*hg|e2$NO!Pw)KDO$IH-h9@2-j08E zxyE2E^tFO*wG8!?XxlVpz2oPxN2_?_3DgmjR`Fny#KI6-(O*Zzprt|#TULb-v7a?E zhIe0;5le3(pwGaZqyMgkf~%c&tExmwulDoQ1qe_XnTC_uSNA6xk z)Pjg0rkpM%p>#*@pH^I{#~X!ouHf^0yC;V_b;-O{!p%_m*cNIjf&W zykczRcnGL^8k|Nc?-`KK-1vFDjSIhlRlT3B!o~XesZ}vvmQ#~3p?S4u8 zJ=i`KEexi=k!SPUUr?{C6wR+c09yX|OMyWJ`#*SvDAfSy8Um0$X;)y&E&V@!z6n&* z!6#uL? zow3zaY`kv}p2E$;WMct<2k?Q`EKGrf_4x7}TBOJVFXk_wEcfhud zd2GJT`DBIhO3yPQ^LzmfI)VK`aWnS0smyVw-Nqj+1yetmPPo;0v-t~;&&3bAyg{gFAmxZ_{c2Thm*xS9k!&7vdZoG*D6assRxEORW z>+16Ke4A{|6s9=gu_5`GVq{m(%nhITmHV%p%+%^O0V%`UILD=USZuFZeAteY1k-v6 zfg_r)s28+9{tlFAs%llT@OGi>?cU0Lqs$u^pRO$3yMhi4o^hr7U#fk(c`Oe7g2Koolu_srjnL zwSHW8w7z8Vx|4=T&(N|e%g;SuE?m0f2`bkc3uNwR#@kf!9#&SXlBu2?#oOfAyeMnQ zNU$STN65*M(_!2erxCWmL*)OaEOnhJyd-CyqjIOkyG`KwAeZAi!M9w(w4=_j2gQ_` zxeodIDlm@)vmlFGxU}Sm?TMD5heF;)TobQ^F_JG^kK}8hmrG)(pQPR$;pEIW^hVX6 zo()(1pO=hhN+q{R1umtK!NMiKT@kr^q=)*w_Y?!fJ^7P1z6Xp55H_dvAdQv00PtDY zefTr*!0GZH5I10Hs7HjU#*MwXKsJXUoxW47N-_Wb(EN#O6uUTz6x@~T^XW=x|Lb~~ zm*R#_lqEqFer0To3^_eU&NqbOWJqEHk5l4$nWxGSG?bZy6ycw%6>&cT zc7tAGFHzU!99`G^`g>1bfOgJ(E4^9ouh&-Q+oJ3&YpjRAaB!$`4|yT>k3d2_YfJ3q z5z~b$r@DcHkRK+*Qxa6jM}~e|J@wc?)0@#(XK!7OJ6T^hTE4jTwUGdB7&0li7P3@;r;$hq zL|5M%12G*dz1v3t+W)YuZiII!YoaYus2?nFM1hV|Z|A+3Y(*`KwY=YEG&kMe!$U^R zT;~&b>6-VB9sBZ7fn9wXHNrYJX?O0Mc>3lvAteOMxzWn!I9;rO?P-*Qsw9+QZ3ZaS z?S$5K?Pa573i@ivK)bBD7{j{L=qtU;3y?t=`Q$bk0RN4I+oDx zfP(#oTy3&bt*t}-t}>pz4!45f&!Nl=n}K>{CRX2|CVPagV4mxML9AJVPES(8Ojbjp zI^IW7*g&JWA(aZxC95z&sh809OiwTO5?HME&G@E>r3Sd%_9NAFt-GY4^w!>@|L*h5bV_?aVv@aBc?X%ObMdJpI)Tu4G64K2QRcYkLs6{Jxp(?mOh@0+R%r+4@A^1~ znkEceahB^N|HS8)he`1k?y6=-G-5-d{6->?;6jQGxapAV05>?1qxN&*K*^PJc)Z`s z#f5&7MOq8JkyC6mbMYc%cb_h1**{!}|_0!Ct6bmN1S=6+3p?#WvpUf)I z&8?A2=7{X|#?>xF6cyjQ%yXIsM=8~?G=z(i?npGbUv%3A^cD3ZatF|$9^JSM-GiBX z)w8`e?A^-f=cfX3Mf!ytw@q}pjf97Es}wbdJRQlNy?5Fz^@g%8)+tr5_+CF%?8eet zrD^)gGTs8yIBiX{;!LW=zdp}qAH?4zjy_g&4m@XWBdhHv*XJTk6y&UwADl8K(2C9` zxlKH|>(NTNva-6{FBY`(~1AaET^Jq1r1>_R_o^FkQeX`^f5a4!8R4 zxtaF2hLT}-I~Qm^WBGiyY^dwZ;S3jU*1-(8UutmWgk&v~9n~W6em7?!?RT@2%axA{ zDz-klU3VQYk-i$jz zN~sDc1>~Nz1ifWE;dUB6ztl^PznS^lDtI~)+H5k!U4W$8zXKH2C4XwGY3pVBONV@r zT)`tpzQS1@wqd;7-2bp(qSyHTa_C5}{Gq9#{hK=1YQ&y5VHP$1?Li%TzPxY**FFXo zdnv-%m_%dnvSM=1u9a%M!(uFM&*^IRkYxq5rd+ouCtv_6y$NaU#_96CDIEq{oQWbmBWLO51nwQFc z8(TDQ>#_Z6ZjkzqALZL;KwN9fGVPSv^aYo=QE;6?p<$0Rq0@4qy+1{df8SaypK{~} zkY4pY_#Ph(P8!LY(i>?M7N6Knr~RIX-Qu4B9l|qVf5(>P28P)tb&ERh)Jj#X(!84+nJQEDD05b z_|-owKJdd-gT!bOie1Qu*scCoCeT+f5hqj!;UMFk^BfcAt)H;*4cqYBOorAscBCi1d{+5OJ70v%i8sfC?rsTw6Gp4Sr& zZ@iAmsq9^PpNhxNKIj*^Ms$E@ua3@qoQQojA3e3li&oJLCBHhK^&7kYvppG=cuCfA zJh##Tdvb_uRZP~L88B11(Q?GOe1SOOaXO~mYW;>yf}ZO2=br99H$5Bdq!Psx+|zBE z_}tPJSs0Pw>y(6F=Z}3cX#v7bzNh5d%nL<`eNJ?s$LiegQ8^CqhO*#q61AubYB zongyJi}E~u9t&}`<^w(jwS?+UH|aE*RL5h3Fn#2A<|LiRKlt^duxPiMn@mo3&88b4 z;8kRV(4Am;W56U6-#fL+IAq9myXEl@Gg|AAk^=7{sQLvs{Qe{-J9SMgo|^ELL6fy+ zGUCiP3_OAfabUHu@ZIqWx-V6d-AChtuLs4`D{fOc_MJsl#Y`@0VAFje5GuBv>i3`6 zPsB8$MDu^ml)40S+puO4{+s##$KBYhNxJiU$OW6Mtwq0&$+YL;Xl{O53hv>;+RE3n zj4^#tH>y?VFt}+S62{(9)2QB%%Pxs3-H%jIuEbffeOrs#-%<;YZck-kyz<0TEgNJ` zUT&aj`vD{1G$dPQ{r%&*d#~?I!!E5AwSMB?;VL0t_W|^sJR^Ebnq)=++oa%RxBphI zZFJZ*NPAPT1y~IPCL3}o5Sd5+uxtQ?zGY&~0?N#KCXj zoz0|piK7ebtY3`qZZ;RtwX}^5myOjUVip&cFktPklj?n{Bq!MMdMj7(AYMt!PHFEQhNH*5$baV4N?~I3 z=#0xiLy~_80B@$dgwAKWX?XNb?z4+g+e(QGI?aX-Vx3G_hXl^Uk~j8#0_s2f_b!1~ z!=9^PORk}@zIO|VqiRK%{RFHW`N#c+kRiq`5dP6_%R!~DG2UnnEpn0vW<2%9VmpxB+!F7EwNFS2D#LwiXc>^F?ieaM`ek*~IZTVe$EfBz)2 z*qk{#-tdAQKEHcsD@Ef&^Kg=Ds@opHBPyjR@|i(o*_+vK2zouwFl835SChW9uv{3+ z#KIJC05057Fz@lLLUNhibyJp2BZ}VM9L-j_wO;_qJGcP4|HRkwQEU;clLnzur8Ei0 z&;nk{WV>Ih7Vw+T+utulq-su~x4T1U9Nc}88D=ec4dy3hFAon%z1@p+2o0thC&~r) zi#U(TEGnpZLEHJdkC^WYSR`BgI0m=WhUkRe9-9>gUF1UthPjrkB17sA$X8aB~~$SNnz;ZqM)%L%~Xx*g4nQ zdn?LPq_&=oZGkgdlSisUf8#%#4Qvp&#UBt8EMnpMep#`fbjnNhqTM<>9314x7(Vc> zBD$CJzRzD9`61>R3=X`wjB*hB%>Q>(_?YQ=;O}ibr$QQ^nNzcRw|L*;*12J7etF4QIj;|Go@4o9mIy(gmh zd)hzfc&NH`tR-Wa@KzI z_}}_ZiS>mD!oHgDNinrX4L?nzcpdI_lb?Q0ZQY_j7lP&IjSUHCrTM-AEdaOFsje$d zavf7!VPR4&;~jlS7s76_kt^K@5?>2IWgIF2d8;9^r7G3)4vj%On>{vMYNjyzqJv%8@s|t>>B&RxdZgw2j`9-OC-K(U&?aw|Lms)-u;Cw@J`(*P(B8(`V&3&SpgRy#{?SMz+eTb ze^><()_!<|_C7Q<=fB_9m=qkLs*D??;XVZmRj7Yb0GA1W&9)JGLQopysR7$Au& z6`=3yGr)_23tPJ(LdkOVPG!l%)%#Ih^kCrWIU|lw7)CDIBR>z?JnSj7oan|k;-RYq z%lF(!^m3WBoL9OBootV6N2l_KxI4|Dqeql_ne4q&%QN<-X1k9Hswj)Io`v0?NA;bd z%?ktDY92*xc;saCj;4>P$l=VYB>)j-2TT;eY*F9vE{ru#9QSp8PDalFamKE79xvJ& zs1%?FLO7JxK*YAR&rg}*NIy>GIygoIFPBGN_MnUYj&dt{a6TEEeB@C>#ZPOU0(NbZ z37yX$i&&ME35Mv(rzvaU$mi^X-3Br%=0)9A^4{y7%bG_8^cZ$mIp4%P9_WD)*LIhm z{0F+!y>|HVgS*+j$>&{*LVnir0Qf8<=CZl;q0pkubYe%k!=2D*Sx5e*K z5T_N1pDU1TXS{uh?&E{OGipRf`Wmoi)9ggHOV2vZFn9qNsj z%E<$&E)u(B8uGT_KnNe%(}<2Nz(=J}#t}DM(ox4jPyZ9`SaRy8bZk2P<|zrt+;H)~9e z-k!~ELNMWbIKeI1>rXN??7eCtrKq1r7iHAmI-0WKja2a}@k-0d4l-{ZS`Q1P8hw=a z9(@zh@XT+ITDvG@D6AYR)U4N?m!G#)^jO+WWr&3#6;E*S{nvXf!ZNWiay@Aq>zt%@ zB4Rfgku2&dJ={Fe{G2=-L3{(&PmcnClwdYC>w-^2Ky!txG!waCb%@|-V1a>wwdlUG zo2)E>HQ-mf0hAcHTr5)6ZqiTXgi|4D<6lVu#pNh2&X}SNoGv+wTVG2$Sngdw!X?${I{eJ3)Y9g!VO-#`{p=(r zs1`Z)tnN?f(W+n0pep3X;=2xQ1e$PEw?x7;4X*)#GtEP#KJDaWn5(D`S_CKfLx*7W z7S#w146qIQ>c0?~7@HiXA<|wz`B4N>(y$SUJs#nsIg`)zz)ZqNJ=)eEmz)^)$JJPE z9^eoCT0sZgvVDzD>H*SvpPw^NXqZKO1R^AZDF6F2f-YVg=l?oD#9xkS{Li)*c(ukS z0^4$qXT2OxyIWsZokWKmqg7$i?4A631*i=Pd3g9Io$KE~==-7gx^Cv}L_2Am|4|#b-OQg#AZO6|znnoOM?eKwkoMDTT|T{73O+7nI$5J~=%(E~?JoMP zPItU|obPDZ)mG3r3wybQg+*jlyLlsU7cxm)*qcrw!bSQ6U0J3e+A!!L5}{$?L7brd z!@!70wZI}ky7aY#d3GVALouzarXu7<*n@cTVoc;eb?AL6^9)aG&_vpth~nrM5o4zNtYs( zIVou8-4m-UwwphRGW<56A|E**+b3bgvUsNr3k7|yJI(lqwLoDYY93T9ikFeTYA%Eh zMdA^cj_|t_pxHgu&%ND1V_rN0m-g}Ox8={(V8o>>aXS&#LQfOwczeW+l!t2mNYFE+ z(_T~>&lR27U1wxM?FV<*MX4n=b%zy2~L0R-ds~4<(F8q1lsv%I+v1y1Cn2MfQ0^HA(7{-}x zr7wp2I-eDMbpL3-_A~_iv(lOIC~!&?1KwT5HtjY`i*LsrEt({L2&hFXLMrl>ButpQ?=4aK z(sm_6d^0=Suyd9D|9&`d!syGI0 zqC*sjKEnE!Kd{G}343YZh#dkGI*0Fx$?lx}fhZ3~OYa z(V_P^Q|=sq*ow%y$mhjdr2d=uVd$A2TVz_6=!P4l+J3Fi;EXVQ{>!43dEGi6Th6OIyES&rk4hJwQZyAjBnL#iIC#{w z8O}0gem<)A15Ls@6``DtuF(_}AHmP52saNkQE@|@mv2ma`@hC2?#Oxq!Gm0H&$!Hn zR-10pSzobSe_iFhCPDY^DKQE9voVghY58^`VFWbzyDT+>yE7@g72BpX=H`3i-qF1c zJ&_d<`8{u=w_B=DbudR6Uf(JSN-#4JdB}a8^67Ic`KD3#CxQ!OaczRGgiV}QM~!!M zqZ_)Tt1gSDN=M#5jWiP6`QB+&7#JD7+~_r|_3{65g6mF7ma~TA>RTE}_mLx~MSMA&%4>k+)Ol3L+ti#tVT<~>khxv#b` zmwHcx(ZIIygL&;K~l!%%wlCm*QGJ2)O>SPeJboB_tD4lei5$ zAX|PENT+%3TjMLBT42m9Lu$@9d-FDr{t$wC2|P?Rc3RWEs(zRvq{>Z=N-o;1cD40S zo4dmAhsEaenZ-&QUl2`$XTID-s25eoGsmEJ?Uv;BoL<&4??0Jz1?;e`5!$9%O7QF( zpT*8D{Yaob1t1t8Q$V5nIJnW_I;8n&$*;5m+cU58Tthigf#8Kieh!C$D3?Xu)Y4Cs zKJWepu{;xn6mIC!vImR({<7C2cq@?nQeNoInqcbrD2WVx!?NqN{FWB(c6$~)fn4p{!g>LxVz8Yml zY_i5Ex7EZEwesG^Yr>an713Pn)E9VP(+sClRTi#YL7=JViq3tRPmEGX0r~`1#SOclx8sHd zdb9P{OqGB9hgJKd>sGfkD&gmle`*!yyF2@{Wzw{%+^&3-5UlB~_arB1H}6B+pBgKr z6Ijn$g@cQY`~809Kdc=Uh|GtRc|#Q#jNf41u*mCk?6)_pdrFcVNKklNHO*EF<1)nw zF~TZ;HE%}RA$9f3Yo9!;bIH^3mgbZ#k-dQ^+L0zZxxJaqCK0~D=kMUvB{@5dZLxR%bGZc0cC5d*Q4?G{Q0skMlz8_*P+G+P=*7 zSwMLByM-bSZIyF%+R(fj8e=A*fmA9$#RthZpu9nsT!Yf!KbfAaTsyF`0}Ud4`${VT zYQ4~c;)ux>p;x*TVXkq;>bh=x?bt=kjVq5(WIdo4LO(A+PxO@hT(`G4I^|oQiqn!P z6oJb8vwcZ1NfK-)yIeIZMC^7Zufgf!&lVQyAWLj}3fF0HX^vG?Q#UoK=2I=-DcUhZ z^c|1zS;K`s%k!C@fn5X1l^x%f6U{5-8D9Xac!T;@X=A`)I=*tWxX7a(1Dh(k1o0i< z8DIoYR)Q}GAKSdJ_NS@{2UpaQDq{@hOwfsJsPPCj8Dgt}eq>UWfQ@MdyOf04ekji+03W^;h7}T+v4!bbSA?oN3{j zH@X=yo`Ml7;^|dHao{4M3z28B1LI{mhm&U&`rizsXQnO>!A8Ezt+O%%-RbnVw{SOo z=?AJq+;+P*;~%3rNn+QB9n}sDK3wJX=aq#%8~dN36%YQ60{+k<9Dcq|Ho0;c0ZtkPv#XWDyXte*H&hM9)n|00G)XigmCLj&V?F!J?&GtnNvbx=H z+v#Z}v=Xo6P=zZW&K?vZ=qIH>Xr|vcxl@^U^N)Ytm(|vn9M9*r#{^zIXTDmEK2DH^ zaZT-x{o$*T!smxEw+V#(5(@@J6+;C5SOJ~M1H61+7q2^`r8r&XlRO_E!SN~>Jx6jA zrbvomar=PO!vpK0+Yrb`X$>VgFhvmosI9}^%uUruQ8$$Mq6qdLFn-?s;ow_SDry~c ziTE+(Z@&kkmEzu`hB*o~!f)0o70?N~Bwp)nKT-og_NQFv?8ZQW2RGOzHFVFa9${`e zd>1eT%IT(o2CS;+IP~0YhKq~U=O-;q-&W|(r?tVD$1}q2>unEgZth1g_!WM0a`O=?e!65HPiakz$3Q7!&k{7V~C z)()Q-l`)1mcPpMS)!W*E@AE_pEX%1}*_xdMlEp_I(lW-jO4RqAWGz451*8)W!0X+6 zl&bOHVk`Ai8LB>ViAu=u)>?omtA>Y(#<<3k(YxG?XWLtqNo4hvv68Q9G`{XDqE&QH z;s=zbFPvp)I!LbYIzjW)#fDHrB{7)C8L65^_m zyJuPhA`mw{?Ef2MRniUyYqStnY_+1tYRag(Z$F4#hw!|*?RAXGP;bgbXqzrl-or-f zeO@L{Zk8g*svALP{tCPL9##q@w6xW>XXlOg+&Q@6JiyRZ{(}4(N=M z0a=Ioe?3gJ7ppea8uk_m>O;iiOn{%BZPwaA*#lQ_E5*fw(py_;HOe@Dl2o<uq)^M~rr9CwwJTAePhhm;kp9#T&>~b*x!6bNl{?_p4lV>Vx2 zT)iH&@4jGY^1PrB67F_-s&6g)w8UmCb`7J;%*0qxlZIL~bA|2&T|%-EW}~HogU_+S zA+jz)c@>TlxaG$n)g0%)y?dkU0kwO2K&X5r91AwQ^G`@IWwbvJ^e zE+=*}-BkcG@DFSMO5i2DaBX#wd#%69_I9v;*Uv+FsHE0MFN!j&a!$0L%`h!(BL-I~bdv ze<4vpx!Om+OE0N{J;Rb2Qs&FD8ck+^N`5~k^%_MBW74&X4ftN0Dn&`AK4G&O734#C z1qlPyTz8G&+PoMlm{Tc(HfpK6=P#g(tON*Xk(!(@C6M!I6z+)AdLQ-eB1*vB*URfpMjsSMSUXCUo^J;^!et7RY?k@%TN=i+jv}L73CsiV; zz|h@KyXqeu<9MkNT2E&gVMvvJBL1LaD@A zPBNjce&x)sVj945f(=*0Ge8*}>B2uY)(>M3Z1@RN>sI*+Wquw#(oo=jlVJKDBfFvynriEs&e?gxP2^3t*9 zCgN0vPwI&5w0l zZSGdb32qSjMA%$V-{YlIgKVm=t{MB1F{(^PMkYz2dshLgR?so& zp?$hZQ%Q#UTj!>Uk1gUvu`Df3fx@lFi0u+2OV~QFE%3>)iF@)zkDHNT{b=d8`R=sP zA!dCf->2Z<^iIPLhG#(d7&>M-gMRdvgv`sQg@q;FQO&bsvD@4IohY723qkQLg^=40 zc)_Z>Q&pu?>OnxD!ZX*sI_xnxZx6hkkL2{lH6Ul`Ul*tQ(ME!nS(l`B0;$wnA09wr zo|iS^$YZTW&T>V2pJ%Zx-vaNDsc1tyg}Bt{%WYKD!vqcYg7fLXqG)@BC zMHMb^ciFIuXoB|>`_sPxk%+n=48qR>**93V9F+1OxkWqre&=`0I|7t9@Ebk!&7`j7 zw3Nf zXrE=}PIGuZ7|!|hN{&g@G$&%b)2)-q-P}!yuUPJ*clI56+C&}>;;rxYCr`<>Ap1Gf z9B$7(U*`Nd^=GYtUYay6kV0Ad{ZLOvq+UeX%3S?z3JvGM~U#Tvm)MyJKuf0 z^d7w|@O!J_5`d6Dfc`)deSYU}x^e*Ol<<1!pmh%@>bYJ?FnO3O1Zva~GIqnSssRA_ z)Qrb{L^y3kdTrB!bTr%HVD_dgY)%68Ga_X9%|jr!&dKMiv|XE+(w+@}C{6~59C)>Q z&~-jb+g`J0ibl18Xto6~+MQQZ;cSq6aD6=GkcT2OQ->S0QH_QS8qf60Hl*t-T4buj zRq3-w&z%U_wE@Js^AOI|6oxdMRDj#(*=W3V=M<8vM9z16Ztsr`c!Ox8(Xip92Q-F?PAL z948r@uZ_V~z;0D`vz4{Owb2jt4&?L-U5K82#NA~-4*_1Ln~ibQsfR><-BYT3wfWjl zoeo^3Ru%y4@Aq29TA`}3(35}NGQG;a1xGA&Ol`iJ<4-2`AWP+hwN`^!!4KFjlc^sYSTw4u)}u8;5*%SP+;IAaxjQHBj|Mw%REm ziCBa=BJE}+Ae$Un6)_(dz!S{#gM;B?Daxn;_E+=p z1Shcv#QxjFt0M1Xn~ZS>3`}t0&UaSRrF*$*IrEyKAZyrNU>a5Q=)>Uc>p54H>IEvT z-grF%w#1d*)%@bLQ6Rw4;OB@Fp>1;w{LWt-Uqxb2YT%WfuXb+M=xA&p0S7hPSJPvXEWLf2xu~pYY`vcDoG3iMxPhm^W zv4MFF_oy6#rzin;NR+3s;ZE?+w{9$C=i0$n>7xxTC(x+%*jjrxnfwK-_1falF1t<% z`1wm$0{{tO^-pjAe;z&m*Eaww@C}>Ly`OACeh{sLJ-K9lDfLx4&^>QAEwnV6(JY<( zEg-Z{V*z)TVe~hm@(ZDk$kfTQ0`d6WbG>*)>6s|?0J&>*?~(j*Cx3ifR*SNZ3qP>o zy#Ei_g7ziy(sPl!FQ$v@f|umjLyZ(2V>IM^ggWuP>27o7;s5OrYBmR~1uf8mXAf%% zDO};+S}DZdTJW0}J(q9s9^Mnni0~+)Q!;u#tj=RutL&-*IWQY2;+GP4%7g*@CHXz$>X&WkkPf4!+rT!*khp+x{;0pkds3sV|yd+8TO%WC) z--9KB1aNJ+@V?KDx2dZ!%(pz0<*H3Q)@@G6h2gEdPY~FL$!-cjOc1pj0_}wcq6vjN zUK%$kMFRNKYZ;wt8?(x?!BVxWt%}csYtF<_X5Uvv{PV`yqNq4CWB&w-i$5oae`k$9 zqNwT#yGO9D3$z55O>hypp4Wyl;Rt#GDTmvQ+@E#Ek*}3k*>HIKKH=wyv+Y3Bs6qkp z%I^_EPf4+kPMX6Vy*+d`P3;igvQj!opW`)gl2|U((G`d&UYTe!&yS+NUO4_wRAcjZr^3kkBoC&%Hy&Kh%Y@@#?)~;#BMl{HrbE;M^u6EI#v(g9 z|NQ;q>0wx7+SgtW7*Cq*j7yXy%g{ooV zDUSXS?O~NjbAj;QhJ{HnJ1k5ZT)PY7JUwo}M@a`#Heb>w_A5Pinn>b(KJ{3|<3pFq zretcP%57TgDEk2A`L!J)&G(A}1>`?8$iKmB-2eRmvtvhX%klu9HR(cXA_4h6o~w|3 z$J21_F((g4j(5D8QrbMDWG9Sk1*k89FEEex+KfU>+ro-%%teFS1l^x|l8jBG=k-tY zzxb1%iJ&oWuk&s;uqA(S4w@4((Nc2Xg;E{VG;+nCRiH_eK3zgI!W$r@OIsyAw?$b> z#j~JrVZ~!qiHf5*Lbe-o4+w$7kt3s3f_dQhzE-wJyai{BYgHb|uIQUG>zLWPN_6RV zBCJng4}r=Jl*s24IF@xY9RZYnHVlUXcELAEE$@Eh5dB97v0gKiK?zo#Kuk z1nO$6*BfT)T%<;rjIH9Ejt-Y}IJTS0F}Xqqgp%7c#6QT*ePF`^!G z7MG*&2JmGvM`CUPh4t-@soFFH+Qa-sF$8%CCS`^&GIv_q74(=E8|suWwurV z!2HW)UNra`>LDO4;OKcR`}3&3ZAvM~cy2JWVq*GKGG#p`%}07~s3=0iq~vA^xWACP zkG6_CN+Vu29hsp@?R>)`C$8L$Vh&OgV>6CInazAZ?+06cT6|&p`QRjFXepv9W6H zOR0`^gbhAZ`j#F_iYXJSF-1k*^W36WzsrS>OVDqwmto#;37@d#P!iE$(?}rl`^$5f zfY^h(hj&*S=5;pI3l-871+?>Jyc>sj(XdZ1Pt_&gjGs<G;PFzL7Gqfcyx6 z>Ph4XAjfy@_uhPz$!kL1ApAW88E;2q$R=_F;-{wM)zeeRcusxfWm;#zezi}yAd%bsyGlrIrm2w9Hl4J%FDMK`_9#A%r*>5> z)86sn8YoclpZycU!t4!Ds+M|@>(6_%gy7jUo*RH^RM&nS6{TDMt{fub_k-~H6OddC zlpHhHE%O|MC}FVb5OisN$;bAvEqy`Z*813Q>QwZBOFWB!1B`^H{LcpCpP{!~W$4Q^ zFEtW6XVFTS{!HM@JsD1{6`HD}DBG4sA~5{^CYKJ#kAW5U!PjbV&8eZyB*4mS^{j?2*5Q&bSAD z)!I)cp@(VBjndC8b}nFxsM>6}7>j|0?B^iPA3=cWBK}WG>}6LVpU}pq->Hj(eyD4& z=AyBxq0w>~Vs*~MPN0wrRCT*Bk5`sLZ|UvIw#^=wsi}FidhR_hx!GYVV%nI-H7%pb zhuAdZ6zO@eamGELPb+COKC|6E-Ra`jf|tt@lH9S0*o@Dp{{j#wH7XeiaA2}6eTNtpncO?E+g7dns`I7Mo3k@goT_`<7n8znCt!jt<!HV(ZY_u~x9|Bf*#7f~L;v(0phFU&ogIe5*lw6yf1!Vx|Bf7GXL{qqZCqsYZP}s}bZ4q0RNjTHF_bCwjYCF(*!?Tgk3VUEm%1AmAdx+2jJ6*9e(`$qlMgMQ51ck*2<0M7$bR+l3`U;Sl&-B#V7B$L^H zAXuxt1)rOnp^*>oZqqo&cm1kBL_1p>7oO666bpH|9^TVAaNXGDkf~?MP|BwKVUcqh zAUo`&xKwcESLCqEYO7huO8_Ks=9@s`koXbK;i}DQBKu%ik;4=2WRuLDk@wAhTrb*Y z+)=Dt$_vGd$zLV`buZG?kD?ySSbn5_P%dyYrL|kTGym3as*rp(8KI@CN9P|9TiG(vrQhzLfi%6qv>eUa}cpLXW1eIppw8s$h_xqcZgO^4XpAJFRaMrxL* zCCqhx0OwA1G^A9T&Y9WH71Qs%<NVqM`xUohBZ~C1M_7yVLC_7a9{Q z0h5C+WeWry+9(t9k#IDb3s>lfzgj@9-)V zc>&a3j8%VbX>NYVaC?JR!kXLF@w_M16#3_N#{$nc>7XlLbj#~wvT6sZ@M&mJ@%~Rs z6zXru)s0Ml>GK$k>VbB7$YjA6^hW_?!XxSr&J!LP&1Xg#Bo-C!-Yph<_w^nk^l4;H zUe;yYHEPc(9deX7JMAN`6<1m;v#OT7w^J2qv}>xSRr?~hw$|-sX?Lwn?~jwA4Pz7a z;hgudr;x%`@CRyL(|+;?MMc|sJAy<|nFs6ZZ>@A*>L zYZ*jfW5*hB@1b11gNC1@LJh77^U`8aw;`sQwlXX$(sFAZ&W9v^InBopM(F_qc{OUX z9mI|q8z(lD_~M?d4Aaqm>TDZmI9mVlKoNuXZ5@q9TY<*BZg=rzA)YPA54@A|wLiFs z-!cP*g*s$I4U_*l6K{zUnzz#_qi(0pI%qe6JfvIZ zwJm1uCL7CwJ{=mu#fz{mdmeAqYa^{9lNVdh7zk@g@k&AX}u@%jCM z`_ZF5F!_w}k5hw^SVf%k9%18lft%zGy>zEOT) z!xAXwKs+xBo^uGWZ{@Oy?Z}o&z~B3H@(P<|y4vMr|FSoDWI3WTKtIZuHKH=)pke=2 zYaC*&)w`}`QROOoXB0=k-Yu<>oroUp_2~0xx3q+AvihrQmF8a)l`{|Y^}fF>t2@(^ z{|3Y3tgtO%T_qAuHPR}iy2nioWY0U(H^xT@_&cn}in?fLyf=v-_2Z1cIPqThJK!)z zr83F}B1$yT{?J-x4kpSOAKF(wWVDIbO`M=A1HIn&Z#L1N+xILp`9MlG*_`0EpSfJ- z)-D}iG;(m^bPO`@EBlLMm`S@}cH0Q+_n;WIS7|}fACP2y3u5oh=@Gi?(vtlw34k{N zAnM@zZ~!nYR3C{?Qt6aKnBajwEb^}^nzJBl2Yej69{6&~ihyAa4TC{NeSpmQlBRr! z^Rrq&s0iZ2Dmsv!h7#Uklq)Etm`LLCOIHsnz4Q44wKe&?K2RL_1r41u#u(Jy)V)ua ztug!{aS6djfTZ)9;*Z{5j!S{w^}9XCX#WeBwKXQP*41?4-9`FZ&7DGHHBz!S zb>H%j#DtHNG-M3u10?pmRY0|S7umC&EAeb(oX+;FO{alGiumkS^O^$pil`>_eKj&Y zau%OP`Ei)hP#noWZM|`579W7QjTYoniZwFkpI~=Xd7}?2A0^xwg{;HNeO?3TDpJj& zCxh~ zdDH&OVVU*wb{5=50 zedR;SudCpPGu~?LzUg27^vvXU7*YTV#Qe`=XiE_BoH*M*jkow3Yi)A{#?O}R@Lidk zSLMx4%D=)QbbP8L`YohuwQwatANAe1d9O%J@PTEvsxA|FDD#BmHruDvq1vb`W79Z! zhSre!!d6^pdp!78W-zsA->~k6rnvBpoJNWT>eh%lipTvG8*AsfVmCJE2jXeJlHghm}V&;(Qg9uJf^7{w4n zddX5q6vUN`c80qnJ~dG!KqtsM8)%Nr12Q+0YVjF-q#aK*`c{WJC2_KVXzKx0hR@7j zoYR*RWRzH0bbi#+>T0xJ;R~#1u@T>jB6WoI@`GF9G_uwd{c6M{XLn2q3KloDmzl@% zwNN$rT+_kK6FU;QN=EKr#yf?QQYlWEU#NXY3YTO*$A@?M$3&}DREc4;@tU*vPVL7+ z?ML+MtIv;B%pyIW zIRFYsAGU0hI_U|Ycq0c#;LT^347GuT-te+qk~x{o8${rJnN$ZE4)5L~0niZv# zb}+apj;V4uYCo;F`{zZQ6FSTB&v(|7CPW3IC0nb!UC5pxu84~?%}Z6l{Viyp5uv@E zA+Ef=1IDKa^~z8up^Pcnj+a=7_@zHwJfa##qr6)(Zd+Zp?^E(Y@@n+coC&5_h)X+8 z0=`ouJfFCI29n15Ef)>pI%ccYEWAh^wtu;4aj`}e_s+FZg71pF^@kbLMBwi=aJ|1g zOW*+lTLC)~zDSSoXUfIw$)?MVw^*4L#1Pxs>|EQNXnP~ zAN{MFx`Tgw^DY`G(`5!?t}j?AceUxQN-LRU-}`fY^Q7BL2fXq?KbQ)~9c2cog(SFb zH-{M&O!)J<<}1(Cj6PrXI({oov`q)hZRDGkUk4^`^4A%|QeDRptN2H^x+~4ZRW%LL zYkq294~1@&1ESWX@%m-;L5EFbW5T;>%rVBl#^0YtzE1Xem*>vkr5YU4I;0&vB)q=% zb7=NQ^z_Bi=vKxL&oL}qgpsbP9?ubRstop|@nr0qLzmW#DUR-Y_By>Q5wea&$}%cg z@~M88F(Vc0rnCh&N~A{UYq&AfL2owxU?Oz0`1sA79M0&GbHiPbfT6_ZcT70owWW{| z@6j$0$QT3TsEvu}6G@VJvh17wa^>cy<8hwQgFA39t_L5bNdmFPba$V-75@-Pb)%b` z&k23!{g%K~!+V_;tAL4IR=K6cT$>#|FWHpKn-?_vEur?7pBF!5X+siRBMkI*K3#T8 zbF5h3GU#Um{_29KQ4jmYx36iOABWw8aOwn0JLpDOHou8Ql)U?*kMxH4>16S7tSDH| zJhD~=%xKKUkFpYQ^qdlV-j8xQzN)WPw*CppF;49vGmX3@Li{fOM8anx9NDk`L!j`u zM_Tg?TL<4lsc1;$^E{&;F58Z$a(-DcJwRKjf7JVajVT+gCDheJoYua6r}Rd~7-v~amWg*Vf%05H zYVMwb82pXjIJ`|u<5^HsC26}|zEcKZbnQeDE?o!!uX8jq<#ymQmHENQhnEH;Q>@YJ z%2RAr62dh#soXEu?DJ#8UdtLL#suL225s^E43n}?6~y_wA*?nlis<~An0Bo5oT$1C*krr_yv8T#yx zk&_jFZskVd?5I!5R}TTiUVI_}t36qxR%3JJSjp_}x$dVIhux^3XEJTbukU-3ROb?U zOSw8^1b>cvHEO+$^SS8+aWb9HRgaap(gKdg9?X+SjXh(l)^BJQGUr3YWQ|9p4lJ$e zRE+-n?HR1*7%d*I`gTA`bKD~tQMoHfa~H%JMJ^o&>;2)!$1#)b5%BU^R;^_*g7w1( zFDn&aX;BFsIUAP&yEuvresvN?>M9fi_lYFv5Z@GQy4d+;vboO70Xj z(;#xCzFDglMI{O*m-7I`ywRu5D!d$$m+690JrI*Omh;QgShhDiWt zRCbPIAOo3D$h8t!Bj#29fWq#ZvWO#zw{E8tzpB5Pwj^lwly@`#E~kgz9xo4qetU?yti=UxDK0dBe>Bgt5S`}<5$?J*x5tP-XACEkO}(61 zVq0G(4Qw>gJ)r^;X<^FCA<%D;v^BH7wCCwzP&$vLPsiPWT=on~@_X9vgWGeOTd{lF z0s5KeQd4VKLx66{jTZMkaRK~O{d}EvrVh&Avu#$rv^s_W&omGH0lk(OF$CIi*_d>d zaOly&sM$1lWalM zOQYZBFYQz-__ICFfzGuNSG+OiYsjIe{<6j(x+bj<*koDrhrwG=2|e`b4_3OwD0idk z^pX7Y_hN57T50zELZ+9`D7S2B)ak7)4lGkL{ydo+y1K2DspcAF5N3BX5*SJEJBgqz z1uDtsWRW1^Gv`n<@#dq_j3{$O0VCD58>d*(Apa#*esG8{Le?{nd-qN*!tpbLp(UXz1OoS8VQ2+d|Tn z{bC~BJUkaLQxHt(5c zQYd|XT6(QId06be{W9kx$Ew^n2A6iJqjtiWcR8rqCI#vOmdQ$=&r}Zw?7;3ambXfT zl`GYibu>SlKYf4&RmcCuiP3&DLfNvJhnE-McD}TUB+}Z8K=iw67E*fAda~w6#dfZb z$8kT2W~Nzd3`_~^kfCv6TE2*q@)I&8OhN^!GWay+!&uDzYgE>ovUBz+3i2fxY}9RYbU7tTa)B)KdCUEvrLv3 z+UB1v9ltp_5$}~_|IcIoBpg9_AP>Xz^l5n`iD(v2FQK}&HXU&i-n=UAZ z+}N2S5?|_$@?9}bd3+4~0RAh#zou&Va_>AeybqIJTo=Xa3d{@qg4111g)%;A`g)Uf zkmL__^u`KrrrYwat^Y_L*B{JOTr#>HSVlHnYS2O#puw1Ya=C6AU70l{l!Hd)Q8PE4 zLV)LC0op8kkon=@;3$dQD0G+S)B5nv;r8MLSaX1zz#I!orz{3NDS8RV6{ z1R9+MZ~dxei^?UkSK&>nk`l@-7Yq{HQT`w?B~KM5Wh^>BQc4JG$ON4};~YK$MJDG( z{gyT|2)`XL85{f=jrr02M@dKE_vkFyp)E)6YGm!6DcoNM9m1403or~9lVEucyZ*vx z!4IRenHRC?>s6sO2*T!#RbfMJuXDpzbN2yuoHLXhQ2d0Vm>OX=%@N@MVV)vnC90g1 zw8#;o@U#UG*@n6G!GChyngx#o%9CjG@>7qU{JB7Wj@!6BCIYMyQJBAxJKLOd zj(5nrH*maehUrxfoNdv7c8srnyK9<+7)G(u!XCiV0Z#TwPw)(E+1*UlPGlPA*?Tfa zvOk3XNYUmx31?aQi49Q+RtE+t{z=J9TK-K_wEy$Qak~q!T2SfR68D`^lanBKsgK9Z*|p z;;FCkBnxj@>!*-oMu!83eRc+8EMVE_ zh7@{1p>2T83ss0n(%7L!7)9kseYdZ7%pn}nm{XjU75VS{-rOBuAA<%*L+;><=eS10 zpP#qtb#ax&ef$*w9$L1>^whNW?y5$$WLAke- zm-HOe^xF0V>Ztcy6d&R2S?dJAb=1YXrnIJ_Kt)0z6sx7sVlvY0tIA%hLfUecOHJ{c zy=Gt1E0ms0UnO0XdJ_>Tq*deVaG zQwM7|PM&d<*lK?3bMxW)z3&@u6$8}_919e7j8p}H5dwj`y|4d?6jS^Q0Fh#P;W;Xv z`Kw#lEjhH-+v)o?y=0fpKbazc@bS39cn5XS4;RMAZ^y0dbEVy& z*&_bSvCH1Qd86lr_wUzegDD!(TFR|DF|)|mDzS$-TYXHCdNLnV07O4CQp?6X$M^v8WZ2k3*E4GuruPx zUX^~|*FG!6x_W%83l}om@5aZ1Gd7!@njZO!jLAIl<{4^#f@|g?)mIp_X+KSJuopZM zfJ1qlzOR2YreXSQYc*h7i&qi${#=uoi1AeGI#MxC3R1HW*WK9hBre*V-p=!l0ls%;TPIu;CIFa?-(t`#`7f;H=ao zUGCdGgQPOa|hU2;6=?gbM90h5S`i~^DO$hj+m#6ua3CY_my+`zqRqf)x0Plr zn}qCmWhK+xl>-wC4q4@EaX$XDLDk12Q4){Mip5s*6gL|fpZ3_EsNYb7moHWZJ!l3Iw2ihw37G zw@+-tGB`E2w7(EBrrY5JLIO$l%!pR+CkN)&t6M|V`O;1+TcKHzP4u<1)x+Q2FO!3)k1cF&_s&m+ z+c$l5DW{S)#@blJvZV+aiguh{9YSd^k6&Rv=Lht23Poy99q6rH2vpyAAn32qP?0y) zPeyy4TT@!N?23Fj+vBSt8zMuJp&aw%NoYf>#T!APiG3?H zUCng4!+fiD3yO2A7V$C)=%1t-Nqp~Q?VT&%s;|h|enRi3YuONE_IgKAW6M8d9Ql31 zPQgoh0DZ!1ZEt*&-ExK7f=Yln4BbsNZl_i1Fczye*?##Fr5dH4(!FkT9wm<<6h5QT zq+&sq@FZE38ku3d4AidzOCH2mkeypYYOkWQ8vT>$UUGX+Aiz7Rmu9@Xn9t+J9w6qn zTZ#?Jl2S|{pdUzMo{-}B0qdTOb~!%;qr*Tf2%9(LXT{4+aW}ZcKU}q=`fof1>gpa6 zdveIKCeWiNza<9S-`&DX94%d=wNW9`)lj}VXNt6+H8fEEag}-$A&M=JcnDjDkYk%A z26IOBZH0|~NG1h;RpnmQLt0_UBL^^7fx;DJ;fJ;m54I=hYHWpk?a)LWSzDbS?|!n! zaWO5m=D8le%=(#d#?Neq){w2?&4#+^8Q#znuGPHLsIa?hkygszELb#u2&Un*prkM{ z0H=FF$DMQX;0&4sOG4>A!7}oj;woao+}GD-e&=PC;$W zB>^v1(!*pP9MvhhA4prLy1W?+ei@2*2XwlnOG{_eZ^G_BmV!Ip%x@cnDOi z`0QHq0qT)5;c5b50#I5oXCxqSn zNUCoAHUHTmHen$9;Fy%W&}hPyao^7aw(dRD3Y)2K0}->R>v@ z9$L+f(#WSk9V(Pt8{I0cqx-C)(J(?%vf#(8^;n zjIyw#9%%3M^6pi~?+J{IXY-7M3@{<<_69@KNM=kFq|W0YyaNvi3|zOadR_J#o!X<^ z>1LE4Ce)~^*ih*?hN`IV{|W%iZ-fwszXH6SeYeFrX}X1H;x;kGgsfuyBuW}Np0TFs zSjzRo&#BadJc9b*Y^id+hTDQ>UL zKmG%s)`##(>YtYyCR>zMvcOx%wez`isoaNcboq2WqmqZ(#5?Q?rg>yw^*#^I_ zqjD(*#_Pk>LlQk+u&$`or6$gYYpKuM3#dOZS-Tpgw}QVJYl;n>X#cK^_YHTV&@JjT zc}yzJPF}5m{;_I6!CJLYs_&RnglopA4V;b+_|xfp=J3wRD#-NC-ceED2f_ph7qNyo z#p2yr1>Uj)^==`WUWSBV8D{U~m99!*XR<;6Bo`hhlNf?F(!r+e3Q`5Y#t?s1G^~l$ zzQk|k(m#36MP|b4DWkj6b>9tU$c8D3B+z`rEk$>LO@G2)x=60nP89ss@4eUavJ@tl z0J%dl!X`4f_p>O8Gkd=(zth0xtvW23AM4Q8rz-J1aJBBvttssBSq@yI3J{ISi0v>v zoa81loyqh5Mv@UQ&4XTi7PyzT4SR5YAsV@6S(_t*^l zqMF0ms+04F>Ly@a2ZB`HteO2!lNaYZswF$}G%LAgB+Xt(w^!#p|B#Y+-3?469P_G{ znXz(|fL*5YuhN5L`A zaO1U>o(IB$P_4#^Z>y7*lT@XwKayE;UDdD`-<1l1eon6j?e8ysJQ+ zZpp9DzrQ_s$20Z2?z5LXR{kbe3f_nLLN!xWU>;@RdN;vOP1wCCb_?OoH}NYhALMIl_x7_5dp< zN+=1obe$S4SQzkvFO@^a;lGt1v{rJuT6>Z%S(au?$Ixz9TyCkzClH*x0ElmgXVy#C@e)*|}_ z`bdf+(yXmxV-NYa~Ct6ZH)JKJh;>VS( z?%amJy9bP~MK6p`3XqFhg9}TlAtQjnse%#5hU-*_N))>gT|{ctq*GhiDdle#Czt6# zQpc?7rjJ`Vw+A<5r$QsjKeLgvd>YDyzk(HnzA6-{gLTG+t|-T&$z*4sJ!s<*@1}zU zo7}g~-k`?nKG=X`d?!2oh-I-C-<}Lz9Sdw$=v*d4H3Us9Ttf2EG;;RsM&mN5t^5w` zdgI8kzh*~S3;fP+)Jy2AQZrWMNG5m98E!^;#9wXh(P%bGgUh;EJl~2qI&3ykxFEeQ3D4$SnPEs?RIm%1JQ2po^COol(RpFCFf zR4I1IF*eTH5@V)M@syf!TW+SXYvLVcNmoe9Z$Ma@%>L|b(^%W26X&k2s4SJcLK|-9 zB_CWiW0<~dt!Yn`rUPjjYT2o=s!6n`D0onr8VJe#j<&b!)SEk?pA#;SJP}*{KSvL2 zym^Jy!=FaoJudmOyZ*M0mwvJ-c=-T-C4wK5yvp6+oA5d4k)a*<6MQ;N4=70JG3b$X z?hRqJS8p6UjdEs7tKBcW1Q`txvJ&hJcu_&51c4>3j12nyB~bFiQ7kn7a=&5W02tbO zh_>eI9m_5Li$ib&Gjh&HQ*}#(caS}lsVY#S$JMp`boUd>CyEtvx89ar@beQhzmY=I z?p%OES_0EFchNaIwNm_EX_ck4X3+5Z$H3tGeLlC8(G=|u>Kp?e*>_GKrEoM1i%UVC zo!bF&$%OLG{5?&UxSLaO>lp-;>#IG$vR?5x9pJ5nIe%@mAv4Hy?)ix_XzdJSN!M0m z(4G5%jlS~KVm*qTxC$(WXb8rr&nxe#b#bJen5jv|$|3qDv$#9OuXNt1;2{}4l~EUf z-;B*kbINoj=6s&Zr+jQn$y=g0PGb-Tu<-XF)v90WwS~%X#l@uyBfzRY*Iijw19y7D z>S!ZQCUc~=glYEVo;-b4G(;XR5De$rFaXdZcV)6lXd{CNKdG4l-slXNk_P(~E>peb zkN~IKNN>y$SPz%plHAL znVq%Wym?`#zn&z?{*M~&I)>9FfqI$1D~Sufz6m4b-p>V5zhNrez*tqA6ewnXsHfs zX==-+@T01~IOl00w(j%2KUsicWID&KAu7FSOojn^AnI$(xN6PV{pm&)yVo;l=LXp{ zB`qeq!{?jSji7t{Z5{FJ5N2_L} zW*Cngu6mC(Mwt6vrd{6pNX&t@YJy;oE%D(WY0N;MT1Ly$~yTvksvrHt`*`#euk)Du2t0vfB)zz_C}x zOIG0jcqWvznb0VhIuUzr<1~GPyh$AE!BDMyn-AR7zWlveR&~DuRx>(2P2PB{<{fvPL<-F()Qaq@=?XYX3M#?>Z50V{lvD}rPc`v9fw5!{ z3Y=CIjh0+|ihGg8&_@`cG?m8KjOMdVu}% zeYTqkAb>|N>N7n{ztGtNh7+fIJ``HHYPNU(|KL(W5GAe9hSuqA^vRRLA4`r`RhtzB z{NGp472}5L>5;}B*5E`dnMMaX6PDlzc-;(}D{7b6eNBV-p`M!6@uf;{t;)jJ@5X8D z8MqN6vfs267{~D+OYcVZZTun)DKU&Y=WrMpRC#`4{EC%9;g1Gm>~E9Fu8-e11o%}H zSUaY*6z;5M1m&LR0p{@g_^zEI+R+ftPQ`ijzP(%OlCeT8)!b5o+S9d{=Jrf&;liiGLTh}uhc6_bg7wa1+6z(mRxit? zE*_Wx6RiA)U~Mp4i(9j!a26Jd#a%0VVp3p-t4M^OOOZbtBnX9gpVR$^R3->x6AD`W z4kzTXcT>+@4WbevwHM1}QuVrj9}X>{&;rJ!bIxm$(rBYRNQ&w+;-D>A>dOok1@5R z!H2uc2-QFS3cxC8a5Qn4ooyEKVj;xm*D-Femz}S2`T=P~BZ4um#V0uS;Ro90n7kBD zjfja{Dx0*O^lwu+JHK@{izh!#7TQTc^Lq>An6Ti=aUj=Z%I<}l53AzL!Kyy ze%r78WkuP+Q?9u7hb42qSh^Jleoj|#csds;*ySte^I`5%ooBHTXCZ2>EEf~Jg8NKq z^$)R8eEB&2i-Ag2!;<&u<-tPBcSuEn+MddA~%zu)e-7Rq4Ub7n}1aV)^}Tk2v8|9MqGXJ04JSB#M? zg)vH?!0iAjz4lRwPj$vOF0HrT^XHUUZRS=NC)|ZzusQ_N~W?IbJ+q$8T*ezq@HKypY^w?P0H6N?_OM@xCF(`-V z3Qc`4d{?CC7?nt_O<=f<>N;Y^ilqISMFn3tz4CO3#vsL`IwMI%@1!r}gP+b|xb9^M zn`a5b4rz7qF7n9)iowONFW0j>rw`8TEOIAh2|x}Nogg8yU&*_M=#Q0yY7Lp@)6E|J(vGD;LYP&ED9J35#*;da5A zv(C6Xa6)gdFTDrVHJ`q?NyTaZol&3*8fyEMk_kSJ!Z)&6h+;h>FODb|Y8KEvr5}r166kV^w+XNgYYT#F=8@()l6aAXnzS^W z)mn7O?w}NDpf&^Qz1hz^(Stk%HmYzBo0kER)@>vfKJ{eT`h{csYu3PbcNGk z2wMz7O^w&&{}^Krfyx2_cm6yNnHX90__w+!*agZKqkJsbolS>Qf>q)|Ad!{g=RX1} zM|eXpODy4p4YZq^4c(yoiuxy_I#EDv1@(Koefh^UvPezR@$`V=6+kyFQO@)68#cM! z>D7y(ebISyGU}J~`^dZ?`ySk?9Q4PDC^Cr6akAzUA;kWrgM&NT$|IqttzVXKsx=s6 ze(c|#P3+P36XP~5De_4`VB=0vI^sw|%Jf|4lS1?K4@(xlYeZ~-UZ)LR$1+MQ&F~UH z-u0q1%}pGGDie;s$;tmPxGKU3o>A(*xv%p{iU3-i+s37?w0}2BiwE{86XB8t5ZR&ay)wW?dt9^{NZf`d=@JdM|nyxA-jp4Q28S2fqsHHwW&0qG5N<`RUI>~ zGx$HBpU$A{eF5&(Z&CJ&u1ryCf&9838dzvKI00!7FzLU4!zRP4LCj6~4-K&EX%_>8 z(tFl29vT*)y&q>|Xsi%uL!z0eon{I*{s5PpVJ(8@NH~$K_8F>El-LyG7as^U|9qSj zn{ZCN%+qXOd77c&{=?|=pay(A543+yMbXz_OnmYDjMhCmA>)`A#s27m;0RT@ATi#w3~k0CtRUA zWb;JOx|CXFcW0?#(a6V{-?3`9(^qw2ZK~KbiSVC8pvh8>%j^chFEmO>E16KjS{`+F$HlS7qw>0FN^t~eD^nW~#HvsMlAm7$C`5N4 z_qXiH(|-h5;-c-GHql>l!km-4upQg01w15ZQDjUw{V-tUigF2PcR}@> z_ac3VAE-5JT!1*zV2b$gFJZ7u51 zE}+@zdk-XVWvKlyJ_P25($>9_O&exO7TeIhOTU1&fkUDD3}rI{X0qWEN0$C3Ght>t zuMBQV47)62BpL)p4hd@lU{Pt$D|0v%>;YFm+j@A%MeNLPrRoPoB74F_w2KRy=O5daG zWo!L|d3F2W<9Hv)s&)HEVV2fnoSp~CNB_?_`~uEloq$YyR0u^FA>~eyK7Q$$T53~g zmc4;K9B^l>e|t6_R08}Q6zv}*4ykm?{B8mu*_d$kz^&E6paRV<{OqzN_dr@11 zd(S$pCuxL9Apgg)H1`}C|Hu&g!j!rAu*s9cHPC++)Dl*8yo zSu8^2culjTQnjKmqQ~CsBK_&4Qv{{Rd{JIN`Sp1zOj=GYmh#*J z=ciW)qiKA?&7o&?xu;3wtthM&H)CenZg=`HH)YbwYdc|U(-JVdXiYTL<_o&9Mqp`5!zEzTRXqty1pZ&1&P<>jx(j3{_wPk-{LWpr~@qw#a6P6EmY`=2x-;J4%6Gb)MU*1@1-vA0UHL zDgAd5LU{#2Yb%I5W1X*Tq-K`PJ>|=)rw^wOkKlcny=X4Y<|izFJ^gDJQSZttqRnvn zMabR5T}alhZ5Hg2tuK9*ENfZksp_G<$SYk6n)#EcM{7uYC{X5njrD^%EmY>Xju;cP z_wA_J4co`;bL%vT)#nq5?LLU_r@M#a?&FME9&=-F%*r?}#VaxXJh=Q1oE(C6m~IeX z2N6?XwZ+Bp7YU%Ga{mWpLE@d28{H!t*PsF`s?9$=Ti4N<#1XgX?mQ_J!G-a2YpLS5 znnunk3=D-|+3yGPzOan?`BC%@qrj5}2Kr(|fm#nTV$99=b;kGNbdM5~B5@K5W9i^d z{;6EHLW?9FAsY9`W0WkrRKklz&MDNS6B??Ik+F^F*erC%RB=E7Y?WGNtWJgYvgps! z6V)|sB4z&dj0^tfPwL*@4t>6Dptv1C--MXc3mv^fb%370|1hmNm^3Wed@ETUKjPXT z-dcCZueqNO`AWCa%|bWh_Mpb18(x9hwXbO`TeMy2qZC78^h~3!jdm+Mv{THW%_d%j>SHUD27`c|@ZVwb@G7 zdaX;do0%M0fjQ7(7_Oll({ut&-%d+r_Hg&n5V^$Rsd=^5Kt z3ameE#w85#dWZz}k-=o(`8`m7ZrO8gv4Q-;d?aKm`#_i?vV`lwBdC=Ft7 zw#3%BtyEy(Q+N;5sNyW_pDu?Ok7ed`Ss}+iZ75s5soyoqFAz)lsz`|xP8i}Btf3?1 zw4UR0D%8*T9(m{D2hPFH(FUmd8M_ncD02gw_j~`7WPOGRkrs4;X+ES0bpwbwv0d)X zJ52x^PbNzGizEzKsp=^A|6C|cdXTM5_dxVPu&`FaW$tp=hr~=+wL4Yk?>xJL+ypi! z0EnRc8<>plZvWpO9*D7;&Qx$bV~xE?XH@Z|-)KOYJ?A-d5Ze@34Lbh0hsMCMQf87b z?~FI9>FpJ0kD^BEQ0b{y@>-Wfod$jSc|k7l{sKH?G>p=p6v^#H$oJ9T8YhfC6)`XC zscsMMXF&GA!DmG6+IL}s$R{9|{ebgUXy|Xv8*D7wJ zJ>uiBvEz1#ab@sSTfEq1iuj@~JmBlCX2uu&M!{Zd4x>|}b8D~)OwgQQQA*T^ z6>y4vxD7;EU<$v3E$)DO;Ps5OR&~^CTFAJZm6^*jLTQp4Xs(S+x$~Z$7xc6U=*(-_ z#C%Aj@!p$CAWP4Bc5)wRh>}4%r}I3FJ@`4tU0ZJ0@cf_6Q<15tK-8``axow8G`WxB ztGuuTOQ4k_N!2Sfn+t)xTcfWq%djxcQL6^QH?+&bK+ zuiV}VPdg2?9qu6_Rvj%g!+oIx9++Nkba*G33?tzgIs=qsEyl0w%u@ zve=0o6eiJxafsba#J=KlG{x(UvuTukyv<+OhgUFNn=)PjQI%`K)yk=`=c# z4^_F`!JQFa&BKYt{BKWY+d_FQHX^#v>Sg&$izD0gXuV>ku^@F(KHWArYy>*H$+ zCF~Gc9j}$yBI?>2U?aWOk9sS7t*p}ui5Xo}@q_J@+Brkf~WWJ;!AftN> zBx}$4ja#zt$f5_{_1Q|VQIO1GP^wS*|C5|L*fbtOG;j(B!|T_=K!`Vd%SUwEbc{~x z(+a9}R`ETakC?lJq_}WX*e0Oo8ViX_*#OZ5P$4HX9B!QIQx6yA>x7CoH}CI#UKsVb zVD_TLXh&r}*%dr6We>!leBomLH$5k2ks#sDuS{J^b7N*VSi|dz@P1Y%G}Yn6J~=@a z`Z0J`EAN!E(JpJp!~DtY+x`>zsqkW6`kA~(Po7v=KV27kFXR_>+EgDGTf*=npS@eD zv!?_)3R-uVkrS=G(yw*DaHJ?ykX3ya#qHH|!Smei{wbz2xA@{j(=bOX zfH;{`6_7!Kh+#2W;5XPSoHfz@dh4V{V-LOb{O;+G9lIKd1Qf|G;j^NBF2|+qSBw{b zBpwv3K6z{s1E=xQp&H6+lcgvKXj?->wNVGL3$qHHkpB9+wNY7fcealxCrpm;!ZF+L z?C)WsaHuR|N3$k*aHxrpg-?UiFg&MR4Qc*VOUSf(JZe8BoTH%G^5G}15x+m*?rQAe z$3W$tOq%Z(RqhX_1ETKXP5ZfgTUz!LO}^O*OG2*CM{mc?Oj7xr1Gdb_6}E4X!%!xh zp_mtx84R3}7km!yUn)3;U<`9^fF!EsE0Mbu>pTG8D@dVw^qbV`uN2Eidb~p%T^&um zU9^3&P{iPcSZ5u{pV++F>ZxZjllA-@-)jGBxT;V!5cQ;*{Oa1dJC_z>T*^m(DHFVa z02BnA);jF}fs1zlWKc0%bxQujVs+ahzrHD9pDzhWHaBnKzKilencr;mooh zT}GDm&yh=2rqN-fkm6xPIHk zCqC&RR1zDbe}&UT5UH*`5^FD3+Y2OouZT$ghW50^KNa^eJ1jIf^_WzvC0o47q7N?)r0*EiorVI+-DP%(0< zC_td5Sz(`KYX3AguI$Z!M9MKdcV1qi7Mi6`P;wnz5FxWEgqH2m`K<9$#Xka0x6$Rv z$dFRdg5+O2oI&cfiPA+2v!v2^{yh)zW3G;oS(5IlDy970skXU~Tx8tWHp&+^+lznK zZ>Fb%7%?H=ed6!f3aJuY=O(SE1m#>{Ma|L8l0DaE4t5s11<-2_Ye5W{FPkgF3R6bf z7Rc0dom#pzx9Rt|NZ)7UZO}?Dtz}JMa!MR8RM(K0$KQWkFXJ>+C=1pdCwrN{5HrgZ z_tF0Kn(yZ8rz(kW9}@o%G0OUMXR!%P2)GHfJDn`V%!y}O<~tF>HxR0=qt{!ZAhVWV zJ}C4^aCVhTXwVa-C*{RI>=&O~q&9l?D{3GAE-C~!2U+e*p+2usPzw3#F`I0bj`IQV zG`Onh&M#-i@@QE3vn4Dd1OG6nw+@+^+RB5;30v&BvpV6Y&h;H|w%q+kP<4d}@Brt@ zIDfUsH|i+d@j96`o-2O68mY*}y-lk^Q2P5rYFi2>I@0xPfs0dg>Kw;0N_lB$H>fJr zT1+T@9q{G-$54=)L@@mYz%Xdey}J9E1PyhMDhhr`g#o5|0!B^OA5gZ9`3{TUG6K=sw-q&GaohA^>KcpH@@dHMxv`9P}UffD#)n zzAo7a=%QRNThuK1;zDwZ&-J%?6T3@?q+GiK!zcubAS-|_N)1z@9T*uxj<(W!dZymk z)S%Pbla!U*aipUBbhA+B9*~_tGyd{O=Y8h*lc+N|BUa}a_4(vMshd?>ybYfe(N}k_ z?+;}JZ#6@&P)W{hPCFjuUbn9&vD8oFrgWp(>3R?K;J%FFcUoRLdZ9R5eZLf#?4A`j zT-6@<&0ccS`OPUE{oq^0NnS2s9NZLlky@zt+V1z<1y?QOk>_OKOBCi=08MM#rHH?& z$q1`Le&NzoBacn`TUp-d&4cS1ro_KNMdLJv$vfsO3KVtf9A-Cl5+Kq$`(vQb&bk=9zqK zkxA^ONoOYzKBm!^BW@^Vr*@y`Zr(i1ZquqZ^f^s(!4DprDEe90JDtbQHe3XE=Cl5k zi!hJ;o4jsD*Lrgf=vFpE5moa_g1F;lbl@(@r+{z^^j*k`Iij8`Pl4P)X&+9N+qKGc z{Eby{jWte^V#_F{pj1{%Y)H95@+L4JkPY~8jO>zGrJ}lj9-Jzx`#bDbTX|ac6(-G$ zsupJXmJ1L(8shF*do8R+rv9F%aE)j&?opRemaD1|`v^UXACp}YQGPze;NH5alDYOv zr2w+obB@xSAM}c=IkSzaaWBP;dlOwr_|zhHSYLsot)^=_FrIQ6R+m5r#9XAL$DrSL zjd`5(Jcm){%)Ia$bFOz6^Y|Fj`(AwJ_^F?+?PAP0I!niOG0s&buy{DI*pMau&UkW2 zAJ7?WkYfAtxoT&LK=Rx1l~VkLxs2$AD$@<+JEMEdaW$P-!L&gPoQk~mstUE-U;eI` zOZ>W92HaMh?R}j<>sE-$-^qGfYx2$*B*rD&n3$*%sM7eapv5|O zMQ{#~xKUqxpRj;Eu#Ix-B(v*9M zFahbTd9Q(d=-K+Mu2%tTdX8T>A(C9RFi~{G_+1B!9ra7apB;v-$7idMs35xCrCCOr zMW>h^9B;oyx&YmRzYxeX{Q11AsZhJL*jl2*G%NTHgj!9HuLmjU(Vu7FvH4@dEAemM zg=D*e^l@FM)6?C>C-s9rizN-*D7WUGT!1>iZm)&x`&1Oi7_Lu;doFml&knV=t<2R` zHP=ZSD78Z;-};HvDN-2YNHK3uk~)Amqg+P)F@(m6m_w37Cd7lT^rbd*zDQ?|t&MOs zh^#zK(%{<$q|NB&8o<=|DP*7NNM@O7ByJ>Rr1%ZDw6J@>k231;tfb^9~XMx*EFvZ^*c5k~rIs%u}-K zj`rtWmNVAcLY&&ho#M&d7R9?UX`xL7o>rh2t)*f6lQ%`ka&IRGe+Thz`|9#7f5hBW z*4!GUX8V61zq%JJ8`VJ_9&04^dgspvYn5E&TnRR3ZKF8da2j4SvcD(!8^y}>jhxQq zp~L>^&Ny>|3)fqIh8E}#>H$vK5OcP+ej*00PqInov_sX zUebxnvsbxH2z#$tA04e(-ULg4;tI18X%XzELXJ_T zlCP#rH|q`It`yK~SGtu9AO>jojoyF-YDxl6eq6r#f>LO}fiam~W)`@QvV=3mj%EV# zl6uku*f7wF02|C_>t+5YYx|EyYNm>cDU&Cvx7()Yh2010(jhaDfBEI3!%K>+Na# zN=Ecm*3$IW#_rb<464%@5$TyxZmE|(tzW`$1~tKWzqFZZXz171|3Jwc$qGdrVS^Xr3G^j{Gq)k!h*$Qhy` zD_e08O_qNI4A0FsCox;}21TUqx`~?kS7)}{^WvPxLvWh8-5Dm&+{i3gYAN`4G_1&^yF(MX>AE zo;L*78xNP85|rpb)nLG|pru~*uB+h9Np=HQ#`&tgWSmm6JyC7@41tX=&+&VUl2nW61#`9rMm@(kp# zS@yj`?e5Yb<8YxTMHL+e{#76YK8+<@dC~;vv$OgDod@x!r5WnaAHUoRiQhaTPg~gr zqqmLSEbFVI!8c^EF8-Rfn`kDxN?flFJiaW;cNP?>hPc2zKl=&J!2ghD2jV9f+Tx1> z6LAf3e*kvfLR~VjXGe_zB+SY`v2#y^6Z`m;VU5NtX9yK~H@A_1{6Y!M2P1>~I+lP?Ousy}FP?(ZwdX zq>06C8|iNOMJ*4|8KQi*6e8YNCJz7gZg9lKKlD0Iu@B3YZ5DovawecOXGph6D$wsq zf>dmk1GJxf-wgD%Hl8=3-$xdj#JqN;20z)NT``1LFLmtkedXsG_vFZW!o9u6m|`vU zG#D2f6i>_IaGG@a8)MT-dx8!UocR8?Xm7dd0K_|wXv5SQ-#8PNgbfN0Jc`kD7xWtW zv5kj(?W8OX?`W*%&zsf?hk_5F%fM;W`My|pJ3@~y{I!8A6Qfr>A~ArS zC+gMb*){+U_19KkuqFGB0B6^dO6%I*6|USu=@vnJ2rN*xuv8`B11CTm`J$Q68NB#y z^1M86Wmx|ofx;60-amrDt`iAyTn!Kwowpl~D;M8Sq6^#anOq02=-J>( zv1V&m#w>}p_(ssC!=|ZVq22HE^0gJbZrjH6DsXV$$rDL@P!I_gk!yu3!9Q!jEyTts z;Tw{$v%tGcpD6eAPXV_@6R;#Vo&J2lnw$Z#-$C5laIoh)`9~lMKG=5v*b~{o07z&) z`!XH_s7Tyy?%$XIu;0!}X|NqQ+6{C~!i%u`@Q+~Cq1$yw-K6J>+hol{Txatc{Z;YE zTXh8_WO2q|Vq(BAx?YLUkLgnuPVfIP4k3Qd%rRV*jXJ(mfd9*ZT z2^mR#wL~yZvT{(y$2U;e>mwEJ1`#R%iuVbqLlOLcj{@M8?-xGn@tdflHPjo!*~5{5DT2$&TDtm z(++6riMeaQxLUvV5tpW+xpq^DX$5$v6dr4$P3iMP&T&d$dPfwo_qO#sC1YS)eh1aS zsM2C|AbC|47+|qx^&4ToM`~a< z(A1ts#_e!7I1)_tT-uxp=pZix!&R&?h*p6UHGjf!1v>wWxTv5*nmN$Lzt6@tAnlR zoAi{Lw}D$I$jRAiC;W`j5}_CKk3hV1;8r=n=+$sxEW(Hle9P~X#l2Y>z>(T^9gdif zHSmlEB9MOFj(BL#LRWJ=8|#wxTtMe{QIK10EJVAFB|qB;{(E{PJ+Dr!+mtq??fPRV zt^s)6?C^16{G^$z((QswzxJ&S1V3a7tRyKyL305g$~W?uFu51Gxj z&l`Z{AN~G8*=>`|_0n&uo%tR!ZycETR@<8Rquz2^;;(Z^o;1wX&VBMhxBTXa)wVx>wVt2ISHNdE;OVp- zFYbF>6aj}hx1GvZ8c@JcEi4O9@Blug21oemD$uCxNb6vt%3PK*=61SckQ<@J?9$(4FfA_v5X(^U-RBd8mIY0 z_sK|f8;k)sx1zfi_ZmifLuHEP6^+q1mCYyunr3gYeW4+S{JeYPKPy6qI0VSJ0^X1- zMGi@%MRC*rZ&U>e>jYAMx>-JAV+UH@zG4J{+$=o5{dC_?IYoYl5Ji2X4}Q2Si@uwk zsi#F}z)L=x%@PoR@psKNM=I!B~|woLAIX=>Z{*Yc3&iFFicX;c)pQx z=d!&ku*(x>UGmoy)|g4+Y3t3TET$z=5hJ7rEWl)@|FE(j4}U+7?*6$0mDT)iE}huz zy;lvY?Le|)JmP6jsX3x!#IKvM9=kVz7l0i(omh4m#1}2L__kC^QBmNC>u1hU3r!kY zA7A&Nrdmht7-Rh00zb4pKlrYrM*ME@1?idLK20h-r_>LS9Q%~&zGM0`8*tNgYk4J6 zY{hrD{xNSJc?($y-&bF2v5Qsek)(tf9MVMlWVm0f%YL4o3G&>rIIeN%N9Va~TXHdF zGb@oM^|O7}i1EK;HgJ4<`2mAEQP`e`8M|BtWzGy8>O|;YJ93wiyJG~p9l-TeKIAWqU(Jyp)vs7L(8TnJ5qh=poW?>mIOqaQp zl@khVJ9RIDIneteCLmzb!`F91X)d`McK^*E-SSY=3Xz?^MK}3cwkl}f?~P!AX2@|} zmjVdVQj55?)zZ?K`h6WCZxx*i(n&D$XHw=piVQ(mj<^0J5FWS}>~G7x5S3N4#VUX7 z5>_)qb#pp$EAfxOYpSX04GER#SIcE^!}9I)28NZx?aGy8vrQil8JXy*I~=@L6cQm9 z0e|yIO-o&W*mH;|a>O}6jgILeJdBJ_o-w9;+B%SEuqMRS-1Y|Lv0kKE7nZWkYbZU~ z&byf&?XKoT(Yfd9YTCgv(#xkwCF|bv7B|&XY&kQ?SB@GK%^Vz3Z^rPQGx>!rP*-1QS54u9s4qJCGAls`u{O~*en zfa~qiJs5+m&3-?b?gCurly4z97y;^B9~<#Uk0V0Fdcl$FEF_*%*uC`@Z9R0&4&<`B zP=}$vV_ZyOVDHekq#9agVp=A{B%Oe``QA^7ACAg#hMN0+ULO$k7`FlO z4I;_~sG&-7Hjm=fnLG^`9yBkRjAI2YOCa~$24a_5<6cO`1gh>G%(jrctvt~m%H=Og zE`wh6-Fen`uIumjdC0fSFCYUym_QrPO!J&9`=i5H`yXvHm--9t9v~)aR>Ro)FO8iE z;~m^^!#@HCxvf0|Pfc~L(HChtQ<*9axLL%-xX=Op9_F02X06Iz$F9m&E4eQqyYtI6 zZ2$G`)?Cq4L(|{G3-zSHlHn^uvhFMC-{*umtbYon;cY{MI6pw2VpDzGdX}8si+hO6 z4)70LqWG2#BT@RDrKb-@EmoJ-5~6TWR~NvRv3u@wRTk-)^45GXQRU zoAeU}>;lLtPdlxuc{OYIC+C{RYMQ1kPw-DWR8l4KpC$rH-CDgL<;Ia(cyyq{!3fXK z5~mNh%Ii;lKM{Q$K~!QxNDF%P&ah)nv?M_BxGREzde`6wqz>6oA@`^4SDp+a*X58i zQ@V)0qGHMS)^%B%5aa&akCc%s{KfOiNPUeAq9OKl23yMk>RmWFUroNwXB7twtEa85 za{&5D5fcrz1b=;SzGrIIw&vgRUrk{$Sxx0~-#CPIX#s>9ulM*b z#!2Th&S6_fnlI?WNv>@WApQa2;-hGBk^^HnAOX0pP}trs+0Uyx`(Zoks{9O95GqcS zte>!^rP=fgTrQEEIc`w$D3nIm<}3M!v3MEeAi74Kg>CqqlI}PDa*O*}Z|T{%Sq7Nz zm<##LUB)i7X<>XF>!ep|mK2Z99g)Gn&o`sVl|>$ z>{dzY!R78xe2i0i(>m!%Hzi)$;K{F|vFSVK&JFHhM5F?>2J4r!sxy^+(x@$R-jfi9 z=oaP{f8Id+-1Ygj!zOp1&^NG!$-W|XDgWSx%F3@9j@7w>NQF{~<9e1f=8(VYN zvtX+b=DUzJ9b)tHpxnO_|BG>(VrxSas#4{XbJV^fL8&0PA*=O(8~A(Pf5WvMJz28j zUrp3c1WIh6hu7X!1mIz?xGd4qQlP7P$A;P5VgT zYXeVFVVL}#ck#m~uCsa9d)Em=6}>o~i8kkK(td(TEI-`Q|C*{x2&V=bZy`~`Rko7Z z)6-Ee!3OJ+ok<+$&m+H^+0O>*jO zJ^>g%W#c2qAa)FiUi8RBvvQGX4#S|g4ycEz%KXa%70a`LnTyTh-N(iQw%m45S#4Pr zhgxP6=mFH64|T>^m=#%dY2FR577Ra+zYL-@KNx6FXd$1|v%u{%N{&f_3Ia-3j~Fqf zJ>-U6hMNx1k(r!9gS^a`9O-z3A=eD?SvKh&c+QMrLa>|8<6#*Qfe2Zd)D>-+i8uyC zmoiij+j zLc?%oou=X0$ig=oXyqi8sCp*){rbe`)z+(IHmR{Lu|&Hea*n9iVx+pHNMQ(fxGRKa zehg`vM;D?dtcuh^i)ll*&A(959UJ`2KI?fCgOluroL69FF65?>SlSOabWsN*&lrbY z1{*h9Xzrj5w;J-DqKaFxM`K0s{jSslLrqhw3gb;m{P^eFE|$MR zE|ycpZx_3~8HetDUUU8eH`gxkCze(u&IH+S*1#XaW~+zbKq=%s+9c`eND3c(LXU_@ zUXmi+?-I2KB5h5Y$s~@Wh@dXjE>J${!Kxn!iXXuvt{r>AXqFT}3xSQ`Q)~;l#{2Uk zNAbJHy9S~ysij1J581k3I`uBc9I6}^ZBvw+E}x?gGfSmAEPtX;(p5{KNKNrc(RCJ{ zKU%cUZ5k<4qze<`$TXy}ypk+?EgH~MKDpj6&z^G?wY+JVEITk1t=G7jmRzl~;Aph^ zQB{1cs_VlZ6LMf09qs8@#{2LnCVOywdIM{@`bDJ7q`f;_R#o=wt;1>Hi$GST4sqdX z7F%e_yHev@*n(%nLR++$PS0#w_F-_(DFL#GE2b>@!y?ZX_@XaCdlge5*6Z*3=85Av ztHW7DPz*?)7()>iupEkrHd@X;i?ong!odGs}pTIJw&ft{cOl56x=BrEUI? zr!l(b3EN6xLVA-e$e=p-*Rgqwp-HT;ljpr2^da<`66c_xDQI42s%pWPeFUj#$I>L- z7?!->y8A7L@vt4!kfrl0MOhrTjT(BJsqZ6sUt(5E64;;(Z=aMnBj+-5PAI#lRiHPY zHSpt!O9SZYYh#9kUZ0Ja_}-3qCGszfmq~qh8W^^HX-_G-nKA=3+iGj|Yt>Cm^gESo z-iAnXIg4odieDI+Gx5&hipUuJB&yG0S)76$ld?a==RFPr{Tw`kkXUM5VOhYNXU9dI zC8^VT1TPq)$tp)?9yRfSj?TF%`1T$>?!INJ+dvrOy`N2bs@uMO+VYjygXxN*?-$<= z=q`Q+M2Dp<;*6C`^}C39`a_2kCzVa9Gi6g zhQG&5sDB$d7XQ<^HdB_N1t-49a7Y(qGl^C;@X3fD`{@Z*Y@nTBQeW|Ab4~U(c%cJv z9KTDUHt{#TF|WN#Kv|SNH|?o(Vn|dxV}+rmUsPDt-IX2eJaxeiW!M(Q25la$d&F216gO5T6`3qSLn&fzs0_ zBL2kY!!Ec4iF2uF?=OZoq7P>(#-Vb;W*?0_SsD!&K(%xSVhSP-*~g(6nU#@{YFMPm zUBz8GZr9DVW>uh&dxqWsYN?2bho84*6@pAT7v|wS}?9}YDuUuI+kC^RbQ$O67P%%RH_7LZ> zXZg9C3!y(IM_m*&(nh|t5}Kdz4wGSMci5~A2R%D{ z6Li?k$&AaIE4p<_t)QIg-IyyWHN0{&8=g?x-(OEOT2J(4N9Jq!Z1dd!<`XHuER)dK zhb~QlOV=J|kh3`dOl`WiLkDBNaP!G9!pzG&7ke)uyQ_0l!KR!(WvCW6F$D06N)@>p zz1JnXuPTO(GB)kdq1RiBx*N}Pt}{r?Zt+l_XU=U&Qq5C53_@cX$>!jn(IT_U0@3<* za9D1t%AD}6tgPs1hLQR?`nbzX>SYw9r z-3>a<7^E6ExsDr*JNL8EL-xn{!7`L+)}CV&{J6CWSC_BsvF^Kb+wk&$?E-s)DEE1| z+K76v2l^}1-_G}85!l*gzv4Ou^&Dn*j5jwgJv!iV=O3#ju4BLWn{~|BryzlqtHY!n z-|8Npak|h3+^QT_bw4r<^aGXq%}DQPx@JL)uN^sg3y3SrRcCczMVkn>8hUidm&plm z_M>vH>iWd-9a8s~T`VTSWzCOap#TGFa;c`S!yKd3`iz1GR!KXKRbzyHKJD>p+cTW= zCRZpLZ#9t8F`t{4&Qbyj4qX$s>iT~#%zZH`)@cJ|#vayVxV`TT&^T`5mtCq5waW&C zLZ^k69k08t4n(MXIqD^u0-*d=q1i$bBNP7z%p}g#y)U(y-tG* ztmfxt3zezRhU=(3GPpNUr6F(UNQIs;GZZWQUgu`XS|k2p-Gl#2UV+qI8QsS)9*UeV z4;1-0pbSJ64yL5*239#Q%48Xff3ZWVH-Q9hQ3%VPmZe0*<*0t-g08DFb9=u~$@8d} z`Q#8WysQpV;0Ytmg;=PnJP%eEFLo$PnIeB*Kv*khHKlVD_-sXDhv&(MN*`#Y{%4Rs z+EZ1uWU9W(DH(>LNk2q?8qbe<(~1V<*o^;Kyq0DQesrL{nHtuSYypiSzKaZ`aVJSe z0Q&4<;B;*M?{t*h&D2W{iw+ID|8z6>%fv63Z{ZADg_iw8Ce0eslV4kC|5e?K`!4tN z^>ucPemsh&@SC^jrA>kDzq?<5pC(k9dq+dQ^c&;A2c}P@)QkiA_iU@CYAi zr%S8QO09m%Ck_Ot*cMGN3E(qNkdV$DsLTS-ge2R{N=%$`|L^x>sEw`0MsaUaF;$OM zPZZ~+KR5t-E$3D`gQPaInl%9C@_v{TAWgsF0lu@%tfCOlW~$g3^V36E!((i;*T)nk zL9b=Ne#1QWDTjMos))R%M|xp?SFOKSw>9}g=tYr%w?L=QX8JsIw*U2sjsP;O@pB!>*$*940ZYOUJApf91?0BT;9bQVVJ{=W+`61awd96;bI ztfYq`iK%sUJy(JMA8L3MIazkg=(P!MsT`u`+XdDnynYv6zdg{s>f2GDE>Iq*H2VEO zl|gLeSE<{}F4Mw;^P=IwZ?u6?d$#037E!QYbwqsar6z%z%-(rl!z&RVD4p{XVREUu zYE2Z~WgoeRr4Dqsiy8$-mHj(GP4%7%gUWX86_~;ayu7%7aV74rDg61{+p6DtDi7ry zvV3?&Fge+Mj4I{-RRht^Srn{TmAZcfhO4q00DsXPJLL50#C#@U<5`VZ;ekcn!22EYwQ?5;U428VtVkmpZlN36tD3fL-w-5|v${I#OIv=g=Z?G-rSAA$dVWgYgH(jM+?@AtC%;V?xx zT+G!VFf5C|hrcjIY6rdRkK84!0YjO zhP$9kkj9ig#}C@5^GUjZWbe&i!eYSrCl&*wZWt?a-_if+ka?2!#RJ9S;|C|-?Uhhd zQkGEg;RJap-`JAMRw#S?=l6zO=DgxXk6=+$9xrXm8ix&M?!=driR~cnfd4Q`CxSYd zr3wt+f5#V7tC0am0+M0j~=vAgdv z>(7qUrfe0byqW2KR`wI~2&{FGxHgTw$gbg|{zv3&(crWGXJ53787{;}ggJioBn<@! z_^!S)j-2=nNq~!D+5l;FQO9PcwXVb8l|1VOe8l+F#ez#`-Im@7Ep|5LFBgTV6}NnX z3N6vE&u?qf>TXnW8Y_}K0r6Z5XKj2J7t0;K7=&elhTM8on?<0d-S36=hN{zEX+8{G z$NZ~0(>wI#u+u=x#qCJ+H@}L2LoM7$As!So9wak<&kHa0^PO`Kf$#AhvSq>s#20u6 ztoR>+T^q9x^{RJRHNV3jx&isiZysasj0aow(K=e0&an+oWk^$cuTT_pLZ;mub@8uT zd$I@qA5Ui;*5n`dZ4?oM5CJJ+APv$WATbqbk?xo@18Hg45JX_aNRiGF5)&zD>5`5i zUD7o=w)xz@=XsC!j~xepY`}KFeSfd(IVn(u?{t&A_%EEA@GqYKKigN9hBy{v^&q3jBLhweQ~$|n+U57Z88>*Gs)eTW+G zZLE1=Z51#;NpzjuhIXAe;n#RkS+DWqCfv74a&?*@(dKBu&{WkI3$E_LuQ9L&pdtO= zK1<74T|!RJaqXGT?^BO3or`2RwL=^~KGRC@gL>1GZErHWS4OK&hh*~C;UgG{+)ZHz zgZ4Y}0g*7M&Epi;my0)B$|_qYPmI%oxc=tO3GYlk_J5vH3W(l1Vb2$#{%@lScb-4X z5D(ZH_<}esZ(UtbIeW-JO)#!^Y4q(1s?|>GuTH+pk-psk9FHOHC-oLGS!y8$vMF8m z9Sf!%?sw9)d-|<@*?><*!(Clk)vA{9ny=K2b_G_hZw(Q-iZLWN1Lia~3qOa^dCPLm zv@sT14^{cEJ821obIs8>)OOUslDxW&w90T<#dCN=(^Wjg?eLq{nRC@n^OuZ>bLdln zlRb%=g9m1XsVF5k`^1Djd~cG+eh}%Z1@US&NGP)4kqJpIK(<|ow#Z}LNZO2SB= z)R(S+eB{4JWd%4@!GqcAD>AKf?f*#Q-y4fl&f6ojbx?&L}B1OsmItbUr z4$6dm?e?kAG$H&vG-Vi03;_SlNLl+wqD|v}*o519#i)+!uJbRHTOlZ~Ww*p~{+INIol0qqG6DfzwTwzT4#P8o zJ-8~}b^r$p2zrh|Za!4THj2J}t6b}E_)JVV|8v2P6<0oOqm8taZOOLKlbokLA15%w1euF# zph~7xf0n%L#dua69QwCbz|s;(aI<4zV4f6RRJ%lXXVb`|*7sFbd*ACA zgZkQ^Yu5zD_#N85HnP-y>?jOS{4DLQcUD>%rm8&v>VNhKl7NTI*6Vd-Kj zrp~*3J|A1&ma*wKc@?B1_z8I+ZCBy9=5yVho(7~!FoBS0MDdQ5-3Iq$Bl63Rb~v+x zbQiZSM!cEC(<9k35u8}$XlT5CUv(}>p8{-hd2imkJ#^&WRZ*uQ*yiwat7nGQWL%I9 z8@xR5N9aA$8d%LTUPh}#=)WYbuM$594U~It=b)LDFSj5$bp0}1>B=>g zGgj>Rg!4ls9xHvJLxAU#x-|*1{P{Z10Q}gvOWw@Z3%`TMDtV9bV~$(5rLM;5?Xoiv zYR5{w_m;+0_6+Xa^3;BC7v*8hBG#LO9TakL%*3zxdxZ_Kh?1_j@gSU! za_X!%kP$O!+c54dp4kru_i9(i-GDZSF0%dSO-G+J)u>@HmjpX|>+Rnj8VXs8fL=3& zgN9CF97W?r^-!C#7}Ss2l>T%41E5OSeJ^ z)aC}ifgIxnq;LLQF&Q}7zuGyuE06-tVrG-#MR$oF`RNRoynJHfCbjbQ`LX6oQ+nET zqTH6E5~-uN*elC^uT9OJ$j1{=z60+8+Y%39U z*KUT5)A;I6A7?)@V-VV7Wq#26k0>D+oLXUiDbc*bYoOuu4!m7{1gqEp#7Skhce>!9 z3uJ`8Seb%3Bg6ynD?^s~-bx1vx_kpptgff*a=fi-21~k%ffB>{?-jNzP7hsXSy-m_ zBmVBMA_=Gvj?dl4Y9oe-kb!kZ(+w6)a%CZYRmUUUsYnhd@y$uxD6-E3e zFMeH-Wu@iGMZC8Vfzvn3YfafL4!&4C>9jv)FIw( zvDCi02X(^x-D1LA?b{?vu#EBjp``+RmqVX)>YZz=M0TU$YDZ@77P)1;R^x)k{(J1l zD@{Y`woe+nsE;<8w#7`ZxFd1u%eC{%^H%5Ta+-f!Y{O5KZdQKhMX8q5*C}ivi3sCh zmTIR}8jEKwjlpd4=6}?a_mHRGdQQ=KC*WD+%6dkgoZY~iDIx+p^=$ps*I8GB4CKaz ziP%|GB&7}UF&OW-D>CS%+ocyf2KW24+3sqTU6@9?cbu=5E32{0pKjf4GWH=eXo!O< z{-h6WG5U|_{qV=MERvyouR`j7L}2d34K-#hHRl>zG%C(zguj8r#Tt9 zC`1`(d!fIQ44RgIIxhI9fwdvVX1K7Mt?I(CpyR1Wae`RACM%l#m=@Qvqa-b1T)=nN zjBItpsy1k07R>|0?pjc-70n`$y2&Ql?x!C8ImD{`h`3_=G*x9Y9?d=y^w?2JEBt`Wt zIEUhE+j^@e;dUU?VfL@=a7M!p^qtp=67WGsE_|kFmF1)u;6G{ zXHwFNs@fuGUM=?Gv>p>^g3k`jDG`BU6~3TOIv4|1pwwj1M8tqJpJFKdBDo>tIYw42 z;~c#!E6D+IryQ#juY5`{$G>ieG(e& z7acTnE18NnzbAc)^;Ac=@QS}d=E3d%5g~S#_OzS0S9slXw$aI+V^d3qXu_%+XgqWp zv*2l3EvS9%?{P=2rv~;inXKl#XZITWvdJD<9~a&ck!c?e9uhti8TWn@%#Hif4;h1(CvqHveW@t zGT7ne-;jp{=4}&3R>?#3%=`48S*$9M3;O=-au{zfnyFfpO_iO~Sf%Zj+siA%6UI3N zE&*RA&xrm=V1v7ERlXmWRc=^%XQ{IaZqc5$iR-{0IO5@LE$nCde^d1QHI1&yF@rGW z9`{^g3 zw>HYT6z^DGjE&g<4DWrmj&k|DdG)3-hq!{Av>Wt=hCJ>&>Z)B_FLItfPeHgWD>>X) zrCCP26L(oj4rvEmKHW4vd2u5o*9wzx#>Q%FT7O;84{Ho!y27ME%*4M_dyt)MY?bD7 zC11Oc$zY?I6n8T;mRz?P#P@yINzG%bv(G_WPHZf7v7DEiW@cOEb4Vghwf>)e0Ge)j_`w!JEkR#Vws<$B)WR{DzFzf2>CZjAIOr;ql2g?oMcC4C z-Rw(qKsAekZ(oN7DX-SqZdkB9*)2OUIs>9vun~}JiN$->7 z#PuU1{Gu?0zUepcoJ(iMM{I_~inng++Yh;h3DB9bAFfsAIl$m)`7k>7(p@>+2p+^(topR2$Gzx|s3OT|G@(|WHrV5JNx|opS zXF|!%%B)277cr& zrAEmbh}00N#DJlvKjcoqxP}0ihTTKO``rV1NKY*tvv@_gaq&@D527v3Le_0>D6e6GZo{hZSNx=_!L3KZI=D9MHBb;QH_e`09cqND zcv8Y`HW|ON@WkI9{#W_~CC_3)o9(I1^{gj0zO0Pd@2IP&(Ec=fgV0ubiOMl62t|pX zt|z(|4EdF)6ofXbrbS7K*K?)6o>uc87eQ#LY$?3@4t$i*`}XCJbrmW(C1OnygSagm zb(q5`s;`hzTS^Be&SYX~9$CDpH1+H#GF1xeuK!>kEg%O=WgaT(Fy!#vh&oiWfWB~F zJfuB9d*n1<{AV9aVbWSc<3orH8#>~9+~vK9m21?P`mWM6 z)7^0(EXM(qHGxPE_YT-^I1$mcQ0Fky%Vfv`bgDxBm?BY1F8q*UV|^s;30K ze(><;&JOh#^G_)a)rscthE_;p{gwUwHI}}o8eV8c;9?nXqGOb}qyPAEyg@eVWi)2( z?g#nyH{TWoJ^1dj++htEs6ARjmh5ZVYR{$B@GFvOy1TVYSu@yy#}}H04JK_TD~PQJ z>W~quCoDzyeDe!g_EI6iA_0>U@<|ERQ)uT8@_25loAS;E?8 zl_rk`>Pl(Gv=v1PXmadg3Nxf~1aB7H?;>&^lqH*bAOE#S{GQ)qt8`#l@&NJ5U4#3{ zM@kea!v1DlJ*^sRZ^eFj+1^@XV=`+9?b9O}5=DIm&PbpYyWV1e-PlvefkttrbZn+r z{Op#W=tL!CUAVBA!M{JtJ}?|>I7@M^*gS6#*tIeZn9gFL4YuW5C1u4z?)pY8XNT7q z6}0-!s!!~7aC8G^VWm&W&Z8d~*uO1d)>$~QE<$`os+MDFK4!CjD!yFq2c88)m#by( z`w(hnmAhC(u&$l=7ppH~ugoJ27TvcGMP^ZoDY9QicPT1DNh(W z7eg@?%s=Qf-rJ1>(RPscWR*xC;&t6qP>h@80!LP0dE%wkNk4v_C3Wuvy%DG+Nd=Ua z8po>p3w2=ePBqWK$@F^lzZNyrDu7ZcZCJChc@3RRcb@+79}x!F*%B*Tcw;@rF8Rx& zQ*b17a{7?Qhbz{B+wN5QpfdW1gkCNPIyH4Jd5w!0{*uvH*B>Egj3f%u(3zANCcAK- z;g9^>T-?)>_e_>$rZdEe>sA1s%!`@oBQ&nrj`u!4;% z5y>@;ReZU>!jyVbn1w^5SL^#KJXE^fZWEQ1iE{W-V#$y|KXAuz%u;k)uX%6L2k#JM zK>0NPWk8h0Uzuq}b?77T7e%JHE72p$4Tvt(aBGE}R1LGdwD2N!VeCdY?knTv54|?p z-PSvTO$U_QCXZuM#$B3vd)&!Z-GK5o)bJ5pwLuoU*&(Vi-mo9QSy4}IC7>8s%N1pp{DsVMEK+_!#J`QPER!o>!2PB z8Z-Dbi`4B;_yCy1H>)r6ot2r*ZKR>*#xSTZxe4u~60v;p=w!b5l4+dA_mubTrpEG0 z1fE&aX{;ah91XEYufbq1Ru#jI_XNR zca*QxgM}BJ*Q5;SNgCEe{v*5jg@twL!~>^>tMTQz+pm6z_Be z`SnIOnM{LwvH}vLa@ACX#`cou;aDl z*^}|6gNT{QYs!n0ac^W<0{Vm&yWAdaxCr@<501eZR;Yi9G?*dZ_=tPscSdBm(ZX#N zl%)z88aRNmoZlZ=?-@VB>?>~?yr^(8$0}v#l_<*x4T*lw-I#SJP@PsNm}8qTBzxkl zIwEl{cYqci1{fYyfqofgg>{J4bAJ6B<)JFqCxL7G*q4za=S=6)dPq4XB6SVbJP`NN zFxAGOjwR^uGZ_Wy=X5yD`Z9cu8?WzafyBvofO9qCXYb5N_p$Op4e@?TQwuD3CUjbG zhB_H!*HtlVe=H=?>)Y7FLVu_c4%p&d#|D|6i2p?;w>Ca@`r>&4GBb<;>bk}i%Dan2 zp94i)X?z~O=14eN-Jtr+eUs-SQ+jm5hQo;jc7AVg5XeQZEpq2P%LQKzq|OG$>{2Rf z|Is61NxoF3zbFYGD3TRqlsz>-78sV!w{o7xOD%c_D95zOT-0@G4_GhHFR+Y*kiyn` zV?x&*{CB{+f1A5}Z5-pmk3P<`yZBfc&m#@IrJrh+i0^g9YLGIQUJl5u$lBTJ&aCoz zdYjYban?>Y+&xg_a#IvPw&3Bk{8N~~D>kQNFQG<4!p5qr$`v>D?TNkIYJS!7LN$IZ zVM^qpMRSvXy$qMv^64T~I&}F;5h!&1ldK8&?w%{mFe!04tm{{GhMUHDA}(H|TmbLb zvjKEwQuov$HvO;GPV>1E5(=!=&ctm?M{nsAzzn|fi4^67s@bpgM{V-W1M$X*g4R9=H9>;3|n! zWWGBAJ=zWQ=-#|l;@^UYrA4+JzBl;o$(70e3qC*e7th$jj&uv0urv=!aRpKlLwts~ z|BYOB+zy?I%YLcMO?&C2+{UtecuyN}u+bqtbZ_ikk#v(@8|)`i;%_%{ZiYV$V9aZtkskTS5?-;%R7T430eL4zE_tS4b4YKALAi7rh+KbU@k4}PYD z`kT|ChPKR+v`hbpZWozCqFEj!#}Y6003Ed2VDu?E$JI*MQ8G^QwsZlcF9JO*m1_DZ z+ZWx|dMsJk>T`?-FFhFl*t7iRpo_c4*GHm~hc(T&__n7G@$?vTI6^y3O>?Ce<)QPO z{ia#qQ%nyQpd~$nZpHUz|2B-FJ__BlSkBFDIE2LRB8_&-_VogKJift|C06J!0hP~5 z)Mg#xEJL^i2>VtQ>LV0&Vf7d*3a;5ix*ZuPZhMXu5?JIM%iEqjlMlw6d@?BNHS~_M z|Kf;lG;V?|TWj`t;+;572Tgw3B^;gUc5i)!FG@kS^nv*V>xv@*BiyNXAPswuALj<# zYAA|P^+R^9R82=acZ<85Dozh5g7|kSiA?awW#zC3(M+w?bJ^EWZcu-V^v`#31Kj8O z$Tj{0b@dxXzA|lT9?|sGz8vf$1IIf4t&OL`X|o$w@Ki%*dI|ZNd%D!DPX(5KHp&L2 z@p6I>Nd)lx$JTG`(%Sh`{#M*qfmL+1mctSppXqEf>Ju_)Lg<=mzmRx{()5RUC?Kx|@1j(#*48m^(k zj4$*X0bUK};+8Uab~E&kF+TM!%og4R&pD$dXHJ?bIIG3ZynYfLkT{oZQW<@cb7L&) zHXj3qM)G!n_LYuqmyX1%@n5THFQiL|PQSf>cbj)2$F4x1zCebEmZzIHA9yy8Adl7! zahw+KFT2_|W9=VxZoESHG`~r;=794M*A#g36-S`5l&!-Qwfg|G4MkyVZtJx2nUUtl z1ycEESg#gYpOs?m1LEkKm=c+lD#)uQ@t@nc589Ahur_ejiW4KMb&qznTy}2DM599} z{CxqF;^2h+`;<rcRI1kJx`inkj=}M{;*8CxmyE-Hi>R>-Ea> z0aowAv!{C{DZ45P__}k9n1(9N;SReV&@2E(X{0#|kiWG2Vcd!dXJeT6@?{|Ai8mEkUZJ5P>m6vJD zkp||>c-om4dtwj*U=6|l|MBt{D(57JK78V&^va^V#DOuhqA~CH)OoGw(@K?Q1)*co zT;^^??QOX&t+PF2gM7v~x3mVEuAhBsg2VM!$R)^Q=;Fgb5z@%r7l@@5nMOCi&u-G7 z{W^X5Yz6bTCdaOEa=xAzDd)&@6 zU8Z-+$W}KFTgQsr=D#dOh~BlG&bB`T0W&_$lrM47L6^XjL<)xj@#_C%_qTu*Na8mNzY2AGfo%{i z?qZDgldrRJiUG(_uP>9;$;Pu@gXn$KsT7DG#(p~J8;)|ha58XaGC|l$)zx?EToGyZ zFCIR^-$hG`wO^~b%;pDj!c=!EPj0l)u7E%UrXDQ+a(8|~cY4izk+V|v*w00sWwC{Ub0{_u8bxWHMKJ?TkxY3<+50Pr=Lz$$9~+kfwu0PkLYKY><45e5 z486_;J^uT2z!){+YjpDuyp1b?s|XS<|HK70YDe1GdScRrbs>QdbQm43bK(;}{zBbf zl-Mji?CB&wN0%95fg#~;4IfTms&U)E_wnHmz<2|pe@BOF!ZGA6XXws=BiCnnX1n|y zae|Xzq<-zj7&mw@>}~6Pmto4%*Ob-dKo!OZVt5DM*Z}Umy>;yut!se2F-!mZm@^u< zK!zD~;;w$;1b|`G3{YM1^}b4ZJ~T&`c;<=3Gw57kZr~375#4~V-4Anj-l9DR;@!h@ ziwtU;^DHjl2H7et^l8jRQhW@LZz~a<*|H+~WDUnHD}~G7O7J!oeUs&z;C}m-+9W3r zp*GiRqM)uwG!;@oOl%tA!*gEtIIEf8WU`80daKJ5W3Ef-jdnEd@dZXubx z_xIBvK1V0=-WLaYS;bGSfST`>^m@iinwfo3OGSFr%GU}`nx7{HOBq_~54^hQIabFq zl%C&v`+Pn8o!S+Vj)p{pX48ve+eRuOVvY0zuGtPcy;g>1I`w@9)o@nd?Lh4Sa;RiGVOxa|)5{16wFf%3@A&x&AmFW&F zt-BRjuIqc(jV4+Z#CmELAHNJHuPMo$EU$6BqfC)sxS&B>enRI`*=KkBQ_(uH!)95f z7xr~nbEK6bko^L>B=l%-Wu9HRt_f?~?N<#a8G<#}Sk$<#k0FJwH<_7ITCcAT+yo}D=tLc8HV%uIV{6J!eiBjWaZJiGO% zKa(Wp3bdiY`g2W&BOq!^tbLTMF!vbB-0$FV-6vj9Yrn3&W0_QKg|NQI>*mn*zs6UhU;nGxNVy%&NJPSRf};6E{X*3N z@`KbV8)b12uU}>;B6+>oUmo9A@w}Bry~ zS|@5$Z0=fHO3@IfL8Xg?)ZPiOF`x=I{R;kw-fXf9zO!TU=*q4nD8A5W*suCwV8I+6 z{kA{xuv+Q~h)S7IrPc^K>p~vUZVWu@=zdFGoM_>&k4l-!&|AKZ$xTcXGh|YmYTM9g z0G+jK9u^bYzLj(snNgMrf>Gc-Gf5l{;xBY9M1@1{^N$_t24$VtKl4Oh+x#P1*7zgE z;N+(~^$awY_IvX~+I0^;)$|fFrwq7l#rHbBV#1f8}~4ap~tdr)cd6)_F=|8vCrXEXTk!6TKE z0Yr^bSX6J@jjPuNGb_@79Esi>D-w^4CwUF>s&aaBq)%He8BVuWDen>pqRq%2m{4#< zsKaFl9sRXg=|7_CiBkf0b?Rs9pW`Lx#V(Eaj{bSFERVmxvrnNH6W-`o*oH@(v0R|1 z_)Yl*M7#fEY@N#mw5<^w^m>Z)M3g8P^@=O#x$56(X}~}PMgM5@Z*2=iHZ8k6-f*Hd z4``-?Ul5xG-+b~(k%%vrh&YG%`?uk~U!@ey+Yf6lZt2&8U#Cx$ts=q@N@$KU_E?en zuZKjm(2za5_>d>jRV+lr<$kcn@=OC=C#U2e(b(x;WzW4${;Ip5y=-ldrTO(#&=pP( z9glaTc%clJbQpZ$IT&T_{A;D4`!)&vDY~QxaN$;(u)Ih4cq=a?GL*QTn%q#a{ zcHuv~?%r@XcuGTJ|Cn$_d?bk;2-S2ma&a;Cb?rc?OwSI}_Fj*CckOSDTh%c*H_!IM zI~{|;W&3H48&p_Ih1v^U=b{~Xo{S4bkAV+TlQ4{*&}n+{u#N)P`C|KKf?rqQ#tJ$@ zo*kBULcP`F7fZ75erP3bb(K>T-h$^`_EyJQP9am>bbQb*Z2;NNf@N;cg=Xj*-mHEz`DW~=g~xNlfJ5k`jg_p- z<)xc6J0#!rRaZIenubizh4c89linp7-(@$QSAdnhIZNQc zQctKdkln$x@-4)&Btrw9Dpa$?&d&DggiV%v9xyv8PU7T>Rr4+o?at4xM3qxSm%i`z z!gfk_`&G)gKE`cS0%6kaAbLSBN=jpg1+m(wPLrpVrDc+=scaOYvXPz*qWL3J<(Lk< zm}za0?s9E`*PUNNTq)kKeMTv->|nbCCv9hF^-d76XIaSUN^>(sVu+9WAgpv>`OEhY z+dbL$`O$yj4{_#r*l|}D$-9?pdbM?RGd_>@))f~d*Wjn$=CHEV%}v;9F}4o&kg}qY zP%Uw5Su7-~yTG>c?-04eP{^##I65R|;SG*CW+Z;oX^C>?TJsAfa;Bn)_1Dzyd%=m- z3bqGzJIp~vvLX%3NrrupnhZ+GNF4LG1Y?^qn_7+|pO<2iNA3{AHVp+3la~gJ4o?XW z`^K|kW(Zg&`Uu*Nx-68mu0-0^RFEHLcuJ2PB(1o6f*YS##HkDare=6H@hRv5pJNvL zqGeGAEs{R%U6ALw9>MEGR-%3S_e|^eb|w7mAOF^IpHUW=Wtw(-M_N86ZyOITWiei+ zym1lQow72n_=!eG_D{hI52i)ab5|NY8pGJFA*uRfe9fKRq%-~zv4b<>w)@a?oy~^Z zsd>gdMO$CCoLwKZ=aEsCoO0KfS3L?aJUt+Gdj{gj=V^bdn_Q3D8WoI!NN$YaW}gjy zTjfG=+SEdT;Ih1)pOgv)L^C34ckX{DV^q5?&2nkCmg6rO!MU$O?wd5He6ER#k-rQ1 z?Yr`xz3$DA6Yj?z{9lLtMV(UUKA|jFhN@{&>?&Fx0RGOugSW?zyyXy>vriYz&}#Wo z=3mUHx=)1Mmw2brN_u0 zvm>`yIO<*ns4)uR@4i6){$liN$!7d!&IUbNu=TT)VHuxl(D2Fg{4Ra@3F_<7t1jD> z*1OwM*A&f`&-|@cz}rsH!20fYY0|+5Or>ykNSuYm+n1~aeUF7>?P^N_)Wr8VWuD!l z6N$f=!)BDP;q@vY_n&#J|7ed70#Hc7z)fL~n9Y@C*ClU3;!K)p zEN^=0Uup8EQt@Ny8pGm`LrsbkE&r^fY0Bz4l&V1QsL*t~s=ZAe6wes1G%F&@$e9ts|=bj7>(mVsz8O8}yI`-?_fg9Bl3qtmYbdVO$I8^?NYp|8T_ zZ>YIS5U{aorac>K7)NYTbr_)cY=NzQl(|JiNuXcGFCO?rauP2M<3mkm1dFd8)mfHR zz(=_Z-pLdxf#M~4(%-dIe$Eu!!ERk-#YUE9{~Nt*0^p5r@-DgDS1}?ZSA)-QT=F;~ z2e3Dm1Tw(pM^wP~L*V*4FWy=d6$T1U+)lwNv`gaiC!LAm4M(@Yxo8;X!$1`1p%bG= zJ~h-w){JsrnijiP&B7Y8&NS^6>Lk$=`PBK=9h6N>C@)Ff2Uf?L5Lu zW_F;D#kfFHfZoTmK9XwhaUz5*%WVSdwQ4}E)>qWHQ?%{4!D7f#2Prves+4O;Y3RK3 zy!sO>ZsxmClm4=tFucfVN+cjC_de`JB1Gz0II7>%huS=G!N*T?eb@0fqf5G@r;`&6 zH~y10o1D$!LVppV}- z%CFNOo(O`+(1Lby1h>$m7u6Ry>D->anupoO_oaa%IkLRGhrXb%Vsk7SMiZwh*~qJG zY3br)YSKm5kcV938@1~eLs&oyZaU6i#}A+)BBi^XiVtP;C>gfi^n^pH30F$Qrxi}n zAtwdJjG2p2)eJcKob=Tr;wy|GU$&gILW7+rLTzKTVLvke;xp)nlR>Em5UNp7d7U4t zd~}AU#k4Rv`M(G#XC6Y^H0=@HPh#1Ya+5P8ooV^_(>&!N_xW2s39BO3B~06r7+=!E zzy97*mOARzt82*|Ahtf;5XxJiSCp(1_4`g+;0G6y3Rr%osA9rkK&)Uck$iwF^Wz}D zb2J5(^b??uBqx^G1v~v9jVivuLy8x-D29t1KrBOsc&5hAf>I~nHrcKX1&B|*C)28^ z5DjM?Q7-pCdsg4PG&^2&vD#sH*%SZGxQ?KBv!w-V2C>6>*WcJosUj%$p(CBsdnd%N zbKn!^Fa8mo|3*0NXGg?eT1Zc7;!fj%VxeW!^a}F}i}>5A9keRc*16rWbJrg|v9|SV z{D%1Q6{g>lsX*qi0F{>_ZI;IiydC;MF&YZr{I5|g;fGfoO~|e@WrBq`e;K8(TCWt2 zf8%THZsK^_74dS?AZ3)vq|!m{G(-VuJ|aR$Ug~C}7Pr56%a6Zt^<-80N0}|%nO!SQ zAev4)u)1hlxyv9UCYlmu%4*F}Hm{@kWC*H<@3O;vl)zhC{3yDs7eq@~UE$Q|E3Rl@ zQCN>;hqi8%B}n2=)+RXCQ;GgqyA&0_0F)PR(Mwmy9*K$D!;2*x$?*(38tlzd8RPC< zR(gmOwXs?Cd#6daKnARaTXcwW`Hs?;sN(jmvuEFLY(5fpzrt+PhS_)tJ#_GQBugJu z>woOOpXzyKxQU6iGet5wy>0cfKk6BixQplg&yQ883EyeO5l0_goE6>qHnixdRXs{p z{#WPHx71XT#DQ}=aO+xCh4fPs*a@fO2#~^`+@--oEjsKa=mOwq@01`RbO#DbrsK?~i!?82Qtwi~8f@&g{7eSOJDd$uYJ#29UMm z?918c848~vl}1;2QM2eb6>w1vXs{!{-qG$7^12dzR8l$U)c-~)P6m^?b8IeV7F5Ll z_w&8%_8ZJWeWz#@cEsO6Q=2L%^}Nu-P9h+$4tZFTAg-LSxzaJnFXQJU(CN~%b`?Oq zW2(d6#jYrt?6pnqL6#HkRo)CXjL|aQPW26LSCe)iH1}V7F_&_CHl$BX(4%&A`iQ$i zrujPf`aR0zUef%!4P_R2M_R6On}qddfp!|zx5s$}_g_Vymgp;LE4(@@`luu_mPJCs z!D`{C(K4|lGyWNxulMO@)}vtFQ(H4FIsW-pd)MHrl)W2R3NVF80EB$Y!hERCyNRZD zzLP5Za!>vqI&a@)a?RrL;aT%4h-xbf&_h=?Pu0|q&5ThkC~yCUhdr>(Y~7iPYKbHz3}Y# zh*KE-wHa_Ckzq7pa~uy*C6o1&W*oUdoZ9>y$|@RT^v<{c_NOPvnl*ue^p+RtPxeaY zE(>(l2Xg<_jyh-ed#M5__Mr%t);*zXg;Rhjw+?bDF;Nb%2cEB_hg=J~v(Nvx@`6Dt z#kXp@-6~ms-K4KeNc-iM3b|XRk30h&ph@gQS9tbeoW5L(1g_dmi>oP#^(`8B*$eRB zyx4q@FyP=qujjl|dVvG51S-oem;+&Ik>ten{+n#7CYjnPaviwf_JknK+oig+ubWJy z9#==t)9}OX3o=QZWavi+bOj*S{Z_%@!da6=k}g?U)F2|SX{{A zYlP;I*ePQyEoz*6{d20Hd?- zKcY6uNb42l&*_>IHUcXGk0j_wkdn_HD>g7l`T*b9 z9G>7ch*9B-^Op-xL_8m3eD21d`lu@}zCo**H^SuqjX|j0avqPr*q~Vw`M-#G6(y)c z&TXGAcGeFzE-&fDSLAr5Y3}CF%2JmxYpNQ#FnJATu?L>Mliz`{qkl^L!3@J5Pr@s` z94e+)?!tW}uRk5am$IzGcxR2Zyh8=Q!JCMA?4Ba-gf!q51MFGEK0JbB1 z_($|icM>TAJ>x4zC5~KV`F{*ngkC&6L zF4QZ<*K&@wD~WdBZq=92}z1aVJen%cOY-)f#Br{y?~(Jsl2HYyZaT8qO+dHclquLv(-Xpmu)m4 zmyzB4RtMRC4hgO6#dSn|kEL(%svd*Oef?ye=B#qte}sl=3zA>8PU9kqNOZ_|$Z>vf zoW=jDtK|q(t~bLMU~pG7TNgQ0Ra_B9&fyfVDCMdC(M;K%mzg+AL~~a4mo)0PreNXI z;@4%(nkU7GY_<9i1*-82-yUCK0y#fe^V|W3l&Q?T)2rh991VBm+*N=r7Y6nxKhQP4 zWsDrjM%8;igc0VM!;1P^>LMq^63HgmPiCkeT;4J0iPop3ozP>IP7;f?tdJ(bgB7|6 zW_B!dfh$*x_>pPX{a048wqcd5sz$cJjpTh`Fg8RCqUfuxcHvj*yFT9DB0sfSJMs5& zbxc9_?Ga_8tEXG*)3+VjY^J~E=u0j&DX&#<&#+lx#e=I~D{Lp51n7;z3Y@AP;)U+g zxaHgv2)EO^1BPGsTpr6~S9!x9SYG()C2?y@GN=g(r#B~AbLYDj_U)!Sv!Bd`ci^lC zO6~M>P&B(^fkXUF3A2MvV_PmkF^3`J%Lb*(cbd!(=VEL$zq+6r?zsxsvJ2fBJ$hof zd7Iwj?k&cuia?=J30mlhv8n3MwLOM}n?WNPXxuF%3TEaD>RJF_*!s89$&%AlZf$Ql zdkiyW%AW#07hR9rqRq)YJkxE%v{xSwOIJak$DZ+@G}LdHjlM(Rq^0Y4Yn-e2eMMDr(vK~qhixBCO6qdh2-j>@DB zSaL9%$}=1~hEk^>yyNlkL!A6!Ya~@v+u7ae#PwOCeC|dinC=Bp(9Utc)IKBcz*b$i_)f={Z%KfJ`#4q5X5jgKQDBbG?cp< zyhFWKGL>lgnagvAlZNh6&&7XHp=R*bdMh%XAS_ExJ{%lKP4`IvrMeFrmpwM;TM~}{ z{!wq^77xSDu-f;kxq{7ii=3Ui`r01DymE%bUxwjRO*4^djU}93L3BZM^YEsvm`g)3 zLhBMp1U3v4!h&K>LRXrfjGoMj@Wx*B)0>#7(Ft&7gs0p(-|OYTlw1%xFT~nCaMr3D zqbEUpzGE*LNL*N;H>q5iJcftX?;b5ZK8C16S%_*zPLwc#ri=m=nRHjrQdl0|0h~Nc zNtepkkr#<$E!NoXTr|$-A{dw{)|jhdUXGrmLu$9_M4cZ%&sJ|dT%bO>{K`r`KdcaK zICeN?NY=y5_hHr1eM;@TI>^8AeS?pya$ClRScmpm>54zMlZCk~9&*wEroFhYfn5yQ zxTvK5M`Rbl7VF?T!=={}t96ff+)vwp&5{+A9M|Xsi0}_4)|?; zB!$CBo9kKRXU+yw+k4qO-4opF(hS_+w=8bw2b2DV{K=xS@x`?Ye5gzeY#RR4bwF>b ze%9Dn+xNlH{usk-ClzQRp`S}T=;~LV(Q4`(wCH2$D|^pbjczK%8CJNCjQh)$SNPus zhQ)|kfu7#ABAd46OgJw&t>EaEik|kKLQ*UuiRYw%ReSG&M>Io$OR^vl11+`J)wpgw zwF%=?RPB7wTMc94`odQt4|xfjCB9wm{mQRmBcNOQf&?R`tbAIx`exYy}Xp?c`&qLpcIWrPjD{(eGf*hQ2LKQ6trA0kmJl~_5D%WQF8&{p-SNedA2gOxO2S_*)ji5yp+3y#Pba8xK zW7P)NGv)weRLf7GMIit#r$lf!<&|8ty`0yy=P#mDCU@emA?c;fFilIf^cylWwpCkkQGwN{5#f+KnvT=xYGmEuosOB;{TQ;UO&ji6)>*u zua_+-v;Os${mok1uL~Lsaw0n7_Ibyu_xjwQo4oj}YRjf?n4r4LpUx0f6prGX%*lVzrsP-SoSkKnb$^QW#E*qf-06BWp&_9;+iMMD1xInJ)Kc2?J64R?dhTeC! zI9!37*LH#0@qYOUUo=#Q3DI;b%Lsrod+oPHJk(hI)+xr_a9cjyyak!*&)jsqPjiH{T*waWR=#=HShm`c2KJ!^)I5;ZWk z?3&c()3lmXDwMcuOga84&{d@yhvoLaq8>KNy#O{iXzA|I$QQG#`7$0#3+@HoDM5{n z`Yd*JOlrg(-l^E@!dW}e`OkmXel~i3pAH>;4JmY^A?$^*6JRlNxN;qH=){O!_Foo? ztS)$tvlk_0xL#L2!BODI&z`~3+0M{2O0q53;m7(PPzgbiPKk3MNb!?Xoyao2r055; z^!W3`aq&*G27K{y;-1=>bfJ?VC9@P&S*1vgdd$yKB{RDfdK#*{mRjFqZyvp`)H)E= zA8YKEsYiQy-_~&XSgKy*AIf$by?WJdT1d>?|5ZQX>6FTM%TMC^O8dLg z-QwU-H=@TI>!BV%cSzmEgTj3GI_nSS!|8-U((kF{_tfCP3Te?lbRl-`-;`fU(l9MI zKl1r7f00A8BQR2DqG>9O5*O+aHTS8x5Kr7K^{8)9`sKxWwlZw(9N=pgY`DVrc4|b# z?G(VIt&<~soS%5*yuV24P4<6r>EM!mP@xJQP!>i?G{)PiNa9B{rO`~XisD6YH#+Qr zmvZD{q-+YjF1sJs!xg^azjtXm_3UB}j@>1|==Jn2RGGZ_<^K}+?P07&%Vgg3E@7hY zzo#+Nlu&W7xG>n8##!k9`BN1;G)lz|XIB&_k==>+oj>4JDk^ZJmQep~EZjeaL&O-I z6n!Tg(M{5DATXGi-w%x@y`QH^6&z+(EWabcm&PuOt`~}qwlnCmdaGa|bw(%Lo4p@o zYI*p3U@WKI;`k2A={rd4DmJ99|2tE2@1lI+hA*622Wp89n%FLGi2&JLAb`w|6j-D%Fl}MXuyCB=f|u5rRRm@7Ay z`%?w1f|nl$Tst(X`%91mVt2q_k;c~fUg_7!5p8DMJCQq*@NkXs-Jc`g2WSVxkl@9g z*yhS$vKyU#%yr@%UlH=_VgV}7ilr!(l{^>%@g#F_dvmc@UcJtmQ_wvx3PN#;;mpR# zN{{>S1$L4jcM2_fH=P)n3Cx3L(fN#+4z80fd<3)nMannBgWKL`SnJRCROnl*j~tM0 zK~l{9dz@kwU(XJ1A9T#r@~)sPchVKh-&44ze^s zmtMRhP5<=|%blt6{NA8r;BikzZ4Gfg1GY}+pS`Y9*Y;}SpkE9kWEs)a3TV?V(b9{Q zGR=Zj)!0jaMNTXMyN&GYT&rVcMThp(2mDw!1wOeUBUQ`aIzT_rfKbEhA(!D|OMnHE z6+4SREG~p1P}hq}mW#AwlbT7+RKS{MX2{0nm9#9t(s88CHOX`JAqX9P^3mxCzw|cJ za7z&UjO63J>>V5BbYkvDhz`Q(-Lz2!7)_&gV%G!%!K+LFq(Pe28^o+OzATq{z!; z)!!k2D5NQKLn|g+7GdapI4f?RbS1o`2&U*B4s zC?%P+H!l>GF17n6r@%u~>{{`XP1RHSV1SyJ-I0ev+wC3Tuy{tmF7%_=@+0GY!Rqcf z(RPInGca4R3ANvpR6Vxv^B7>2kC|4Q7j0ktvz>nm!~p-p0-C?d_-{&aXe;XPCsB>{ zKW+l{4@8hyc0+WR34QV_1rx z_j3c0OYm(HLgmK9pV%w4A^&KNG2Qssb8p?dSC!=8Bjlt@{egiFIl1<$`PmZ`N(Be3n0boQv5!aC&1NpfY2utMiQ02ed?E z>DRD#{NogV|0~8qUo*!asXoj}X@3u6TbM1n-P0O5geDwq*z>-xx^4PXAXZFZZCjmc zqsoRS)bK>;Lb*Vj)y@~L?F8gFQ+kKIFFN(KzRNIOfkt_T65S33k}(b2boH#uhMkR0 zQa?ND532?0iZ?y>IUohyaV<08 z5SNdkM*9zq!#|q;E_x75k}}}i$_xuvZtZ@U57umSaz&CA$NeJo$3R`IN8*p_2=J1;hAv+wOI9!E*m~#hcAAP7Zb(hhT|4iw!B4x zD|{(*&7mpSHr+efT*rq2V6S>XZ|!BX8cR24H~M{u4*R?1>G^A*SBoI&uKExNkN)Tn zE#B~`%7)@1djC@8ylKVy`fd#(JZ`CG=k4UVYBY`wzJiHp-zUfyziegBexQ84f00`W z@sr*Df;y6`&*oqc_FO@90Y)imFqx3=2e(^BmPBcyY4AbA=8l_LF}&Bs4IX7e;F_8` z_ZDQe>m3adt>#@$#i`OTF}S&sV9o=0X_xEdjL4ORgCK*;|9&^lWmlc@mU{lf0>D!{ zF7KO#c(iN(eiEnnb4+t|LJ`15L2BUs2Q~Weap=A7wRqk@cGd_*C}SWv<)!lR@VFpl z!(iaXy!6)l|Lj8D?pBpjZu$aoj!{EQ^qBHQ?-coKHPab?`c39o|y zq!sZqno>Mf>j+t55hZ0S@51NWkN0GH3vek z1ZXJfMx*la&gGo~Y)&Q{d>)s5b6ljEulU7e?eF*i!b~Lk30(B~>A}b2lD0u)-MAHG zpd8I0VmTJu&;VcU2D}XJ0BF(1ncSqe~t~YWVe*Ql#=^!}4((fe-h6?cax$CHeOMTBzMWtBdQ;{1@RmcbkanQgK~7PVM^!RM z&Dhj<-FV2u;aMVw;;zNt$Q&$#Z(d*frxC9EpBIxflQD25Rsm>?(iJ$)=*|l`H?m|? zgBSt@YO}t9UUO}`q$??U`npS>h&+E6gG`(s22Q#>HJO`_5`l*&(_MTE3IO|6S8(k^ zmWmck{7C1Q8Txj*FmJTc4l|oZSuNQ>6es|aAvl)rC#?W1rRbg7Rvn!{(t5C$yrbyZ?Fqo~1<~>?gtADG>(N+qQyH0_M)-ho_xTAp?4JS+ZtarU`na^W4~B|-*3XXUK_ML&6* z6nn#6Bne6q-XFg0cEx1u>s#j$>E`Z+~#2i%y)+cQ0X9d)mD360^Pu=_X7YGm)D1;$c>w zedyv{Ag}$n^5wRizbyp>yZ_praSsiuo8FJI1!8-=_7eVs2_pd889SDY@Jmx)+tCb0HMLWjghZ* zm-#+=WCC0|yU;JnJJEDw4ABM(;K-4LV(N_>SJSp8eM7{|NwcgeE>V)II-S)Y6WgG!(*Lj& zFD9e>%)L^xDH`8XdoEV7@h~?0#$3nZu}x8P`-cnnf@5HQq=?_{U~(@7*wQq=>FPum z{FO&Qnuz?@;iL_4rG|hTwEET$R9xW zaw>>kuAKGej_N zPR1a)p=1H%qfSfhhkIus_nyeeYE!gSdFWwtpxfo}A0o-~Z)xV#HW$i`h+qas7fcah z`8SsIrELe6KVm68e+<|jxh}U|t>XnyE&?*f*pldN==i8TQSI7uPh}R5jer9N5;3Wt z>jPO>(wF!X2{xgvWs)HKD*+Ux?atJntM@~siqYpP<7V#LsIsqZacYjA94pd> z_TKhRkK4qJwB+t(q|`^RUy0pa0iiLD;MW0no;;RxY+*m^?D}sam zL<2aDnDBiu9NYtV`Eg`ejLi_#<{EtO8>~3xojFn+gq_NnNw40Z^W#5O$zqmA! zU%r`bQC=TGBtIF25BHfe&`Zb<02CQlefVz}3{X+9glG~9HPeBETy&TTpy zqds5m_9cwJYG4c5O)-(2<5+SrQ?Wl?9#yMPxW?=0ibe7bDl3MTv-q&Z@mP&D%5JS| zD;!H_GIdtc-lwg&nKcRRkfpPa_SwvJu~bOmZ8I7G{*aI^wZ`V@BG?Yu()L&wLW`aO zofXvoJ^GY|>OjB?@(Wx$)W52`UA>IrgOk9#8la%1sMMJaLL#FO$!`G&5hC4hikJC# zi6R--&C@}pZ)4Q0-wSiPcxE|QfRDig4jn&RzRlZ5~cnknB4TBo5PN(8nl&dh!+Vj@kX!vfHO*WzmhZhZJfvNr4iH z8ecG?h+^t6KjAWsjboz2Y^I>SmX^&{wid>ugJGHBt-Sr2HX49r z>kOZn9jSRbdTedd6e=zK$zk~n@byk1(%?G#W(ZwhI9cC&MdOyxqU4$|HNyfj3LEUf zPcC6gF5kMXA`z3Sjk7EraXS95&m?~s(q}QKd4is;P)#s1%0l?rB9T!8qMb|^&Vko_ zp%N6S8GHG^s*#Gn{-dgEY|g?>=%;ZUp) zyjl3_BY?|`N74KE2+u-poY!S8!3VZM_vr6OT{WWV-y8+RGlpd3;zf=t-99ss`C`R` zxnohWMVTp$6!~rrPO~`6Kr5sigNv%nsf)>>tp&|qoiqKO9&#?}0>2_Ezt!ag*HOtW z`whTEVO>h1$WcAoCx7Fs!LFO(eW^e{IbC=w_|&Xc=^gVx zbYXvft9FWH(M?IBm$E}2)WL{~dSSK%tLLcQ>TFjopp4YcRV%hWVbT2xIWl{76F`_y zZoz>Js6cnBY2O3g0Imu&(h5GI{MWkP`K&>P9<09F!v)f|*ltUyK+K^8D=$)gE08Ku z9jWsdTTxJfef&{P1y>Xz?6nsw8rsEz^AGC8js02{s;08%z& zs7Xm-VB=kmp$$;Kh$fLqj)A3>P|}mfav|yHa>8d)609erubeRy`|wkv%5jmU6@@4I zo0+M|nFp$tGR>1Oaojr3DHyDTITRQVAAPv4I1?a0)L^qumBKAultpIz@^}G%f^6D^ zWqIJg$z2x;QxtkyT*$wQy;uID`$H~6#;FJMlPl3rd+a({g~Jrf{!zx%l_1W+Oarj4 z!D&5+%$}=tWa4!(Sn#>_WJ`DD^u-x+ZvHd_4ePG*9HMydUFbIT^ovZvHA6PnW?dZF zr8)=>=jfce%h~Xs)835rjhzOC*?moo*m)l5P@8%i0aN4p{I)&OKm2~cXD}+aXA|+J z7Lr*q67(^iA!6u`9GMNA?L33?qL7z4QE0A0(fR@tUAgOu6o$A>OHX5?~j7eUDmMQ1})eGQpWXM$-O zkbqL{IK9dK;c(lQ*Wc(@Qg@{a&5PZ?V3v-RW>HdvPd3LYGuvb^b!|T5I<`%zmME;?lWsgdF9-VZ!+{7qk zX~F$r|D2=&z1z!R8aDC8Wxyi4O~oVXDCck$+|zE{+nkk2%gy@Id(iYHQw%gsDfcqy zKG#>*AUgV5h}UJ@Uw+}fe}IiOF*aHEDF89U()nK;ru!zJB3&rC3g-e(XnIm8IrnSc zMjunww0V#wZBdgdEzg1f_Xjeg`YtkI-x?UPIB?1c`UB`7#VbkU0j>dkBI|Hnue@^x zo-F*FyBr9tOQ|jrBM*PwR6kMHIl^@TG2MJ}`Qbxb(|vsH4*YHH&xIomW3fSpNh#wk zXze-7ef}Sovc>n0;L9=aCf>u)HFCbs;y|^c#nSi2Ifp2TAHnb;@>W6^*UA*Hj!BTbLq+q8H8AZZw0Au%aq5Dhz4S} zRT31j3z#VeWgY15dSO-eWYS?stPJOm!b;QTE927ANU_053c) z9EG3H=+N()ET0ldx%YkrN`Qk*>OMEmHYe?5MI>$M=dsS@oFG3E1&mvV+`E8u(Rb1d z=*M`J*X_c+H&>jEacvrYI%w*z_Z}yx_JB_Wq>jhDQ7BYbwPxB@&frgnt(SdvMmmi- zn+ut{O$S#AFqMeolMd>YD>Dg^yiIQ@x9gHStTvqxNs3ZHz-8td`GSz(0)>ZjL#ZB` zW^-`A4{27k6akKHP{K8J&qMl+qomn|`VgCLgTQh%w0;S5E9Dzc3uVx!v-s8|k9LT^ zU~ORwmr^Ts;Qt_903W_ta#h;mnwN4c?92Dc#;h%RE~x{Q^G4}tYMk*nuLUbY{2hN*Q8ttUuo5MzJ4Ejoo7dMYtaK~f1LCXhGWO?`LOrp?{g-!PS(K z-Xrw^l`o2s1%blw8x;zm38FnB)U_B{r)G<4}*7h~o z=k>7pT$p!n4~@874=>cPp5&>XaQ&k^d~CQ$$XF(LZjxFEXOq2{oyo8ln-P9&_Bw_~~bimK3 zkNRSM)*X&MtGs6Lod<34Q6I0pw9E)u<_Q|<0guukV=Dq0b~;~%aQ|Jwj(bH3(K|%a zA=R5+Mi+I|-9vk1y10j56#O*!y8LU=OKr;_!U_?T@0DcI<;~pKqA^vvNcLvm;Z$yD zl`Sdh+`RO*wjhF0Q#;IH#bPShVAjq=`@H_GF3l3Y z^BL`uGuTzxPCT{^b__Ife0dM~3vb@XvD2xfEGoY-FFj}n_r^z);PU==UC1zhEoswJ zFDPD7srv@z(PrH+>uA*ybMeGuLRP%zhC#pd(alytAuTGi9+Hr+t;I5qNfK|J6{T+(q-X$^Hb0Q?ws zoDqx~d68IOHuNXM96J}cV(6yp=NpfdN(2Z7r7FLI2^|OK`>P3Mz0I5YPjk+OQXwPl zX3zV58HjzlMrk1}{8J{g%w*NkX90`bJOLX(13J1JEV~2RH5q~yn#@!w%oY2)lf1bj zR|Zwxv6fnQ4Nf>TvCW{J%G=Yoe02$RoP>H+07Edp3hR#yb2kmv3T54#!E`ry2KOTJ zL%k8bV3`yv&G1u+l)^4^5!?AI3q6PYi;pk12U^uxzBp0j6XMLS;XcUTTL(vUK6l9<%lr z7VX8%!jz9V+G1EWuw;^6{7|%gdUBU9(?IL2+E!?nQPt*hYf1-G8^$wDf6{d>es2I5 zrnFZi%y9Zc)kax&sPVW1^bX-56SKMKA#JY9zg{1aFz^{iv1RAnhSn-*8Ryu9cIrkL z+|nLtez(mD6=u8F7gKlSoN_HXYh=jVf+f6$H!dqf7hkmeg0)6|a zbn8EA4X6Z>8;#GM>M`Hf<*sR3`LZsb z;hJ#h#{6&kzFoIYBd?2J8BwJpt0BcOBc3nR^CIB-okf|fT)~L`|4KSmaVh9XI99~J zC5aV`v)T#0J&9Bgf+YzhpAZvBbI>^Hbk^rr5%Y2g@x+z7a|Fz8`v14%mMJ8E^IE~~ z)ez-`*JC+I-|9HLIo9|Gj{iyUp9Ua5CGO=9^TUR@|0Ycehw+uUB zG{ZlvVw_6DxO@9ZsxyAf5)pT-1OsE2H|WmPr}UOt80vj$ zINwT~vO9kpQzspk`xtkO#y}>h2iMd;oo=FtNnlygb82ANPFh21x>m!Dr(*pOX9WHR z+&Y1aZcV~xsvQ)V0_%=EL>@Bt$G;rouI+m^()x6yZ-#Wh+oN=ZWbDRnM8H7ZQ|?%m zKQcC3i!%6!UE=ca&YIB#bT@jc3w_x=3-HeLd)K#|TA#Xbuh}A%foa%iuo(a$eRVzy zHdPu_5I_t{%VVJ26@|cxC07;GjC^6`?dGQk-Ye>Q?A5NOiID6}R7(1AS23=pQa{UV zcoyH-*P?9MEH>zMTdCt}*FKNL)sMFC=`GOJ%3naO{~TB&nQRrC-n0UX%nKAh^g}Qq zoMT3w)z19}{D29%DD?=M%2ht$l~0SDAD!R$oL7V}N_PY`4Q|hO*IbbCY6ddWOYO)5n4HQ zq_gGsS?BKjD4}=txM`j=;)b-8GwEklCFlP9E^A%qs>RbQ;_JrMJLTG;xS{_#xCcQ} zGRDS(S9nlKMUfA7d;?7w0{YTs>eZ#=`teT* zNd7zG1qd0~)|iy3CCnW3se4JUG3vGNfca(y&Dqsys;+vbEUfA=FZX6Lw27|iiZWcZ z?;LM5>mapE%Ojd&AH8A_YTVsKY`dj<6WT=y`FmbGM6%vqc&GLct3i_g;WMI2c(Aa@ zuJs=l!w;d~ApqMje}Dzj*qI^}XrDuHx}!T==~UPVNzkX<73~7Jj2=19U)Hm=>oj9N z&U1KNFu_dj=}wP8DuB?Nm;paVh-b=gl+r?%VW=#qZfhFdhmYtZS98E@FdPtO5GnH< zq1C^}H!r2(YvoZ?RsQ8u?8}r-mw5z~PwTJ@1I2|SFf13;db5Rr(craSElhC6-xhTC z4U^HNVXPMNm>lOmOn7`1MFcl(Le@Edfh@RoA)iui>0fM16*n+BD^Vj?uJijB9ZX=E zW~BfjbU(=-$j^NWL*kK1z8dEIqUk#W{glwTou6}SLU+P~P}(x=+TQAQi7k2o)Sryo zbOo9% zUr*>w|Gp7dYqTYfDR8d92RB)Ydkpl;v8ZZEc}bzWrLJZ174BCu`miJMYN`o=`jj6B zMTPVOD~CC>%u+M&85OK^s8)-S_rEgHyXJ>)yJCcT65JlKp;xLei^p_=YigF7Cn?12 z9Jj8Cpb|5Hs58`;*=f6rgAPtW&huB=Bh3oklit@A*r)f!x@FxK>|}=y^0_g5Q)#?zNg1@AyfKd9A4;nefw4OS#cR_Q&Ye zfw6?RbkLSITIsT=JBVkS@irQ!II8m|)sR60=d;H-8dP#ynQ0LI7H`;`cjsgJgNOvc zP$6lESmmtyr2O%vsir*6H;^Sqy}NEoZ2A|MSi2kjNu9`x2xmhD*C9f-H}Oc(J458P z%&dR@N6SPGeIQw7l(}?T0W1ym*h2{>!k`f8-?0)h)nX@ivGh3*J#z%fI^P zgc$m|lj>q99EG>($k$^=(O}HOtPsRg`OEB2a5;F(THM-dm?deh-Qo5#g0K)X35?0) zuPxI8Ud~3YIvA7*urv>iKC-D!dUCHjb7nPa&>{G7zA3PG(e%tOz$W%Kv33zh#9l&T z%B)vk%Ci?h6|2xba?v;+S9Q_;OP1Hxir*WThUB1=?)8fY%ROdf$i1>z?t>RpyJN%c&j8376z8?!XGdzD zX$kXi}n$px?Q9t{A5_CyG4D)v+`^f zR*_BuE-K{*pI$CJ7~Faw3Ou4%5N=zvE?=z+>kkdDSmcwiCq0fjS3D_iu<2E32DvU@ zFP$B0wFbZaosiqw;E^aQA7&sQFL(2}OFe)ZC0B1)9{GC3;3sf4a}M*9nRH|b*KRkH%KR)cjafzypfvrR6zq`ir>)*5TOTuSfj^_xt@oQr)<^yt^)ja~3&Zua&Np zukMXJB{Z{TR<3>Vwk_81j%_Bp0PPT4Bzg~0jD0_sFKzxFF}Y$O~vkbU6q>nC=vmGX|W?MD4r6-msolEh-pWx{eW<0zX%>iu^u6s_0KO25OO@3!tX|;%qG#aRx zu@)qj)LlwbVih%G&LS!Fr}=8$?_#WXqEk*6mBALJlbn11saab=jOfaUw0Y?@_9Gn_ zT(*!r0WlpQ4Ekohw)+nYT5Pr)>yteC2$C?gX--nvFFpu2&-DrWQFL@$X;HTPayeA}aSMY*18`769=)B0@r;l%OOSQQNl!AxWQ!%7Xl zDoxy*r@b<`s&1$5Gov;$cvB1f9ohcFLS!^B#r;yil&jQf6}AH({imwCtf6~DUoGa1 zhSo+YdZTxz#^G?l|{U6rsl5Eb|VE#QpmNNO7V)-@JJLqS=9RS7(T9z5= z`A$3&%)d0Yel|FCPf)`cR)Q+Z^z2*02Vq9dzecqM^>(foikX=KSZb6#(-)*XFo*`e za{=9Jg>u)0r;EXMpK8~AgQUIPc4|{LvZFQfa zDOrO$g4Q2j?>^+*$5?(^xL=eZ_mgMC$R$+IZSi3^E2?`zOkHaYP4eeoa$uc7y$>)- zlYL4)fy&)G$Q9Rd-K7*KBsg1?ZI3GIrWw4c#@EwD^P$|tuo$m$=pUAq*?yqyQc>ad zQRn(s6qy-sfYNV2f#0n#6mFhfQ*@kkW$WOc$XaG{{1@N(;jYHJ5UTUOR=$`G_2FXm zxjND%jW%&YV@Tr8?^+GQ){CMG1f#t35d5jD@ z{1%{I&B&CYZFBL&J%R4M892`=SC>-P#$uHqNezvPj;UU!KZG34W3c-;88nw4bk{FQ z+QSr9M0;+&c=pu*Lap|pr(ik5Nbp#DOh!&*S|ke&%TeI9P{l1*e3pW8P$!C>Q>eq{ zOj?hdXnI0Z<&#(^T+jjI*4bvw!gEt}8S)v*(O&pIBgTm){?PnXoX9OW@?ZsvD;zF7 zZ6xQyVzX~J%J;C)eR!_gC?I5~S$`YphzcuJb&C;mTJ5|vE?EzMW4BfZ8KosXIwjjP|8!4?zev%#(C)af5y33 ztJsJQ9!_B_w;AQ|mKvPec|MiApM-8o{wh0!F>^=ZuexAaMAs{k$lmVXf`>;9knXNn{>ua?LOYsqWG3)F1XrgoKD6YVEC3lL~64` z={F`yVj`z|s)NkqW(bvx$$@t`B zhni&uYj{uf6*!L=Y5ck`i38k>BC|WRY6~Mue*MX5>u7&K<~S~|FXcQ15w5H#J^pQF zr;yZIIXal*4HIyE=d;x*kC@z!i|rzkdGX$>YHv@xov-CrP2{5T^zQf0Yf(UV8T-IXzX?H!r6dl z4nEW-4j-XE;!JkLnW3K!b8^W1X4C7mk_wN;@!;I}LXj(#=uNqdvP4vtBEa1j!fj(w<2{ZtWgo@X)KT=?X#bgaSzIo8N` zaW4u3w@a#J5qF!$D=RD)(p3yPrSe)e#~Z5$7EW5Ii4$i;(xdc<6&X zId6!g=wWK|-;!Jo3s!Y*g)2UiJaZWt9MBq_4c8=bxLzX|)wk+xaM++0VJ7E&(`3)- z=-^QQvyiQ7SOnQMUgO;~+Z-Ey8;YrxU-wy5HEClxsj8-E6TRd{3Bjt`KRxiQL3{1W z$BT!y+J;%%W~^z^u+}X}+d-Vf@{l+5O1~>s%?GJc0~i`E@Pc?WV>W-v@RJp7%^OJV zH1z*C(0U<^t<>Sx$pW`-+bWMJnk^c6{p(p%%p1>Jcr2BerFqIz5-iHG+yt%hfq?bR zuit1G!ZXQDA5>vq9hXKOsaFpJZq@HQW#GoNSrhJud^U2!!*Ze{na8lRVXj5ndeyVd zeTFZRx^X*op``A(z{do(#^rP-?tnC;#~ln$}h6g>Mgw0^HY{iXc@lM+JWeQrweKI%2L zs)Wv8vC8`h7jnD?A&$vPh?I6Tubmt1W&EECZ05>UFPIjb9 zaMaJ=+=1!5fvnD?oYK;>e^}YyItLf6*N=rpMUcxw7>c2KSkAef2n0b4*dlChlQ8D} zTKwUc7{~=EP6`3-BZgL^zQj@*yHaJE;&R*BOTavBkhxmH(ia4z<-e!JH%50(FASj@AjJq*2sq4v1fv z6dV-mRL!Nm0rOFbUBzd7uOl;??@t{j1K9b^@i_r++1c~^$HF(vDAqb)nDeRVo8~XY zY(VQduK#eq&-QeS zF1|5J^{2gJQ~0l9v2R}!|DkjklcLwWp?{iXrp|!g-vQQpn2?`p%w;HCZ&FjOM!!98a6$dUq6fAG*Y&5P{O`I}N{xN^Ee=ZJmZUO}4Nl}PRAcN3}WcJpp z|GnCq=Pcj5%uE{naGyAQ*!#cjYi(?A(F|X8gYVFc2ip}}(lSAr1ZV7s1ok_ZKf+~& zR^yfL$KxpAu=Mt9d%4I=hOG~gU*u=pDRQy1t|@QBPev~uulJwZz}W^n^#)VBWPVy! zF-VqMO*9WNkX{b@Fd1ia}|>c9%vjJPW*ntL zh3^l6qKik<7^bD6Z7tg6z7nrm%<~;%ezKmzzGo9EJHmZ9-+Z2JS7`uLLyyVSvOEw3`!XzxI!8bhOCB=z$W8hQEEPjn8fVKIRF+ov5)Gq&V(274B#dMM)JGq6r zQ`WmcrsO$>w`2Qtkk=3kU#)9%sw0}6j*4e!oF;_9`kSW?TU`r2^P?B$5mm~2rg8?D z`v_=^zrj{D*-+kVp2~~6>9_=+P)5(kzX zazgb1-c5~MjVKN4?!B;QZcDkA#z`9F&9H%3UmX|&0d~1;caEy227^wZN_Xz}S2$S) zIevE5sBLFSJf=(5YVq;(t}%vyZgmczI&#fN2r|6(tY;QCJzT6NbubTxPYy57l|4Hjw=a4vO`#6Rsv*`LX9j>7zvEsXU)og7(SE*joq` ziPQ}m7c3nQxcSzr`%6Oy(AV5$2pYU+(u7tQ@&h$Fbc?ivqGgP<<2$$tBH9FI%PHeu zNfY~Xl$&;TZNz~b5G%I?$dCQd-+l`!$b3Ln51b#XVRAE<_hV0cFqeaaf|qftDlv;r z#iiMk``vf8ca;Iy*D;%VKfbKm;GVYgm*>k!6XrIQhSol70wz;6wwa{i z>iK2^oSO^=n2*BpB(esH2ihGaRng0wZu$j^!LWH{zH4uRRjfaV^oikE3Z8{X$ZiJHD{MKD)E{$l(~e zSZ$iBeB(?4k!w7Y+V3RoMtNpj#63ok_jii(i$vL~6UW?<`TZVBNF;=kG6f6YLi#2{ zkXk`365qz|T9bas3;LCmEv%5@O_vT=FSLU<{b1suQbS3pKYy;&?`gQ3ddxl8`hm&! z>KSP*R;neh50B9%Hv>{`6LyPR3GXiW$BmRY;sRrqn3ZYa*|=s;NmUOWu=M0LxdAxm+!MK3; zcRXU~s(R8-Z}5SHVF=LQi*-?!^G80k(lVb-U!o7XTJpzEn+V-kyoZEPaP=NNLbc_{ ze^{(Ah^prH_o77H=iT1JxX<7T_G3AZfHZzZVz%A$cs! z`k0vt@_pP;_gJs;9Z&lQBtf0|-_*u%2FC8JDP>!fExQp%VKRw^?ZJ{r%@%&N1W*3T zshG_}4co(n7g)1%HpS>~h5zzK^?=CsBVn@o(oX_0#2V`@rz{4w4 z8$po7OyRlJ$we7cX=YH)g)w(oq$w%qA)(%qyS(P20Os~4yh}ZxRBJxI(g)gqc+skm z?eo0={mvkrBn8PvJTQbuf8%^5=Hlq9pB7)g;R4H4Ar zLVom=@3OpJuhiJ`bKJj~`_4%A>Tx<54t_iKyrKbIb`~8SCXR(SJwrZT;*yMtj-RH1ok3_70U0t3kDz?=O4QsWKThlF$@X}Rx!`@NR79pnu{A?;ZL z$P|e^Hw>^Z{Ju*GZbb#awd}-hS6hg`9w}l|-VZv^Tb3a)c1)kQ?Ff|>eRSL2a)my# z^tY?Xy)NaSOQYROj|`RONWUp>Q{d14vsD;7V4@SfRlsh?9|=fpH=l_-CTXgywLlHY zG2Z_R!PFI6r3J>!C1!UCL3_3ClJJgAsf9K*I=DweGQMgK3F1XOSIMuL-;hw)3AewG zCT_3%hoxS$;kIZ@d}>OV|9q+Maa5+S!vYiRmZJHaT$l=lK*AZZTX~^95;%lJ`p9`$ zODwFkJXb0ZWT^G!axyRcqSsw~A+3p|nf28=7cIcf`zVDo;0d9gWsxE6G7Ff_TijyYDvOU?GsT*g%-KhcOo)>}xd+?mLKXv$?d_NI!6-tvXX3FFd;dX?Q~Ip# z=dbam?CVQi%xhB9iuroi_cVQ4ehaF#N|kI}7iH)wbD?>S!KuK66&K)>6nnH~O(ZW~pSaY2MC0-ro++p_vrc>+knd~t{NYyW!1V6R>*z}rR;;&p zIu4h`ThM+sxOqg(TtwH0n#PgW%x|N&A^z~0oPglS>$oM*_A7W*E(U-i4%GXqK)l)i zJiLi?8G0aGp1C?S=l_`9fSTQkV`_Kv_M|f?q%OzU?Cx|?S33k0_n5H$9^bBcs$I&H zc3FMp@5>{(?g^&w3In(K3>{%t8v1!uE5%^mb^ZTMK-X?vP(75lt{4!0GIemfPHLkg zGygFb?~WetX52j${Pdc((9p^Ku*WJcB?!~7l~S3{+?h`$L6n3bm=d{eE__YZ5tsKI z4heo`TbMT8BiH72Q%#Q%P%kP!9rH6SSwE1gvkt@-jz0(5%V#=CjO0cm2j8e_hy z@G$zR9}*i%VJDKurpu5JE!yFM6lwm(@_QESXBAN_RST^hYJIo?t8VmL7Rllg(+$bb zuZe^zN5pE1 zWv0p#q-rfoWdbVK)wAadlDFQDyaO^aUa-Ia$I)3vMfHDO97RL{K@gN4WRR5Z91!X5 zh94l^-7_jGIFvLDCEX0&-QC?VbPpZF_{{%#ckjEq)?ME?=j{F2dD#WB0~-%^i92=G zZoisY{S~@=hP=SHL4;(1s^4p5d1u->5p>E&C&5v!UilUvw@$rOcR=M<=D^+f< zysAh!eBMOqI`~%3;xnghtOe~l1f*EFSa{d&;aRO88Z`5^od?>6&K@3U zpZmZ*;V%e(SUp+eym)9zuVK5;xH>fhErX{r2oMZX4h6;mp$H9(U_#tt1w4%=jRIMY zmu+9JeY8Ub>n%-eDEBHUl01~&Wl($^do^41FqT&UkN)sjLN2hRM!-y4x%~F=Dc)$q zW09BwT~F;d8(M#o(Y0wkPa!70L{x*r4E#cPJN>_`n&Ml~xy10{VjR%&tHXw0o1Iyx zCUu944VFaoTePEth|A)e&A0`82&wdAm~Y5|HtaaRT{yfBYQ~G3VGc@ei@q7{6?00xOgPp*>7mMPBsIMg~XnNG=dIqIUrE~FoH&ld|Tvw`x|0o+K z=K?6~_LeHg>W1?Iq`aisVyf)h2Pf0%R)_TUbaVTkM(f?dP|X?LVCH5V=c|+kDKu(y zZFo+UtI39)e>leZL?3p^eCrSjp|M59V)`GjKs5!BcM68cTPjeqap?N_z9$AzAcz@@ zM9BCdwc0Cg)%4F=b2q63A`d2g*D-?APdckSPrdfk)Kuf|)ZfdJGo)&(**K%Gra1BJ zrWN_w9*58`GJDR0n^}TR(N9cg@bY>^litLh>U~;&&^*%KkY&bZVr=^&!OC6x9l=-6 zM_AoF#q2CznCmf_o3qIL{Fj>ysk+K{Oh_|7o}Doa{ED0p=LePr)QvXo$)TjaZC>=~ z#%xY6%M0P$0B)h^V;YCXTMtBw^u`?Y?_7yO%)&n`A0J%1zm^xA(_UKhX#Q?$PciGH z4p*r%f-YROH;UT!#XZGjA#f31o%f9(BX*a|5lgk&*=mK>iTpL$%Ferk()owr4w?w1 zb_#e12;;Yfp53}XHox0s`}Q{4-(Wp{^zF;`foJJZ1pGn&ivP6nrl*+7=^9N_CwT7p zMk#5X+Kb|D?F=hm?t(BVeu%SMiAZC0Nn-EOlB2A`Gv3f42$g)q$x6=`*`e`VKlUqb zp?L9N&1skjVfv|4Hk0fb(X7@8S5ynPFs zNybc#6+|AKDS&5U+LNEBWSZilO8I|c7l|~ep1F${8cqGnSVf{z@@(uYs(+?&);IC-0p3+XJjYCMF&z8xZlN#cVs!fDiC+|O0(Q(VN@j( z_dy41^Bw*f-ky4Zv-hyO=99Es3RIL5N1^8?7>Lq8%RH0o9wWYWd-FxZr1?+Oy%pzZ z5~eyVFT>7+SO3Fuio)X$3AMAnoyy4E<>xZ_R4qZZGsh z|H9kFOpv$>pA2MQJxt!NS)qzYuFkJOpV!OCcjMfyX2Ukj;CHkto1jK z^bEk$^q)}=^lhm?%|+fT-{Pv@A-%`GSV)>U|L^hwHQnS;Z`u+GL`K0_3lE~x`@Y!) zvOZK%NdIcmkht=M`yZC^7zgfyaLFs{0lrezgNfw8>Yzb?tTMs>9(kd4E`J?JUYyQO zI#~7Y?Xd2tH4={@dR%sr=WuK+6JW0+C*oQ6TnA-e04vCj&7O-{<@(yFAX$9}g*e1O z8!1wS26Cc)<{VakWG7C|k~+8R`0QV@Pkop1o6Tp;4WMr=KnL~<{7#`Y$NuXL2srSA z^UnmbmD~>XEDHYey!ycSKIh_tlCMC>R&@-SGU6x2s2Z72(NqNEdf9`^t=CBq6Ty}z zFM%W1ZBbDgLuxKlZk*0a+DyvQPLS|}szWPh5LM?N8f$|ZwYUBBR<&yMY(`pDjsg{t zmK2U}0o>TQ-yJ4f*%xj-dbMuNWPTke0MGISN`MkUn?Lj?d_`-Icd>T=K5yJ_ezR!? zHD!VxtjU^)taS~}j<1=P(bvlMUK8@rEK$EWck7kGR98QmpKQRN$ueNVhYceIYBNQm z1eZT$cz=+{-Vq-Oxtui5?2VrjC~ zWUAD%x|U0l{4)jgxl@nMZ#tV+Ug#s)ZAu}{n~j~80$HQs1Y-Li?mo-q;i(TSO#F98 zct6__P8ehkEtk`O739Az-8Gc5DqYlCf>xq-*WMsGyb7j{QXVcJaSNCFx5nL)yG~G(Go~B8Br^x1Y)d zYP#*q%TZ6vruA5hMkz{vb(6uYLp_$y%Kv}JF=uk~ah1ukDz38?6Spkx>mxgCk z{$^=!|HEObE5&ml##TNL*12CPTq(=QH#QYSRYas9v7=^N0goZg9JPypP%#tM44!k( z>hO${Pfcq8UGqCVjIa_emPX-3ikj2J0iD2yA%Ec8X2fo5Mx(hZPWWaxTUH!u%ICt={%xh}`%%C&;kSL|d2@O;D<>v&X-xR^)#6W3`eQ6f?=D@-7 z%wM6MHmzc|rVJKUfvAG1Dbn0TCl=ZoY@vQ~CNVeT8rEKJ_t;9=>Oxg__3uso46J?p zTVZyNO&D|#V43G&&oWAkh->HwXlvd>OhxpxG9zi^PH8z&rjX7((+2&w#55;$S2NtWh zNj39*F7=+jRiQ0+Lc*ZsFi8wk>7Q6 z<6QL?54jRAKJQJGzN%pwN92d-u+nY{Gw=xX&!i-JEJ!^DvE@xwC-K$raO*Wky8)v% z0LxnY2LUt14!U*GWvA3?zFjy~@!=46kc-wT+|*>z7u+dsq!X&du<-C9bA40}+2%%a z1M2oA%cyGorrQ|q_0WVFf}91>w=`CsJ$|=eEVy8Z15_IBz7hs>uXIZ{pgF!bhpD%; z%$(-T@CyenB{!yjliVu;{ghmPqIDp6v)FREm{)*y8eUP8{EP9QJ^j{>4b2KFHgwg% zWb$mSRxaB~RA(4!&Bpu&iauwNWW@BX9o~v!QNUeq0mu}MTj@qzPS(2_9d4n)F(~pxhgGdaa36i0?HZjr0(s5&JzB*{;>2 z5sz1d#B6^iW7!fC=n3TQ&W_7-UtD!A+vq3PP4O$#b)V14c)!zMA=uXDYO^SJfwR`UOLW@^EGfONDYVUGthoSssRI6Gy$CIwzdNmty);@ius-uUVtK)`2*@N~cv#HB&IyulFfrq1a z!<5R_ELiWY)Xc2WXIIxf`pYFVj|;Io^MT%!)GCz3!!-Dr!fqwEj!im3itKmbV zK5ib3-wS0wPoycHY}n0Y&h4`|_McStrjp@!g~?0mF|WqJ0iA_6zm|st%Okc|QZ3>e z`0{b&uX5=f!3p4jId@w#Tuv|j_ZK@YE%BLAd7DLrvFLsv?9z|@imh;~rR5d-aGyuT zBi1E$IefrDMayt%nj@{i*sx%aK7O32L%y_M%m?{&DJf8)fb+3==uv-e(=Y0meo<1< ze1T5H&S{Z9M_A#FkSR+pC08!9(u&DPbP&;WHbHThtX+yc8bcr3`PtT!0oDEMh-BaB zyUAHW9fFq-H$6lDVa*1%gLZQ?+Ag$Az0_XulAR3_VY1}<_l#A31TnNKp~l9~OP7gN z=v#5au48TsDIPCpr=qU^VhY*6o3&05do`*~&}8)?%Rj7jg0_KTXnx3DD!3CIK1nSd z46%c2kJWj@qIFv(Y95RE12=cOyw8cWqo?kIjn2^1*ERsVv!QQmR{iKgV;x^n-pEU{ z)wbciUmo8f=T|{vsHnngL@5W`u93^4M@KmBqNmXo=&0Heq{(ItSmfR4UOS|3t^0@N z&+oD|(;-3IC%2i+S-%$vBD^kWy;v@Qv((&fpXD8%9!xG}D7=R}#!)VWZMJnTG=z2k z@pxQ6{D+mvTqct2O%EVr;e7DIbY6=}Rm}*d?y_6*-YhlNhz_^e-Ow;~*jn z!DeSGI}VWmC&{hET&<=Olff<0qV zuWeO0e~IDqs1y+7_Uv^r;AZMK@!r?T9LG-mx$Qvy!+OxMIZNys2=@l@`RE;Xjm_sU z-CABbmo8z$2Pe8oN&Fbwv^F@fy)=6)XR;^7QQOGMR?D<<&grY&$1S3o1_E3iiS93yh<%T>TsK7dpHfxr zBDVZ&flPx=d$$#Ua%*hnZQ}5BGp;uSrXzZ9)v@65&Yhm=x4L)VNMu&&trrS;?bi`$ zWX)Y!-bGXZiVtkAtav|tY(@8-=LN9E-}@)hOuhR27^MTa&e7P=^;EA@Z21O`d9v3f zafM>rw3*PX7On^sWh-8pOhjW!Oa}C&rvgekQT1?2s!HQH(k6|7r>2>+CS|o#3H&^al(TOp^J%%Re?z`Bn@B7P6iz9Je zHRobrqv1Xr&41U2zs1`zsLdv;9&6Zh0=qhin)3DdsGe_AHLc|21+Nj(^wh2$!29v# zmpFNbhRHXIn-M>i(i)6(Utm z(L&4BDXXSLzu!bEguejTLim!S>@=3GgtnA*Xodex5)vy;FOR!xhsoc<~(rI4B7z!9)zO z!5tv#xo4 zT;tQZ|5VjLpCSu4Y`AzC<8fBk3I3+S6h39remI-;(b%R&Q`XVVVolNfCOe<+S8c@i z4mp}a=29H*_Z0hzLiZCRqy(no%z#~}qU*k-JqI&zG69N5p&&y_TwuwE+1&H&NIeuc zZC##Smfdu@C5aIu!fFsfrMay^+o^7McpE}1roZ!fmQe4J{6k)iGu01P5}9N!?2>44!8ZNn+QXi{g z84Iia`cgP2C#yIPXWc8OpYO+JO6+9Plbr~A*@|0pMDf-7Ba|xeVT6u6FIMArQzv*` z?e~^TOF-fN?BS&r#$V2QJQQ=^!*D;yc4`OCT)ST~8^05mozd9Cp&+&SmR?_)M%87w z7Min^W5icLG{SOcVOE?DdJ=nVRzKG*3v*AY6p9NV&uu?6T;vEY^NqA5jIh0U(j^bp zPkh`J1JgJxdIEknGQvm@X0Z>d>mJ)>y03#C>JZ%<2+^T3rr*#6pKnE|j$a0f461j&KCA`A#qdEd9lql|w~LzUfNE0-l{CUB0H4ihH$PC3a+WR_v`URw|K} zcHR4Q4J3EnVFEYNos~TZxyx9OmDdCc<+)2|hjHF*JY%^>~gJAAo(7zeXB*+;di!e*cAQZtoDa6Wr-;BlBMpa&M<&wsE=9*vN2pf?u zvbaYjrRi&b-Obe*5fUa7V3)6w;WshN_@>%ptjL1-djp5kpIoGuBwmOr1#Cu$JudLg zq|jmx&~yHMsBd3B*;WR2*=JLshH95P@DwLK_VmwXP`v-J^fqp8AYlNZv2pXX*-o2u zFmVHOE;UpH=A(#Q`tV+cgV5H{jUo$CKKbfB>HQ~GIrAT1EV5XFdklK*UTnsH&FiWW zq}E9=nV|@WN>|Xx<1nrndULUU>IE0)*flQWI~7Uq{1BFXmJ?lPEeGI{#H#4P9luq~nvKo2IWY^a^ z8<4-%nIrsnza@P)Vg6v&FpQ^tOC)G-#Q-B=>tE+W$=H1tnbRw_6_IVJIYqE|0Em}A z_b{qCbroD`VGz_Ity5xBe-mOn(NpE7pU(;})cuFZ$JDgP#`SLWLNXMn;e2jg)*9Dj z89u&(+Z^c;feYup!wET@to~@onbN{QZpnknr^WO4{LNwX#DAoUzg?J3634`7hK(b& z2Rvlu^L7&H!`=@H_=naEO@mGMlnVuf=tGL|O-Qvq5de4B;xDlbiS zkO5t$K%`IbNwwN5du-LlWLJ+rm3f4gABELdUIL_h{A*F=Jcc^y+# zn-WQuujp2&I;VPn5Y=t=`qszrthP`h0&Xatw%yRx`zV)*n~7UNP1!~E@B|b8&H1yH zVgDc2m)WdyXQ0-WNjGj9@$ndvvnU-ziR!+MIveVSyy8!s;;$c)>`n=Y!yQZ(B@>S- zN|!q{8${W)lZTh7mV*4l4rlx^v(UaH6sU)^;xNYaSO#O_q6JQ#hxaJnoNrT0_i>|S8Q3s z!ze`YlJtk__@awt*c{{<8f$B0mYo@)bhsYCpM8g>HJFC|uQg%`di1#AZdY5#i|8IF zwz7OTkDiE#9C8EaGU|2ue4*WsVD*>$v1db)&2z1czP!@a;Y0j8brva-19XZd5J?qq zfp1OVn+^P^*S@!<*n&V>a-kdjr!g{GNP*r)E{N10}2`Cr8C4}Gy_a$RElMtt3U zL^vm^rTr!{@Aq9=4}?LHJ(QVPQX)x1Hx`mZVnC_`pO}+rJ<7@ zJ9IcWQ&AlrDf4B*4O;vCk%d<+_lZ4z`LK0-99Ku7U5?LRO5U7^KU>;?NJB@m>0oUd zJdzii)#2g4cKLn;Oj?HTq;-@6-+#@``je{8NF_r~no_VL`SmNy|FUw`pCaW*@FY2K$VpeGo&Vx&rd;Ju$y1DAz2Ai^hJWwP*@>mz$;}ovvRn-0&As)1 z#ki(9RT`{Dk2+Vl5Hjz;t_+WynIzHwGMC|tukp&sEjPx!#7gSO*JbW*?k`cizuvhT z$wrZ8pUzL~Wq8x^_~Q~&DPEGHM_vi+^*81J{Gor&cmg!PT){JJvii^L@+UEA?-)H_+Z4z zy%V9;HdzdpEc#CCl}Q@@A}*Sl9I{5#w>Mwpb=e@mlpc|ODBUs4az4`*B&z`hGt=pn z4!h}FzoJqBk!lRfuT(qbYbYYhlX7!=$Z0PH-jYDQS-4oR;<6qh-Xqh&Kk}!yz(~Ha61L5( z04pROoWDNmG$LDzXOutOLK}6CQo{}}uZ?5lXapCG<>b>Q=k10=A1ime&dYP!^YQG& z)-^Txw+P(YN6gAC6Vyx{KyJsn#~+B>Mtbd8?Y|ROM_5Q6>XwvndIjux9*cW0B!37U z%pz(&K58r6EN0LX)Rb|tdSk368j6rP7+%3GKT$Ksr20i?OK+s{&C4FLAGK2z&Si6L z(f`9LZ)x#QR{@J-;+*?f|L&>#z}cGg1H*g}R!&5gU;^;3&JB0)S} z@?+muw!7zER>C5&J}$ar3zlT0*)+r{xqDG1V+>QrEJA_If{Ku0gqTC zo)b$wR!&=n(p#Mh?9)|sH)&BHS9hvnTl=)>XLsM^-cl({aekTQS7}Vo<*iOOe+X6B zBUW3bgS-J_=_$CmeGE5PV!F&962)oXi~z{bG%E!zf-yl4JaCRRZ=uA8N#PB-x!8G1 zjkySWdWr@z+zJ9}QUi6E^dHtfxsT=>oDBUVDfukF(K z(xn;lWVN+nTTh!JCZ@rw{O1vYUc!6=8!u7P4*&cw{m` z;#5yLZ>1Ok*81J{+Y5g&t%Yl?z#H$;I3BiJjf_DG*?UnNYk0msot1AQ-l^8qePNWA(VQQgh*0u>3rxMR3gp-_6 zf*Gqp)JbXP5AeAU%{AhyFH>ot7SaQu+(vNqB9MQ*;CUTF?KQpPef&k{ix?~Q??;(Q zh^+`!tmDO2PQDRV*1RdjfdAyv*uaC8;O z6)Ki(?wq}BK3-$3mf<0L$AF{pZ9jFcNq3S*a z5u6}aV*=<43izsU)Q}$^eGj=ft1=!JFHF%<@mYY1s1wLGw}*Pzp57u{b1PP9M8aD_ zBb%*`ANI#O%^Wuh6#>8ZnLmybjxA!8E(j0 zPrB%MAWyLCG$(xoa#4K2|;TzeqHG1CeOm4^MO|2vaQ1gXWbpI9}`dcX{yUQ_M zTY0=qZ9{|=R7RMH>g!?OEnVBzk^U7wy*DomQr%9s`io{6!Z)n)ksL(79!pv`yyRx^p^zG1*7dnUs$dKZr^m0H~0h`wB-H)G6oQ{-*;aAm8H5m zAEuKyfs<*Gm&Yv+lswrm)A8I_@+O_%jq3O(eIW`2L&YobLE)cj@SA?LS(^3U{TEp2 zK!Qe?eAKWS((+bruC2zO`iC$!IX32}<3PlGYx)v*2Fu+sZcMX#wY$f@E5LU!)p>3z zA{#!hhOx6i@>f57;ZUIyK9oY_>Lq;)ov8ILr&~uvl|sZ608`++59sEEDn4E0Z^bJM zu<`wq)kes(CEHfuJFO8vk%NjdD{Y<5uJnrCTREgpna6{uQZU+pKI%FWl3eIi6J|nk zLL;phBg9d<#H#(2+>^E4OP88hJ(`RfRxK#*-`7d0rAjM`6z^oC3JCalzLD@BVR!U& zDvP5H^sY`n(J({5CAPa`PG4@pz0%dv3XOyPHTYH%LEaMt%C#5agkBc%pAvnmq-MX$ zFkt^8PEDA{h<k-hTzD zF%EP5pzk1JK9`W!DAgH0jPP-Oumd*zOHh4Da3x)*mrN~R=h>S`H`bIGZ@ITX3dOi= z${2-#1+(IFvDg}+R7-5(W0enFxe_kCRBmhHY?Dro7H47#K?PYOn=F6CYluEUq7nZcg43vCJ)Y<7|47K`EiVBm_``zhy5gSaqgmDAyTjctb#f*Dr*>{0)w5k z2SW_a@UXjr_(1;9ho0$46O;ZaHQu*=)Z58Xnkw{VIF@|gpQwxaGEkCpHEEQCY~qZ9 zbq~%u@LulMC($<~#1m0c{R8`(Z+!$QX@&*wnP0lSmE9R`6Nubg(_Nd-&wbc%3AvV_ZcWr{O|pN-Ef4O_b^Y^qA4s$rduh5n8&}lJ zZI`aghOg;G*0%9$-#>nsATh$}Lv~{!Y}UMHyd2kourHl$eACzYKZD~@jRzmx@kh3V zx#Rb~RJwMb^|)UP3QBmZElcEFi?_!4IvkIAG%hGEVMIDAD1v@WHrIIXz0)i^ETXH z0L0!_ZR9#05~-Hqr3O{g9xo#2RBEaguPcVI(^DrJ^C7Qp(}&!Abx$NlTw>f#@^?^G z+Qe(aw$>4wUI%5lY6mzFqc13f1Z&E5BymRe+=+iif5RN^PBP?Shf?Ft?xqwU1Dt-E z6yiFfVD@R^qQLH6{Y8$r9m|WDn6Dm6lEmM_cOnC{kT4Og9ejcTF@H@o-zE;z6bx_N zE_YOjv~_x_hYkNfCVmF4yV7dHfDFaoI%&0Z1N>E_7SHCJ(|1>^3XZeDYHIpD3*>K7xBt-dNu8)wEBzBJ!S%5hWr5M^Co%k?mE$I8edWHNB01{ zQH#mieWG!1vXVEvb609swR_`3!Y;ZsFHV5cDM~x~N+_s*ADJ#>NE++O%qT4j@@`J7jU$ zh1Hdyv~NS;dsDWQy%isN;@h9fyvOFEUN3(VD&Ff?hW`xAw`>FF?|BUVuBmm+-FPlr z%++lGx4pUmMt+cd`W36N(K9QOZN6nxNnct~Xfv|KPN?W}Ii@$Gs_czth{I{xki*TA zL$H~q6rMVzoJd?RF2n8GP^dN=Zj(=H^6EoC^39@GLO#BfAKqXI_XWY+FfHgYO)Zq4 zje<|~^4Gk)a;vSx7w$R+SzH3%aT4xBGhMP>#;;$~X3iH*pKmXOXN#G9V4_JMn>;k~ zqy2?1*u7Dm$p$e?-bO#YRoVbNku1$Mdg&5+carxcPof^wDgM*{yG;2P56yrls_I5R zcUn*alxcYO@#MK=m4?(_0I}t*Q>K{+Cg5srhu1yhK1q@XT=H~%N`UHA}U!$ znDgF0$S`7Mdr=i&-P!r_{jWI3@1fRo&i2CkUn!naux$H8l?ChhpVUcsE*36%55F8P z-^FAsO<^A6uN(NM>DZ$+oFh`|pt^1zVcIOJK${Eui@s;jOVHc$x?87pC!wIjj5!R z^t+f`ImTpFW~n}Z^`L$3LxuL@?I*T;Q{xryb^I7GFmHtt^P-lEBtCqPf_fpI;L3#; zS8-SO%>1r-tNrXj5s>6>eNxluUxOR~y&UPBw-C#92!SGp%aXpJPYgkr@83wC;$-l? zQ{PFMiisH^RcYy_XjLFC$U59lxBUUW3b`fjtMRn2=nW%f21Yz5dyS(mjzzW<(!$c4 zwIW(Jhi=;#)V{3P#dMe-u@x62%64@cjcG~*mI`Tq&$O+}ox!D-KRZ0LulSV8N{3e@ zjzx{9r2^!i2<_D=IW;sMyWg*H%ZsU`wCjYqDd@+E1!zt^>ES>~>Cvb-S3Bn%&VF?y zQVf*lp}R6yKGwzL+^@(k0w+>!_Ensf#E5z|f1@mm`}0Muh%qrZc<=7@g!T9_^30M0 zL9E@8hWM%@;~A01sF#F>To}LyaGNzMD+rNQXU!Z^FJCmWx7Ju|pxq$nsWM;YY3|E7tD0*M!KMSuwwBNReV}bAT#Sr1 z9&G#SVfLy!y2iH^iON8|NobrN>LG2h6PX*W;uCV4P2}^*#pX-aBcODp)+)#uEKkOT zn&*mn=6zH4epTIqgW2wSECf_pd4ziO`|JgFbCsmoEMcs=QPfl1Bl0gb<=C>VRJr$+ z+edLYm?^&vAFL8h)+psom+0@qg59mct%Zpm`J+zi=6>Cj@gT3%2>ah78J?0aA1f8q ziC3ks2}npE-Ci@cIFRwwYGnSw{oPBUZzX>TYymfH+(ZbE5%H_0K9&NuV!i)i)tQ!o zpyy&JXFb)r=9%0TaLX9?FWYS$tN)f7iTgnjYSDT%MA#*tj56?)YuyDwR&fcnLPzyG zpf0v!#L!F51abg3bFBax}ovZreG0nMp_sc)5 zKjcSyN?ev}r4d7O2&FFj6 zH&)`>E5jOOdBc9hPfg^{P$)(gX?QzmKk%NnZQiu2cx+t8*x#!q7wj?o!zyKa=;vKj z7xfoZGu9ik!g$_p=yyi(3sxz{6{w*)@xQjM#HDZx_|Kwx%@#^aF&1W$K+w0X2=@#9 zf&tU*%oDi>8aWq5bRZ%MZ-xJ(sXm;nIK)MblOs(OP~bX7R?Lc}6r<80Cti>8gA=pz;=n12|?;CaT{X=FU9xNYpYBl<_~DvFGf zk_VHDU(yC>htaAS$=~i9!dPDCsKBbDk-?g*9$H0jKGB?L$EwvNfv>sx=BQ@`&o0?x zNh1JxU~@G8c-H;QPg5S2`M?oJf6!6d#sCJzoG(GEATd{0*HYi;p8vP5HbGJzI8AtU zmH2z>oybY1CwFqbH)k04I+x8$Nh%|;NMTYpm6@;u?&0c z>KNqAAm(2HyP%@Rgsy!enD5#KAjxtx+)8qe4c+oN){7607}6gU;NU>arYYl+u6t-2 zTKZ3l{4yz3V3htRcM@Phc29b2r2#U6 zd<}mkW$gqecf!X-U)Cl%QDpNSy~PmCCtyO1r^8L&7f?@7samNyn=(MT7*Z z#~AfghT$pi_$7Q05ZS2Pkd4xvT2)-gXcbCQaf1EP8fko^QbCAIQnweFSfQ z%+-2Q&RgRyXS^H!PN1t*cS)`tOmdb1MFl_}Q`RGBgDf<xU*j2gQEX{xCvF~-${Knwb`4r|=a}LCbRA2&M ztf*=GGRba;eqOcRDh9IU-2Om}!Z;Zb)5cQgpxuZyX|vP}d@%hYO6Sbcl)b9v0sr?- zU41PFgDpcuy{;p{$*0n(7EB`eJG~DzQ7`%>N&dFhjolzR2XSY-Gur0}dEWhwi_geB zea(88!XOl>Zt0TkmeGD;AZ6knL%-IcH2LryqmmpVlPs=1Wr})yK8|YKJ!v ziKCUGUQs=^c#;(|7YJ<|o>t5%(Kthuh2K@_?u3}bE`^4M-FsxB!i`W7ij+$4kxCtJgA zie9bGHRQcDAY&eVyQ@nx3Dr_CjQ%eR%KQdT8CyvP>pd1!KR;&B7M=~_uPO;fKgF=K zg>rB5u0Hq&E95Y31p!G5#T3Nk+=U2;3dEu&+b~QyRFkfr-gMXk*A0 zH$P8-M#63%R!X3)mR+>o8Jw(ROdvmBY+(cx@~c^-mX@~}DNs`~*lFYU%#VfdydL=F z5t@JYRj7U6Z1==GRj*esfr*T5)Ufv6&(ElCtniUanl1kmUK*KgY1{C1p+!?`LzwDR zmP1%m0&x$?m5*~U3e*30H>>^-Q5W0{+91L+P-|0v7jvNJc-OWcKOTLYQ<*&0?Cc>LdIrQ(!^{VUj}P==dz)3ZbiWxb8NQK< zRt#8U$0)$XZK>Ff--UXvkj&Tm$%DYCs0}LDARPaB z$JWtN!xnK)45%1znE%BUH585U$YSn%(raP{2yuJ_sHyUypAwz`TrglauCm8sRBQ zLgiBjkxfbR>u@Q~(u)0_WsUKuv zR@B~?DXfl5Io{tbyEpO}@N^;Yh4+w1(Uyw$W_#HX?v-*d*~AD*QP|l`mH1JtigCx!j43RWf@9mtG{z@ce`KP>Sj$zPRi8AlV zvDRMt?50cl7UQC8%SW*_TDWNl9PLCME6br!kbrmBCPZ6iZe5@*46B9sE0dWUC&urd z2_iy)#xdu~%?0KPZZd=owSKvoah(T5gF!7Z=Z%2=R>TjNkVbq|t5xThD?N%w-I}Pc zp3YAi8rGlOB-U-1Fj#Utxhe&wvzj_#4($y1DKC1cYiV5$bJcu77$c$K=W!bkn#_V= z?fZ=_l}TRSfjXOpDdF7nFZne_kVtD_t1b3_;pdxt&!G2 zq7TI0iMC_+n^|n*XhGULrf&4UmFuSZnNQ8$ZaFXfeBolC$ULmd)YI;`8+SUjdW1 z_4RhKo+=c$FY#@tLPmpRU%)5f{;2E6Iy9x!M@q9ECy{CXVJgq1J2Kf*Mp?$yPeE^` z%OAKdi$-?&L!5DS6%^m}$nqLxklwC!Q;J`w1#D!$o;Gi~V;rEDpz_i9ZbU~N`FPk} zGh_i1v!3|Ol8>-wZYMr_ff7Mu3@-R1d;$q)10G(k9rWKxc~scap`i^7uuBMS;Cp0L z=kXh9BvH4L@8ts#NirSr$?BPq`l|L;V9`-N-Iq&X4cO?TjDn%d7jW&KR>;Zh(Or-; z1Af}pPMh^VEORp`9O3fI-Iq!$5uCpvppRGv+f>9d0#D1g#eXfLtfxJPj`1}bCcKpV zoSdB$W2yHnV+W(9yB~~j34go}fL@O+G;LOAyg;+rNdRFgFq0vCaY=81sy`I16}5n~ z8+gG0GJUkj#X2z3>-%-JNuAs!l?*>EOSZvZHwJgC!9jO&+8+SvX{`#gz^E|}OdJdQ zdjw2nnn06Ridsuy%)lp0e=Ag1<+`{q*P?quGvtrK!;3~5Xg?}x|zwR9nodeqk{*XcnSmXm={IXg>`wY`wG+4s^x+ON-eU;ATucV5Zs3 z5YIv>)|iDg7v9rb>`q%tH#TCEI$0vKQ;D@52Hmp-$%I-2abNotQ-j2A3xAYQF3HD` zCv^a+fnwKvSlU8QOWb*e?L4|3zN=8E(4eF{b|_yk1r7I9%1j^SZZGm7{Pb3vL+-sC z#Quo>OfRgAkba69du2iEE_F5sLLjERPO)24jr(wpfzgahm26|D>mRTjGWa?!nid9e zH`IrIj5t8xAF+gdYQQCt58jV8`;^~VqmeS=L{tD}1VuC5>m-3GeKqL~ckZtd)6Aa% z{nPrR2BqSO>&Y9Yv?7T=t_lLBvrPSbT)&t70H64#oXRkx888+1C4;*im|;9;PK;x7 zmC>n6a-^XoAARU^KCn%7N9l=)N9V`fr~YWyF+j!)=4}pe$=dRk7kvE{7{>XGL_y-u zJa9fKPY|uUs{zl}r}rHM{DffVsWXbMfvesMWn-6O9;OptZhu9_(i_~)DXX+`FVSY= zdFqM_e!DR`6Z1T)Ki-Y;so}2n=-v?qy46f{bm-z{K5uP9%Ka?mcMz%xBC)jZcXM(j zI%T&8oDZ7mp!ZMMwq3&giK4)4w@QX*bHBp<-*q1{-wcqNTT&i}TN3_Jgv^Z9VMaBr z`#`#x9$}^U(?@%?IiPGx>9ZAy6^jRdh!F+gDOv6FDMYu~%OX3FE_M?3On<46B6LL3nX@kRRdR;eG z{`>6{i*q{2TOaR`8u5uaf7Nm~%Vw(aEhrt5GVj-%2_gyX@M>&Jq01 z4uq$u35BXoT;vbRBQXoFw$N4b=9aT!d^5_0Udn5sINa&d|KsSYqnd30K1hlPlG2DE zDcv~{X#oLgWD)`cY3Y*g?i?M$7~S37-Q6*Iz}WNd_x^Y9oSp63&URnd_mgi-6m2f2 z_jE4MhbTG5ye|4Y=_|K3Vf8JJQSmKvWj--53qqT1H9|wTFa#&r#G`)XOTxzhA@{!k zm7S{zTI)PZePPzkGPW+9`F3^enNx)-LM~kRHxc%Y2^U|~Sw$ahA)}|B51#e;_+t}e z{sZ7@bXZ(Lp2@kNf6O>6e>g!5=2ooX1);JOicWex3e)SncU7quIEhx2{Cmq%S!&D0 z`5R(%b%ba%MJY`@S!st(ITGsXTP}aVnlc51V!=tC?{4VVWB)@VIuyg(6SMkWAK}3H z5sFZEX?Tk*o@&UFp5i<)0t~iv${H?=&}A?`R-U*^n7$RUaidzPt5R|eOpIN zIrp}^T>W1UUSJd&?mrPcF?1!uugIkFLt#`a!Blz0&br!NY5@t8kV1NRiVUkAmM z7OIOBMV#=+f++n9tn^Yd(NIwscP|=Ioi!VaRvv;`{a^uM2W2j8g-nrmY!Yv*?!&oL z)Lt|Gsca)sZdG>GBs{_lL2kk4XU>W1D-LNweY7!d zsoADdGz`+(v;yV`v}4wS9Q0<*Pog=Qla}ZNSnP~SU5u2>aYP07p?oUVeZ-wDI`JOq znk9MV{%;&TPMN+HHlG=xvJ1FbVEHuD0ft`7Ygyu!%;M8i3J&Y>jX~q=wKE@0S8qt; zM;Ym!|M`79aJn|=#@3j%)tBRX27$yh{RSbYVG5!JQngK_bZ(?rfrF$iL44t5#EYj- zlek6G+Xbd!MH)NviQmv`r)sB~Q{MY6s-X0BX6kXOT|e_8!}(Y%by3UCo(;r~(SOY# za-vvCA~F3Zzurn=`&v{2&#<#@>P4BzPN88G84fAy_su`W<~6^U-&HFu7FXXiPW3*)$8{es^gaZmT#6F zr^0ba(Td|#PUIXE*j7HZ=h>o%Nn&}>_cjJWLi~RFZA-rVfUrngdDO{NC5&S;YJ`4< zH}Hdrk31ZXiVo9%Q{0S789y6!BuOPTRv1EG%6k8cfkmL=H%>%MxYG+-KEP{6r7`@* z1g=1v=wBZfV6E!6Q^c{E&INIEL`89d4ml&@G^`l2=|d6fjk94YFJ{-cJ=2_MD^XqW z;B+-O?m(M0o~_y^n;XFq_T0F|_pC$a?^x+}=fCJDE(egpo=e-`ZH^y|)C1v^l)i9W zfPNc&Fr+Y!%lRm}l%1yhw~#?DYCvS@daPIZlW~>%9ZP)@{VxD-Bc8)sDUw9A4>s%i z!Tb{cKeT18)=;#i-^IwLXal-(l|KgcIpI8vQF1RzIx!~E`$!X4Z>pedw4~cuLJ^6i z>au*g-E3?Jg?tcREK-fTqPJ)c(glSlpU$~>M9YLY_f$9sQDy|UUnxkqhE*vIo*uKo z4?P=fQPy1sNJ}4Mpsg1VG+<&j}De&D}6H=q7quP;)OGz`jEhuAX0Vd z|M5FG|1CV!pfVnmIIe4U{=EI)6)Ty4a(uW7F3LVkjESOuO7}jZEHp$eh1t_{kVEwfhe5Oc3{w! z*01~9HjJ7ysAyD>nAgKC=Zk>HaQHY!RI!|q2Jlh&nQ}r)$P^<%4MUQ>dT>sRhcW#@ zdvxmEeoEY@The14_8w`e#ACK5X8*t=Sx#}%$=(+!6x-o!k@_gJ^R>c9p?r?)%l88ZmNIT8Ymd+c7=j>7S+!R>?s=bSC8;C5 zQ2Wl-gnl{s)sS1t{iWa94!s*83M^@t*Uz^oF<9?s_WyL~Vfy$;Zwg##r5teA_Lg24 zv1T)HuFXGg0H&BvB=s5}e@o%ivXcr96%{Ea36IpQ0sfp!3KD3#DWdx3*7vURkqw=c z&XS^5(vLQtvClH0>9tdUUPL1aIc*gaP~^?T4<8tU8VkNGR+8_fVVn3RA3476Cz zgTi-Fd{=a|Z(FqI!r!B1ry~#N`V7{U57oGX;Ct#9ZJz^BX3eM2Wy{JtCS zz(+ugQhIX|rB4*Ay?5tMz;Pt^o~{+y;e0-|s@LlLiM8J2@%baT5Jo4~rsT|~m9+E9 zal_lr`9U||SwLt~vUkHlQY>MS5TYx#nrj@rjrG9zO(K= zb-}5%n2qX~t#0J!4jTClPJ-wSt*ThWZm;h$jgLwF7Vj|stMY(ZnuY9tPgBaK6E&^-~sC&OkH$p|x@dTWg$2vBJ36 zccJCzEQjt@UIk$HOocAok|`!H(mq4sM;tR`?%kc>%~ww5>O^})o}6%PPtrN>6LZ& zjeo`t=q#c`S^RZQT%_Knd>hPvpYAd3CmUkCL~5q)Co83X6~wUn4FnQ27T|m#Cv%l< zuEKC%Y<>QLnkueY3}&S?q=@g$@tN&QK*E@@$WmlRx{?cyl~DLtb^IF*6i?kC@SGQ0 zlB?ht2L5O+rS?8Yuw>8x`o3g@yl3Fud{vqdN3D_C06C`=N~ zxAAYS=hj<8*k%0QhiR@tUq6~&K?R4jhajAc-^7fHtxKX~8`sW5g6WgIim867QKx7X zB-c0J>O-$2Rp|#lO0kpCtpMb63!ju^wh7_i(}N4QqU3f|z$)>g6;h)sbk-&=3PB;G zXoI%6hfctxEmOreaxF!TqIl>anuZ)o6BVB|E0_A_sHpWcmpp5g&u@ce85#1El(O3g z!Z+LLQ0VLB>kGWuq}Am61gS47gLGd&ICt+hj2Bc zXjIcH(KRvB8z&`nF@08X za0R|a05->zUY(IeLra#1nzTutGNbWo+j!)0D4|(`9VGC8K4QChsF9f@12CTGMta~N zLC@ny)LoTVvrvGARGBx+D?1Ak6&4l@1{{plO)U>vhkW|l3H{510caTb&HeH*RoK+c z5Fx@Hw3jDG7q6IgaS@MG=Qhk)SMyPwnJIBg9UVj)^GiM1#QFq^4nSuZ|nEybHirBKAoQ6vdSa$B|4 z#%X*vtMkqM&XIL5|9SHf{hDyNogOa!WTFdHw%)vm)UJR0XiB7OH6K1=P5eiJOFe=Q zi{@h!M@(CE-Cfc}n1p1Gm6#dTcs^~d{=b^#RWoY$MEtDkn3Py)N{Krtm14JIFN^MTBx%+uDxWv2|j+wxogJkFawm!wG@X$)#Vl3o`i`earRy zSdnZ;(Q2Lvd$2VnhCU5-J>w$Ely}|+sYOXqp?08Q{*QO#m%a} z=8dOjMlsnDfI@<69Ul(frp40vt>`%2ceJPS)H|@b2o*$O>DOEX@wrb;j-5;Vk z2#!?0?@gsX&O@;yOh4RqGK)O!B#gQD{)a|N&N-S~YynG%TW^k`FGZywncQ}c&z{|q zUB`1M24Uy^O3zpo@At13y( z9|*Rl@*SdmLIiJsT>xrYy8&{#x9|XvU8&&%Ulu!3>y_A#FG2Z=mAtGX53cy<7)%SK zzabTBNe)zlQC;|a0QQN5o`y%EV$*HHSA$XuMQXI8-@ZCg3#hbM4c*5I@vfS5Fz@(c z745M7x&V8!S**I!H{h3Wv7J67N7foAQv2(^ zbwt3uso|}wgpx>Oq>+y^PK8KH^HA9GA6BaN&c2V>_G5XS-I)h(i>HqCIJ{hq@9!5?Z_`@9 zC!Abx_1^9XH4>Uav0-oLZo`^x01f=?Cj@o+yu>M6Hf_b5zl)Al%oZ$ zb!|21L$2&X7Z(@CvZ&P3q*;sG)-NtO$ntFzw33D!d)QEQTnTMH z9e807!a(~P$^f|;)x5IQY;|5~R6g&hlzI-4G#Rr<(bOmlQHwROr@q7Hk|4!W&g%q~ zzP{G?>kYPb8(iJ`?jp>HvbNuAIvJUCV4n1xO;@No)X}+uqn2LFu$FyLBKK~V z>nzc9Fe%|mP`XL5th^(w!m8TWFQJuk*C`blJ9o(xNqkxm>kpoUiN)a zLrr94lp>T6>E6hv?_WV@*-0e4<7~QPz1DOb$J|SzFE3+4_h`#)Qm*4-ZlK8{I*!fWnIiXzW_o+*J(z6c)tJXt;@-PnWmL1^$>V;z9enO` zr5lLpCCc0cl^4pjZ~3gR|99H!z^XM6Q6--(+%WmV4f^Ui7hv^6ZL1^d1{&gFl*aV7 zc_uhRBg@yB)RBfew)olf9Khc&*dFQOrFF^CFC?~CU%EAtc|1~5`D9kH*Hg{&(=}Vq z4hf%2tCea96RPc%GooiuU&>o zzlr|3lc54nCAs>Rhe74JZe}v%lxWMEIhcPokxBbkaB*e62#ApTpOunS2|H?V((LXV zHO9Eb1v+ZLU7X?Jt%D)|>4Assm{rVN%+h;nAQr;llD=nx6a?$rmXH2Y+x!RHa#DTK5Ln&!^ec81^TLjX3 z(KBJCBUD3#oHaE@6%M`j#sJ2nOvzFb2nm=)?+>w8Dj@QK@6J{6fNk#YPV3imGri6MtA^kT_M8lt&v&*evd&WW@ThI+Z7{`};ir}I zKdZ|Irun}Ct-f%Q15~gG12HA-H`jkt{gwSyEV2|3Pl{;#tce%x;fd}HrmIl`@!?K+>=;4V3Qgv_0*mtu%z8kJC!k(caKpj+#VpWYSbxq+`3nekmAHBe{=1RQbe8=F_v*(Yq{G2Qm;y~jnFm;wyGSbWV3B~ zG3QhAOdTXr9MADGG&zf2O&)2XN5j+E=#QKCADT0%FT3W)=U119B4xyVOtwSslYWW3 zyTx=VKRHRP4ml&v7kh!e$S3Csw$q0*$4r9?nIW^%imgyoUK@S6z#^HTBU)E;@x5`^O(WDaottM!Q z9hmVEbsl(qO;G&CaJ9z{Go@i7bRorb$i$OS!;|1vV;*E<`@W?vE;pNeHM$s+NFBH+ z16gxlI6YVVSe3izH=*fw^5{~2JT!Dn;Y{TKqFIB~O@wogt#$J1cG|~9qvUiE|09{c zw*q%WY8$~alT6>!1vx(clhXa}jOW>bg3P?sw#_O7o(OwJ+pWCfCA{C?qQz`WPS`=b zx-M}vo2n|QR3dnU0VhSvEmBu*%SW*AJ!lW-=z0TFH&aYqF)@vhogN#iWP-+S! zNBD7;z{LCD4+Y*@uzM{%8{0oujwZ(MpQ!^L{fxkvxgq)w0i8aqQ=D}xPL5ZL5s|$s z!8aq`>++HgK!qZc0^>L}8p5)fuoD zFyDIJM=qvS(S)C@?_C!;hP}Y^xiXo$CK(q^Vu9tdL%V$%@%Hz;<_Jh~>{JO#La~a4 zKM?ETsq+DLZLollDV}iJes$0uzYJs-PQdcV!oiGg^ISMK!rQAZ&7LmvW*jSiZb8vo zh?s$|+oV@ksTTAehV?#BU>99HAq8pEZ6QTJ7Q-F%tlyX9Lyj$}ndgjU>6_DVXL&VJ ztPniViDH7Z2;I#zbEkfgd|W;E|5&Y`5h(W;<_o3)pZ&z$Jl3wEjU0pSN+?bCez~)R z#tto;b@JE44(UJ*&B4KvG4rI}MS3gf#}SHqN!E0AiUVk&IX{2>J|y>Rv~gcV)&PXr zNkOfUfl236fXIp5mclGS;;A?u0qC_@M&`9@x3x3@How}6UxMOQc5TH8%5&vprIHjV zDN=MLJ{^3ty`LaJ?4UBHFijh$9H(;3x-%a^X6(p1Sagqad|-u`sVn*^ zt0j8nDytl_avmw>nXw7{CH_Q{?#SlS38Ba%iWYmEw^>XgADmuAPdPc#h+EEToYq#! zC`b-9#!et(=*MOYNiXQ13#Map4K%Dmuv1!K?8PLUXrTl8hRW1=e|uH)XWkWQT)7qh z)NXzgJ9;g*C7okR8u8Y?aNIHv%x0LUVV<0!)NSdqCAXz=q=Y6}np4@7FA#{X7&+OM zyAnhlB#fPir|^+WmXh=@93u83dD-6a&rN?-?U%EsxU9ERGlzMo5LrI(HXg>uL#B3L z1|K;BYOCtvvH41L+$5pWS-l(Z9F?-<-&RCioAXx}*hq|5$x@7n$NtNoP3Ze2Q?hz& z*U;nA0g6O;p$g0#23ArDCT8QlX@*jmjnSKZQzR+jsBd*U_J^P>UetEi`&TTmS0pb_{- ztLgKqjA~QMvXLfMSpJV)nw7~;oMyWTPB*Jc)Bx)%RmRdK_GnPqVhIdU%97@le^t^~ zVkIzk>SvD$ok-dQ5UZ`_H-vhdrTOLvdQ?d`FobN)EsjgAl&thh_9~qFYH+l0#0b4l zM_wVONS3m38y9ivMDRW}Qrr~(963etb%x-_Awk z%;~DCmV4~o=lP;V)81^lX~fmRVJQ`|-sQIBPn{ivw=@UiQ|}|eZcKj!FV$e_j~88| zwn#iTzSGRQg2$-byCqh801Gr>yBXMA%!1g+G;^9=*+a_uO}r@lCa-Tbn`36S&|CAN zBq!-eBN#icAB*(emnG~-lIc{)asd#cZZeJ9TUq;A*%(9$DRS_q;{rZS6DPj#(AcE1 zr^qq+8J$o1^R76lbL<`MVdKmBL@rqoG|-mYc0_cq9FM%J75V2nuL@aod7rx1q;Xl% zv#oRWfkanxF4{QzWDx7`bFEe?2rJ7qo$CCic3aCNM2pvAAV*7hl-g%+&3d@A;I)lpICwug~hM zwwCZtV1DR;53AzY7!%YJx)V>U9#qxw<)M@cKi6;%trzl>{<6Dk@JR0x9ffb*a=%zn z)L2gU?9;rW@qLSgk{83vs9c##g zN5J*dxM%k4)`i~Hm}}9GimdX^dDqv&$@{_>&44w)*MsHS07f+F&4oL1&8#h6xZAe9 z7MYUcQt=KgZE~!Asz;S?Tp96~6h`_kX!C;#R@Cx36q>GH|PFvSEL zffjF&bYc0qR{!PO2GBB<$siH zMBGqcUpsFa>uWh!q_>^UKWZ6ugG37%K%*0Onl;Wn{NyQ1$# zEeF`J{LUZEK}`VnGPQ7Nbx4rU4PRWDGGGspUx_-zrN-P(lW; z%txIW^5i0Mw^|6-($Bg;6LHT!MEwDi@Bd23NhUiA~v_^Ctx@w%rOC%Bc-q**PXHo}H{krl`_%@c_{@7Z@cR}@$S_WF>( zwwq5pLgTlveTFPCXPzlyk^q}>?VgveX95Bjd=uSyl05j(N0GO}<$&e14p;hiQ8EHK zuV^*RW7=;^ZazzP2!m4T+?Ao>hut(3MO|TE@gwX}C&I1cE*zE;n9H$cKqf3AZs+X0 z{y@83{S}<+0Z-7o{0}V*A@3|W*=)&_axtts=D2j5b{zx9B^zYUYFgKR{c#$cwwUZ9 zwY!@>uGkhGYTP_{1E%q^xdcbsUOS*P?aWTp>zV)PA5CrO&nG=B1p6%)*4bvDE8kjX z(=}NhN8AZ+eSZ^>(ALklXKY1jZ@*PY8A@fdoF&ZuDa!j8u;%UIO7;O9;9M5}VOCF| z1aQ<^(`ffFS@UqEF{ZaKT5w#g2+vk;WDeI1Z(ieeDfgsQP|K=Na`ljLj-s1 z->UP*?zXFC1`FwnP`4Ni&B_0BY_rF5UrPzT@x<0UJ@c88aDTCXf!_mIlfDW+m!dc< z+XjbG2}aJ3mFcc2b0*E>oHHtirc0#p?v=6q{B9w-(`JV+@ei0RTwg?HJsniY34oq7 z)fF5sh@v|7h~iUS^WPG}o~TjjT(#0kkP#azY!26YW>F{5!L zBTt`kSfxcb!AgjM8aTjvi~5Xy%Vxd0xxcU+$Qh=P;}(^#%$@wL1-u^nQry2}Gtw2h zLOyd=Tf$juBZ;>Rs+6^P@hFdnsgAN|@@e=-)I+Z%&TT<;v@ztsplmkZ+XZbG~!*Re51(WQ46I#qJguh?%(%nzd1X}NJZr| z3l6OzY~gBVgy3xh3&vXMJ(SFqH2fq#W&5~eZ6@JH?dFTp_pQq@Ztdtt&R0FjJ><=t z>3+|LJ@oUn+jj5H>cUw+ol&u)a#J5p)Sru`$n-J|)SNx!5my10fQTfdR=*XXZX(F$ z>dzS7`Odnc2Ja&dR3D7FKS!Pl@|x=qr*RtR5x9rYxBrKhGy&D$KYV`B4L85D-9-v^ zAFgwU^rjPlw^eA$mX5!LeaqIP&d+zcPx8Zt)I)vbFjF7V|3h1VelT-hk77cM?Zp(n zZCX5F5hxZJqIl70K5J*_H3K+^+0!xt(fnGi_g1x5u)jgh#s${6{fQ92kXllk;NzbA zTYE5>8~ZLoK>jH1b{NiStF|a?@htx^j!|#ZH2=t#c(W6 z@vvNMHV}{aRNq_XZo0$(fgQye$XgN_GxhfZ7Ww!-&KK=u9b(CT7WM}RxF`YOx?1A@ zN}UCdfCP4-!s*I%fvRA+2ZO73KF)jlwGXA&Tp`*7E?F&LGN0vjvL~Ey0l?et)EIR? zNFfSNl4KAyeAenvf|ujpkUh>kX~Tbb$-)A@HOp>D#W1;sMK@~J{fBlr&-Bc3^@I1X ztk2d~GU%+jPvru8VZ`}iW!I~i*f`UHW(34NV9KpqEfx&sEE0Z8i+d8j^;Tw<;j1iZ z*oN&{ApO^18lB?J?AjhwUPvOz4HVGJ{nD}h1LSkNm}W#lx?vPYu?1vidD)}pfKjyX zTEne>9JdrlC1J*Dx{>$mj+=SZQ|9V z^7H@De*3+0_e@1pmkjMZ^@KP4`cQ~?aiEsP#mFcg7hrmFWYl0Xy?B&dMq(o}biX#v zL81xs4HRr@Uvj`~=E_-pZcyp9ReKhQI2XCy5kwHC@2f*2AX+wTLT%K5QXUGBW{+lI zgQ5>`;UUww+JB#~rk~;nrs>XfI%~@j`oBnK&1tnEea@D=6 zZqdeNq^a6XHVu*M}v3;G1 zo|*)Q-JE*XiQ&fi+`;YiLy}8@5*jDMmZc>>@=0QOk$FPMZAbZaNvqa!S9#J;DY0j? ziS$mBIyWq@++;(dh4heCP(WomT;uBEpm}-3014`&d;cAxD(VA#J7O(7*U*vFQI^g@ zUbX$haHYG(NIo$r8OWtCpQ8CrWGC=}u8 za^O_}&I*x^n{M)brGIQhIKw5NB8KMNvcKfugf`Y0R_QZ=ebJJ=`x6!X{MGVg>zT^h z4huwWQvvz;B%{BX#%P2NN;R`%x)+tK7KQeD8D1vW+aPo7F+BII{j%xX$-cgZA;3sxq!en%g2fe8g|B!^v-P>S<%*P4FZc}y@B}e~# z>xbm{8&}ms1S-*aFSvg$pUkY_>}b-K?Ncd__>6p=cTI^qQRph3A-dX-KyYzZ${;gq zXK{dZpOk|L5zzqIX-7?6Y72Z~>(5Vuq1^N`viyfe0t5;bdySqh5MJ}1dyq^goZW9+U$Z>lSi_dxh8)WVPzRmA zK3UI|+mK-|cjlyW*BP#X=gI^GJ7cj`enpn^e`r4R2#(X32A~B1lz@B`=&(a2dxJwz zvcod17~c^%YG-78GVZAQ--Q&95LL=E#(Bv6OF(yykXVf8tGF0lAcErz*szveBZ>fZ z{6-(g*-=HX4#dDN=$(jZW=486bqp*eJPw~s|1 z@8v%fn%)Xr2+7QajmZpj>=B;1BET^SjO_r@o^md!f7a~x!FYqi4y!~!7aY-D!r`*k zVh1czRJC)ksa>`5SVzpmI(dyd}$c12A8p;6soPu*xZ{%PUA zZ9o+?gq1>0|2#?d_m5M{Fbk*V>)x9dx%g%32yqbq>+0T^8@1j^oJ+Jp8TtNt{dls0 z6U=DBy)AYQe*){^=3B4YrR-`9iD(uQyb^5$pHa z_j_}C@5`yTO!uAp9TrlIa2qe+rle>01=YxN@h=H1ZlHtI9O;oq|b%&$Vk(`4d@U$VbtS{Sx+#rh?R`9dn``OPXS zM5`I~8w;ru)-1A`cNI#Ih{R+g>;0uS#A2yMv%7HJ{%CF4;F?Y{-T++EA<=(1xyP(a zlQ$UBT>V%2M)2U9A_wDqVQA8iU(Eoy4s&qw3tN z*W#jZ&C|m0mBEM|ql|>P=(nUl#>15&KfSv!={;_J7h`~P2*9P)@*kCDu4-tZVdJj{ zX8R8TjN+9)UK~(7pc%A@^*w?r}d#+Qe;OPR($z6x*@ zmjCg|!sj#mTo_pvlrZ%)a?mVgW@gKKb+$`&4Gg8S4A70xYcP~4V=$);>UAR~>^1HX zyOp48!}>OV;{=5}qLTQ+oLudYY1SwEU!+iUw;#l7ofyhAQQBwxuw&*%TtkrQ&GCr- zoY>Az7(tJH15v#K^6O0dZvXI3xTgGxVHSdHmeUHmUAu)I9NOZ>izVsF?XomVAqT>r zWbPQ?wLY{6-I$*Az!d?zw^%i?Bv%721U9>OLmyt=*jYV!fysu{xgy~xaW>W&oA8{K z&yGoeU*GE_5lQO-3Fpgz&#?Tl!lN9k=4F5Kbz!{FU9UWl=*x+3=yq0*k8`;yPKt+K zWbB$H51_J|#cLGgJ8P!1*$@G+R$H#V)^EF1^($8_mI8~f7=n)QIgKf_8DG*2uPiA% zWXAV8#qw~R0+tx>CNIH#zSM-l0CItnkIvL(I?4Ta>nuTB9|CRN)|C0oay}NS@)ejC zI!unSsTx*^pX|;Z4WuhPvwTxr#&!bIp^{Kp5KQen$s*Lw$+53%MGmJe?kl9qc_b5c z7E+S04E0v^d&KpJB!2sUM{E7%hn`5sCl9DKxW~SCs5=5+O939{fVx{p zV53~V`KVy_@^!wHYpQ=Y9U0dWLGGe?ib|V*pnARmbS%J@k|hADJ4vwo$cF$vA*#p8 z^r$3%tzQ>b^q5#BkN}^m+M*|AZX@;JDt)I6?_Y6G-TaE9u=*dGoA1ku$-9bZT_y5x zv!kS6#t;4+;{)Albc`5et@v`dD`L^UG&$8SZhTQ~mU;Q4zOk5 zGT%Uccl5fqTiXZ&JzCtnhS?!x$+Y}DeW)O6=lYCGB%2#350s;g;=E1QAHo{_`ay1g z`GlUAevPHXcvVv=a#yR&VCXkvJPGmY^4Lfh%Nom54^6;18-G|=zZdMJ`2t1Sz%D}R zu!L0rVYRpp_SjD?x&y?fVC zlTb9$$?$DdtJ8B7O*(UU*Da+SQ$j7fC*fU3lI|riTs6)1_f0aPE0>P1I(;b}1W`6{ zc1o~4k>Vn1E)lQhXs!R<_JWZ{n*qVgck|@gqNnevxXf0_;{MbXb`pz<`WHJZ=lc2P z701c1H(32VKM0$~-KB1|smUk=8>}saE=d*4%nx(Sz@0kFFff$~-)|XcNO-(w@tnKl z%=Oa@KzxS)r{_S|S)PPBeNJ%d0g`@&2>k#f41(qp7sh`9#u^X%G8*nJbf0A&HOSMi zrOB8gvF6)cRbVcVUYJ6?t%oez$6^=`YO=LN}fPw|Nj?N~XndsP`FUreOgo1QuY*hPp&CVQ1k4*$pKC_g9+Na}j}mV14t3=0S}S`{?DR{eZ~_!F@T>?WiJ@FD&V1brdFt za#~4+>%5SxZBdc(cTn0k)9qvLAMN^X%ekBtuu=-iPE);C@KNQr^Ox>Qcc1K|Kds~&dLKkU*ituFE(1+3} zh*ca&n|N)|zKTt1_YPw4 zDkNpazSisP0ZU`}!qr8n6!SK_`Bc~5oFRh}Vw`*1+E=SEWn=c)H@J~2N}_K2vUavm zV_xNVv)66QQ!`!4KQCJNS+0@dZwUZF3%&)NOPV|NIio+O=7UP+Be8ec5X)trt_qV_ zdzNp}5C)$Dm(7Py<;D2ISid&!$9(ct_*ZmCycT{Yq)^7KF_1g zu8!ygZf9JKx&enffr2^8qiuz-yu1jn6ZLry&)1pZru)!5Te15is!oBQ*ARW+tIqL4 zVEfynkxeyx!TwxdP^nsx{)B^oFdsdiVxA6DK~PG^Cp2h#%rrp~g{Ipnci%kUGu5+N z)_-WBzw~R1D(jH+`v!=aXF?gh9jB{$S=H8?iei=LY%l9lxPj`tm+%ht?{$u*kZ;wZ zhT>RszdwroC0xCjZ(6ws>Y%A5r!x<*tN0A@f3!KaWkfcu;!f<&-9NZSxHErXnP3Gz zpf;_+YkHQjE&TDwCPPzhMFZ4HrV2-(am>-dEnD1|h$XOYgnEryFIgyw(+;x!_K&9j z;A$D6y){I=6WTIzEk@Dov+`Py_3V$E^Wj`Rz(QZhICA<-q}Xqa(^(Wh0z13v4|gnK zp?)+?)R;yj#)R~@<3;Ur1v193+#ZBV>UDnacJb`iMaU9=Ij*!+<9le(p?sg1@$3$> zk6Ounru}M4#Z1ww1muZLwkmw3?``GCQ&4}bmN14~_o2eVyoj(~^BK7fD-;pIyNJ4) zQOk>{S~Oo)X`lN*YKVJzCMWq%iR~J%*Ykg*fjL-~BPiJBu5bkv5fT)9CJ^YPR&awI zIFvwH`)HmPpm{^1N$XFLmmZh6th9ssK3%~`jV#5pTz`p`Pk8;EMFZfVK@gMYq&d|w zl#_GCk7+ZQan58D3@4WgZr6-xykH+J_53>C9N;PM4p4yjJaO~EbUhYRtcaQ~s-ONt z8{Ugp)0iLT7no+36IoTo_NmjLaBef;)9>Z9(RJYL{52>!C*pE9K4l>05>j*&&GKnM zhq0iBwomN|BS@`NyJv(c@Inv~)&C!wBEpAFnJ{kVjtA+kHwOJs3tUG`ly%v+u|6$^ zj6d1XU;9c*L~;P}kH}5E&X{wBoSK~yQA6v%FhKLd0Dp9G0?i&vLG(oB7kb%426YTH z9P&|)y`WgbU|6Ld`TKGB&#XqIR{UW-fZEvZELhr(eaE|+%{aYOj<@+2>Bf7P5^7K{>eK(ed z|3f=XI)&{?{r&~edEgg2pG+w}j(I;&NKvXlISvY;gwE+5$l?fO_ovWI+7k-sx=_lB zzeos=t@>~3p@%f_yf*3K0k-p6DBFz^m4mtX**tO@GS6()#up+jhSOITfz~vTnFRMSKK89Eo2Y`dCRDi_v(~ag1{`I|a~>R39hBUqvT+ zPEW%?_~xw3gqWI_(TR(*~3y{rI674M`>7MGfLhZRI_yahI6b1Hq|%`hDkkJPtB?3d4l$lCXW-?Rbcrdy?dbJkf(bmzZhk57Coo-$vnB0}@g zeoWk3FG4a+5hkkr`QWRXRJEmhw{)W8 z%}c5XzTc(a(%7U+f?Y}lWN15*xamgQSPY)h_^RvG95yF)Yy>vms-^lyd9X*%fw1)D zGDojuQM*$A%%L0786;omk(oSc$hEYsLw|;5&5IB&R&t`Mm48kc*aclt^%su6A(*|P zuvhy(vd$u^&4!ECv{><$;+7UD?o!-~TXBk(Lb2fPp+Ip6PNBGaaF^ol?(Xg(gnT#u z;12G(gA6iB)_Rjy&OUqZN6J{c^L-2X=h=d+G3@LUB&yi$MOmf3Io;O-hst3!Y^CrVHLXz(}N-ZTi=BopdmG>GSEE}k2kef=mhY&7oE6k{IQyN%=o;?WYXJ`mv8 zlON?;OWaPtVu!l+V5&f6EcX(b!hvq=1TI1Y64nxXVlW@c^7q4yoCZ2(dZq&Sz@#=H)JMX&f8umSM}jQOG- zjEi%>Nkql|v+#V~x-RgKf<1S$RAHs>CEk^|S_1YS5xC%2tX?0f8*$(x+uqt(h^G56 zql~>zZ~e78#yO*gic`Gdq_I_}GW~aB2s1S-5t@D?tw;s5d28sU?N=?Cs}1_6_mr+v zztn9RlSj})LYH_Xc?6vA-Pcc>PWI|cw{J7GK_HORo z24uzi!f^lVT}C+?u%#b77S^YZgQ$?#lHtH(o6K+$vj8_oWYuL2F+g zODh?~UtKohO(yhEg{}29n^(^`t&#QukI~YD!nB*6=GB4Mu2!N!vP%rm(!&rer?+Kw zZlD~#@6r}QXr5kiX0qpn) zz`LgK08uEzvwFQ0s51W##E^i>V7x%BOvTRuztCbpo)PRQdbN2@{rm;E;!x5_zlhZ~ z!!MG_abxJ~mHVYN@D?zVrqvXmI4PdI=EZEt$kYOIcL);-iX6jk*L>ydc zgJ7Jh5LGUuS~+pX(xxd>#|>N8e;betm8aeZ==S@1ae5RVBE7+&_Gcq7KGXG&r33~_xvsb?*4@R^@uyPw zGF;XxHgJ!;U&YRCs(g{D)TP2x8d$__+vVHErwgt%JqjDj{`6Y`%@zK6`SFS})#qkI zbR~3gwk%Rg9GoR{6|>^M7x~KlJ!Lzms{*+;yXo->3L=r+1Hy2QdA_z70==$jhfs#< zECs;J(NZZw=NVFHQk7q1Xvzt1j4=dj3ITpWMkH2Jdyo)4(#s2T)fCv6^9EzCC8foG zD1%Aa1D(M=72Puf+C9j<>VU)*<#YHBi|8Glh8uE(@n%f_oH0Aemnj6<XzDJp~!asavV%IDIzYkFt;WX9RK z62O?7xJ~V6Pa(F~d79aqmwb~7l|;QN!X>C;(M5IFYu3{i|GL)qogiQy{(c0c*-khHj;=f_EBEh5 zXD>+Pso5`LQGMz2No_@~|T+mYD#b@8|^NUSrRMC+z-Yt$_ z)DM{zr?CA3;miV#6c1~{nqXxp> zcXI%GmGMMp3Pt8zYy>J3lpWR~kmOc`z+hUYp5e)KYFkH#Z=^caoE0?z5z-~4xI2?- z&B*_JT3>Hn9F*8)yMmu0=u63{ZnG=>`mnc9HHKeveem3^3}7G3!ujdlZY9Y1Q)k&#HUNlB zH}!f*NDhfg_HWac>O5{QUvvQO7h4_)`jyY$BO!A9_G&Af=S)A`xOoWDv3kvnf`f9v zrrZ2iV;qyX4!b~Na+^PjI?TwRnhKBF%44CN}QVcKOC!2GrLBrhI`2BFw&?0 z$zrKKRuW*w_R;)4qoGRz7^j+L8b((CsFLp@X^rn?^eyyuJO{@)^`W3^-ocI6Gn`d? z=ahXngZ`piL*}IK)Nhl22(i$w$7j&oKU8*E$6TGHk2jz>wv zZbKKxuEp?eS@NDaZxQkLMjykYoe7IM>$^Ciq~aFPsO#QA&^te&A}`?^?U{PK&odj# zm25Y8$2ni~6xZNmhA2s7Di8d(oKg0Twmm>6I?{OhGE5XMN=m@v1hB;+1pD7L8%;oj zqen2OxsV)kig1Yr!@cwdzj`FJz=-{Xqs97s2YV&RpaAve#;&X9)nfzXQCa!mVy@fV zBod$MjFqq}!C4uQ6fl)V*$BL*ozkAgTMX982Ap9A z3}7)9g$w2t7}4Qp%*HD|Q;;y7@fdDCYuS~|mTys;nXZr=ij5((3Z>YEmshe_U!3bB zj(hXL?a{>_UZjqIOYK%$08*$`Z#qFFT+!?$)2d3e)P^XG6t|>obwj*_jH*O5eu8y; z_>dZmM)7bgfF}YFFdoRa7ssx;%QYK)yuBbupDi5 zUWv9yU0hENvJ0s=EaI(|?0(AAC>&pVmU+7>T*ZelI*(=N+p`H5Ubs9xq9OiDVn-sl zU1%Fb2xqxQ3U9eyZG@RpI74<-JE+5Py+bP%Wy=~FzvASTI?=Aw+O7rq8?&&lf>(`f zNjJ=5_2TGhj3m?O*XMqtib%4@&d+A;92#&r1r$pVY%yy*MCLU#bSZlZ*;x86CUY!} zSm_dEJ-1$)IB$3O8eB<4R=8IROQ(na_DJkeSH0%?E*>ommN6~$0iOZ4Uj(*OYRn2u zI-7PMWqpCilxGjaY<3k>GBlFdTSS{588fa;i4oHhb(awc+#|&M>!J>CyiGX$@3V^; zbP;>bcYZ^=oYPGVa@zyi5B*+CjGCEWCe9ofLTT~219UIItB&Zw>jhBMW#7dUypb;irDD1ywCmZ?)%YxDVaUSRLsdj{&X4UBHk@ z-x>W`oWzAL?Vd#)=2E&F6%+EAQYN0CgM|{rWRps9RvmT|&Q8q5uQOH5mHsUgRsKV* z)DyZd9XUvAtfZ$E&piIOM@)Zvi5$9!zV13Re@hcCN@?2c3IrAWntb6yZF=6EXtsG< zPGlB@|3<8Q?#-Eh?UTDj!r6 z)F=t1L&Lgg(7To0z28W<1iEHo-}jf%9|4g6P!_84)ZYw>#?Y=#v`u*AZTarfCL-Q8 z|A%rovERjc#43QfbRNt5rpbx|@v(Vw)Gqj>Z~mL8tZxPzH+XE(8=1ed@O9yPiWNz) z<=y53r_4@>tq~l|(+qDc@Q0fHw!O9;%uwFJQvk>3tLGi$g8K{#G%FHE7-9nMd{KtP zx2*V&Dsz`*2bb*^U!L*+<0qswv$O!N8m&ub%>PhGfcoE06j!)gVvJ6)sP|IGCL7W? z&6UeZM}jjB(VZ7Pi$oU3I>o2q+%B`w=YNaNwY-2PDf>TrRui*qbSSko&r4hP)T;i} z70X9HS;_ACMwRi`h$ zYBrIlN%o4yv*prZ=`LK)7sZ#r(#`)(F+5u9IrRiayaN&1K4RS2oTJ%45?}q&ex?a% z=&-qZ)}|*f4E=b|22y%J!_dJkb`t9z62I%zm|%@`FPwVF4U9b`u-Q@-rY$0~G>s>d6 zUF^le_JBSX6}M}K>v3qnQK3kanqL#IMq>Yz7l&qa965BFx(Z%<|7KBN9yb<)y&OHL zzqcl#A)DoB*8&R0F7V_jnEDFQpTB0aa(LwwDeWxzFGCYI@iZ&Rz0y#`K6pIIZ!qVs zG$TQew=S`k8VKAc;8E{HYUYjJ#a2WaihWM^kY^}+9oLK5_?a8!K z5ZbC-lF}JXHx(O5mK;!Dazr{o5ovkmR-B1k|EQ0YIKEFI^Do%l@;*#CswbQ#$=M~` zZ+RfP|0+5CoR#qsH`fa^uHGXpMM>y?5Yo-ARGPWc?pf2SF9Ubghy~k(X9EDkX~n`q zdc$ZF+^3(LY*HirnGS)KpUd7A9KZ$Ls-lsbxznGljwwj8I;BmACJqSvY^N(XVq|yU z?;~i31jF=>L1(17RT0mO8@88ySuEG8ce@)Rk{?ay@j#g^RBQ7}5RWdBd1O~0tS)?$ z-dxol)XfRSY=e$mSBTP;k9fVFp9Oytmx84~0c7fZJAew+(yqR~_72?tp{QEmya10O zk&Dus-Kkvf!42cMGDnmk#HcBE-_6P-(UroqDk@v}iPOl5OU0Wbsbdode*@0wU>Rq9yFX#V7}@{<04<*pLNZ!r$&U~;aWPpcY!Ww z^;_p`#q4O^>;X_&n?5t;;cz6v#amDom7yb5?Io}CvyFa)sB~+HV7KxRp%(aYU~#gA ze}|Mo&YLmrB0+j$a6V#KB9nXpNTtWfe%FccST)tKcv88#@SAvSsN4rZkrhn7JA+~M z<1~W`oz-pOD{^!^GBtDYn>HVA@2XZNcL2XG({!orq*&dQ|E$nTY?*o@vDbz@KEZMc z1J|ARc(K~i(YqZkMT8t**jj$t7thi~9`_zBmFa3lf;ew#<|DR!bR6zY7t@e)Dan2!#*eM6?w$bml<3m?}oz2$f5p_5|l9KZ3zXd-3bCPJ_;JnCS@HU4jC1gDPzoFBiyR%H@ohILH}xA+@c;m#i|N2O4R7>Yx;- zn?bs`gvHLgBJNvElyM7uLcYh<46@o2vUuFAl2a>8`oM0GmY5whWW1S8i^&bSA*g(FI9GR3YM<; zH!nj6d#2mP%T=K8||O||8oZJYLg3DBc< zuP`(y;Sqc_K?KW|#H4@5o#Q@ky(mBcn`;`(7QU)9-|IsMKr8U_g1XuI4mOP0t+8H& z;fUu5XiGll0R_8O8PExQB8U}FOv~aQ2~{RAQ+Kek(s-(he=>f`)(YCT<*qO9^!A^A zZiQbSSsugGG7AD9%4Q~n$C)=7I|*cIMo=m-*gvifR&t1FsaJwN|F}-BA^vIlPC%$* zZ`B^Ye0PGy${xDQ7f5q=k(7a-JdsuzGQi9{m+4(5o7-HNkJ`ZKle>mIvO%Hg_KJ}< zM6=B4 z3iWd^BT0TtKRzZ$GMlfaZy~r82KxUW~srEiMe`I2EsF}Zro zs*vFgO+Df+Y$asStn2qDX26S&%(;r{3FyC>r!|PJ8ChwKf}GD{cXKx0j~eRI5Z8EJ zrMaI9$J+4l@+B%H6Kr!Z_h z=7`M%8Dg38i9RkV#T!AkRR^pGSihh*#PKV%-<3%hD3VdRm&+P8W4fw6mdww$YUda7 z^miAczD`9(fH4^G*xAX34g#xxB>8B(SRW)%ggeWU6~g8TlFU%9e6-oIs4BnpbBGCR zT3Z+Ynn|U!otBjS@-=ooQ7$6>MyPP_RtcSnT2^vuBSX97ER4ljh0?}}Rj8jQz6aVk z!_@G`aXW@i!1~K?Ih@w-7~;j4#FHOHfUVaSEHD+VT?QefdA9Qx(wVyb_Bs+tzY&l9 zlcuxHCcxXpny^@#IJ;kaBTgAkXQzCtM_*L)!u*7;D)sT7gv#Q13=O}0?zL&ZFDa4$ z5TSbXAIg{6^IXlYP0LT$cirR_Kg3W_oKUu28FNNeiO=tJm^e?Vef6h%C4W=pcEbw2 zjN~?1<%Vdr7d~Y-TGWeW;YZV)u-=Gp5+njb`5BS!-^Z_mP}-DYrXrii&rV*_%O+Bk z#wH_M)hre7q~R4_f?|@Q+9j`0N{7TV7Q;OTA@_6fdWHH@u?-Y+Th0cw7ya>wi73A7 zp*R6WC4uDlIY(4-Vh^kj$ucsuL!D((kU|8xwAQ~yrY)Y4nHEhReB;(&gU5~6SGtfy z|9rzs$K41O&igT6Td`u7xq5Mfufj8`l%tfAAqS4@(LOEz0F}Puhu!S7(uQ9a94ZJb z{;>Cfsuo_5T-vS6SbMl)UIDvRx#8!03wo~MeTBCZe9 z_kw2oM)M%3Doynb8b=?0k+d5(K@;cti<&kPz>4BsAZdvz*jW3}pFB#|hlwsy>ewLB?0;Xwl8nf=DqL0cEm4V+ zdOIqTnL{v8wHW7Tny3iE7Yi-^K#K3Ts)Mck6n{rY-O@ctCf=}bl@>P7aIpTx0|26vQ%3Z`O)XP12fzcJ_v$;8+^WY{oA}g@gbHS!*&pO>yuqTaeo%YGZk&$ z{&iA@!GU3$A*lUMthh9vydsm+pnm?`ysb7)88>a^G=sVr6XGO?oP)l*u);Vp6((4m zJk>41Lq-PneCUBxw~@a!4O?*E$}J<8vy=a!lw~Y!QJh9=tbB-E`_6?9cJZIQJ2%A} zXg3sXz;j;3~DZzUz=oywDXn z0&IQ$q2z}b-?b}8QmCoMl&FsDw%3PL4VSIuvh5#C|Dl}B&VoLelk>~%_QDSSx<80{ zY#4w3np2OPW#}TNP9LCizrRuL?dkk!f^bgdbyq?g4ofuNct44%MwPxU7<#dK$1O#v+#ny{KEJ>|FlCi1 za7eusz;bpXx1CRO+-t+4jq3ZKd8hacw~wxuCWgLOtNpyE?^l=Pz(wVjc$ybK-?f{r zaR*u4YUN_LsqqLe&L7e+X!lEd8KLu&tt=*|WGP#CrFo)H7k)A&{k)fX2I)GOJU8jz60RF4GtP|ijw`&le< zBx1U4_1nEcoY9e!Bknz#CTb0$3=h6EN!@%2q(vU+pV}UfA)u7(B}nEn*~GnOXw699 zL=jh=l5DyqQBXv^sn};NbVcO5c__cs`5Z#*q%aFi6mdBG!E04Sde2GlP>i6#uHtQ9 z6ya4k7nUflZ~DPnKF!WCI~Nt<_z>CY3u`A-1SH;YjWH*bVfm`&fB}EX=l+B>XR4C(PE~DD_v#LG{GeG;Go=1V;Npc6OJJv` zMoG9;lt0fm2An=nI-FRENnA2DvYCykH5i)ay0Z|JC@GVV3Fn;rl;Q4?G$-J z+&LuW)w!u!_V%riWbYWW{fCl&!i|N3*mbiIKlvAYSH6WzB$O^!YCJv=WgG0ZYwd#Y zym&7{{>!#b&`Z|^R7ry(u}I_km&Ai48&@MSTz$f!u|{u8VASS2{C+4THusy3{K5F|bnfr)S-tE(r9Qngx;!`dg}}4r00nuGfi}N@3=W5jS;#xc2P;x{ z^F=BrLz7G`1JtZynR!)BTbX;hivNleB&qqjmr!hzhLxsO^B$YXl%L3nET>Rm5H_1H zuDRbKOY?TKc)xK_2&QQrwlzQ;r?X{y+IiV#-?6+)k4e4+X5};F+YrJpNUtkFxVAp7 zC*Ox%2}z{saJNW2KA^lh@h|WBINz)2l|XN|p}BQ7K`_2rsN2~2>%NkTY}&8w*%Ivo z*~dX2IA)}%Mb^)t(qFNF#7xI=P|xVCgz->2S1isuD(&)Hm%IHY#M)%FNEXnsS62eL zEuFEeiF0rq&cXKLOM&3F>39?|_|1AX)a)a_F|CJZ&6!twqcwPO9FoxSB>MO~n>(!?JV{XVyF*sYtJ= z(Uc@lM;M|#ma3HY@v42#+R2O!kLVWayd~!6L$4EB+v}=wP z+d`LsX_j@r>hND*{xwb4TqYmEYLz<=m}UPumb zgN<~?S&g#pw|VSXnd#n8f|I!RUVx+g<4WtC240L!ulJSO5~v^ElCntO(PgzvR=RbP zq~mmGu2)`s$)g{ZMMH5OrbESKB!l!rJ!5VS{+zsp7&o`j&nK*(*T5QGd&u5=FPqC% zWM*mtsTEh@CtW!SmBbxn$%!|-2rvW?zd3eZo_rls+(pFnVJ6!^afwQ9Cy-Z8mFh6v zulFl1#b?-T>wN*=Q)tddQ>E7#(}~LCRK0s-y~GbqXN7ijS6#9EG?xzwm{XS$3!ThA zfJnJ!**nNj>{`n+9kwiSnat413O4liZyS+-X$s#o8+5?4_k0Ir*}1a33aMFdJiuP= zM4Im-B{_Y(g!o2$@Wvxma6UN8n=7vpC8K zCAsnueF!qIxJW!2n|sbQLOnej*w@Oxt>b2m@FSEwqN1UUhtBb^sH0D$GLAIWBx6=l z2mJg8n_pMz@n_ZTn2wf3Dt9Tb0HF06(_@qDt3}hRu3!0c?wfCqgY_0e=V#66xcZzt zMWem8c5@hKy0~LgR9;3hg!cFR`L{DMk(yeS3%_B`+>r>~5O;xGFl9vW2a${o^Ync`)g5Ec0pW6B0Aq(hs?nKN z9~bHh{SjuDslk3hI+?k5q5Hck#UDsVM5*ei>l7rs^}%gEFQR^(S9{SN=t$t@(tl5_ zcJ@1TgLu?+?LnJbnF@vV0`$A9?jdID8{IW#w2JU!cp`@2NN8=X;{(xuC>3!a|7Ogb zOGDGTtYF5lPO{Y#0UwD6-O}=5)^Wa?v$ia}y*@+Fv2Z;ywO9Y_Tuu79pVWl)N<(^? zY#${=$Lqe$Ib`sG5BwUQ&3hKJvEdS>7VQfgbmHbTYRl*PE=V{O{E<%dN0&;MjR<|m zOLn`4hn!lXZ_zqu<8jCrgc|UcjeC%<=I!HOL?hoQ^)bCNg?EDddoIe8nTZPX0;yE6n0B7lN9U5)*V!+qrrL zj;!w@w$^nlUc+)={}{^}4kn~43sl6c5e*XLZ$!nzPO!B&&`(cFaDyF9PP3ElA}tg| zUcq0k7XFrbi+{K#9Gg0>l{@9LMB`Ba?5}VUor`qZ`L!T15{Ly}Kk+W8R0R}s&>+LZ zr>ZE=p7sN7sx)lv@^=FNC_4L1-Dgr8(SuIK_L|7&p(K%269a`$jjbVPSt~lvwO@9V z>FzI0d7(wXRZ}Y4&lE3rO|2)wA|^%(Gs-U1GIZY2ibwLJt2tG6qv&ir2SKf%UqYsJ zeWS@o(@oFJ(3Hy_2_kzx9kKX$KIpaED8s@psUMfL8`fqVkG)5eB^ z3CzjHq@}2( zFe`n0?jfA>puLZ-h$l$Wj(T-K<+dNl*Kj((!m<1$ZH?oNcn$|BS>vzf{7<=9F33e|5f?jTsf`2D5C(Dfni6N2h9b)TAXpv{NR6ZAI(T@k6 zREu!+U4MDQPVaWvlHydp_>kBOZOM{;IbuUrvdQ+X`LFNBfV7T!xDq9yPp0)40 zWxy0SN4Wi5O*jZ8rU)`5h7l^)Jgvlp2?s4P3PEI{R=W~VY5qFuRgoiDEaCC{l<33# zG|b1uQtCyznf9&t%zAu$;P|*pq~Mt7)%>p4@VCq-51=Wg30a0n{mz}$5)JLu=S)w5 z>|#~o`bX?fk=6tD9Qgq8*_qmFKE=M114TPObZ_V_HFurjXgu;yaF&ropj zajW-Laa45I@~^0sH93Kfg6IIp)bcNzefe?TRC@rAme37*VTN`E_e%WJKUNV<1OC&C zNzQ9dQYWm?#AqldapI1#aM|5vF9&$|DSK5EX9pkfxpzQ&|GEEK22yqbe*G@PiT6^k z^2{pZ9fd=`r*K%%x_QE*R2sx?7d?wdVun^@cm&)jHs>$vM|w@VBRd$?FtpmMSGR1F zu3c!qL2A8zAQKv26TBGsX3L!_9eU#Bt-1{G*W(E>pV%BNy?4mp9;fST?ESLH*EgqW z-te{Rlbi!DB{KrraJb*EVp~-t{GM{HX&{c2WC~k4j zO4TuY;irn#(ny)fRKzY#R@h1hmf8BZ{+R{!CQ~Tmq*+a5Lb5PS*7JCC&-db&=SR8v zW}yTw?2MN^F@nXKsuV##AZ?Tqf`yD7}`AY#EKzr9n#L;${SMztKUaCtC zc2L#B<`{x~N70Niwp?Z_bqlaz4vSQ4J0D~@~m zY!QTh!xgNn+voV1G&F;N58~}aMA)xAW$*CdaH1~d{(FD&CT!VE`uyof z*LRSm81tR_GU+x=8qZn6!@{XnfbdI|43oqYVHX8LWUT`d%EvAwes^uU4t5#eXFLox z?Od^p?WDTm3~Xniy9tfhuH;!OezbU7g|t%Ar6b&j%xv)c0x zYBHXMfBS(H3Md8Bi0T-YEfPyCJ{($$o%Rk)CHwj21JqkU|AedglX0=j9yTwn#-j^d zBf~!=$bw5b3p zhRtmay{;X;Q!)QwM32&MQ>)KaK7zPLz;5l(!7^p+K5V3tjrxRrUG%-SVHTe#RU(Z4 z*wf_WOkq>T;AeX7&iS8oZ1SjL5(d|o?~HLcaexAU1Fb6#%_fE3IKvhpn)MdRY4=Ot zLCAhCXmyX;aDjU3T&rEDKH zbw7z!2j2KksCRe=4-H&cO(+ur^RI5gnp0#Z<_8ae7j-o3gA3hS|3(T+rRekO4LCYB zEALkZcgaMfaBz)Pe*8XBZCsr{xh!@}Zl6GM#C#HuI{B{-ntOd@yx4y}g}PfQ8%fUU+hSqSYDZ0i0h=LVfFj@>UANB+G_j{utpRV&`}naZTwqN^(ix5Vq34`9Kw&aFsc6Q!U2#c#}iOI<(tJCHV3!b z1`}W?KZka;o1howqSqmp+@JQfHcKBM!VAdgVR0XqczSJBVxeMpUEK&P7-rA7nYhVF zGk@rBqH>n(*2vRkP3{K3f_AB0tE8H)EGA0Fj}k|*%Ho|j_sEOFb4Ko0o8bj|fl!cs z(5*&mO>+$U`F#|)=^HUUj=hxHL6mq%bm`@|FWZW4TG??b1x7vqhPM*f9dN_7Z=o)y zH3V;^ctSQ<$#q^`CxTZiEn?wdIE0=xDbnXED&&z_!=7ngrQOsJ6S5NRH& z8NoARD(!$dEu=BvDUmubg{J-L=9xWr4tc%GjrpJU9+#Jh*+C{&f`cO~fRRdbhyFV+ zQYc3cn^^!`+GdF=Oru}EjoS8e=2t=mviqW(jRCEagf>ReIZdVbApI*0)gNo8ZxY$d zSVbX^I@&3WjYO~2x488_Ou1KnvC?s`Zesj|IVnP*;y(xX1lU43_^)~Chv<<1bN-oZ z1^JD?NSd1NrU=rI*1x=@dbWQ31K@1cDRzyoLV09UPZUATGE9G?rU==s5`d{% zLJ@Z2T7dFdf6o$xulye5z+ac(cZpQ#qTh`v)605*C-c(ribwpd%`|{7ns0{N+wkNt z!xxvpwtCEHKi1}OCN38E)b`$_&^ETxMo9H!)zfjl?s$%*PsICpAqbyabIY^DG*OAo z7i-eFpYPdKjZw@7&ocYLh^E!|#$){yt<^wm%mH_j-TVR^n~Dp5I^^zjE&JqXp{=>(M1z z-tewHy2W#pH)YdvP3GZ~-moXB-1FD2F44bjr3c7Dh6G-a!Lu4(kb9Z{+SHSY()5s}lSx&= zvv2<8^^#B8s$?s9ppy~lW9-2+E|8JLSf=W~l#7v4|N7ZKhY>0Ncz~F^WEGJhOX);e zuzHM@`RjZma@_|HO{BRa&&4Th=@g)u+jz3EXu=Iy>UBdzh|qTU09c%+0HD3N#Vq1G zntRvQH?7LXz92-dzU3^(Z75FP2n4)8)wQY+Xu^6@n2))KAgAXdD-mXiuZ5UIN!o$U zJF(WqucYOI%x5ytiF68(JO3QZKUm7+9NcREa?r@PB5M=ha@nE?^-B<4Xe*bEtm7#O zR_t%h7bM;Bxl-iWc@`XG&N+{Z5M3C^$cr)0;R|c-l%#w@eo6)g6NR(|Oe2!}SnYLC ziwsTDtJwD0ev#LONqLgfs6(e;lRw<*HOA(}Sa0(M&eNZ1VH&CpKAj}Qp|RnMvxX8fS^)gOxG-$KE>y%#%$h7o_8 za4??SEa@4hJE_S(UIFzIguzgc9}bpmZq~w0_}yN6qM=ZVXZobyMqP%|pv_SiQhcvV z?uwjWdH=+WgsL`rb<$Y@LiuzlO;x7ZcFc~Jgb+HzOcf><180Fh52>Cno?Qzh1i3LR}7m|i*waQ;^unhXl~^X(QWmv8*gWS z&bG4_elwCYOBW;^liN;SO(1AyXbl0AmCm~kmtz5_nMs`^$f~;jdC)!&(g2!79XkWF z^FHswun*~?ciJvPAO7dy*tqF!H~r3*X+PO4DT+v1+bz>Uhc`ziq||zN24s<(8&O?w48Qkg!Yd|kke(_H=bNTR zFSHk7if7hsnqtRY3Cxo&EclmYuaB%a??)v!-N0TG+~<)`T|0Uq6JeE#^sG_JJ2UdJ z$h+P^S2o@0xt14a-=Yuyd|0I2FmRkivJL{j54@k8cM;F)nwSvX_E}^YimT{8s8iY?1#SNP{4Wc+Ts*x zBSmMTi#>lEPK@yPRcWIy(}{Pu7$EH1%@`Q2&qjO!-1sU4nF**-nEevqhAq?2Aukux zn$^pHA9LA183v*Wc(Vj8H(H>ZhMVR7ZtM)wUkh!Oilx>Lto7exjCJzSJMF}Wc4@{? zd<)=U?s@3ahqswtNC;d50(A`P2F=zB=FewLuk5@`6D$%0ySptlvP=6teFpyenzt0b zR!Bt{Fzr$|;ct$#l-3gv9zBwPD~~-tYp7hGCFrNUYodzgCfjp6=xE)chO~QbA!#C6 zOvn}ri0o{F?WAM;^%|*?MzMw>f0?+$HR02Ote23PCS3elXYeCe#vSFC`U;{vD_xGz+cZQ3v>G{9p{?PyMDfWy|{N%+S=(FULv|d!P z(lS;|>ej%0PsZ`R=}V-gHO5=;&ZJ%ZFh=e)vfm}GFjlN)_%YZy@#NK|abT0(-KY=2 zH8FaBA4yD@|Cn%@3+VlnE{R98!Lt6x7EKxDn+3T>Mv#2TeZN}|H^rIG8_e%q5-ddZt?d1k6Hp%hA6F z(W0{yFYhYao{iP;xcOh)DJFW{Bl|B_9ThN$kiaJn~Kh@V5%QTJuTKKZY zBfz}IL!c2q1QCIDY&jC0Y_L{e%ca_bN>-qS1`6yK#$I=gMu7hz2}O#9K?Sxxwmzn$!^I$DSaeE!2C6<&>Ku zCy5RJU-ZOYfpD`ff!M}}b5z$BmNoW?L(|4(^UIqY=lwvMXn)Qy@n z{|K2!3G3A;Xd)!Dd$$({a&iDf97()4{0tNAIauecdgnP5d6Lb&GKk4OWiD1e)gVUO ztJu*p;|6T#oV72oXv`$aN%;_qV#Ob;k@SEM4Bejz2d?B66@j$ca8s{ z2*79@aDH>ucN|yQc%MH~b>};(!M_6QHBp;qKY! zP{UpPq(F{bAYN4trk3;Do5$>O4m`ij=;+0BQPym>L3CXC6$9lFWzemO3jCITw6okq z8|KhK(_=51t`Wqnu=8t>_)b93jttIBAT4I&e=LC=+Bba8q6ug1S0@HS^xwM_n|%9V*wPXIxDP`o$!&KrhJOQaSRl@NB;w#0f>e=q0|zJU6&r zg7X_Y*q|{Nn;I%i_%Qv@PC%}fL5U^;*SBiHU!m$%{qBV0M5dO>@&A5)4w{H)3^NK*I%odc+Z*ryhjnaJK#e#rHc2bwbe6b@$Kr+ z3zF99BzD@IX!7~d6&V*^&@D%;EsZ6n#u}t%6&}^@n6=6}818d1Vr7lXv5?T1>3r2K zaf+aVr}G9iwEc%-981}g#oHFulkO1I%8tBcE{zq^H$}Nw@MfWU4w6@^@6WZUm73y}IH z@zQ#c*8uz|;0V52`WHa_^le)?C2AM5*EiKJWkHirT~sBy1JMBUMV2SOQ1(;Y7o_Q> z3o?9ow6LLLL_Fabx9BKp4RpID0PkNU-k(5iYY&=y15{}`W+J6#`SdmbE>%NSYpQ(yHUi{R?rqtoSDX(;KXlQ}5oBd-Y+=_? zZaCn&xYsNTfF2W7BRekbd)4cAq4jPW0cVTxPhKr6;z{R485n{@TOvrTCHM7&aIys& zs;Qt?0#L9as=+!+G=WfG1Vxa#E)u3Q_f`t>aY3BMx;1%!BHy=cqvu9JA@XxLZA^ZV zc9X-)=`{fj=S!lJaT(``{ILlk1ge>iglO(~8yBuuXem`<#RqW+4ee#M@Vg8knA~GH zeuM`a|LZGzH6w{sw`FD#04U)KFsU_LJL3(q(PHuU)9AbCvbzE9O9K}#*X_cPx5me< zVuue&T0OMszRPcOL$%Lh@@txsg%GQTaoY<_7+h_HCLCqi4at=8o&QvPjYSH9LH zA1&mp@t^u+09gM3@z6gSqEorzxsL?Hccb`jYkgwuP5qwIVN365p^bBlo^ipzB>E3Z z->}uR-D1dJ>C$hEfw&R1(2<-no`(j$N}a5tne`K+DBn#Fd-x%vlzn5x67)n{7(TD_ z$Vbox6|?cj#0{YQJ)YVViS?Nl)p_U3A3TJ9059WSf#CfZXdV^P^rE|FX#UYK{{W`5 ze8=(_ABB9S`$qVH-}u5055n1pMT8>&c=P4M$)9jGui;;7;jT{c+1)$q8h-Qt0JnDg zQ^PD^=czpq2*cI(GV&%d`a!I2uw(Xz-9lrU|K8w4;ToO)J>9<+m=mHnGq z3Zx|m)gO*fsNpp=x;mXt#6B6C2sJxYf@>LLD$sctnkfgU#sFTJ_32f0Zxh>Jcy`jp z%HTL@KYF4)t-KDWDh@uJS2plO?U&^Dq+4i}w*}_EA<8@lt8jxUAfdHdwo@@3gNca^S3^agqij!!gXsPBCLwKT} zfkr9w1%^>lU{Q)WhDC}oQf(A%6mtV0VP9$f%<^Nw8r)yrze0Zq#e87uE9_s{p@{It zvmbA@ss8}S1$>_xJK!Ix{LY-c+Pd_Ak@R2IlxCX5icQt|Dec)3Wag7COx$SPY1n!a zWYm%U?zLZWso~^irHaL@8d{Euf~-d&=AtYGGDbVGOsf@%yv3t>dQ=orvmJ@Itteqo zQi?SbNS`!RMAC+(b}JI`P|8RdPz!tX%`WPEe>bL9k z#lzRR{>nO5i>iD?)^0U>7_X<%wB)(5GOp!%rn zl{o6KuUQ-E!0v0kSsqm7WPJVce@oOnLGVlBCWE8e&8S|iNrlUCPI+`yAty2ylJ5OdS5e^DAnho$^J&~7dK8(}2(3XJSaD7(FtK`O5!aG{R@ zN6G=|Uy^?j{s8M=v{#JvEnmVu7lXi_H3i<{-Z-8eBIQ$@T#yKcp7u=S;Wq4|y0(wbt;E&cHvazrNWYI%(ry~o?k~F{9;4BRLOnsN9whMR zi2N7gOV0-Q(@UR2*L3zGYfFKK+B!UK_hgLqS5iS8>mhSix4Jz`CYkkDg#IR4c(&GU z8&o4r*4`Ib%AK*!`%VmG^nCvS59MDs_=8>5JV|=Dy6)G$jx>Te?ugob z{$!TfyxVzz$>;AQ{YQUZweNqjH^p}NS@C~MxxAms(e=_a>+GB;Ntk(ZANM5MewnYG zRfsvM?X(R;QCTjuxS8XP<4Jb%BQl&T9C8$%r=YCxQgr5=OUF`Gpy4;VKd1x89e$LR z;UBZ7#9PmZ{{RZ~%l&N{9eYau0Eu>~8}NS9YyjWxB~7ve-zU<&$jQZh-8amWKQ5_F z!cH%sj%p%vL2Q~#is(qr!h?!-;Zf$CDCQU*Xf&i^n?htXIcv;yJe&K2kK=0e^&InF zd8%5?9o)syl}i!{91-i9`CcPU^0a=d;e6t%)>3-eer6Hoqbh21v>s`ULFr#C(fbmR z%P-yD2@miqx8y7H-tI^&tgbaH)(HeE(z_Fm2PVHj+5D((r5#oYfAj-ip57&~x74&0 z(x!ZinG?)Ve}PWoIQ7MPc}81P7l@~Yq`m38FZ1~sSIj73DAmQ{U7-bc-pjX}>E=|l zxm`(yp%i8~q($1T*BI}L(iIdjtWk*6U?{~)&3|y@*=;-)7M>EG(N_Nef`1HWnYB!Q zGOjYN^KIL=W%61Gd8X~i;+;wkE7ejwc_KZ8P%2D{ZU@$?=1w98BZ}+1CF6e)cqZYb z(XKOVDa_WAjrQ{oM?>Gxj1kz^AoGzYl!%T@L$4yIk~viBh#YL z{BDZP6sIv+-%h`EFJc3!Jb8*Ydgi`$%M29YuTN_BEd%2v^iV^j_~IwkY+}Nt!kk`0 zy|DiP#uxjo&!H9k?ZzGq&XblaC3o)!{{XhX(f#M|{4L`YDk$M|rRh<0{{Ry0=6+px zme0f~;vHhk$2Rg>_=+2sk5GnThK^<9XvZ5djhOj%;~ZDG_yffa;%hb0bw|##r=}xg z*5(E8p6;FdkUmZN(D1RAhXuGM;uG@t(h-uH{o`!tg8l7s5UTl_;#w86981 zpGExNKAu|jKM3(}jVafk_I?sqOX6wn{{WZe*UwIX02~ThTw;>HdOr<8O(ueCLekTJ z?!lCm9&Y2uxpDU50a z!12xJ-W1Myz zl1~5+a%+RTx|&xnG5OeI0OTV70Cay-+}Gp2F8HNSA;NncGfxvW?yu!v%>MvI)%qWQ z;qL=WIK+EO%G4nB{`3A-`jwsX?C#s-kmcLd`VT-m3hJ-a!SJTc6SegnetG_>X>KDQ zlRa^nIIhE4isI|T9tqH48|0qe>V2cBlJ4hd9^>!kh zAsSSmtgmI{l4s4o5wy?k{{V{~0PraVn(9p-PjQT#UfhOk4!?D?U(m;oEwAppQ>eA2 ztdDOANKugqQME@LdS<_!e~5bawWNG+zwyt6q#k~Yb8K|`sFX~R%V!$K89I!s1A|@W zllx(OT+yd{i@;^Lnc7Kbj^g4OB$3Wp$lS}4c|2F&WmQyg@sC~p^Yh&E3hGvJPeq~n zGx18}cZ}|BB>7s-C}JvjBP+kB{0hEE_`UmQd{O<9JW-)qd@qMixnB@j8?8zmLfp(G z@}_izBcmgy8?%GXM;uotr~cetE|uO-6=>0Frc;vgDm#&k47LFz3=Ep`UyK*u1b=9~ zcF)A#B-HgU2&8B{K~^(9v=dnJ}*x1 z{Es%h@IQ-eNBTyUKh`j^5BzkR@ZXH)??L!!;v0*oB)4xMMm)qR{h63g0hS5mjZ3>QMF0{>13Z3p=N8Ne%N{aopohd7ovxhPU4)-y zg~=|_@gwK4exQ2{S8ZBuO2!msqVCU4p8H7f%9AQr#>qgR95=h!N^5u!Q#Qy-@ zZ$s>RipT0uYRui^rFEu<*;f8YMe;G_u*fUWgWk2_T!CIQ;SUr2q7v~(xI}S01oDi3 zi3$D_)PIe8u8jcI(H~1jxmGwWj3S>#Z(m_cEy%HWs_PAgq~|x$EBySIo6x*oYlfjs z)~z)d_in|zXdRp76-UHg4Nn?dz_#dq-)Kt3K0kI5zVvQ=JB)NcTJrH0LCl&8-#oZlKu8?QrG(t-{&r$B>hjy^Zx)6`PZ|{;r{?{ zKhi(vZ$p6aAApm=(aPrKr=H~zWbGK)(`f7Xp4I5b<@tNlvO&P76u`;rU(vV>1q>}1 z;qdTLgmy^$zO`zVX6jXwZSL6Iahggq&M8fGGE)+YD5OA?v{66>6j4B~1bR|`T3U^y zRQt>YR7sSQ)sE{ESD1Zz zQEeArDi+--_GkO6_yT?F^PV;FYa-$F)?r@tI#Z9mH}dGb+eY|YyAg@jjBOw0dN1>T zCG#?;)zv2!kt0bnkChM?bLoW{IQQvSZM-|GU*b((f3ZQ=WMnkw*q&8C@#FNa#?IQu zOo+p#Kpfm!jM6xl>QEu+kBiOn^qu_vB(;N5}V^bQ8v43Bw$uD6 z-vA0I6TsO(h6GpW0vD_ zO*tL8sW3WWaHBctOwAnhuaM+E8sqbdGn!{KQOF#~ zl8~B@IG}URXyg|$B_Bgkd8jztGwGVMZEbmU+ik6-n4j(=H}VFKPQcj-T-0SfE3LNh zUyJWQDFzAC^F@-ACK;{{Vnr)#qc{TfjaihX+whZ7?49cQHr$ZYHb2 zr1*bD)wJzLSMi>a0@&QcIar!ohCWk=AbCjMopE12x-6}}U}R(6i6`}-M;QC60H~bX zRxUVgZ!hcfKUy{2BzX4sQTT`O+Vb1QS`GY$c6~NcG}gt&Sto#ESlAr(Tvwe2!xdmd zF9Z>SJ&5gJoq8Uru6R>cO?$!`t=566L~^$_GPE*r*zGuB>JCA#sysjYTV$+iOPt@4WWzql!!# z*YM5H!*7Za`M(JKS#N)LjGZ4+BFj@ke}pTLk`dG8Jk@ArR7Wcic&XMw(A9i*rwXLYE)IF8 z4mdScSdN`4!mw|xQkV||G~g(wVxKWeB4UhlPaN?|1s5nlr!=_dnsB2Yv;bhyj%j(L zzcn&tBs73~QWOUjAQ?36qZN~2k+Gm!3#%^@*>y7MH*7!ckP?6Fwez$mBZ~TSz})zS zt$SVB-oi((nF9e|Hb-scs6*9wKU)6w`X}OVl;WuNntyseV~MG|)0dMV$7C{$=<({)Yu^V`rt>#iwcUh;86qs-yKM)Sjf&&|3?;+e;!cE~kbZf7eMB zf2}@VNhZ4A1X*a-n$Dl&4;kMkli`gX_6aVorB-0fj2&W)5Hq`y210{@l56)V zc&^7mPqTPi##*OA9fiCZ{{XJ@az7zmxosb5h^G$iFTX=N(zGP|Bj#Je%`-E4)iKC5 zYUJiuKBlV<1%GPN_)`M%-8RMbtT8hf+l;<^#QlzYm3f6Ol#~Ikw!4$hqGC3AOxoTYg zmCDtPHxL0yw09gDbq1BoKT+_*iIx_;uyy2=9;vsYeOJ%(^*yuTuCZ$yYF`Sq(zDv! z#`9SgIawi;eCWCMJBQ<5RF*URF7fT(hBcNy*;j0~@;T_xzc$nV0Kk6ZA54z5!!EUR z;jiqtt>BVPM@4m#D~pfaNHBLQZ2;yxammRXSF-9}EYm(dYWg;jui8W5tGk7GA-uOH zL}u&cNE>M!@`s*y?_b8MjeNfr>dFmA?R06b>wm!himB|eSZ-L!KjrNHXXxLD{6nT| ze*<9f@5UWKXm|QS*{9sxA33h=pyn6Edz@emha)%{?rWAsd#yg#R<}oc8`a+g`$&#e zJ=C7H@rRCe9c$sK(zM?d$1bmIZSa>+5Mh5H1t)8e%OG5CU&L|K?N5UqEz#{h4r*E_ z#0#{u@cDJS&~C)3EQO^~?-+(M(f}WxK*(d$75a_~G^bJuSb6h7>*;;Z&hdGDN^Sc{ z+3o&EZSc0=ca7~A9GIs60MKjGe0%W6P4Vr&nl{^OkS^FRPXa|9BbOaVq3XX*E6@H6 z{{Y0p#5xy=;zs_-)e?B%xFZN;3PD5Y!IXjzIpV&eTYav|M}O-^KCS9+(DUg#Cau)` zn(-EusQ7d~op2H&rf{{S=T9ZtsL!qOd*v@kv+Q0Hq%%zjXy z@^k4@=)M|~>rjpzO*TNvK_>7~)KO_xG>PIFo|0Ic6Rhij7G(XKj~n zTldiVtgnb@;%Yi^O+D56o-5;T9_v?$b>XP1>(SkLw{o()vlj}}ty;CyzLwJ0)3u+W;XiDb_y+Ks~SI~Yc)xY5% zu-R$@>T{3Wspre=WN)Wo-1}F|m%2s7(=?14?&{(BC+2x2bNZ5N<)s+6dun|R88o$L zpw02C#$F`V;Lv^<=+J4`HyJ-|zEL@W2i}xKK0pU-=RGUX?;BOVNTSv?OO0XS1NYY_ z?>BZ>*9d@~fkp>PXM_9?AA|JyVwVzXSAk8;pncFfLWldRditNjx(2dPbJnBTMQXS1 zIabokZpSGtrc@9IAEj2F(MZNK?Ojxs+Xx*=;CuZAT${twTUw1rT)2Zmu>Sy__s%2V z{{UR$`|tP)mdyF|Jv-smiuljqZ;Jd6INv^>^oEOK^^J9 z_OIJ{kAqPY6n$xF3B^RFK2d|X#})P0?8V~|BI+LwbqNOPH(l6nKgOf?v5)tZZUflY z$ows7=$f_mfvs5U+FO|JtR_jLVd{rCKE!Z+jePeKWlI@_l_kFG)ogX(tGYE5{{H|& z^(eZ7gWA0F;`mwq6!^L|86!=d$JQ~tf9N&jcVD$-q^@2c3SrM*^qw={{#n--<3HL4 zUm5sITJV9;R>D1I(rZIHOS$6nkgoLwK*-K{uf{PvDVWryD7r58eOLLPM~TJZ?Cn$V zf8=~wGr!wskOFtHZ@}|P9nZ|lKpo9?_Wu9_yd!HJt~yf5 zE8{G-sy<8W2_1jnEg2*E*T;S*yShFK)a<-3qiEVy)C=~Tc`dZqRn}NYb;DqusxU_) zy_?5h5;Y{#dt*()in7Pyv5#|-BF!L3Mr@~?wi8hamTf#r~N{sw>_^QL5 zXYz(2{{XU8f2o+y?*0D&@CWt!UVnG|6YpKJXCVpvD}?y7aAEjK;;UJHSm%!2)F1Dx z>7UaT@%#L5_@?6@+inN6asL4EHHoc!PVwi6wCS{sYgT)kJCFpCvY-qB3^RoXs0Oyf z@MbAhjHcsc)7|uU>_4y3wuzhuJt;sVtr+yDSL&`ujN@`92RN=HSCdS(n&wNSRhhPo zv~1a29Ag5z&ivGJLmZAnQeG_4m{vhcm~y_#HHo0(oW{T`Ux*rJl8vie+x?!^0dXzkh&;tr$Pq+;dm(ND;Xw5uSLb%M zs@-c^weGQc+o!&n=5y}nKc;I>Sh%^=OWod0CB*H5WRZ)x`jg47bBue}*7$wEbh27= zmp?q`YrVGUwSPYPXne+foy!>{lC)OK{KoE5Fgs8Q=sHt??@AZFevQpz=eY(Qz3F$a zK~5oQFrtoh78{yT>rUD!a0e7QmB=!aJ*q&WlpqF_s#{i1v+hN};+lP{*}Nt2gzMe} z)_x;+hgAN=(e%P2iP|F}xO{+i42<$ZjCF218rAsq;NOJ40`Lx%@n6DTCWZ|H@#C~; zFD3o++%Y*Ry9aWBccB?j2^?3-f3BD%i2A&xZk*QlQ|#W?f7Ont<<(p{lzkUgyV&q> zrx~TetZ6mvGTKX6Zf+7czB^zHaAj<6!g}yC^sSY3Jan(AjcB&gdmMbaC*NS(NJcu; zov1j*X}Jd}6AtzEH|)|7_-|Xw07cSpf84K+h7CMCs5ZwVAkKI=uOBV!tnb*?JEb5)b$re~mm}Y8|enAMiH+02)8kx}Wp) zec$j&c70wto<_k1?T+nYEQ9VWBp54=6La! zjE7Bj`~Ls}Wd8u-s!`*g8!rp5UjG2#M4$Xs4}x(w_~ZDK{ijQ_KT5vGcitwZn_Gd_ zGhdq{$KE_smGK4e{{Xj%KgOqDh`$h2apD`^Kk2i7jZ5IHS4l_l{{SHV(x=&t`l8?XE8Kg?4908#OO%^$u00Fb}5sdj&N z{1fynql3ztv2Poh6z#_~`Rud;b6}X93F9QYmOVJ?YxQSF zn|Fo$JjeL7yCeSqkXFA4!o%O6Ht7AQh%r-CAqc%Czn;a3T#!htX<3?7ffb~dQ?(HR zXLdaTfypMj4GY71=ZkM2Pw@4)m81UvTRWYwL+PYq#3QD(v{GvP(s|Hgq9Q`)`l z*DD+`BC0~6V~#Znk*MeuM*s?=uWLGIhjiKXop$Xktf1xQkl<&~cPx7Wl562#ieDdf zj~7Qahv4xpkD|CKB(MR$0IxIA%5qBh$yy6e zt?a+9$FclY{h)Lo2CTX#g#5j4#0!wkl5H(d{WN7iJC4Yw>PJfXN5bD7ekAx`#1re+ zcgJ0d{lnhcTwxMia12F9?wLmesK*3%uQD;JC`iyEuccLB4SZc}>b+WuYFxJ2BlPSi z4PhwJbewswyM0fkKWGmY{?30J{v6rcc!y7)Qr4XR0HQs*2z5OoQTLzDVU2^jWtLA& zaz7(HbMWWH9}nGbuV4FDM4gC(RD}x0dEqBce3^076NCID*V+;Qq?8#oT_tG5(FI9u z?i8f<@~C)bL)BzM!vplMR-Y2ql|<_7amR+~(t}i6yYI32`gt8`1}onDZSZGlpIZ2G zjV-zY^XV>kO%U$Z%zxG({^9w6=uLe7uCKdpa)he_Fv`QIAo2+AE9tQKs8)|OS>jfs zC_70LgI=@nV*RZ=TQ!Io8jZ8WLGQGO#y!9_0;Y z$?ac@e-Hd?CamH2loF}${S$nK$z6&MVR z@OM|}IYu6)A`jU`%h3GeG=ir;ec1P&O&r&W$NNM4G=IL=k$qtY`88jQHonlw#NXxtH9}EmfxWn)}h~+p|XFUSAi+kA+flb={v>CjN<6WB9-Dp}GG6 zgq9)oiedcYu6LK>7WAe60D!;pDM~-gzVv!8J?U_3f=`Pd3#B_^@hz!8?G|6=1z(Ti z7sL6ukHnTaKC`I)X0mun%XmMznk1i7u}?!>2gKhWTYNe2wA!iLE}?grY@uurWFwa< zda4djsle}D0{FAR>kOAouGutjsdn3O88`zc19nY*di+cA)yKp?7Ei4{AAYMza6XF;l3Mq+){;A?k#2hUq8y{E;kHcS?X#W7T73o1AinK^? zzv->9`#ir=IYamXUGACjW8vni`+nOdl_P%z;$eU9w8;GNUmh1;)Q*u!fa{!B-)5c` zWjLGiLBaQn{TCOt497?72J)%Q;wjHqM8Cn z7>x%`l=v&ds(DT(rX|$pZCW~7%Xii4^g1}d#EQA54j4raReot}d$g>Vrmgzhu!;sV z)KZ#IC?}39^ye#|n5dDFo|J@QpGGM~yOPlzFCFM36qTnjEe3rlc<)I|+L~n8OyK5_ zsDHdTrJ}S<#mBh)I(yvkvDufQuQBoGfnxD*i@)I{)1r$`8j;_s zM;{9E_nIZf6{P+#>+^Rt>2&J({O5(RIfY2n#na|-x8c$EUPrl;@g_Gfr#d)FoUxVe zy?U>&g;9VyQjwbCsW&Hct`JdHMS5Cs6&X0-cc~@2 z0X0hMaGxS_-^!BW<8Dz8&D{D{ce(0odUUU=FqD`cPD#l$A8IJBk^+h;3B>{s3{$A0 zfDjB&d8ML&8Al-Eo_ADfm|-3 zd@rTm31|@IdEwZ97Ci@V&(gjR#9lnZ@csefYU?+oBi;W1lInd&2k@(hK5! zm-)VhJIkpqWcx$pFFsngP^Y>70BHV$k6h%tXPUP(h50;_RAg=luk)ij&T%=8DyB0R zE@{o(ztMRvi2lgSGP)Q#5yN5QXvJ9+?xymfl2QCBf0a%9LHP2=!8(PP!>t11IQ956 zI|yzj3<0u+c)ZCGJNfd589%_M)bUnBy%`u}5rRFdu9xC2zjR~%-m%g(^+Z_7)U?Za zrDQ?1Ri+BE907n!6N;P!;Z44Eb*JHf^D@W6QnQQN{{W4@$oPidHnxcp7HE-2%dwC2 zHK4cm_}XPIJsbx9V>R_%*!VB1%+hOL3#~0BY?D5Xdp(;1d-+ASeznNz9}fNv-3Dy~ zS=aP=j&{X;6j!i={`7la74&N{r#+9Icqyl<{Z9gUA(&!oNL>1b*ndM+UdK(Bta3;G z>uw*dcRHtm^$j)4Szf`bMcBd_B>YkEe=O7V?+WY!<5ug`LZ^@T5g*8YbzfUaUzne& zw)t#%%jkBJa~%CTZeQvvM#AUC`WOR2)~;=W^jNMKFa7Y`RlkT(+-TaBhNq`6iq`%z zuO2dsJVyip*cHL!zH6Gc*WqRfcL7}Wc+N-t@_GGhrj<#`DobOEtr$j5GTik&H{)-` z%@YeX`rWGbxPeJO@Fd!6oA{OSW5*v6>>{`EJ>#7+#ka#?j_m?UM$;+YPs~REbRDY} zZC^>Vwl|XAD#pEf?&qKj*EPXvUL&7bCMZ-zY7Rn+ zQL};0I`yyAyg2bLIYgZr@%_}D+e-3Ubk!dl#atTk%~quU0C<&>dMh{AM(%yhh5j5< zaTxiHbAQ?4xRUNx9)N}ZLafE4S`*BZf8WDjtJV5IS?>SS9cc<4G;B7)(N5Z;g-NP4<#!;Dx zB$(K)Nj-Cl?CbZu9x6DB6!8$+Nk6|+^NQ5mDoUH^>=aTHOIw+RH1kSMDGwYD^a-&( zwHwHYIW=15YmmDv?d7_dkOD-7xjv+Dquq!Va^J$2muRwhisVEA;tOUwzu5A1_YG&R*IgHzXKn{9t%zLER*!aI$*MfpEoEa9&2x?s0cJm! zPpQE7t$S|^YBwsfYxe@#2mG>uvnl@odHOHWMt$qBw70d>A~xD&3vChV6mrMak5YZf zrD9v}a!;*)7;)FqS3b>@s9;~!sJ@ZbZF$e7`y{S3qVlln_sia`js638qL?NJJleH-WtsQz(7~b^L z*XvL3Og(Cl5$!acI5hK|(@&|YiyelxF~u;EPdo~Y^U}1rNew=NlLnniI@QFWq$8dw z*r#l*EEgfgI0AZ9fiXrq3Z)V5G%YSMOGP^l!V#aXA>*%lbs?sJXG(8sd8YK@tV1ZL zV-(tV2EvLylxGxNK}=!+q}@$ErlpR+31f-~q!^*8qFBBlEpPCf#(&*R=*_)HPeXo!xxdD+Loo3qJ-KFHKSg_rNE`@QpC!JCdU;P4slu?IHr{Z zax*|AQNf_p%?l1!nunb8!Ki3rb)XD$OPV{&U z^dG|C7`_AiM0o7J+c(2~a!e1h>c`I0WOLdpwdHx~yjP-YJ`nK+g9=^U+llOD0LOW3 z!h2Z(=vG+rxIX;X<%`7}$YPlTq3Rf9ezo2FKlqpN8{t*YpYZR*GwEG!sC-4I9e?58wPoWSD*?RCI!)6?4{2@B?-2U& zUm=XZ)}bXDvA6a3{H}6K1v_&`Q<>>b6pXS)<~dZOuLW3OgYFMXdf@Y1MlCP`oj?Pv zE<4gHEC9_o3Omz)??Ff@K%jJ|P-u{tr%*YkV-+m1l<+BVDRGL9VOWu5BZJLOtPdip za%pqv#Zr+-z6TVzHCj1XaCxg?mAMtCIY>$kIFCwD0j17Rz%$aE7MuVbDcyexmmm;Q zVpLIA5rmJ~sMhp`?XX=bc^sjC3^ybsV znmx+!W4eU@0OR7kv&OLb9y8bB#x~#F{<1Nz?C+vpD$>VNeWL#WGsfa5-i$q8;0B%gE=XQ3KW6@&l7zW8#k! zUU;(hDdvn?SXr-^XJ&`@a7XL@)Q#2L@=vw}H^dt4*N84-X_v^fwaVLDDau3-Qhi;& ziN68GVSq`m<)oeUKU}qnQdrtd+vJ5ykKzLaeF(1O!`?dhgW+jXPY!smRMWP7)Jvy$ z!1UPVmlezfZoR5S2fZg7?r9W#7o`5wpB1e~m*O}ypA|#j?beGN-}mw?bNE+#WBW*a zIKGWXh`bklqudkoNvBS;#0RKH+J6jJ$EvkqR6&G;_&wse&kcQjWo$l?t4M3% zUj=+c_~WT-b{-Y*j;nXB>Y9Uy?QEu0xiS&dv5+08qnC0(QOPy+?~Jwcr2Idz_#&qjMIf+IOM^xY1F)1)K~q62D5S@|I4Pzin-Y-b znrEgZy~w5QMIf=2aRG_9 zFWf(+Kzio3%7z#{4^dgOTbPbl?w``SY1O`r#&9bT0nIWG# zlgraJ@|KsNeb{do{al201fITzy8i%*`gX1G%i>m-@l(SQVJ4hP$~cXu3yI)v+h>m_ z>ze*s;p{`eW1bniLRzEt%-agFIDT%i{=cvI2iJcEd|P4Q9|rh_)5q6FFAnNbM{}mb zZso61{dq4OeV2tNI2j!Eud+M`@l!$Z{{V*ce-hf*U0Pe}5;db-P3474xSL@~RT+$B z$jaxgM?BZ%F0tYp--w#@UMA8lRsPcxNpEFroJVR8%&G=*$U)wEmIt;m?tchAAZY&p z3v``!4SwVMPRqn7g)}%p62~%q+ruki6O0T3Nx&p=U#ix_-`|H_Pt7My*~RXTllGUk zHva$?@52scx3)i$WBONslEbG;`ZvY4y4S}q36G7|i*08%pAoXuH2B@6k~uA%fM$FF zmW-?P0|awlIt{J!gOF?bw}3NxIGnPSDa+o1Nv_Qwjqw%=bn!ECR=c_ZoYtp@yg{jW zTf`Q+u9q`KC}5Abx;G3CK^;1K^I3l`-YOYZMu#4qE9x^09Xv$|(WjK`K)fRijs6TD}s`qnn7D(SY~cpI6s<)=c&)cPpz`BzJ$N}7*`^lelO zq}GufdYz;C))uE{CB)4n{{S)V=m)3LzmOaq?Jd#!bY*>3$A#)PGThHC)!P{+mzHnj z2cY(@r@`J27A!UU<;?Cs(a?|eYI?u>$G4!a>K_f;g*v{qBvx8{Okv|0rjbr(pENvZgkOUw->T~p2`Qfl>Yz<4E`t6 z>T8+S^=}CHVT@Y-s- z=yv?g{{R9exY%stg+j!kJ$S4f-$SM}Q`Mf6seD=0kTt)9wD`0L*kIhri<{;i&ckef z@Dcdel-!GrLh$MuobumYKUuCE$mj8F*J0uR015bq#ZK$t%|1)ykNsr%5n9AMm!0s7 zkFIL@kKvz(vnPl(d3;rRr*HOth75Mf!?f4Z4tf6oo@@BlH79Fa-Z579QNay5TlrT{ zvx-UXWaT4)T1m1w8&v^pgVPo6pB20>6S33o9o)1`dLF8NQ^?8nG92>0U5bV-5xQHiC%^UH z$CsPnm0D7!`t|caFLwes>q-Hypnf>~4!!tmsJ6MH2D|Xpi3Whwsv&|AMl z&tCx?jGF$V%5$0+>Jh|Y<#@lnKF`e`lVWgnDNB}XQJ6g_1GPMZ*R>$_uR*h&ApqPt zr=Z0k0B7Ep0CFl^jTAb8;Nt?FzXzom$*99KHzSJ4q!PbFr_uEtUh^6qu9tDCDh8f2 z08fw-1b~)h$ILf*MiS3|iW$?`Cse${;h*h$9=m|A};Z036eQMV3as{^VlLNpp zKg#*~1D~iB>9yxN_hMJHs9Iw)oAc{TjG$N56R=2ExAQ{G;^SKqJ9@o{)| zq}knfl!FPI1?v<)|gHMh-n3*`ajfgnl;1kF-Hk0ui;pyE?(+gKkPV%VhJ%PAlKZvaCzS;p!?fN;6HqSNYiM z!P9Y-lfT`!Qx!)7laG2(r*F;Hoq=D}y!}t0j0Ox-fb^x?#W)YjlbL*ids6ZR7{Q{U zvXn;RigCwk05o$IVF);++Hu86fsC3l%}+T>nGndN*jQ&ZHo$w*g}}h6as^LPG)#Ne zjfpvQ9-!4M3bkhYCIhDKKhNV?RGrbHj;6jgbJmTl)S;OB(Uz`TnsV4l5Hb`!>zoJ! zzu_41Ilvk%_x=M( z{{Y0-*q;IBboguK^$tUaWU!3M#ds>Zp zlwn0aR@Se36BstHbMt=lMAo#_Nwqyb;?6k^6{d;f3`byC44Tcr?_Xwoc8mK%!ZApz z^4PMtDssG#I{KW~#$y${m6QIh_YFeNzEX$Sq zEt+#I#fL&i%D(2i?FK{e33@%CM4syhI$jyEi;?Ijx z${{=s0zBADT_1p34kI*E-Fr`b**sw~qcK{>^>_)O4-|OwQM>U!jPGTaOwx4b)1n%Do?qHohQ?CJ z*(Cz)Cm`fwrCZnhFW|dBgfQM*=+`#qK~im2F}mT`b_%g?yc?@$+=~8J&U{47D_5z5 zt6SN^c2bN}l1@(7=DK{&%Jxm`qg$V|Y!zrxZrx%rT9PKwz=ZXPei!z)|GOu$IQ1>1~MBPu(Iw@p?>oX z!=?>;55+GJ{8aFN!|x7!MK?CG-`QQuZ8R>cE$i%ZxeN|iWEEfu#|J%Y^a@!Y6L95! ztY9mu(QA{JovAqemjFjWxV~#|y{xXb)gM1TAjq?;VX+jwoUhHzWa6Jy&3(OWj~dYL zHA~GtH`VQhwbErAE496|s;HO5Pl=HO_c$+fz$fZuP0;p8EP%xtKZ1#L~9! zEeAlVI}S6N=)eTm_74q967t+X*ME!29&Wz%wfs-PbBIy;-CwIa!I*J!5^KvCDyiwZFoB2Kn^sZ3@B!c7%W>znFRR20h3Qsy)FK`S%@h ztIA=pgMb#~MxwV!v{?ai1m3?5A3uyev z%JI%edi0y0ieC_4_j2mSRqiB;H~wo}o&Nxhb?d#~YR~3CaelM^0IdqSKf&^UD(0@IYa66Sm73D%gge`J zmnZPxp3T$iiuzom#N0L_v}sP8joq(w(dSpmrxfKGCu2vPangXKjyljX02SZ(ZqviM zZ-%@<<9`<0NqMK}u*4Dwr1F|ui-`nruKwN0_OHIqGn!Z$62#W{QRiUra<5TShV0-x zvQ9D0BS(*F^iTLoJ{Mgk_3+lAZ@Z9VyjLIi@>LnHz8+lsuvzMGliajVkJMMl)A3I( zYVI>%_V4*K(R@Esq@(woCJ$;}M|$WkY_tYDtS;qWX`{#ff=y&Sgxs5XW1AhaSNV$c zA>u|-;^plcw|L1v$mNzzU1@K{6%IuJ)KN+&j7uLvNF$*H91usSHE6~$k=DN74U2_Z zaj8w+HFSAXrsCXXqeC*rpBD}XeA7bpJ?d!HatS#1qO;qe!WUPlT( z**W~{_JiR^!z;fM_(Q`QwzVr->20Oi6;#NYf88$wx+nWxPiziP#hMqwO#?*MuYMrv zhA$9LsNKnNYbM;!(KJqpI}OVRadt*vh}y)(F>ZmkhH zF_YhJKR#>tUohf$VKLQk^{GnfyY%$mb?5n?(>Sgko;wRlbSf)beR|sdpVr6Mz9{&y z;17k~PlqhvGnm;{OIr}9$W!;8Qb-%S?at%gsraYkzk)Ok6*WyBf9@kP;?#!r)Mgsru>J)=zZ^<$PkwZ%#fa@`uVC0v}2 zwN65Ditrs-RFjn5+4Zo)!kiQ-D<#nw@^ruzbHn-#$BFgpKMz`6TiH!~{#ws;{#+<< zPFcPB0o0rh^$epKCccXJXZtr?c*{?<@h6Vv`!wY`6)c{#pgM+-(%t3F=cUHq5yhIse(T-CH44h=`aSF-E3kT3dWw5q7` zam(A|{{TQFk~$pmUpIJP!5%L0)~P0q;cYHkyO=lm+p!WtQ-UOS!l}<2PdV+@zh1-^ z)|Sv(TA+#>bXen1N!o}2QIbhG1HC}8Tf^oXc#;z{d6JmaFPSGz?}76%1Cn{KxUY~< zl=-8g?7yuK&Tzb0iG?Q#d(!CN_5FSa;~u4@>Y660K9{Q7%ckm54@LGdHuZ-jm*X`;`>P&7Bt z1NLn~3@yY5whu;3^+w8{*1tABCVVXMU&71fxA81%KAmveZM7Id@+Cc$v&?dTvfY1# zn)0jQD`92NN!~x!=g?p?ia6hLu9E(?KR9)bBUbS4r1~zuXx2BW$X4ZKU+=`@<#j!X zHJ@K;>w0FH;w>!bej>FOwm_DQ=WMVs{y3kipKgb^ub2E`;4NRl#NDGEOGYONb_O0i z^jXw>)Be}pKHV$Y!Df-G?#o7V;_woss?{Ey@Iv_wzN@4$$yZB^dpO#EP(M2QGf2s> zi+3e*y#D~Qar~>}4~9M`)BHE$IVII+5L;fuXvR3Eq$f>wA@Y!RrSg#Wr<~GY;-51!a9xH7;|HZUf!I=y zN>Ah~T5&UQL9}O@X&#jzWzGrb(=@fM%9RF{+93yTQAy5nYE#yb^G!1rx(&`nCwe&) zxXG;)gwWb4NW~>1HiD3h(}=|wr(tr9$2p*boYV0}D_&u8k!>_n=`mEKLx9R&*rmp4 zF;~oH;3w9K4Oq5;r*HO0Q&r58)Q(<=TU%2*Zf+2MwY-X`p;T}wp;Z(ri~&YXa#X8! zYYKd;LQK+AcTy8lhS)ZHb*#&qoDz{7p6C22Zf>v)QJzmz{uP;wA6nj>F095(UwSC* zN!q$hnhGeRng?#-MHr%haT5ZTiYN|HqKYUB3YY+v>rgbD9Mpu2WS%RT)in}JSwP!c zA3FoeDeOPmJ-s^mSA)eIO@zttQ^w-uXvgnwqF!wDvP_c^nBt>^!z;JV)6a66Oe;c%YO-R=EeehRu=XOTsn zE|mnoHH@Y<{{WtHpZx{@0LHbBBe{&IqXHP+30D~YRlBAinn;iFE=SdCJ>#0)ysLyU zx1)bU(AA4ce5ujTO!M?S8mYcyXBgR=46To9h=ZJBuQKtEN`#8wxz^E9g?4`pu0F%# zjWT1#?c?wmHObcJg^6Dk=?oA2v~>RfzL3#$Lq9=EbDF;#f5q!nwB(xl!ro}3b}oSM zr1q_twCzNJ{*5pF@N3DM)b!$z*wZHlyf@?RlMnbswQYQX z$UdVLb0!;b@= zZKrhu*?@2BUZ@Wl72uxl#C8K({N2_gqaR0>ZFRDV;JV804UKl2`4o9gdEdz+JZ=?_oCwvp480Kh#y*I zCcz-0iYX0_?@c++trU!K3G@f~RmJLMdzrr%Zc;CXUIEg_-?+ZjuULBn6iR=ccnHT@ z_K%Ifx=)9?cTX=uYnC7FT-?ZW_=OeY91cZ(vBMO(FfjAG{8{B(reU9guO-o*1u6N+>9tN)O)R zq~okWyeFiMGXxG=wQL<7!7<21iLU==8_*wBhDEMvSsMk_B z&W)?$%TQD3g}-+%)lW6~rfLbTt?qW4h}GG74bg%11ac43zIPkLbZTAG_g~(>&+c~e zDpKb7)&Br9^%qpqbzKhU?0UVVmXeOrDO~*zL---;a`s51blZYSnCfz!;NZO0Uhx-8eEQhCCZJ_^V4|8 zy?k~vAjHwTYU{fGwDopgW>Uhbd=KwCk<O5 zQ-J2G!s<(HCzW|^IsX8bQX~HW-z*JszZBr|J|pYs2M==c52O6w*0<+4M*3EN#l+8z z=ep_kn`=3<>STcl{cHNK=%tFbX^NxX3H{FBspe;K=A#$c{{WZy9%MyuJ}U4vmx*rD z^{!{MnG;|9G07&)Vo4S#TB^7e#;RBDE4Q^QLR#x`RU5fx;}io z_*1MvrL8X@yT>3pm{|TJaQ;=v-1tAlc8peiGEtA>jEPtCQ=ihltt+2nL5)up@c8cx zvbwi$i-UchpL4nI;rOQ+4ccqX9~@6*HL2NhrWWak1%I9?zC#axr#1Ec#+_|;HeAHB zDe4>sAFXp2pAY;qb&|G%-Ouqt#y`+;`d7!|{2{2F@b%O3lDFcU{Ev4J#_?KJr2bFz z)AK%Q5sH){&lT)9KMUlIiM7-j)6!H7N8&O3tC_s;R-|%*Sf$ibAvjbD&moKmU3$MiD!joFMHjSxkx`cXWl^2^lyyWnRqpQ1g3ObT$ z?WNwp>MrdjgdRCUbM(eVdpCykbEa7NVSKYT41@Ta&@uELohuB3f^nGaPq36+68M`< zTkq=5yw8YeVW_H#N=@x;Zh8-fy?lH9g%qn;|Uxh*o=K#wf)5UCm~GrJzjstG^K)}*r2wVh>EqhGMvlaoB4?<@ZR zOyf8?KCQ>GuX6ZUbnOoIn+Za~;zq(Ek8d#m_rf{G<#4k&gBJvY>Z~0TIv=fPVZX5a4#R9pWhS#{<8b&}y2*@=0Tz9B)D#kqa{!!k%ukxyy`GNGV zh^RjGm2q$7e8@7t%D1Ioqasl$zB7gBYs)?(cslFEHu2qQimi8T(HrI7z1MfIrg*PZ z#^%~Ok(yi)*Ql-x+b^Sx!_>lJBX~_&{{UUrL!$?XtB0=)MHyOAcVE|UBjY_n!&vZK z6Ish>qx(^E#zrzOQ~S6I0}S=9^26f~i@Z!R`of7%%zjeL=!>S?5EvEI(Owwo~BMtdV2 ztOz~O{{SlfG4W4;Fu8s)Q=uPn%297tx;1|3tNhXZE5KY;Umb<#uKncNf1Un&edp;< zg*+AEp9Wpqc!%NL%$9MyZzZPoHxVGXOfg;Ob_m8s?w}gEtoSbH#NHf_UGTlP+O-=N zWN6jE+(E>Vu{ZDflDe3;R2H{0Vm6YiY0ctv%oFO>V~~FnfWY*1p#9ABQcq zKMOaByd-7PuH;c9sbs=E`1{#RZ6z3Ufs!*{A;Y+u)aY_mmo>J(ZpU7GN*`FfRj=E* z;~x+FeILNPU2@WRCe$^*6T*u&mpI0rsJQ}i*vrYZ?K=)J)bJ{A3w$}#{viA>)jWIQ znH_cg66xdAbm+lol@HE_Eak}Aj->Jk?V94gCEd4)PLZj^KFxgwA-Ee(e8`VZ212@@ z&pwqNiSZj(@I96Ok>EH+lc;L&s^02|+wN{7LU9^`0!9;eP^G!ZBEMq5;j#I49+qK& zlTdc%zujiPrhE=6cx-K1VR12B>qYt9KkM{4xTcCyLh!?=JYbI1D{_tPUiaZ&4|sdx zx4|3DS3|Le+s1wmn69ZH$~D|XZWEm0OBBZLoP+IN4iaeaCXJ$IODuje(}Rx zTZhdlHwDWjqfhkipGJOjn&lO6l@uyH_J7w??0*FK)52O$!rcVx`jSU;63U_o{{VY% zcDn#JE=uHM1+XiH*S;`nHzlI*zMPtTeLm53iDvq$50!r$*Uq|q#9C$B-59q;KGmNC z=D_IV-G5Q|SD{I#N{4YOVqv*tECP^v9Ft#waduHt35}d3w3}<^ui$=_km6%s4;e0r zC-VOQGcQuP)b+cswrh8C>Jw*!Gj0q1>huHXDi0(pD&v+oRUJw60CH+e&11t_ODbxY za!oUaDQ{?=WR4fF?;8lX&peauS^og=vvu20WAKHXJ|72zbAfSuN3_lePxt^8^D@I! zT5{{>q3ps@dffG&4tyK&_laV-ypG6ewl=Be%{AUtl`u|33z=IW9DJiV>s++D-+(kQ z7fG<2#8;`opHltRvas&9ksf`#$q%@zKLEZb>K_k&DnqSn*OOmqz9ZRVs!MYtO5s@k z>SD}K`nNpx3R<`S0BDbf?eOp74b0Xt0i)}L+}f2NDi%3-$I$XUh_AHZOa>ONH|qH1 zcGct2U2ePH+UaBGc#|Dol`d>Pi9LJUx#ybak33c428DGC=JGAbFx<6I> z(EUdXil>OAszKc!qn`>sD0pY%cZM$XeQwSuwOHcv^-V5LQPsHfq0cQ79zh(b;NXnc z#2*~~5jVo05k(b*t#PAjTcopg<xdD%OtS(XqibP(2O2O zKA#8YRWkf+TxI*Yw4}OgqJJ*^v^-4XD^+Q|HCr>`b^x!|quK{1q}!3{N^)!ZVYBl^ z4Q?*3E0f(X(r0UbYb zhxvnBVF2$&-u25;lw|JE{gUcagHEuv{{W9$XyN|=aTR}%tDrdkA~Q-C9EwnAagSo5 zLJMVJ$GsuI;}pL&E<2iwC9$J|xZLwj=}X%kDS7B9b2p4e(%{n5fCrOP%vy-w!i-aZ z6mi;_Pmf#T70< z(a@^oWqKZz1vJYh-)_I`@%dG}16(dv(BG3nA^`+;tZVcFQ;y&t(zl4L%|;MPOPqYT z{Hm$X-VbJdf$&Bv={^~jw1QduySEG?jK2pRcs1$~M}KjZSZ>jPIFIbaEB+t$_##M%J}k0Kju>3UpXXnN z@eLY_9a=oM_pjHgzh-ZS!d?ld{av3b{8_fWPXg+f7Z&nDg;OP=F|O_EJu#a2k$^S& zC;LZu)54nn0KvZ7`r~>ZDjAj7{L9YJbH#pl5PDbWoCc(@l5$q}zeGiP9I~+) z&ua6(8u+JH(5AAr(`*gL+6%O^NtK2qL6QrP;yi5~>(V+`2ji~@KZ>BVMZA{SSj;X2 zaj;aDLPoklc_g2*A^!kJwTN|X-+6!?>(p#~7vpb@K04K!Eh6aJX>gl+3uOj2FsKS; zjQpc$2LOYTYkT0_x^35vFZ@~Y-aEZkJu6OYJuco5RyMfK3&;T*kf#W9h4jx)b=5vU zc*NTHelHBi7l)fbwfibPxDUKCs*>Ts1bm?52cB#Bzk_@{%`upzo8~aRTeg(6N%WI% zR{WCE_q#t(;%+X((V+7D6H1y^Z9QL-OJ&~gTUh6Q68_Df@UAp{D(d@MT_;7?GjALP zioL6@;#@0bT4yAJPaJY9Ye(@f!aoCeZu41|_UpwLejd~=zS?G7MJzFvRoFRAxWNDs zlb$Q#?}t7$x6^ffavAk)Q^gT3p?4+fZOxt5qdS*pfse}yrtPetl6fbBG2Qj$q#pBC96%nnojoBC%N=F`2FI~5a>E>w}!#+_LU5+dtn?= zn|qiRaK~eu_E#eq73cR8PkB6-cXBlI%*^iYNi6Jg#FN~fO=8*Gn|Re>4J=3i=a)b@ zA5o9SwbbM*4nuS`{k6i=!8|-(6)tNoZ9VJxpU;@zYUeI@?%zexAqWaMrj9xFsWG_H zFafYD>G_?GE=9P&ImIqJ8g|@L;-4{Gqd@IJQYpXyX?x<6kv8gjm%>dt+Fuc_uc;_& z4IVi!?sL$^YNkRz;6wrV*MePKNvi8wr;auDBYRzIH$;*!shM$}-IQ0+almvBgj$u- z5A?4S&mNr~I3fi7?Bsh9wNKW*LHPZu{{X^0@Xhpi2>WM>E)vWDeqn2Gi6%cnaHs2E z%Dy4y-(AinPA^J+>h<0JEq%w>U??h775>)0*W>q`9+mOO;x@abN2`zQ)bq<0iJ@>~ z=kDY=&lu$Os~_;VymX}bmU>*jQ7ZocTJnE|9vId~1OmC#Jih^H^TFuN7S>EVYPZaBh z*>NBv+!Cje+Oa11f3L^LmfAu7%aQ!WWy|1i5~tmPAL$_ewbLCe(3|ehq&^<_tvqF; z31mSQp<>=m)OpDSeaQ!^y7Ql0j@9a48JOvx4g4wKh)}G$t;6e=XFXzw7_F51XLDD` zegbb3cn8KBcZjd78cijgVAQ~o5BejKnKq6|h~dB-kO=QzVti2hAB;S8d3B*kC(@={ zTPp`#lxtX{RaReS`J3^tuHqgqsf)l-!q4Kp+waS_;?wyaej5iGbth6gY`%N`2hUo+ z#4S6)zu8(n-HqO-cYSErj4hfq5J%>0RekKIE!2BeSbjBlfBstN+LT{W6aN6{E^Fq0 z9e9*#zZLbpV$_wjhU4r|jQqY$nDT}_i41WPbWP_)q&*#N+Eli~SYD`{{U*%Za*b8^AM6$VqP~v>Q#sI zryJBx3y;T&B_A_ecSqCqKN9=}f3Cx6rfQ;DHDkA9MJ!mIM+YvUa^PdHCmyxv>M%Lt zz7uUlO6?Z_5J6HK1z2(j^dR@It~>|g*t}h!M|xO&vfTMMDmzM}uhEChKPvsV!(JhB zgTT~!tyiaG=CiCGzS(@v)w$`~nkm@FIn5?Guh$-^@VO0rD_hkxS7|iqwg=(kVY)d7 zqoLrBt#`IJ+E0j={ReG^qIe5*Bk5k_@VDV6neh(o?_XVsw2OQfhA)=&^ZYD_?t8Z* z(!Y~P&#{Rk=y=49&;4xHO>R9BsJ;P@{#=d&6{g^yNb=j?Ty-pbP zXng?%bMlDTVHkHLk?Ee_J6neF5SolVrU)SVqh2tzGNJ>>7D_tJ%(D^z| z(dUf&SKR*qvsIsmt$ZbM{fpuWb!*F(65QE46th`|@<;R6>ZCpH4LUvwUC^;$= zj0l)y62RxOp0(MMP{dHhayobKUjKA6{)?GZ`Yj6Hrv+j21nu*Q@ zT%Afe1af(*a%Zi6fRD+F=NRU*^-ViZ)+{E|HG5dEtgaVqkWPW)ap+aJtIP?gnw5%8 zr*re?;^*wkt9WqF;!lRi`ksx;%cl}v>eL=dgq~%Le{}rdblqPg`KrqPUL6}K=);0K zGPU^t64y$omYdiX3fA5vuS0XX6)(z7>Wqsdz3h8M0;5*!-mWz|GS?+&_WsS|0+n z+v|O5Zw+0`AcREA9lW{S3&wXc0Nkh?%U)icxKb<2H0L2ICPNIa$u^1flNZe-? zAOf&FMX20qmT^sOv`aLr8txmq@=iF;05#Kxr7Dw9j_lV5fT@YE96c3zN!b)NpAii% zX0wjp&Yx(1nYJQ=NEtkn)3B~+YF;q$ERUyXv6D94%(mbmRmd4>9Q>o-BC==ucB!hy zv&-gO-%A`Q&4mL$DvS<*SF`wE!K1?0^IOGyZuJd7EDpdGhBVH29a#JRg1)Y`Iyg#c z$tOE?yIy}K@;{?;9}n<2IxtmfsnxaQ-P-$}mR^aMJU6ALjo`bzI`}M_nwX68hB7gE zA2{B5;a4109Bmjit8+E9v71dP9inLi$uz7!VHh9;jd%kmJd$gQ)Vx>WttL@#eW^qz zViZRiGD(tf0aOHxpIleXLCUn`<96G>pWUsOWAjfCn#SYfPXiY_D_JY+*K7X(z()T7 zUbL0Y$*NyH+;Bl|w&+*_)3yj6yH`J{sqD3@JFU?z!rV<9hB-X7Y$Ifa4W0owu8Co% z6?-2gjNq)wg$IbOOGU1l*>`rmzIJ{_*3+EQRGjyuz%`1Q@{S`YqKs838MGAOJ!zwg zD(*`{pTdo#jEX74r56zyQ`GdPRL#~g#>6gqZQ9Ou0=)(1qDgzOW&Faz{MmCjPNTu=Hv{=8RYOyI_m2O z%8#k)YcrFY>Z1;c!Ua;ixr zmS#O!k5W&qdWVc`tslYO9@ed62_nsPXE`6l!(^yWz8Qz%UR374fV^npyly`@tAoU8 zPK#VPiOW(%4r-f`p0#2^YOIF0j1E~c2@fAiff6=Jt}doqTT4{)C!PM}NIRcW zPYR=!JPMsv40BxEOt!X`s$`ydKIBL{pHg}%j!EDO@n04Asr*p!+1WhwXtjQbkW@{VcS0PRwy}~Ck$kI;`P&$*ia4VvY8BR(OS8V00UBx*`*&ku(J~HuinnX6M ztzAVFJCrPMv=5k`-&)rimc4L6msq`UeP?`sI>*rbHLP00JX6@spED`|9WV#4wRN`o zE~J11V<7e{pW|M|FAuZ1d^jqa{{VIW0FlEkD$AAs0NNv`_mKdS{rH;hX+PePkMlL< zpBz7T;H@Ul88>=}EImR-+@H?9!aXMB9La3M{scduHOYAM;r6ZLSoGK}S}RzrUEGNa z9mRkE@HXPTG+%{OQ-k6Li1G1v?G3YoWIFNq$luP2e=9|KzfjA`@A zdH(>Ow+?@$a--{A*N#v?z8urX{JLn?gXm=4A^!l-tArgZ{DtGpU$@UO)w|W7{*=$s zFz|4t2tJ7~!3#}1ihEOQ1Bo8aDB1@#E-8l< zpwb#eCNWN=>sJ;wH?<)d=}!Y9jAFDxAS!8uRg6?@GILgdu4yUl!KhilqUCfY+&k25 zCt*?KpL%UjgvlqGOpMZ=4K(7VfZ}+j^`nXp6q5!fJ$utB=bB|*!Xi;c6b?Xn{3;8U zD{V85p&R*B=9nWh$rZTg&yqOt{`-MfO;jk4n0E6&e14S{?{MQuBqHI4o9tU?ayjzZ;0-N%w&Ib zk^HOYr-1Cw3)rK(>DHGu$49H`F`P4r2m3&OF^bTS#1W1zEy(vnpV0pRjdD)|QEjN5 z9>+sRy-Omiv+9y)9Hhg zPS0afQYBekdK`+KW>eRSrYXXZq|G3BA063%|9th1X6aiiB)bgBFVx>54Kb2_FfJZ#iYT~h&XsfLPx835a zv@V^pJ*q8WY=(+jN(7KnI;4nWkwd8+7-W7%mWqpmI*$bS-^U*fZ+_qK_g=ct*^Xp_ zH{WS5w2(eghv8pRcz^b(@W!^wYw>Tx2TrxCepV<^(RKM(tKg3oonIVvuWa+6&Y`_zxqT&(pz@Kihl~sV9~or zB9wJFD!_sdxIHWLCeiLR+vL@Bd#SX|LNk=PwUMEfPy5J0uckaJ`%-x4Pe`mjBx#oZ zI@6;YeD)&OQ$f=rbO1^J00SKQ*U4jeR-_f^zHiU^`TU4*i(QYW)QWhlPYC$y;b+9A zSbP_*{{UxdtU1zkg0{CL`b8oA+@sS6HPl~enue7(+4U=Dw|_@cGxR+V;a@FR4=6UI zto+E87adA;;3+`{oCa$<5X8kU(@p~hma||cDQKsG>r&ZBKs!^tsYNb3(1OB&QY#eT zQ!ocK;qOJpP8e`|ny{F~Q3n{sN|Lhsilrd2YCstP9qPBm%YC={73lu}m#r??=ht8; zfAkuWoE0T${{R#nZZv-fT=gQtSkL?dX8!;h{*C%D;(0iellh@9!&8?sP2r}!KQqUB z@k`AIic0?gLz_Mo#9UH=#Tf5JNV!G)XgpF=fXxGFY5BM{Qd>okWDaWGFHUKu*2Z$D zeawF>p~2#lXzD9jqgESOW~oU7Cr(%Otzj}fnniq3LB%C)aA8LjQe+xrHUQ8$qm%7O z%&0QQr4}bduEQDWQOPPsq_OE#TKVUn?3#Q}b#eayJmcy;*q#)7lU)smg&EuJTKv9g zY!Pc4A1Qy|JQqHrf$d+D@!yO&R!F5r{?@Yl5o=HJKePD1BbKHmNBaDi>T}l3f2_!2 z(joH`kY-|56#BREA7%VVuFFdB=AWlJ+}ydpypQfYoT3xdqI3t}lkZ#L?TuoML~*AI zsxSchQ$Ym!*YV#Pc#)cCv$iUHwC{BV@UGYH_1&IjTuXas4Dv|(oaUucc7U1O4+M1v zv#+%)I}MRaT1n*lkVZF3 zard%)zavZF&3+FL>xgevS4qD^ZFw4uCzSjK1F=Jf2imuMYvHNlN%ZDEZO)QB$fO*J ztsj+Odx6%YCdN^fRH`i=o}bs`{DIx3aK7mgioP0P(OXH?*a9#ZwYH@9kW_q&*Y`PWsTdBkK#;s&tY9R zfp7GETI*5pP1y2ueR+trX^73e)=!rH%1<9div5SfUMIw4S(mhFx_e8`FR!eACG%ZR zq{wSRqf{sMwtb&ZmruK~^Vvc=_NLa}xua?t4ykvf>X8^OY~*O9QZa@@gV(pEVvd~G z_x%d7oD|^ho~inswQ1S3*1}Om6{(AeQAHF9#3-VQ1C%JDiU-ORQAGjD6j4P0(4dfW zT9)@BOe65!z3VEJ9CWOzM&pi*=m70b9V(5r&|&4t*RiX^t}BLm9Vr3MGAcG))AD*6 zO2x9Rb1HZ|pPoOv{Hh<}$m?4q1J<*rw=m_`n9u$@{{Ysmm{h)uhO{v6j@Zd33I=k0 zLE^mE{u%mj8SA$mBo~qCI(^Yrb2<`+QZP!O!C=?~;{!S8wR+QIaz4dl8wXv$GUZN2 zLE!V+yK6mqJyUFVqsqGJ&)q-npXpzTc&(6PE7pc3LzZ<=1aKQKIYj7Fx9OL4BZ4Gzf={fpPLW;QbAKRq%7gH=13`X%{-1 zu8Xb@wb)B6>^!-^`Oua;4l(r}_1^f);Qs)GJaezxc#By#Hn)&P6{O8B$KULMwbKjM4s3s8{U$zv}2Ne>~QCozyd==5)JYx&0=mdcCv^w+qb^q;SD z_N*2%7@N_lq^} z_+I@i_3O2^d54_?DQw8S2|@=xj5)_cUO({P##%ptyg_fO>lX>9SnII_y|-m!Bod}} zNwzgO1dIYYt5>glNce;B)5abZm%~r0xYX_CSuAGqB@sh8AU5*BNJbxfjym-frdP)J z%&yLaIjPUxlwI2Lms@%1smF}rN;s$N>dMlOyrjPs`lAzG_|Id1qs8Hy?OfUDaL2K2 ztzeQU+_wz(0T=hDzszx-ahm$STGo6!;%|X|8Xh#ek4o{Log{WTdD^?R3rwm(BY<-f z6eBG70E3RAzC(}paq;ZvziicXsFFd*Eh~lx{sKDGJ}%JwL-4ar(=`786C{2jwYvrw zui%lkC{cyClgtHyLBgNz;0~1Xj}dUw$3mqEC`mTezZLW9x{Ea6$$JlG?=<(n;Cok! zd>i6V1!}+8z9+OcmqkOa2xN6V8CY;da|Q`F0M1!gpwB_*Do7{K75qc9_7P5s_>}Evn8+qVq2VNf=xr=t$r^NX=#5Fd2$A5=8O{FRH$&uPZ{+d zpo3r2{1wMIOyV(xRK2pkoiysV-27iZ;e1A2c`NciKhDOUp>A3=c*bOi?o9gneF)&w z?d{f7NuwVufDxbN_*B~o9<_T)K+~HXkDD%^U*ZG#{&kOt*(Kt!wAR{7)1&zvJSCX& zw}zKpSNRW3I8777B8{#HIrKI64~G1|kADGW@V)bSo+07p|_()kv141N+AsA+xIXVo8;W3W=8RnvVx6e^Mmx}Bv?RD;MP z)9GHL}B^fMX`on0iheN+DcF6h@Q%<5q(bJNv5*LD7VnePkWt!zTqI;?4-+Ns)R)SO$c zM^LJMcgOoGYMqCPJRRVDQY~jpmgip8t+y(&lV@aAh6=FeX;6-#wgc&2NvP`DuCF+{ z)$eA#oB*OpoXGn}L6$kr8mGN0`hO{APik~y=v|s1>A4iMgdXv(MviwP>m(RG8 zaDfD@&$JE~CpaF%wM2RLrta*tqu#$wR!Jm$vs#U%#XACw4wajcRvHFQ4L@k5rN?~K zITWHdG=;D*JJY!BMh8lVG4g`r81G0JXNq1A6u4F8jwxI$9P~7U1A$egxQ!eqCA|hu z@~akcUg|fn>Dqm)*B4g}wm9RBi!t^X{dxh`ynJ?bgu}1jRg8UFJuEI&imcM3N>cPp zci_O9x6qd2L--7^PDP;N3-Xi@D+MH~T5 zv7{W(GI*rofO+G+9Km5o#TXQ-8OZ=?gsLlqtrg7n zG%`C=fGoMjJ*$Md_=TZc^Jg6Qy@3nmAwTa)kL6lE67fX7C6f9liYSe#RsuIK^1gbK z21p?E6>@~sdE9J~$%oA_)ha45sGOffbvQKO4Lpu%JJvkcJzh%3 zt)TU$nA6gw>BS~%p|W`wVXGRBM_PFlQ%ho!WhmKfDN<>qpyiJh>zXIR#k|vOb)9zo z;Db?*&4xHsWUhaSn>g%n2Y$8b+DC&t8>E13wA8tfe;vW|kL2Gd`~`eoVaHfXEyfAS z`DniPZ^GwXFf*3gdY?3DS}w7z%&=(IGhQ4Zd)YTiSOPfoXY_FndCxwbS zIKwM1P;2dBaU#Yci1~4^MNgGSwgq_q0L9$^Ph%I1S(4ghDFoZdPds!-T>a)BbmNjS z=xff(IN{gDDpg>k8EviI{QXL=fKi3w?$4J#7Ei4BOHuJXro9!N?w@^lCy!2sB}k2- z8+SL#51pKrf2V5qOYeqy2Bi(QvEdexZEwyXC=94yyiS9ab?89)@(p}x@jG3(@Xv{~ zty0EwZ8w(TUq2ybU~uE1QVtGl>p$7w;@*QEi{ktHjYaf%8rs>UwM@2V+~Al1$ztOJ zAY=>!&>Hw0_YDj#E~OOoz1PoO^*+-Nlvc&ih9-h?a@{3&eR_2}4HMwjyKi)@;%U=M zh#&P)5hcrx*j|bM00KE4mCAfU@b!;{{7XKSr$X>s1dc>m7_;~Aa-R{u zY30#ldru1@=@OnyvSQ}_xX%g7MW6P`KT793E3003lfs%$i1i7jxVY4yS>8*D8d+Gm zC+2n}ll;04E8E~Y+}QTw^yPn{97Dr(a9BQi?I|{wE$=0x__{B|*BsG`Tna1po@3zo z1E(gCoYRgCCecSQxkME#N(_RbsZi};!?i@7gxLBA;tiA9j}CZx81ZkX>5)o=a&9B-!REt>nm^3?P&A)}MQqLgd8;L)8G5UhRPtw0iw4V}LYX1NYbsMch zKR;6NHKh85xC1RcoMU^yanH!#F!WO1mH7j8b9Jk|-o11C+uc$}xqN>Vv$1Rs#Nb!) z-Zw3;gv_hc#C%t7{rQ`B{Li(iS~I5^M`!vUMgGki4bGEis{BBWNO`AAI5W|rhvt{? zEOGc(q9CV75oGyebrCcaGg>3p6z_=$1gCkZSb8njzYIJOFl#>CT(d$}P#sw;+#UV5^Z zPtkv0iHePqPx@@U+RmO&%c$EE?J^(hQJqRR{{UAyKhc5yM!rW)F7iisqR?*Ukr=5F zS+ih{F_1lLYSX~}G`#-+k1q!6j1NMV5`p>c#d~U;t)d~yJ2rhhE@gbE`GW#5IpEh> z;r{>`{{X@<;H`egPykP$*~>PT$B!^d3Z!E_S}7P~{gOQme8;E!7x>|RNA_QW_0|Wc z$s~Wz71%@jFMiY7yhtrP392!Pgo)?fDyHS+0Mv)b zqZ2*!vD=0mlG89!->EhFH{y*sSHgN;yQiQ-qD2;$eH>%7N(2+*(bwf;{o!2q!w=aX z~9<60PtdU$y zikqZTBa?4SC9TPkz@m?=lI`;VIEO*TXG*uZ|V zDgKpjM%1jlKdPnev?*@|&e(23C{R~FBj9oJlgacZzOJ+VguHr4{?_=10}lR8pZJ=; zu71TZ=-NBnUHHOV$CZRdS_MSfz~}Cq=L3rLvdmRXHF#Hx)-L8Xsm4)pw&$4+Y1|6* z8xMkd#*?O6X|U^2-u;#_ADuX1BZcT&0OWyzp7gKquf)s3O>F-F@HQXKYxnFgA7#^u za;az5>FM|%JuJGS>G>Xe^3i~JuHpU}c)>pGF-iXHtN#GAZLHhR1bDv2*6Q|K5@NE3 zN#rW%#77dBW5ae(O?olo9I@?)iG;9oUu5Pu91&VtPO+-!7xC(vCDfL8Q5>w1F;o71 zx*GX`ziJPJ=;)Jb6UNS};5D=T*64W8TGWH~miRv?1N(DnC-~r*f0|cU_WoT~E zw-8JNBcldJ2cZOTYw?Rh)h)bDpjmjYLM?TqY5~j_D2?UGv5GdP(*<>-6ZT-u_vaWVPz z`JbkwE(j_Z9ta~KgV>7j5856*R&NKi)?O=54#gW2ECqybcz|chjns()oNyO8&o~^{ zW8lAwo(u8is+$X?u+$ZEEv)VphpAF<8GXm%1$_1K_u?PJpAy>-4p~@7;w@6e*u|(R zD-D#KZrLdR09f1%h06M#)%fmp`v|Z%sCO@QZar0x-%>@}c8p=RDWB{?1w+n|I?@)aCI!nkD_ae7bZjQ0g&-&Ll1JG6H_+1-Q=x zug_$8rFS?`o~zGw{(hfv_MB0W;iU=U;dse*S5MU~y>#5PM|xC$Joz)%KR^eqxh)M^X7K+fZbh@c#gff3#1+Uw{@$ z{yx;a{c7P0+GzG*O@DBEVll!g{``awuS)Tci9fWjh_&k-Pe=Gg1L>CW!8SE%Q#VpF zp(<_RAneH>D>x^o0>3i6C-65>)~0_L{9@E^BDmB)dhmIMZCdoO&g+S!&ya`E{NB~i zjm1L`Puf&={QmFj(D$-@l2zrYsqs(tk@%ncN_d7{M_jO7FTy?@ib*1{v_mr57dsM5 zyS!zcvx1<6AKl|M;_zIwG9-~ZLo7@gBtwQq9D)sf#ivgOiDeFtpy`&@IwVSCZ8aD7 z6UM`9iOKn-XI>fGn(#eK;ZB36Y7^*D+{5kznDl;X7x#P}={nPwQgI-o& zokqQ1cSqB|{5kd5+?&`^U5`-k4~-)6R+tiE7gDtWXF>d4k|_S`eus}r^guE>ubBKb z;T!J>>s}t#P0^TL%wdxQXi*}Z434Av;=Y~()3twH@IHG}8-q@_#~mM^nf&|2S!6KS zi&OYss5q$00moWlW;~92SKVRZ&qRG?Sd;JjH;RZLA>B*` z>5y&^5fBiNZV-^1bmv5*V|2slhJkcz zh(jT2Gt`khGBWxUwQ2mOAmOufgiA()kIkZk#kvkC7T9xd67cKYl$(*FYArGvg~JK= zaObHZsUd>mu8NpK6tD2rQ8tY6J9@3QuUAJu`8JB+>INB$T|?9N<|=<6$R@&FFK>Vc z`Eg%-*-0;jDOX+%8b9lErf$3LJ6UOthC1KssJq2!qlcJd?5fUUX#9Aa(s-WDmG{!w zrV{YO?Ttjn(%;hQ8`s^r+oKC(?Wfp%DI#%`?3I0`JnLi!@qgcfb->R33@WmXckvh-B*y$H#n%YNpZ~!JO9c`B2KOe z^lsA~X^A`51MBy+HaA)05sf3B4|m0eSFuP_G88(6?_H}>A>DgpxTr6r=YG(HJYG3= z#0re{!BvSogH0HE>J?sz^gnBC6!YHnRD+ek6WO?Uc5T<1=^DdpHz`>4M~vLH&pNif z?TSJjTn{XqEOrc3wtgn_#f_>^*fMiC17|1cvSa_q0i(}VV%_X=dz_%%D#gdgXI#`; zFHmK%GniHImiK<_-ThjDnc4M6?x!GLr(mJiCge{gDnmkEM(rNC?G=mcHkA#FzxbnB z1!qd6JNsM;ebc{aY7D+ivyEp?a1t&kBuNvZ)ecKt;1%J=P{wcX4Ard7_a)><7clZhtN|E5z0yE_mvEJ z#4md{g|JAK;cw&<(LxzNI&L+d?ysa?+KOi7l&(z_+a?nmuBr4D#A$OJuWdhNWtJg~ zMG>D9u{h{WKCq>WxPs$O(Xp3C9V(lIgrzX2zAdes=7`n1_L0l0^{c&av$Y*WHs68G zv`20>j?eIl67BbUYPdfWUhEdSBn~K|9+2pjKnv+n?uEc~DKw1XI#KA1A%q)u%N~O; z`~2?m4wr<&u0qA7vsYfHiv`5U<3+8~x|^=(OrM)jtB4e^|`8)h|JmDMSemy>A*EmKd0Boi)AWNDtzDb}7QgfjjBNS}pv$|%+n-yQ8{ zERO!4+4}z+*96}OYb}Y zIJ!pV(tkWnsK?~*ePs*0cymr%`r~i>&RW6Eo$p%M2tk|?f6V9(KyQY5&`5&0+M2Vw zn!)`YH{Z%IuV`b*qIC-d>q2;0tvSQRL~dvk^m=UMfEDcBDrKo)4rRy zrKzE^T0M~=3ci|Xzp>S{)&^UYo~B9IrqI-ffgUZjdO?_JTmZ$ZaG5@nywVxNTlC{D_HS7I9FZXbx|E_H_nJq53d%hU%ffHAjPHEBVKUjC zKy}zz+x>!x0%4U{eEQl5_hKIIs&V&Xg3)YYAYKY4=mjhC+S0Hzk;R$k}a* z28MPOXwC*#M*!PmI!Eoao_achK#Is#YwmV`rGKYSN@XTl2oIW>u64rT+tMeZ>r+(w zQy4P*X+kjG%;3b#oR2X5r$WFn-#DAvgP3*ZwE^^Dmj?|gGx}i(r9H(JY_==V7~lX} zP!ofDy?2ovJE-={J?RHgYY9-TeagczU5U6|s6z!IDpn4Sp)9n|?lrmt<}+uGzIwxE zxCYV^>+!e?DAkt2S*nidXVqkR4F30?kbG*tf*I`(ibeXn?ucteknycAEkBFrpUS>n z>D1N@`+y^~z`)M$eb)Qi9N~LY^kl@zkuy*@NP+_Xt=xkf9!d3bzv1^KluSR^c)Sh9$A4wcUKG_v-YCUtfh551y%e z6haiMBXh$wmdTu+<^Rx7B|UA$eIDnCWgmk-z`qf>%4QKIub(J~r7z3d7|G29`OWqj zV>eY(II=7MeU{%rQMB!p3@g5HxJ}oQCItz94~+lNPXA5n1Zl|9A4d^*j*SnezIiqK zM>^$C(^;Rdik(#yvajI996VtXHf`su0|oqU+14`h`o>p@)*QX?xTMJMeWv)lkoola z?9cS9`7YsJ`R_~ciKdhkPBe*s06|?hN+q5nJxe~Wjk9*&pLlUQE7v!Havj93uftq+ zmQ-$4`yJ~Mt$&|rZ2FcpSbI|WeLJ?sJ{_(Tq0CG993Hf*eK+pGBEO%sMtw9tuA~oS z-8BiR4{`aeMgaEIZ~OeW{uT_$y*Z9U5wcatvc)B*qxy2=+cIhB>V zn=WsA882Qhw>b7OV`if5<9uk@n;5ZhXJ!5ul#JGh$C+?SCpG#yd-f7V7FdY!Y;LE! zUPvAuU{pKbn7KjRaJkk0P=;_H7@yC1qO1B$#URI-$(ydeq;M61KWzVWL|1V~-Bz3B z4NLeMy6$P2AAEYc46w+A5B%^%fyoBM)9>`S3BVf5V`48GUx_Q1^gY!wZmrl!rJ%za z7Xth0zhN{r#4#Lx+`8Jks-$i*vVKg@xag4#Q5$C!DNVZ1N_UrXa{l`thHGZU9j(@C z#VmmYa6ID!g=J)0yg}p;ZD61_u{*#Q&5LLEi%w#Fo${kI+yHdW{r<@Q*6pTbTF3Yq z3ZKi2pMOA6zqAI5hy<~vypfYv#AVElaV^Myh7JbD{JoojMf&}Psy%r8qo1sXkC#S_ z5bjKo-G(ObFH?BhI zGe%>iq^MP~R>XtdU!&)^+lEwPOSLVeN)@**KIggMikmu>RRH&ctaTDx)bLVO> zTdk}=mOER!C}-YM6KfR^^02QW8A5ia`^l;L_uKnRgEex5NrdlfFSOqC#gULGfBsGq zwxnj(1kv!v`|!JO9=NjYw!97T|6v3?neev35ZsNA?=@o6JSm|WtpX3kU5(d zKlj}vC|{VLahCaptmFU`?*To{$sw(Fwhpb>AJ$qerKc8@URGWXEFDDq&rR{LLa?$MoB1{qs^Wyc2ft;e}R5Z*dp zW@#SBs2`b}f|O#5)#jM0x>mYoZ28-xoia=J8%D>#ODV;Jt5w_{`41;CBd&7PaFaBd zV=2^QUyWt)kIwXfy}>EPCLHC8$kFHKpq^5jl`q)_esGrTmR9C+O9 z`urUQUMy)%OsNRtIYy=JnXysyz5fig-%*&quFgS*{gW0_Rcl2{k?Xxf0m9 zhk-fmb{Q0;mOKKVj4c7pGHA#3Oyr}tA8^#|b<3mi_PT8Yp_|lg#x|h^9mc&5W0_{z z`9502hZ>2ik8qiHod}(G0`j0)il(}{mSKUbBDW#;P2Kog&8)V&2}-DIFzAHlnVS^K zZ<&a63kE<7(&&TR*)+8`(Qu87Cw4E$x|)LGpYP2=@HPY+9-j#He;4u+3IuvREx=3cS)>SoF z$i}en4KE&&1-u3{Z9m|iwvxfi+IVkzdh5*}+4^n_5d=?#94|O$I+^|QsZIhspba^%_9X-& zxMo4Uuy2dz^r(D2bL9cdna`h+ry39025G1$=)xXuEKDq7g;{)LYVJ6yqk6}4$&fk=l^RY z$NGcL`Y=Vmbm$9L{TI6IM_zweo%5X^k3Mqi%MPEo(J34^l1w}ro?JkGSH8@z#>>UoTGQme`N)>@U0R(N-&I35s+-+*=Fl);x-Lrg!^qokn^m00vU1LUO9RfIxt+7yC4}hb27vzL)4np|I%%x{ z>&Cxtq7}wzip4J^>*nk*{A@mYT|ott;PD48@J2~PPMa6WUCew9LH#)Xur5Zb1nlLxIEJNt4<=2S-Zm!mPo)!nHp)(V` zW>#UR*i-gc1SH4~C7-A8GeFzvvbWdOX$B%Q`Jl7as=CoetE|{qO2)D5toE<&!KB7t z-)7;F`7MIF;+1;OJh-{mxxM}j=z3JyeEU(H?m_P89BC}UVJ+x!X`N%rw>rk0_Cw@i z@z3v%dD}zGv02yj4G5?jA}Y>Wl5MISRK=7g)NG*qow3C?V3+;O z-QV0^sZww-ghw|xI3%7St^J#Vwj$~Dt8=J_)xB+?=+w6t55@xvX> zaa6{qW^r;GWTuz5*aD*{d8EUpffV+t!uiepOpo_!1ZTHA-9>Tp;vAy_ZLsh`zv?&G zz!M1s-BT1+3px=?YMNeXmX{c*XsYucq&&s~h|oI9@+u3-Cdrs_Z)TltvAnOLcYSCJ z|HF5dhWu8>GHL_g5*1FpEUd7%Jny8ke~FaC$|bBAV` zzV7bK$K0iGZOqh_lS&XuxSX0A~u#xjOOF96Y%7J!!(fa@#v3tnd>GX*!!jfYGtTTFb*qC>R_} zA=P;~twoMNb!>oAFW4RZjjwV7NQ1uen}Mxx`W}#9ZKy0|$g7^2YuQ?|7ZCqQ991=-$ig zQ^NB*#f8y6eVXFiYxEydiZf6Fv4vW0LVcQu)S7w$am3uV%apvfnIra&i8hj?%Nz9P z|Hf3$tjPw&w+*XjSsPbx8MC# zl-;$L^DWeduAn>3NEp+Mhl9t;Bkb@j*2sAPeI`;D2S*K2D2sEza*m|Hlqo0wC(dJA zUL%aoglDVXLiL2^CVWMs+2Cd`vLh2(v{Q2(DO=chPS_L1&@z8h>v7vEF8C}Mm*s%* zX{FR-#8J}foFde6?)Z$8$%e~BndRR%X@=X><+k5TfauKo=<7#jG$KLQ*DoT+<$t^2 zQ2JzWYb0K~?{5U>4q7C=upQ0o~;bEAtx)y|xC z>^c~sKFypY3)X9>?oTHMfeafC47GC*LYjB~=I>qoLS-0SPPg%%$gVAm9(a8z&&fld zIFKz{5h+(KW@Y@x+QYMaesc#5JK#?=OpR#&oi%hJaC7tZ`BvAhF<-~Z^C95RTP#S` zB{cMGpRmrKMD>6aqUs)}r5gsc$U~*(hAtK`wrNl*z8l$AoQY|?^7nGIe{eT)m@YOo~ik>gMX`!b+ z{M8qaWSvi6D|Lv>-<>n;aYW!tby*txmWR@;4sSZc*eTstpJwWblF8%cj{%t5moH z#~00pHM7r{BOuX-q8oMO0m1t_*F=HClHmULd#5|lby-$T zeRfWXxgCwFqA{N$#&xB~_bH9p-TbIHIgN$bZ~bg1H?x^xTOI;j=>@iFGsn{>#v26X zFNyps{nhT~=KE)g2)yGIy#5p}|C=2zh{oY6jGlkjtdQ~^tUmC(g=YOZu->tEs#V8% znC4bZXjCxr+yDb&YB=%NE%Plpu&F7{!jtnf;j8qZoXj6ew-@x;uyph-L8u+2u(_KL z&&mqE>{8)mEn|lcj6S67BaD!Kt_E(SX};{+SX5@F-^kQvlQ1?s1Q5Hi59%o9IdL`9 z%@z@E^|kBmusUF9(zioX5qa5eju_~4 zSe&02fS_&3enqQ?OMnP&!9!|Vt^@Oz*`!I07n?sVN&MhV%b5e%1^#&3CzX4xC4DW6 zD6sbVE!dY3UO|1?yY#XR=#pzw!%yMSa?|7R-u&H&IXZ0kHh^**csY!w?Ii^s%Ou5Y z_2q+=ld`NXO;jJ;b4323mE;Kx&bebyp6(ke4Wtb8;}8j<9PfVA59v zJ`!rrafRK$O^PgvW?Do#_yeScX`@ML)V z&C}sRyfv3sTp3(fTh8{~OaS71Qa$Swo>FKX0r>R9P4BdV>s@>EKg)~h1Kg9vmc$<- zY%7og&$A#)gkZ8@S^hqlAFx3VVcQxCG7kDd7Zl6x{LBy<0az{S2b7lOX$%Ze-5o$$2|MxKj4RW?lygL29Poj z_?xWqYR|{H1Y&}Y`JHt+(C3QC-*46lIq~l+>MH8m>1%AFqFel@AUq^7=H#7Kut|q* z^f3&u^A)4Dyg!udW)~6DGO9`s!;ai`>vMlkt+VETC%K`;UE@gdj=#_*jne%AT>xAwk+}wMLDD(cTy-9?j@# z+8USr+i8Zb8`*^*8AgLkw8iSI(Fy@`4hQ=jkCYhmQ;U-j{RPYuRprK0{}e5 zdK)CkVVrK(L>9UR#9y{pIlc)F(GVOSD#q>u`>!1W(%h3D-d3=(2WY?bAY_TCHI!JRncJ9G!>7MDvi}h+; zE8f^<-yp~Q^Jzj6bsOb!Om8}(Lr7j&J7=JZ#5?JlbZTX~5(jA{;WLS?zghpy>DtKD z8q@H(s^piya3^$-Q~mTzE$z0~15f+n-iB)>I%nGq1^9(9saq36iWMVoyVFAca=Ten zHOzkIh&On5>7Ey|B*a7c>jz@G{l{#_k7XOXthJebo!3o@sGEIxDH#*U#zCX(y31tA z@I534RC7{?R?WOL)g0W$WUzjqsn?t=_EyCWGpN_wf}9`TCzN+JNZH{e~`oPIPt5hy;m-!^7^p zjKNRb?N&c$PDvW%(q`l9?8CsP`??r)ecQ+*Sp_}FXvORwo4D^T525l>r4~rl>RZJf*|uj+*i1!A9sI#T)s7;k)NGSt5LE`qr99oLG@#zAp@KByd*}hNg6X77VtC*kgVeDX0eZtk@vVuEZk!4O zn|9vTzL#4Sf)lE9SnZkE@szTRKQrP~#dllpHF>u%%KBv(Sl>;Be15e}WSob&I94GI650@AHajPB(~ zOAc-Q{vU=z60ph2-~-m%XA23u9;V<;O8O>ftCZDxB;Y*o(8-eq9ii{b?Y*J@!%U27 z8Pl4MoI*TC7=Pk3urJ99kDmEZz#7EY4)LE*B8i`^2=Y9pHG@_p+gG@v+6d0z zMaSs%0zDcAz%-FAj4;u{)KrJWPF|zVXKQ8{zLg!G&oX|?8|rj#$U8m7MgF|>H{0Hh z^NByYhAtOHas&iSe0~Luko@p8DjHm~#Z&6}VtLWVyTpc@SR<$|y-eI%gbkIMm*?A0wpg_6c@Xg(n zJxRAbA%3rKey9)axt~1Q)m}Kra)S22_+!KQUU0>_PE~uP0@(vbY+vm&mW*i9z~O+~ zgh*$6FB7mhRwYUTfpb3JimAF&KBY`4snu)sXDG{G`%af$`lyTS(zy%H;(uMncmm=( zv9a_tRM$Kh6Z+ue)&PMd?Ij`Odz$qZEpdHg-1yL_vs6dS zx%ZC(W#yL6`C3Pz&49+-TLWE>zqp3{e;bMo1MXm$%n7iMUdQCmIQ(ry&>F)=`-2Hp z#ru1KJb^K6x1EwlfO@&11BFXgmRu^T%)+Knf{3(*^s@4k2aeyHzb2<+h$ z<;9;O-;uW3^fz>5htB#s6do)_bWF7dN{rfUQPT z>aE3>ds5Nt|A=!!qOQW=mQ-s10;sl)?}T7&gTYAgiS%88h3tKGCm_?0=lr%)XN@=u zeyg4&n5M~7+L!c9Vdz6Nx?HxnmizC$f1)@=asyaAX7JyyurF%l8nIdBa(>F*Js7~w zF&8(13hTkhM$iy}wN0x`Ye*H=%zQGO&i-IB7-UK02fO zZP_%ndHX8-vs5Gq=jq*9tcm={+wrL(I@@yi>lCJc?P2zum8AKxjl=&SNBch1PrrsP z^)_uq?6ZzKASDmR>@Q2@lj{e1qZb(I%eqkPlTWm$@AH!Mhu_XgoC*zNZ_3pD^+-YJ z%h9cKAFZWejz*CQ2J)^f@u^zyER(jWsD9nPazd*@C}0BHm*0xNihmRy@O}(o7%xz9 z?Ax>`os&93$oK5t{jyTg^*%g+6sOte^tj=Fd2lV(Cuv@;`pkETA;56V}wumy?Dt(G*4zvhNGvzHIH(Uz++}thBU{-Gg{5f z;hXFFWMdtv6l5}!E}mi4RN^#>dn)z;_cQByUmx~bdxb*Y?h=hGsm>SOax`rGy___q zpa3&NJ)yi(fF>+j82TCMa6Zr{Me5d-hqTAsDa5UU@cyhF#7r)>pZ~(wU+eJHw zQK~Iq%~`Fh34(W6m0$1nx^_&lp)K^-P4Eu%7K7HVAem)Dq|e9F^z>y=EFzYtG5X-2p%_g>)$9UYf+-(6Z{RmH_1MR^Zzr0_v)YnPZwUmwo=f za{V+H^%cucaAi5?R?zy6=ch^955*m`S-%MsN_OHXH;z#l9%F{q<@{k4Oi@XfJGMiU^t4TZ4?#tPfCbPqtsq z{d?_>cGSmt*NYN_i|TxQgQ;B|5dJCiXfa&Pu*|$SV5U@2LSwQ+1}h8{&UxK(V2GU4s-?iW-PgsT57+sKr)^- zn|Ni;yHvGy_*>YmtvieE2JOubcS011ou;Z(-?}CBlO2HMS&_1vrmGR|z?Yin9@Q|@ zKRlgus!b9@r>goZjL(7!=(7_3bQG0z^Czjq)}u@~2udNvi|rKqKx!FNU3Xpkuep;_p3&-fQ!-_dxdVXw&w)Xss$6V{5u!N zrZS7NhL~y5jEmGI$87qKy9SE@F(o^hNQ6?~2QopyNn=))+?fCIT7>^ip=NTEpQkNi zlkH^b4x1Lu&{WuweCGQmVRNG6dcvSRZ{f)wA=e!Hau@g0lIYJkS=Y}5Dnmw5VpLZA zGe+A|_^lQ^&rZw(AMbSj!>|~$?K=neIs{6bl*>%oyP$qoHf|ht`~y;EX}W|*g7t4% z{NH@yxf#<&bE<}UT2EdTKv0k*Whf|LVr<}H`WfV5N7k)}t8Bi%{zB3XorbHGW}RIA z+h5@N7l6>0#{T6h5AFTn=zkca^Rx7TNjr=O!BjhXQ6k*EO9p{f-XS;BVPEI!5tqJJ zZjlpoIO9IPYno-mR$S+SJ4L9N1r-HiHNZr_a6kB+q%K{8`Q3M=1`dT=fwGbJa|I=m zJtZ4S`rN&7S`Pe!idhwm#YJd#f%#4++Y_{T7K)N}z4wDI!drCHwB%#{+BFUbFL&*A zkgX(EPHe*H1**Wpdh@yylQ~k;O%^@63Sx~<941;3`W31Ww+UP%q=!SUqhP`^VqU#v zQxg7V_^Ix9d1Xu9k4`k5bpJ(ol?H##d@c0s+jD?h3B;P?Vc(ROd(Lf8;nTn3Z4!YT}ZesD$s9}{kSU|Xrln{v`u@N}aiqWUXZhV`ZBcnZ*YUSm2qK6O&$U z8a|hk`l13DEsmH{DS6XoJs_p8$oEMXC5Zw12iNb#7S01%q$ zg64^{A~ZHZ(FF#|6gp3RvnG^Pb2rO_qdxLN_eD2%0g{P#dl?!`;jA+wnV8_ue8w+@ zf~)50z|E-ohmPU?Xb)r}n$ze&o%)xq*}$4z+hvbAU%OH5%NH~Jk?7Ov^Y`)-NpAd%Otv{(ft{f7Z5A;>W4e!5)2;zG+J9sDfQl9F8ra;#W}m4Cr?2k$GL zkkj<+opC@pOZYroJ=(f}PNaPK15PwhN#n2Td5u2Z$@ObJ|A5%}608>peJ^4pP2c<% zG*CdJCqKvZ8kN?ebnZib;KcP3_~b9^cTWy$QuByFfwTC6CcIg?&>aEsw3Z!n2Ldja zg<%Ydlc+Wce8XtBUzlCxsN1}^>IpdCU!@sQWQ&mfSlgi8*eJmeYVR;mMZfpFvHC6v@LEv|!T0^IhutEPXqb1J zvjVN6UNlCfx1av$9iuA2@?-ng01oM8VxmPzpCnX4>7BGQuJ3 z#C~0KZq>CyQg^sACB-L9`k{g!(gi-Pl)x#EQte$TEW}fT{9AS<8W^D8?T{K}w#}$@ z=gAa?O3BQK%WS0!(-!nRHgEbL6Cs`hZotm$BLa3nO<1~n4)5DAL10(vzf+;o!D~U@~T5 zKtIm<>Xg~WwhH|;SZ+|VCZGUZO{<^}0n6Ldm-evIqI&b4N(XlvFXwu*ngQ^+K07~S#Wvv_*noeIqwUq3|ypU zWb}6;$M)fa-~2^Pv`d%9Eh9=4Uc=t>%dz(bH*w`^YY15533 zRVgM1@HK}lpC9s414JiCo!;FPc||*rGJrj0lTJhrqJp$VcRnNCZ#N4r|7MP?*{Jh8 zB(w2OVea=p##`0!J4bb#QYznChU*UQ7!kZe9m#h$*UiXM#0!qeE4q26H)-wATy@?1 zz;b*Qs{^%zN8rKnl;lf=Z)X`y?#w;&utJY%{=OK+}i zFiAFP0kvMLO<-q}olGt>_nrm+(*>mW8o&PAs`mh+wh>`JHI!Fc>2X;W+F7@f#RiiY4`k)uJ{+W;3 zd&vqh+vb$TVCJ+NhlnTQ>Z!U}7(d3(NrC;oorFL}toXAps<(N+O6a_GBF|MM*dJgF z$xgW`QS&sUeFG&AyWoP7E# z!T&YqIAVm8ybQD{z$C3jnTID0e>J7rP!H-XV4w{bq7M8LJSEEtkGF6ROKWg&p2~!x zDH664*)O?iRpnEY&tIWS!HY0d1!C=PrR5GF-Z`=P1FRXK_dq1AGr(==CUK0Vz(CaW_uG)N{CTaCJe`@p68KY=P31ng;TEVF@s=Vqp$ z4Ein!j78X%nKQ?ZZ&3W_e`IgG$|I)`7cJG;^x1FlU6&k!ujnXGr_0~gGMe+qm4bN2 ze$gjnZ$;1b1&vsL5BWgJQaQxvHrK0JA*5cLQLx$)-KqACnK@kl`^SY7qo|aNIK~-+ z5xTjWUuon775kdoI+IIo$D;k|c4Y>5oZUMKAk1hg5azwkIQkBdMLLvM)aNjVoTd|* z&`ps{Sz;EU@bLUTLC1V$h9v3%N1CuUd=$*2#pR$8NH?UhsBpkN@q}|YLfy#IA!B@f z{>X{0r@+=lDpY>tUFx@}c0Xho*P%y9N^LL_ByJY+f+K%~!zQmICh&oR{_m^NIiWu{ z!@+mfAt5ryBLhb!!AAS=xvt_-mZ9Z?*pZn^pJ7-lMnegcLRt zOqw>zZ!p=}_IFD8u}WP@C+bZkUYxS>X-w=kQc`!dJzVvwvaA!-f~bsHD>S|9y*`Uu z8P?6*Z9U!#wF<5{&U<~}H<3Hk=q<4SAkS65aA#-Q;!iy9e*b0f4mb0cfc!(6-w0?K zN2i8>cCPoU401syWVQgya zd_uhTTV*?f=%#=~@7c#d8Vb8;kGfDFdT5CC|G-6&-CVPW*z6!C>Q9z}Hx?1%d1iL( zQn1{kp+M@}>0X5L1R3OhE)1<%{0}2Fce9jk{rW3Mj9y#k<%LEmL&T zSXN#*G6XI^a7OLP*DKVFh9^S!NAyn8anJb28D)kCv+XuR7-ovT1MkYBJQZE5P4s)@ zSK?bPKv_knEAPTRn@zO*ui}p8Kn9Qk_gj6p>x#;f+@U?2+e72$@}qwpr=%XNJp~X0 z{p(Bb4XRx>q=JQl-jGMA&22igEN0i5&b9F870HTh`-ZRA{)Z7+r%e6OnS7sF7@G}@ z#-CUSlz%_85Jr_6Yx3;pawOE4&$!~8VMC<@p}%jqUY^Ds_=|EtVEXqzL-gjzQ6Wa` zGjyv-&$~c&*YCVQQWJi=2*;j|InpfMy z?B?#iBW2F5dOIktgJ;AA*B8CXGdgXl^P-06y?6#7*YUCAVs7R~_coss4rzE9PzHg(`2I{FpH*zlQ7PSy95R%Xx+ zsLi~&tQxdX=EWlF)6~`Sm?eDeOr|jwYbU>2F}@*b%l8{5YuLYMJ$n6FRMDZGs#@~u&*7UWhVp)F951eW9YnUOkr1Uj@nNg;wt6!?n zrBK|abr(0)<^fZ(t42tYC}BnR4Cm%2TI&g3`zuRE%v3z84gsS6!>EHqKJj|0Y9j4C zHX@`5_q_uj!(ZF) zr{kW6Rw(vssf`!wHD2c*z!7yO*vw(#F1BarwvG>Fh96t1T`@ zZEVjfi(uw_;^R?!zena2+C&PcXABw-KD5IcTw;an9Law;S3bKwGDN3L#6p+!7a=_P zdcj>dcMDYvl=Q7SBHX+LWZ6r{cNqLt~ydtq#4|qC46=G1%hnogn5bPur~15#iCx?U$Dj z`q6Nt*Z$)CFNhcgwAwtQK7=jV6%HZ*WW2<4XHm9-Xj}I(cJAC4`yEr zf(XRhqidm#+9Bu4=k#O~&w{hq$wFN+w4>_JmTr}`oA~_sMh8cU2!n zx;AtQbq#4EXly3*804hX`AiBP$sAoeEf&gOrzdzNYQ&~5tcGg@+s*C$H z{leEj@lpG*&rOOm(m-@;LZGBbP-fsD7%+%VFc?;+e2MKSG4Q@}F^<`&4T(CmVub5! zd#B6KPKT?Yu1iSqP@S;XvH!x#Hi0xW>mY;mP%OjiG^ZC+>&T*|JW7?!wyP?em$h^+`9D`!PArl8pyQ*p9CaIRa=z zsTp%JzS0K1vc_hUog*T85e~QeS)>iAlwdZoj^J{h;k>u7AGX zdm<~aELfkkONqnwC6+?ByMB>kM?$i_5gK1$G!vI#l=d)DKKQ=0yN;qVxncC4Z}av< zBM%KF=^;G^ruexy=gj8px~vyYI`y`weoCDXh;8Up>3OEhA}kZRzyjUI2j1PGy;W6? zaeKFDSYWRC;it}aZyIn_*G9i0s59D$p6a!^`?S1NqBk`7+Rvaz)KI7SXOT+l(jpa> z5J}h19febz%3sDpi+zfUhc`1W2TrJWDz$kWY&*L;u+4{i3D5>W9a#gn3P|pxK!Z@S z9$alu8yyF_im^D-U>vhMe=o+)Uw$(UA_KN}GX^kmRk#GfM2|NaBvxqY+lliQBhzX+ z1LdK@gu}12ixTm76^HJ`M@jc#4NW$@$;8Bpsx9s+uP_-Mog!h=QUoCD6F`ruxmEE# z`K@B5TbE^t`T)kj6Ae|SU6PRt#^7alxvikJPXicx6^@*!*K|dke9=@!j)EgS6kNH8z!W@iNN+B6Tu1a`hZr>S}{6xB-AC%}! z;4h?1=TdV!0`c+JHmcj#TYeMBm|sZXyX*>k|BKl6ZP4z3xEy#&adpq;*&g_OIh|kP zN}m4@WBS15n*GKN$$D;OFkgocgwqGvam8S|xuFU0PQs@F8&y}wYzXAkH(n&HSqEqw9R^mr<5R1=7NYEG#;mfwyn+*}n z=N{o4*AY$-&i>8E9dTYr@t7CYRq3M&w95D~Gde?@ByHClP%QUK&W_UTrBMdx)&8q{ z9ab;khuVo3c?2QYC=m4#r@h^^n6Mi=6S1^&#QTq@<-F`-ebgZ#wG~Vfz}|)}{|(viIj`!1%Ng=jX#k+yLgR~ zP0!nim3fguA3p*VcUv_!6~v`;4Q=>()m9vW1|MpeSwND6)dH7l<2)ob3I@{$Pgdps zh8=y;+h9-!!%2aR&NZ*hH`qWQgj^~XW z(@jV~6x>%jX3UPczZc4|_)l6S0BP+lXUfV^7;N}*k&$Ob{~qttWt>aGVP73bd%J`#94;mxwkq1*kY{#3-5 z(_!Y3Nd6Y+ihUOY>swwo_<`W>hgLt@9xT2{q-7>6R3WY=KkTPmdz=q?`24d2h)|9m ze*E^*`uTT%cgw$zonr9yaT2eGgM=SP->bJzO@Bq##SD`J5`ELj^skKn0BO%2d^Od4 zJ12y!rCm?NR&sB)xwpe5oWH~xPK~$>GA@1aK(8M7q5DSo!{P^rJT~iFncrlVX$8&f zzi60$5dZ=71A$joJaSG2OWuQnU$fvaRB+UzLWC4xwn5cm>nc1f=m%-u_25Ze1gEPS&u+US0zex0U6aigs-6u2E}n~rf-nS`TZry%i3jw(>SsM}g85aL6ML(Na+co<4vMNj-qcJ*(#3Q{(hkn(?)k$iv}|03(UFgT&H-_m};F_eVUM z^1mMZN7g)bdM3OA+RhSui&)7q#yv?tdC2O=@WA4{-J~b2e>8Z9$5PE9JPsYW(Rw$t zm-#O*zq0+$gLpO>be1a-X;u5weLKGI?mpdXl$JQIV0D@a)JYtOcPlG$2qUdlc}cIC zJQ48%Hg36m+>6+`Yq*(SmU|3k!6Ne4vvQ^%6brM`A;#) zXM~?DwOt?e)cv~)jD=|PN9MZ|JecHjRo-llO;s|c4mmX)tXhtzsN3lpO|#zW7j3c_ zBN=8V`VQxyt{zia&dTh2n67}+Cbl=pdPHAm3R$Hsz*PgC+3AjJ$-G1G`%LgJh%an4 z3|;E>#1M?Zme^n&vSWvpbAoyU*P8tR@ZW>~GNbmeZr&~O-+%^mrO^=o+ zI~faL7wUQrE9P$qd_R}PHz&p3F;-hCtE?+QI=D|;i=(ht41j$@;{FA3}o>& zr(UPUCwJ<^;HSQ6xam#DcqEG>p?o#XgXXnz*tgGKzLNtsO5%)iupR4JSsm zlJ@rHg2xm}S(u+dKU!65#tWKm$Z(}8Xpxt=ZU_chjsP7=ueg2&e#~E_ zupmheo^rBH+%(t zn*RWbVAHo++2U)ihq|ZN9sR4!q`khLcDkP7%&*ks{y=sW>gCiW8LH2x;uP^d&T3Y@ zkCK~A)M7urvAB=@=$wBsO>D!%{GQARudaUEc0c8_pQ)xj!^klpSN{Nb17Az^_tkzU zT=SOo%x~-MG{{U7@#NYY|uh#~&u$cb(%H964 zC-_(Ar|qqwL1(UbTVAt;Ack0OEefE*?ouUQ2d-Q4uZiQ2-r;$z@U{M5sr39OnyKQw zpDnJR`TmE*?r~J-cw&ZItEmJQ@Gj{jZi>Iup2NARE@y(;-dn4=L@+|To!g?1>Q7-_ zCE`C2&EhGQQCb~76PV&XSpNWh{ZF9nUzp*r&ZF`_K*ZMNL|!KG48A0tVz*Ye(;#fc z5&O=6=cJyk=sFJ7&K2!ea5@2sP@szT)io7lcvEgp?5Gs9CMHw`?H9YLQ2}KlDU75BeCP_5C1wzuIp0xEMQ%iDl(!Dp~3K8RIZvg)Q zk2_n*kG9Ad=Dupw{werBOTT?G`uIaKa7k7w4sn9O6$g)6_TPctIPlH>rQ#c;)L>B- zi+m8Ys=L|KXbK6*JoLqWYvML{h5o_Jl`8OajX9>2qjcO_+V|>unM!XHN%Ffkxz!ha; z&swk-0=0}qa-!Sd<+(LKLDPzH>-45DB-ZLCiS`0%I`K=*99F2ZKVd-zor(@Q%>pYC z=}oCf6%%7TQ%nHzDn2poNX`MrT8NT*(_UpHaXbFwSJkd*>@kxQwoyT#M zQkp7);%1t7H0p`iD9JyCCP?Ztj@@gj(fkYJ4R+A#HX2lsY3MgS?a3Clk*}*yIY_|# zYmwGGU+_!8k{G;O>#k*=U6aCh@lgqb5?hE3zrG}j4$q3!{>-TT?6&f>o zt%p0JjJW_;L7;d`#a=9CPYvl8I)GD#ks~4`$5PTZ5kT%s3|ES4KeY9wo?-Y`;0rA% z4pUI^1cz3<2eU1|%WWC%GL4UV@lP3iR`{*)8Gg^>uNP`Mt)FW#FWKyu*jqueFb83` ziuyb!31VkwHln{Dy5F6L%LVaE?*6~b`yX1l_-Ej0S}%w`KhvSQL-T3+E_I9l0Cge{ z+#&w}dR{Bbd|UfM{5_Y$xBd+9CxPy+^vz-5H}_iQ^T#~tZvJ8_L1xOa4d5PKiSJ(~ zL2qwto?X045{}BCRJ(CsQGwxV6lWR9w%5siukp8#O0JfQ(DpU)&%*1u0lx6Yr=*tZ z)*6D&gXta2WaIwYRkhT97T!MFFA?79+3l$5#Hjkcypn(DHR2?9r4{u&@7L7x{;3|v zzQdqtm&g7j-Yd~B$>@gc+PVJ#fZVKO^{dkSJn_mQnhhdL=!YfjnJ(A!fu6o{F~sEV zP;>1>CQDfawsOgBBJvt1WMT9m)Ao<5UyA<#F-r*^mtT+h9?N-SscEm}>ULAvu0f>jU&P%-VcR_?xH3BDL{eqviD3EbTI5(`=iDD|J3E__kQqJ!Z>D)x!S( z&&Hh!x%#{!HvDr-opoQ=;A1Q!e+hFUo!*%r#IFj@fpu>J#1D6{zn%m5EWU87@%WSB z4d5H4@dk|{>K+?==$Giq@VDl)b0QuVYi&>e()`uPsN0iHrZ-yh&oK#1W6e64YiJgj z#_L{{@S<&=B$^Tb04;6Uf8Q((d8>7<*WtwG4;ol6X@rUSp+C~S90aXeKBmyz`e2T6 z%_b{M#CwWwlt}{>_1veT;%5}iGhys2=bwq+9yOl=c((JxRyr-5#?c{RH1jf+^N>H? z80nmKuPDF$qJAdaf-MD(ntft`$iwlGi2W;?SeV7w#p<;_-w}hydT@u@z9jHR#?Ko` z<6R{9-^54z1j^rQ{mf{Jg%|?fT5iB>6N>vU!@e!>myDL`PYCL1smEl&C1gnuDPfU~ z>bV3C*{4prmc^u*f5;T?AZD}-SQOor(P7x4U|yBI;Wk@86=_qRF>x6D80|lyuPFGx zuXvZiUIw4Uo*ZbPy0?}f%`yi9H#OTD9o zjAZdmn$t#zvNAmI-bPRgeRKPf?YGjJM}LO-rMTda$$tla-{L{ zGmN1)KE{Y`!MKh$`9WX_^cm*6BPFGcoRzdQ!Vr35rozaL6v!k-;HwOS^#;BQ)W2%a zi9Zayd93(9$Nm>hS4_3Px=TBPjbnE;qRPTIjFII_@?)km>)yTB!k@Kgz|R$VQQT_M z_;U6_2a&17(E#3pRoxl-mKB=C!RYk${;X!&vE6Im2s{Jg9nNe&vu+!oHq7})KI%SG z`B#f;{{XY!iLCcc;j8#{Si#~s{{YeE$NR%5e;ilW5k0NU5nfo&72UMsbaKM$ByK%b zl#qV|QUyJ0%EjbZO8)fVuia_+Z}T##hK!HRy>~$I4~69N{6(f*Y2!E#Diay{NV!w_ z)$HRM6`whiiGGS=$dU}H z%{rCA4iw?inNMDn#yW~hI@B-}fI$bErzN7dF`cK=wOjV0kybIRH+?^ugbWiF;16u&er!4oTRZaNfeIZmj=Dd!oRd1#7_-`w$@%h6lv*zW4is_ z$OHScsLp@h4PP0K;TfjoLRVd$kNm$Nv|86s>-z3}$UW<4O}F#(ZxqApraS&INRRz1 z#k?u;v*DME%y1^8bUi{$53_1TV#|6Yx$@iFkbSG@KMYzgKc7s+^qd{?6QUJ&}7#wKBufj((p=)H%z`%T~Q;=U&` zoFc8eB5B)g4@&rp;+-<a1R`ejMswMn@uRQ>1bN+U-h?> ze)8f&-{w6(3aB?o`H{C1>sFF6)6%>LQhc_P&{3&2xpqXL83}m_W@C~`$u(hAV~T1a zBmgt)D`wwLBnG%UNB2ix{Pyd{3cIqV5pNN11H@0Q1gtabDI-5|E zy(dz&)-?9=?yQ_i8+(qR`w_wRueUrsuiEQ6exKrvEPm$b63P9^2M&kmf5yHRjzwS{ zdK&fbgkCO#LDi3i^+|+wTHJs^xbl}+!s9)kr`HwX=XgJB3#F<&%)<%oBYvmY@!t(T z?hS6-<}@EZKZ+(eW%`fiE9k!kY8Em}bKzZE=Fxmcf+i=VQ?UKim^}_~KPvD~3HWOE zW&3`v0@*_;#j}5`GLG+#0-sK%y*Ps5iyyt|#y+1){KFSsF`Q)ws{D_OcZ{?--AhQn z@b;~DplVD0lVIfu{_^!!9?UtY0CpGyub{78@dx}=o*%dInvL4NiWb)Vcb41uCdaDv zC+l3E?f7``RmMRchQ99yo=T-9ea}XY8Z%eDj_bh+o;L7}_rl#lPumc^=8r3m7U5Si zLG+bINBclmgk1Pr#HlHYJtFCfk`L}&073O2SEAb9URhm3d0{Dr=GE3noOM@IkVoTR zMEHtpUlnNfemJmPeY@nmjB+BoU%oNfU+)V2+riHhtKhlv$7dNk#`n?nx0B!3)cq%h z@U$_ISbERiiqZF9@INEt2>cb}DaY>gsYm<87x}QM$HR{szBgE`Uhgz`{Ryw4e6YPb zQ+5J*ujmKI80VtpUnl-&)PJdXPPhC20N@@Gd+@WzI&X$%@m#hd>rSlM42%I*R)_)!JwdM(_~rX5y`tRs zyWwOdlopcXOdQ~KEeCV{=l%2PRgn0Z7mAh_F|>8kT{YX4n{$x z`ctv%Sh*IUV^3^jJD2W%DuZaQfU1$tAoi@wi*GCEPE+|-kgI(ddp3$luN5Darp$j_w|nC79gMG(j)l$TPn!o?+whE^Rz4nZG7M%q!< zu&Wg)xXWUqntC5hXgaKZBhfAOWx$Y+E_ENnD*f-k^&ZBx(lPiN@UMrO=+ZU6?Aw;x zX{q8fo{KN=H{y6dUuyT(iIIw)FmYeZ{7I8M4lY!e!u5}*Z>mx@&&8e{);<&K-!-k|FD0WMs7cEz`>SEs@vnjWQ{nsn01IoEz8}=%^6o5UXqQ}0tW*G{36nPH*uof>u_op z`cy3Xb%vh5yt{=5%M4%uit2bdBMb9j;Md3>v~R-84L;?*BIs7=uwA$slxRNW&A_<8 z?B@Z`_ppD6*UAXe+0Py2m+q&5gCmZhkMqc{#_-=oJcku1OBsl%I45?rPfnNDe!bCo z{{V=X$;MRGD8_vyy}nw0XVM-s@z06ADENeUqsAmFKnhscAtv6&B6=wPCqKmJl6|lS zA`_BNTFkK3ZS~s$s@kZD!P{{V{{WV9dw;UsN99w8EW@@d`xh#~VX$~=cuXBN3Q$&z zW3pYB>(#wmZ2o1()2lkusadx+?%V4B0Ij-PZ41l??S2EKD?7@ek@4d$n3s@UFWhFJAG zc|?}dz=la9kaS?5#5)!42_5U>yh`x<1H{>6usFr6UpyYxl25N^v+_M|72-PCMmnA% zo%vt8CEYf!^X=wkc(+FQnc(!l*EJY#{41h($5*|-wbMn&^HHWM?6dhK4Z}PP^!#hf zHBBOEtp(Jnwnctql#)mz8Og~t^aqZ$UyPn2(=B{K@kT4-r;DPHJwi)g3$HD#x8WH3!VJ(${+5)c)9qiVBt%ojMdcJ!Pk* z)xKw(#}ivSLnMk$0}?m~K7ySiL{N;D&mSPpe;Vl8&1ND&5Ml-a+n?5-{fl`h03eJW z>_Gnj>(|`Ml_)QMc0W0*nODS4RTVq7`Y-Z2eKO|aJu==WiEW)xK?nv;2U2_HopUTN z8C%Cyg^H`Y<&_kiZ(urw>T2ztk>?1co_1AXji?81I-LIiD*Fred;Ooj4*W>?Z>RXb z#@c1Zt*VQQi(A;9?r5M{V3m|&IGF|tZEPI%uZZGKJj$|6I>gsfw>CZ8Jxg#P_}@n)c?#-LBw@&J~K%LmKZ@V#VE**6bB`s?-rp(thv;kcj|F&l8%GMWynZ|D4-*); z+6oFVcal!qy=?Zip8Y;gi1VdcJQX@zk*6nq?d^Z-@H+^u-^>ycTbzfpdDb!Auim#lTYtKFQ~Bh6Rr|c;nm#j-x2Efw&XXWI zUF_D_?2bbJ0N*`7O7wjz;^&I&iv6u^@3x%f)W*ji>4EJBL7eS7zU9gUS0$O&Z)H z$rBOs$c!=>4?+m%BD@FoR@N-%@n?xX4QrBTMEH@WO>O13+!v7}ZEyFCWx9WKdJ6hS z;9jRciT)Sdd_3`DgRFQi>RlRlfAxr9ID$dn8!$cgn*2L9@gp$G^Q!m^bQGywN)0PM zNp0%l(@(vR)HrX1Xl8j-u{5OBSTvgH+w=bb(vWGIz4nQD9#Z;Vb?W0}4UzzaGoaswWk7ug>zcPo#zl%S#cBLD~C5^?_ zlvtwN=&}hdKs*mNIJP`v*Uer%O?XTm1o(yG8#cJ{9n3cRa~;hLhwV(+>Q*R;P~Q82 z^~HT7V|{t2&bM|K5?f5Y9yTi9n5pg)Le;K5+pO6Xy(ANPV*dbTxMDlVsthup_v0tt zzAH4~%JKKGvcIn9>pV5a*>t7sb4pG$`%|)y&M)z4uSR^8t$Y;FJVB?!Z}8JrxwO^o z*|%HYuGpkw{3u3Q2|SKidivK-@H0-j(taw%;Gc=Qd<%W5X_G{BIn(uaEvHU1?fvvyuImV{0CtJdyPtZ0Y97Aw9}t=qu3I$A1HU z6lqsFN5O9aTS+wP%Psbxrb%YpFiS}xYlq1G^QiW(0|%30oVjHN<>_R-Z*Rb^JmTE5 z5l`ybWjQFTG~2TIZ-1SwrswBkXco-=CGdhm(aRO<{f;;JVwxawBM;L*-8t)u@!uAB zDlZSsZ+SPI^GowXun6);SY$uK>)dzhEA7u7Y4*B|I%b!33Rw+7KG$h99U_>Vl*uQ) zMhW)MO2pRuD`n#iOZyL6gd4vhW4MVp`%h?d6!iQ@diiQH_HbOur>>8GnfsO( zD^)VeH79Om6z;X@(fO_$by&zt>2dfIXrDz@?@qL_D z8b+IPm+-yknvuFd2^=eCM_Q72QM32#-z-Oau`U%acqy6eiiyL z@B`y4KN!9m>V6&gUTk~?@mET-RFP8FIpTLx0J=!zQ@$xWI5{zs>9n6+Dw2)%U)S~2 z{8KjKns`>~VW?f-SADP7ZB_SQ((s)V_CxsH;@=Lot9_z`xwM4M12pSzEzw>?1FqL# zK2~4`>{pj~W8=4oVALPOn%0=sSGq*)Z+B&QtS z)HELuTH+g>QZWo8P=!Vp%!sjV>(EGWalr?SZGz2~1>O}M`*-<&z&?FqGYsNMR-~m4 zlI5~qjlF(a{ul3bZEH@~tRmF3xrES3-=PiNSak$<2DQ1xeB-6-`X_^;RL~=9iCCzZ zmn>#g7|Suj@5e$1>0b4sYZ`XDWVhOM!dTDVQ^TS3W$F+475di>aU?O+uPC@rv;5a{ z;Q%WE)Z^VfyqT-prfO)UF|c9ADlCIbUrQ%_8RRYWKKA{bJajc(I!_7TOPQd| zmiawX_>_GQ&G?$J`$lT(CDxDO81Tyqwd~yVX39ng`ffj!d}ZK|5#0D+U9{G1+^bs1 zvJP+#I)m&-2EK*yXNY_a<9`rcc$vIJ*0SD+^9$O$2!bF`yMuGcCy!eE4~=+wy&CW8 z^xUI8bWPd(zVqF~{_@g$uj=QIUE8bV5D(#8o$j4%z>^A}sIOs{!~PqeKV)}qk64K}9`{nnv#7$4l3$MQ8seP6_}0%6u~bI=bx&V9Pqr)xh9ej#ZwG`f$6?jk|m zcFF$$pc7tmYYG0{vap zb>va7`&6o!B~-ZzT=eAEJMeS%>G)^ylj4Sf@Uz1{4%IDgHTj+;xxBcCWL{iPCUz#` z*$a-Nt$GLTNS|c=nSLVOD8ma7fckHdSNhlF-~1Dv$NNL}$@q5w=MvtzyUdXz_n%+_PNSsltXic>e(1C%3hF>~ceM63+{7MjRjU zuOrfI@=3?>8uVQ*IU!(EfO?L`yH(S%#T|7u84fDDuNXC;$}v!~jMjx2jGcI|8u+lq zX3}BxSzGm5_W2h8p17|M_`E;jYoy2j08Z}Tn}7AII-3rj=aJyopZrSk4yECb4IN8K zb&llC8UpLRaM{lvg?b(<$9_1lUkBSS{Pep)`70Xp@setkntGjdQgtGqbIK$5&*MbL zn3l5rV=MfcqSwAG>;59qE_6H1KGrC%fDI&OP3i#$fKEGCnS~S(Fl*&Mw6P6Y&Di%= z775)VECImnP{^$4%j=O=;_`&?8Pv2}*USsSDW97j_~}=5tugeSMq5q(WJCvwPx+hQ7xw<{yPWaoQTG(1zOQYTfw}v>04H*QYg(Q64IIURzHh5Y=^H|=+^;B=< z)$;HC0xGEH@(?*U&`Nw$+`i6v)bco&Q11H?D%$D@JyC>6r9mvY{<(D=Sqe-Ua6 z=`#<@iswMD={^>Pvid%V`K(&KY5I+2+%cRQV@AbVX8GuIU+d88ng=Do>Mi+s&$ zb8BlO=zk3U(w;T&zktt!?4e2S;X5sFX8GC(2>ZKF3{?Hncms?a3h+HoQPj1&`L%6s z>RapC&R$uVyRr8t{EDhQDGHOta^=}R8w*lZXwynk-Twev3blHZNlsTtW_>Ap(})0M zQ!!rBkxBXo_%I|}q5 zJ!#J7)7ci`8Sc!R+4O&x`{kqb;BF0N&m@U6(nIXDM_ zv}YCWV6&BbPfzRR`k#H0aolBUSgjv@EwgX6wYKe^S09Qmbf|3fb-9k#$W9sdsd-&k z<$(hy1GWWrRvN^5z1+8UkUg!1gO+LTPm~`Da1js8$MCL;PWWkO2IjuF+vW^3cB|zn z`pL|V`H!V`ns-xGc+-)n+T z59ln`+B>cub;?P0GlBu&tVh#=!>=4y&G9c4VY1pD(zCpt(dz#I;CY_TL&KPi#-oKD zBDa&-f1C0>Ltpsg;O!3T_R85O)J?p9yOt0Uy4QDccLQ50|DpW74uFUn`&aXa4}8`+Yqhvpq(JBa^EO#}yafLM5_@`&#Cz~e-KzeH;q6z%_p;e(n`4UIJj<)8 zPS`DC9bQKMN;y4+abu}cojFFo!})oYLaj*6I$NJ)cymJ4{AH`!=zb1}YIj$Q<|8Tn z%y8o-S!C#lqmob1bH#n5;Qs&ycz5B3jd1o?OLcuB5A7Xc#?t<<+XKqPIsiFtrnx_X z9v1Nb0K-3m);5-9t>D$mUf*0gg}A*eaS|x%vF!k=d*t#>L9c6<8r10Wuaj#5K1IGb zPeyN6{#E$*3vfmzysVF7iFlh2lwsB*7OzqK5Werb zeV2ZlE3rIJsKeq|W3jr6W|I_xSnXU4%Io)DQR$vS{&j&g#zW6v(wxj(lhmH|4#nx7 zYyBzU9sft4i^3O>n{h@ zA_wok=sWrz4AV8EgP|(*T}z<@Hc?GGw}07 zk3sOoptgZhaOkV+oMn2rQ58%f6MQ`Ptfw5k{D&p zEoJ$72Tqios3#SjscN=bR0ez5HV(=;W=9>45 zp5V2fj>RMB^K@qRPeJebRwl8pUTaUbPu*Jsm53g`g}qPcD->W?t%t!S?@Z^FPQa=Z zU9U&`bj*E_fk1j$C7{E zAbm}Jk<`^&`wKlz)$DZ}clllE;|A(A@*JRp9lUmHA%y(MVI>&W}~@irQ> z_nV9JKQ=bff!J5Fc;Da`#y^Kn{{Zlmcv|6Y<0t(l*ZnHof!}&$f&TCnU9$U{^C{)++@xyG|-nxTsy6Ka&dYl=F_t zJx{e{*)0s3c&y=)>UCyVk)$CPaoW93JqL4NQ+z(~U51%!2Du?yo2!`B(ZKmp9As_x zAoZ`AEs$GFzEWlL2HA7bknmR?nCL6&zlC?^UfEz_SZ6-OO1frCTb)=%f zaH5JS@`IR^XTNG=Fi~D@;x8UUV$Wrz542qjf+Rp790`EW=9|j;Nsp z)&5Ct-;yxra*~8q-}SlJ>st1oqFWoQ=|pMMJxM391Co7*HR0O-0E~4_Y#5>4WoSVd z98BQ)k@p9%`MpT#TAq7!)LH2^buI8Llo!5@CrDH{o61 zj#nCus2}shpZs{&8U=n+gMYh>x}Qf4b!LvAu6Tn<3;ma?URrJGa zPl|eKg^OR3;YVwT&S%>LfgQkAfHX%amei)u3{p(wVlzvVS*T~q&!0Sj*`qu?qj|VULcW+CY ztYuWKd;F32o}c?j{4%{DT*-N-pZafZ#C~z_uJcX!t?<`YA#D6jVp+N?$|7&XKGR>C z9##!8;79xF8|o|1tHStyu-g28$kI5+)+g&7wdI}ii7YOnxs3E^q*ouBlU53x{nMKK zl7=s`$R@OAw~%ybn4^AbL9WA1_<`{UNWjxPOMeQF;c1Mk{{Vc+xK|Zk7;E64->3PS z!x;~>pXdHZ>B(+!ns-rO0O|h#vr_P2+v zu19kO?GgN(F#T(Wd^?Da)_*_qv7~V{-(SG{GdL9<;a@b`e$&1bn2+sEBT%x>Qh_F4 z{B?o-E3&cpcfqu2+bL@Me*mThdGKJ#tA0HCdRRrm$?h zPvNaoAK99{rP*$RInjyy0nKgo8R|VpHO0u&)f)RbE`gpgK_ZSj15G_Rp`lz2GrcZJ z;L+NdL}#xwl;eznO`v0Gy{X5cr217NSsfnqr_@twk_N@5pIUZ(X@{?^Z5Wer4?QTP z$mvL;No80dMwUl73W`~YK7eyqU_+_DTI%NTPPwL84Ohid+IU}1JpH3qxJy7ss-qt< zzXh*2)xIu#2JpV<5bfLDQdhgsMBVW>8%;|*5tUc6(UI{J5=htM&=9>5Cv zj3)?UXRTX7eedpCf8@e^i_g&eUsd>v@C(8_*H!T)-;A$g{{WX?55u+%-iGerD95^? z_OCA0{{U)l5=8A6!w(7RUJr^$r69L7D6xJ zwap}P#5Tf#7tjC&N-`+Nt$h_5aHk$haY=Ss5jeR!$=p7?()~E39CoGmt7GLzUIiHx z+|imq&>Bi8pgBT{D4VU(N{y?{vE_~A z)MlTWV;a^4rZn}ggTfPipT)X7zx4AO{{Y`-Z}qNvs$Y4(V!84iqmV@9T?tlJ0FYFU zK?jgUbW)crl$S$GpMG4%ZGkr=LaGTYHl4*$x5Eu}_fFOgALFhppjecx+6XEZS zJ~PdEZD*_bve-neGwKt|A;g4%x9=XH=l=k&U!@uzpFWl0-6Kc3bdqg4&feMHFhVm! z7(!P(;Nv{k+ES@0yW2z9f=^>FQ1JeXsKuyBcct20UR~Qs3e7AC(t=|sn2>_QX*_eq zd{w3X%YPDPhB)taEjG_!1LkQZKltib-#ZR?rP_O(*0nJ4owj20NB7iJ5`$x*#}v;nO|4&HH<*xCJT8aZ>P>sSKGTu;+XNUL*?W)I3w1% z))F0zzOH_C{4V%w@jJzyBk?AK@K)wqPY`%*B%0+dFU^Zgkjv#|cRHit@$!@2zhwL} z+MJ#f&>LGadE$=R^ zw?rpa+Rk1jA~MC)68m=_TI<3Az^TL0P*&zm09rgE*41$3QBGrMtWD|AB#L0;@<;GZ9H?~`|TGWDQl~C5R?AQpcFpXHT%#I z=Yxu#L-w0zvA&HjEo1~VQz())9)(*V{xub-^E;VNpJVg?0QODz_3+b3@E?c%4Quxj z>DtO&TqT{uFPUj{*LMu@qRAfOkC)qr3O(!ZN49v}#^VJ}2(KO0zh<9;TE4$$;;VlS zN2BWYH*j7@KAz^;e8?O~GZOyjf=53lanOqQdzWj8HMJY1W@$~`Td|TN zIH^%N#ar_7af{?*ovhj%!Iy z%bALDN66nCt$xe#J5vB)N{~oD6dCyn_ZiI5{0^%3Qke-!Q6_pwh6-RGK&793MkdipR}Lvq+r| zN1>n%YBN<*=?@L^pVFt!dLFdKkoqa0=A1d}K}>hGE+`bWnF3=qUG34iA1}~x{(hBa zjFC+sGNy&$8E%K6?Nxl}dT#{%Yp~PxDfHPyu*D6+n6S(jusGtLVX1{LJoxi-p24n}g=N_iHaafOK9%mNUmcQ^$sx;#+sXssJ z$HkWRI+fwYm4(gDdU;U0{{ZM9*IlRlF!-6P0NQtjZ{!Woyk1!R-xd0Mruc1d^zZF| zhkBKdiZqZj=IL`RjDY_Dej)qCKEzf;lU$=1KcDgY3Q~ z)$Q?t73Am#pjikN{S)8@M}*=zVt=$_tvgvOE&fd4%`*v6qNfNgUA4dH(Oo#uX>o_~q6Nu}tJbl?hjremDb>-1M`k>vHb z(lbeePokm3P9$`MWK+oIm_{kBOlF9*x!+3En$g>JwxGqM&qSLXsPvkGjeV%6fRmlGUmN6W%{{X&vDEjm5U!7(dU0gOMczNp6`~3c9%8aVY z)Ya~9CgMg19nWF)tf%oNnwJS}Y{GBeDoHUrVT@-7=Kdq}{Ois2e-vHn0KstJ#19Zc zNQi&F-Cy}vEpc-#(AhD{^a=(P4`3^7Hc`#3L*Bvg{CBGEeQ##5ASNz*6W+Afn(|)) zc$D8-t);{M_5cxy>PPUm^giOfNn|4x;Z>z5Nyb~BFNmW$HC;KE0m&ydDj!;`q~@j# zio(WZqnd*M08X&hbjz(9Q%&}_(U{zK8R)~@9zpdLAZ*j~PBU?7qiD^=sV&cl^-U`O z06^7l^bJ$~KI|W1Zqi11Ne8no57x4!V2EdtmN>^&V}L8_Ux@w#pT(lfQ_%dh)-Bt~ zlg>M1lHB(scSFea8LM6v_)+1n4^0-Osgtd0NHQLE0vM0|GK-Ko=uS<2de11TN^^Sl z>V3T&>W{Khp8by%@TY>jQLVs|#|QRqoiEIncS=LL_wu4VpJH$;+%!K3Xnq|++I(hf z#{1WE{PUCQr}zi5;=L00ed2!b?q%h3{J$!}+k;;-i_U6Qzb=dV{=0b|Mk6t)Uqzzq zeFfk@6P-fQP1B9XY!^Px2gxVn-|_UXR@l*&$E2+_V38S?o^z8D!5fE>J4=!FZfBkJ^hsr z&8+BP+*)U>kmKiz^6B*zjt4Mtl8MuSot>Xklw9umSFn67)@?0kpTpXlkL>+x_cCPh z45$4}z1Gq9QZ?y2 zN;;$8!DAWrw>m}j#+`V(Ww&yN@a`)se&5Eu2I5umhlQio%Wu@Qkhb@O zJiLGM%5Tf%C)8I3G(~%03jWFAJ|d@?;Hl!E^?R<*sr$|o5gPPkRu4vfIr}t~7TzL{ zzuF;h%@6oj)Og6ne3$z*UB++xM`8|GEwrEd7_Y3pmEvb>{i=U2$sSH2T5S1e@j7+upzr+3z9)&4~Jcw7Kf1@WJN3*MmGmt(WrQ z@jmq*#y*|bmrk9Jn9XuH)=qS^m*D>Z;Qs(j{AVKr6&T~KdN0I}hF&W8SFRl!#LyOo zLVnG7ihPOGcUJ6uRPrm&Pc{7?fXC6q(v5l&Qj6-3g0F^^Ik?GLp|nv-25YI16!jDV z%_zv?lVE^#p!KI?9MT@OoytRLDQR)VCe2htqL&n-ip{fi5ioO8LnFlb6&W4r;<1&O zavO0-vaUr+cs{j@9E}rhTGfVOGURZ5D~76ZIw`x8eS6V?j2=PlOT`t=*}aULh&->I zKIihP_^q-t!RTu`XbeYk^1sTpjZTb}61UQf^`$*225Q?HKq=Uyrvb+`n~|(yCJg{n zjTqvyaxKJ;XpD*Swit6?P53`wJ}A(lx^8bRaSh1zbsRtZ2y^w#eA1xSj-9OQIw_y~ zBI&Ju(xGJ921rwpfUgX&U2c=ubDGPdz=U970jhGI@T0rjsE zp6d4T{Ojw7xPR|R#gFE5QIc_9Hb=m8@YQ2ptofw1(?$CJhoOwmB~mUhjgvGqTbpYw zYfinrW?OhJB6$jout39c1GxjGecC=2X!jAvduy!Cacy#1_Z&B#&nzu$d7*n>UUacsx7&--&-uk(Ce-|?0mt*ts64^%q6 zBofHDn+;ldHoS53BZ(<}VDCgUabnuRj*OO=vIXHwc3tDD(?xIco1%AW{*i2~ww}3@ zFG24KUU$B~vHbpzWOwq(q4l@-tF{RQ2Po3x&6wrru)u1woEC?;d-yja`H~raeDgaarn|rM zfIZ=tak(vuT=Dk~>(VLOM4hCC;oCA-(;4`>j+uk6vag}bU>f60Qw`1U-NK$aDo>No zRTw|ZwckY?4plyJ%{Oa(l2Szuys&<{EwZ^dgjQWs-|z}8yx~>0%UkN1MhpK)rsuq2z%bK~!`6;G?8eKLzRNeD zhxU8Wzq|&7fr)F#;r{Xf?33}%ONqB>Y;W2CpNgO~ko)*&JG+FZQNpi~6VB9oy%%X0 z%$G9DsjV;o{`bsMKEWq`=-vRD}7L6Nd|uSp__Br67Mq7ACvBfG{DBZtjAFN zHVA~Qd;cS;JqXJa!h^6Z?36Tv`NMHrPmVuvZMKK6Kr$D2zY%Gry3)3K&3xi44slHx zG))*GP(c~5ALF!QoifW$Hqq7m=$(R}$1mke7=y&{qgYjC#4@HSg2Ct2K#m`vCB9Sa za&a^yqP3+1{ywSs*RefeKIrJ$B&~O4MfOo%x_iO?pdk9PBF))-UAatV8k}Qfx?`}R zToGDc*ckgIPG0>>ua_L{f46==c`yIpAse!!OR%QLQAE=@T|)5k4Kd>fvk4*+Ubf*Q z%$H=ERW)M{nVO?Z#^lOZ%f6}3uleAfYSCd(jCs6K8%aE_g6!TNEyRFDyu=UCF851?9 zg^CI~v9&8m&&P=zqLtkTM(ce|C1RGX#i~PKDGNC9S%0sGd#LGc1ck2#R9TlU;Jx3E z2WWf*&z$%dIbmm9G+r&&u6j9qys974YO;sK-%V%GumKA@MCz$|I)p2bFdL`~CBEy0Zb!hzk2aCo$~uTU~!J zpbvUbFw==cy=aNK-!F(GvERKf&x}v0HjWmgk!yZuVvG?@qgK1BN9De|_wJ9C!EF@j zSo5>1<@w??kBsE>t)p(ty78sKAG~{4LUC#bol}{@IT2ef9gpQw^P${k9`O7 zb99jVeHw>ZYs_KkS_*e3O!+=(Q85XHwCh{BJ3Lt~_ElNO$qpXU##;tw&dVt3W5iJV zN-{-DSxY`x`(|xl4rfGf^m@i;fl-Wj%^DZ|bxwa={y>xBUG>kD#_xLGRFfFy?WArY zBm8Sa!gNHxd_p0lCARkp$M;>jntz9hKZUU4u>;9+ou=ZMFcV1YX1$)iCOy@Mn%qexw4m@lmY%y9p zne`YKvwyd^rN8@I^m~1~Pr+>=HL`bridBQcYl+A-<6~tJ3%eot@>hU$dtEFj+@Yqa zNL)lNhvc>JKa#Sl>^Q*HsMK;=j_maMkEng0hB4@W8VA!SgeF2rd$RA&}(_x|=n8E-_rBoEmPLc&HaXsYQ&74d#wH zAssvZ&Z^w0jESVq(+Kbj>jj2G?7ADLMCUw(>EwYk$=PpAu>m*m}|Zh{fuWk6-0ax$#YPj?bI+8eo_?yZB05S(1^F&xOfV z)OJxyjTEZ5UeJk%{C-gKkY_`;r0Ley30D|CkNJ4@t6aCm84uy=5`O@S@(Cx%^I`^` z(y6=Ez0UoH;s`WF3K6DSJtc2etQt-ZTfboYugF+AvXQ)1`;msogqUVm%g`hYDmmu&Tg`Gj z4&LniiS&%O>DH@ZcvZ#!s?Y9NS^E8R+iG@cy$ekrgYIjn^HvckZ1|#g@}N8?1R{TJ zZmw*ELvUHxmO`qD#yl-Slc zigEQASCbT*;_eP(kwOtyJMDSk6U{>?MuFx|nefW5Y!yOi)KJp0L6_=N z2(fa==QT4pdrp=1zl;|{G2D$R2`3FS_nVX`lUS{-t&p=7T z*`KNVo$jwn`_;(ZHp$!&>!jmir_eaz)<+Y>3u&qkDEh%g&?KS;#@l2dG)zjSOicfz zEU!!2jAlgrg@w^Kmna{&he~_p!gu(}*)gW0(!`j&-sih7=JWeP$xy(d*1b_N%W02Z zG1`xBL#RZu%jFNRiH(#a_8PX(X+@TF!qHuVCy}H1)S;$MGqP@&pH!WP&pHoTIlAV~ ze?i>weLz3UnO2TWr-xTizsh+~@{)v+gsP2dVinj7M*3Iij~v0y<@IonePK@~G6}Hf z|42q+EfS3UHU^Q~WdHy9pfxigqqn+U8!`_`IQ|oQ_1iBp%1H{rW-Dz_o<1}Zf@*nj zn!oABed}5E+pth#4TXEgU6H5)cB!s-)*{)AbiD#kqC9K=RS_viQqLU-@uXAaBfQ1e z{3hC(H`rwear;bC7WmGW4rGF0dZDf+A~~Maq`t)jH6k-|a#yGHR960!@s9i6-j@1Z zUi-w|u4kF*fmNq=t5!8+2t1FZ{QpOv1;sC7JxX^!2@yGUv~F#B9;civclnQ>NC zNI%MX{bLz&lOAz30HvVfJzAs=(CM`FXvvbhJg_-CPD@ME-NVZS+pka3ZtRa5w-Pk2 zFN5wNt&Qp@tiTdNMqTYI!E9yHqCb`r{&=u$WZ;IG@JeDI)+ar>IL*^wbWB`RA28?P zxT2j2_vw~|E10`B6SMfEr=3)abl1jlSO+ITW*j~vYZ5r`G_@JOHNd7B)@h#lC`RY& z-=J2+Z*Lb_D!Rkh6s3YZofUVBDu&)4Q3gK69)u@uPwCkEz5meg$;@7l1znZ^IDnPG zyC(wXgIt=FtCK8Z+go?2VhR4!vuo){zV}i&r^e-&ft451#Q!IHpTUN?tS%^Skx--S zO}S8$UiG~*Y|lVeWVxTx9%9U|v#wz(^st{VL12p!aABDpBB1~#aW{)qAN^)hFmg1> zt!4LcK605@JHRFgvwhRisa*NQ6e4-?o`_*F5~Kj1f$?xXVOpgQF*2Mhn-3XQ<`2|&=!?JnDrx9v&YZl zD-pI><_qPUe3!LQdW}(g#7As(qFMLs`}Z!8a=9_?iS@&%EO1 z4Oii$P%F*XJ-I*{{JIpM=-aTU#eGc=>5%ONxEZ3B=&La-ur>0EM6?&!>TlWG+I^MG zjd6UhJ#xz+MUO;}@xN@yK-TN7`m!NZO_v<~sIlO25HxY*hKw|rC>!Tv7~mErqm5}- z29tQAWTu-<+@}0w{__)^S2zRf9+-*o2xnGn|5;6FDaWTytKDV9$^B(f29WP>{JPQf z)m$4~_~|z7J?5Jzu|(_lVwdO1Ju8cTp?;d*9Os;gEd%oOqEi`F8TtF7IL!jiJgbv( znC{aL*i6+xTM7LrcH(EIMEmE5&gz)O4!q)ue^~WlVI*~{eph1#R|=XnL75zImU&1Ah$r^>ps`AAgh}}B zjDxk1Vdqg7>{~q6`7D#OdFK`x5r+r(*lP;f|0v)jbSG&{?HJ(wh~0_UNM&4yIJ(6P z-Zxco#1I`oSLEZhy4&?j=z^)$gdoQoks?4`rL17dVyN(Q=#2GN?4$=d=#g=@`0;%f zw7|9HPdP?n4C#h<%@m?BA^P}g9;o*pdA7JNMH}qab{W9alQ?;}i-uooF9UW1I!@B| z?d*YEQ=pQg6OiRnz{UCp@B(PzwaUf@!TLc#`7gd9*G?&>_m_Tkbxwp7I~k8U(*!C6 zM^dWAHW=w?q#R!E6G4AS$aF30$q!4V<$QiRfVc;eo&{0`yzR=%e<<#|$hjonqcu*vAZA}O+g(t68o+TAX>PR3h z`z>s8qXeE10VopByqRPc9iUNu*GO0zk~G6!Qme2y{o+g8Z?Wi-9dT);ipJLODvSpc zNt3-p4$h~?_692~06Vqr%`LTk;%ncWSg#(@~tnTDQY-I>eXHfz&BPdmv~z5$DaV?UNv?diXReP>^xuut zQ?qlClq**DpLUn%+;em@Z>r0@S+g15s@Jz{1>nTc#&nsi*c9;Qtfn=KDc9L|*fUn9 z!9B7NJeSd@wFObX&U;Ezdn{H5iGAp3;(ZxT}fJYJWJdmW@gH_B94{$o;OSHS^k zEc4Uk=B~XCMtjQR7pO{%w@I$$wn%SqAc^D+Pm%n@uIrmc@x+NSX1lm!Ej$V*@s{K@ zmdRTCT7sLUlWi>xg!DrP%S-rRjBxsC%Y$y@8WEmM-#9;v=6dvk#3dg3d2|Q-VTjso zeas4{ZWax6&I7{i2YNo*^ku=H{oO6|)J2JLlm~^v@^y56wX@gS@-AiQ%LR*eW{ne) z$929_dk;PpEmJ5J*O!>d>R8mQOWao8tSGj))GLab=J6_|Z%4KmYmCeCGr}>gPsf{u z*d!SD3juB_SK>G)H?a8^Gd*O0KOX!u3GnU2^mPw|jU)_5Y?~>o^ZRBxR)6-~-7Jor zr^{d}*$Iu>(S>{*JFk|R^(hzRGPajA!x|9DCX=KSCNXr8k3Ix2EAVqAPz%$Mn(s7J z;8LM#Flo+|&=NLt0W!PEBM90Zs+s58z4X!QP^@mxZL1wn$U@z_33(}Y6# z($aKTs>ho&*nv7jYGS88S`=lc$mewD=uyFz1UvgC`SD44v4Dq%`H1lzlVVRleM&1}%0~?7h2{?zFkzD79 zK=e~E#h4B@nV*D{Qh-veHq9o7aS+k+IM5DUKzr-o#5F$fS$i0}unK z_SGQIQLsqHyh)AWpW^`QpGBO%Rg^dT6fddG68N)|G3Vc(%IAC}{3yE!kSlz9Z=S=D zutjz;Z6adhsV^Wlt-A^O2ETv({P1!#KxMhXsi1zUEY~n-^jYPegIY&P5t-Z#?-X%c z{AEeyIDjrcX8-6DwKbvP8a>wd>nqx@Fn5`OHA6lVK-ZhW?aP_=yL4Gb=fWx@vL%WQ zES1yculJ8cAf9jB#-sqq=cmJXZ1ngA^?SERqi^1bfRCnGc@~CzXq()-sl@3T*>KCR z&*JZFR>6MrLC&{&2{qME>7th&0oxK(7N*WDuUYOAYQ`i|(Z0c#(=sE!mAj!YtP3QS z@7TZ-)LOS=!k zR|REvCIbpFnqAs9immxY*flS3zDYJPv_ACjOjuepn>Shc#mh_xhEM%Ug=*}Rw% z3uOt+`{qQYB`JS;aR&!kn%FFOP9xp;*v-DY$`!F>yII@jB3n^+VZa*jDs`IBddw3$vq^CA zM90l+^z11w0BP|-f&hDY`Wj;?Z2W+JnmbLa>e{Up7v+1uS2Y?>Q2zYsNanJmuEVqst1c44VQ+)gFm~6i!^j}VE^4SzGTd0Cr~BIb!He8x zksmK|lPN|>FGrPVNg7TI44FleiGoYH5`lY-X|MB%HB4R-%1pW$7#)CF$`c-{_oH6A z;zXkkW7$N#=g#qi2A1AyD!|Wt-bYtDZrpQ8QKk)aHg?Y}&kOdH$ER|A%_FP~>4dgz zo1PY>o@5S!J1-M=zY`)n1?PpIhl?Gd&tRF^#nMfF#KK_x!IUs$(aM#K8oHTsytg{Tgup}OZI~SS z^b=zjiGsZFYau(G_{R7VFlB%)UT!5eVe@jw$6FhE;X0*~=EJtQ6@k};mR!Wtic09E?wiU(NWWR zRm5+Fui=f1ITj8*BA#A`;!Tn?+V_+Mw;(e}IHmbr_hI!wiIx|GntiWEm7a>a)AGL@ zuTnh8Uz8i@PnYzbMTV?w0i8;6#6Uf4>SJln@t1#ELM2Q-rA(^hl{IvjpXYK zd{y~Q0zU4b{n%<4JhnFQ*?q(0KY2q5;`Z{p&+KsVo|*D^1BCl!Li{o-p|$2LZI673 zZc0=S=ao*svnQsSENBo?JY4*mXaT{#cYPMd3Q%9``h+lZDlqU0Tmb82cJ_#l>5s3g z-^+6;^tjZ2zBYF?Lw)pOS0)~5m9XWPt(=Wg$z8;__XoQ^LwU$SsVDrWCo%*ORK*r7 zILV+}y@coNVb{>?09C4m`zTAiIrfV)&t_GH%q^k#gQr)0a?`}vG=o%|yUI*w5aLT0 zlXneb?_E_d(wU_eZKoZ6qptkYt}w)N6H>m6Mu))JOj zi${tv6Pfkc((8S%uBONpwi?!&y@G+US!4C*{%-V|%_S8C)_Gx>xcMDe?zTO%=6u1= zQpWb-f_JUo*e^;u(8r~{T@wS*qE-@{{+Dc%-8`u>FEACs(Zh5jcz45w!-Wb* zL}LB#(HO&3{{e=5kuJ)@(h*T|XxHUK0w1c(8rQcjES|f%J99RqY61Iu-EaF&jpiST zuWE1M2Rj2JuRMCBovSPr3jm@g-{oRI5X7KSG;f=@WniPz{h=klEB?pEMhsOw8C3Z%VwZq9y=Oog3r3^H}J2s+o2>1sHtnlAt9hy@wf1{m4VY)V?i{e{%W7P`~8_a zB9Oj^5bI7BEzV(FQwlN)u)BeI60Hc{ zfYPlXE5!0|lf7&MVdIV50g{yw(Cb|v)YNiD*!s-E{(b)PJVWyo^+rjGGQ`(=vJdSy z>#d`3Ql@5h0yzKfU1DGI#mxp4h#wJQcD7>`bl+ko7(sUqzySX`1q$v3y~H~{*0di; z+G=z1!RU;fe+JHP%UI{&BldOl!Wdl?m%j2|T0g})h+C-4nlG04BmNSn7a;kbwaFL5 zsWzWNV7AvfD>35$V#2oEWu{}FF<(k{glq@QDx|Y+^64d&t$K#ZEsb~UcHc+xJqs|R zO`5WP%;6#)VYiaBW}|#|G=l9Vo+UE+td8Z!ls&*{5&fc>3hPx#JVN46Q!E+xWtg)_ zKRk^hM#OnPEi9>v!x)2Vy0wihe43I%H`Wmk@68Y!n3y<|sa!uur8$Ku4OYEAMhL;j zN*=nJa!NiqqO_bd_Eqt73qL7|4T>pXTjMXxy35^@i0qvaB9U58qxIJf7t1PyJb_KP zgJ*1dB+Ig2f9SHwW2;^QNoyax+h#tZUOJ@{Yw7MT=rZt%`vfC1h@DtYNnU*zs92~S zXa68p%`oc44K3bxB^nwb&k(JDp{rdzOL<}uzhDtba^gB%(Yt3_5EKv~j_aNdZq}qK zYDnxO+GZXS2fp8vhRDv-O0K3ic{Ci|oheVh<@y`vKkC|cV`8*qzK)jeBOs0Xm;Ip$ z`y%AVbzy!b?e8OMU*LJSjI*;MP`!j zW@$1FvcAIf5an9vHWhrz^70CtqO+;1kwUXo6ezZqyOtp@{Ts*+MDfm9;I_`{F&2<9 zYq>beAT`1tG|9>#C-I6zF42*CB}!>!V%8P1R^1%rO(OYWUG|c!ce5X&lw`$%)Jf~D z9v_7ZZTg|llK&$oUd7dG&D02@tdF;4rk@t8on@wT?XD)A>=|nRe%{>gdjILjVPI3p zuK!d?{!B~-e=9##fE6uf5B25u#}TOk#(%%FAKiKN;R+>XleWY%+wv#=z;zq zcwXH)wCF+~CI@gG#x?sU|5b73DoUJv`3n?fumO%H-1SLY28LbB6#EpKpTX&sMArv6 z*#?(+BUT;08z!`~lisEt;@uG>Lh1eo;f<*9LWkPeVkyNfx(+g6*wW;6!%1mhUi5N_ z&$dyc9>ct#7bLcI}3#1*&qJEP~s zgJ%TZxelCa1qS057D>ZPuSYf3tD#ZC11|}fm!(u`&^QE>ql}SByAMEojH4M^=>}Cl zi>KCbnR#0`<1In&FX%XYBlhK6kqVZ#>jVAcKp&L#VRy$i^PSSSZheIe2j}vTHHbmR z$dvG|>$>Rf3P(_jkH6tBoK|E$K7qtv&aa0&Q98D&hg@)5DTvnxp^(-lFODM%dwxeb zq-gMwe|uAa3Qn2s5_HA6vsZl03I=F(4KK|rue^1{aCCsP?-TJ-h|L+@G1p%Bvh!|y z)LARX7r@^DZAAHTeDa)8&|bsoADve6&EZ(WZ|p#jiuQ~c&DybRK>tB>!mev_P(|uk zkCGh{s`9hN@i6S*JyFJj=@ROy(=H31n-$HH?7c{6KiD$TJQTqQ0r07i&Bpd^I)MdZ zBgjGeiZ&ci=YhWO`5GA@Ix1*-uJNB(e*Ny*h83XU9TGqNlT#6kuI0vYN@uQafYUTY z8~qFkTy66IwM`WwXvh7C+`=HJ_u}k)*}7Zf&LHNSv(+%^?kAbir14Czpw`pT!NXFG zLy140g0b;|lv6pkDh`KYADh^n=O5lKhjZKmlaYB}y-qOuOE*wayZNJ(%8d%J&g+Hg zBCuCvJ2})yVH2bu?z+NcGH^bnvO~Cf&)0V%-aF$YyN|{G$~${c>&qr}mQ{v5)$bnn zOq}7*M~&~I`^RQes{!92)UwdL_5ysRDBkpN`{6+A<1voEzAmuF&?WZAjlXWgbtwJW zvXOYQWmpu94)0iC+-~nAo~w}gv!- zwdO|g(b4V8{Zi!bjct+Yd^Uz$)GzI}vio5tbMvuk=U)mN2|G9xI#2y&R196B)L3n_ z1&EZh9vnf>d&V!L(?ibm33T}A9>H^AkEv{t#n2ZH;5(%zY8-2%o`DWu9N+McwoR;| z$ktt~>`pRdX&gD{PKqOPTkC#YFuaYnWy6_~@O=wo(KN7_WasHU$5aE)rB14RDtohI(W@3+y|LkJ1Z1}F!A8UykFp8^v=MdILSqoG>1*B^vghYHLheeu zeFJ(<5dRPndd_W!s`AEI)cx(5&-*`fwj`b+J^xrXR5Z;dOG-UX`;M%4NbqO5qQSSi zcUHAyx%8SkoP!p?C!o9l8LaAb|3+v*!3U*kVv0a(adJ5Ngq*xzj5vmqH{>Bzng}B+ zW{=wb<~k7jIHvULJ-K$IdET~P^}8KE$Mm>`6`_Fvs5f0l;M)8_42pQ0Rl67PB-jbv zH0NK&OPm2o;PpNoQ^+wK3=P-m;s($h-7D;auNX|vApXKZ44sI6fzoBK6j05lWH5{2 zt+j9#VJc*ddknTQlb0h2tDH;gG;7V^^n-`edIv(`Agfutsk>=JRJ*<#U*~K5rv4co zY^ZRNwmx@v{5#oO_oz$vw}(Xi#DYpGetTn2{^^H+uqlVVsre1X81hU#3)=9>Bnv3d z#YU0=rsPM~qsG68mf^82t6y!e;ux6so+GDbVZ7McJApFlCILm`%l_6peg@B`bW(`e zN`Wl+aJ=jMo$c@38BD41-IUn*=22w*?Kswg&qB4*rNJr=QyxHak1LCRBy{r4A*_xk z`NxZMqDP@I&^Eo&?*2*>N9XyD%tj}I5iATO+pcSuU&1TjzEtXyv^*8j?m}=};+j71 ziXnK`(oQbljYVwfX9$GaoYi{y_eFjdQQc$zoc?3U@{)w4B!>nv8Xy-2drYPkt2qX` zKe*G;%2QzEFX1L!Sj%Vd(DM9~Js+yYp{>47>o3#w&Q!612Aef!9p~x^a`9fOyukP~ z(D4HBhC&~B%iS0a2%iZcYCTQQ-=PSyy#0eb(5m-!))$Y>mk;0{H&+I5gG6ZG4-dTd zYi)f(kz_{gyF`0fk=DcSH!b`}sQ!c39V++irb-vNZA>VEbH|A2uyigXHKVn~57uu1;)KRp3W*fvDkt*yEN`9I z`r@OS=mVtY7Pbwjsf=>5E2rCf2CWRZH-WGz!sM7N)c3{Of?I?rD^CfZxDO?QYy zfUe_$NCNn}s`eXH1fp5_Ih@!}jn8=PvyF#(NvaSe0jFhlNHQ>1l23g#JlT z)&67pX#~rOp$ri7^O|=-^-0!rAHi^rWM>ER#z*CSpuOVPBQwiaqe7=PLn0qmC2PlX zFYRL8nWc*}XEo)8uDUMAu&F}FUmyy;b+w4z#!{!rgJEPB;vWf{*xM<{HOCBnDK^kr z`BzJv|DL?BEIv&pNkB!46wTs|d_BZ_srw9df0?^prj)${rCHn*sl=U+GF0vE;?CDC zLeloGF%8*Vj#p$*iRcZz$@@9u^PjFYHri2JfghnFH`3cnd0T7gcvAuO8vuaVwLY0f z?t5xnMgOkRRICT4++(D>=q^GzmKUN$~bT zY=;xO--;eD$3+FL6Ahas;n%#QzxTA1IF`JT&>EaQkuTy)BX_jT8+g3z84aJ`cC)@I z`a*ci8;Wbq&c#!W%I>{|Y#Wf=zy=?d)=F2^VyVf_n@&hF_FJD^YgHb`-2m0@2bR+f zbq?sao!CYkY=$H){@1G9I^itnWYsdkX`h)s>rvUzFGUFUA`OGcU}!^BDe>6eH!)6(&;Yq&)6h;EL*-^$Oe)i7t=e`>JK6+@*~9@qKy zgR9u0F!X^J#C_ZLjGp^rp~_Q)oN?t909RV5M+(#Q6)T0PW{KfgW>n1Ab(!W6x7{<2 zfDuqY&a`>`l!s02f;-wPm9GL85upD+w)S5kgScGYFnJ|@d+wV+vo$`MDUCwcTcN9R9|)c|5?`^!?-0zQTLpBv8k+JexB+YOSNUr? zI^KS@msJ~9%{8E-QTNk)hKF^^WH?lgDRubJPxJ!GKN9KU=g1`D9Rxks6bI2Gp&sB) zJUIxUfq1esgRj^#_>zyokCx(#Gn7@D`k3X=6*;uyIk5M^FqCRj*ky|P_+}1u^sF%t zGrFUJlPqW(L}P=1hhT2W^5~8Q`sn~-RvZuQtVMbIySUJ)JZSkFucSQ(yW{D&45oSs z+ANU1`3t$sxS_d83wWNh2_Y}@bQB*^@hxdS0*eHU)vFm&Yp{8RYJV9=8yz3RsVDhl zQh1y*KL@s2bKqtE_8n(W6EVWCkgB23!`3^KHP15O5tcyFwIEfINprGBr0D> zmHYl+5a5Ru9nLd5;B5nueJ&N~?>&F9ysZ4-q5i9kV*fx#bEjzow|gsE8|YuBVrfX9 zufrVy(n?SM_UxB=$h3(^;+|zoXC)vE>9zpci9|<|Lhz3n|h%t_JsOHDB zyX2t#jdkdGOH1i(bLBfPJ@RC&TKZgL4EVfWy7|X(iS|g+#!NCvA*nMhG_1GY?tt6! zY!EIMFA$D-j$%(KbF*XKl?-_m{T9>QwE3BYS!w}*kgCv=CXIC*xTQmp2c$xhv4{)dG1v1j`C z!Yj|6&*IdG(#n0BE-OH`KM{&dCq$!Geeu^lw4!!LfuP}vePVtPYI{F(Xf%lBXP8Ke z{RP><*-(DbOEQ1@6ku`PR!ORil|McMY#~&>*qonMO2Zszr-eH6_?2Obf&xf4noULP5tKS zZ@GIgtIp7JeYA03s}fM9=u>6!D1YaPC@WyOHPtM8@T_BHtHkFA_w$k?>%258&tH`r zdAty1`v7IaM7X~8S)nv%Zfjr+^%q*|7gvW7;}0kt_s&(tHvYSB{Oy(fR5|lbS1UvP z;bd$#vaqO(^H+Tf<(bWf*}XLD!HOgv*F~}#m&5@6k%j*jS`1*C!}*{zMy|7p^y)9jrqIgE&^u;qNLhWshZniy>O3oLzK9=Wu3Wc1QRfhL8RD;SSA!)Hj)^#i?r(>i=*#44ELODM|1f_7q{aMxmcW&xgSB)5?FGE8i1{s-)f!fMhc zZ|AiVc3#D-OFOly^0$m4ugIm-lk2=c?)$t&_~aZD!?$EFSOA|~lTK0GpNw1WFGw+h zbjRwFmkk1U&HAY8MAI<5-EzRcMzflbpl~aO`%H>jel3CYEsar3+HYKAOQdg&yb~G} zWt(W&<-O3y{Oq6jPCb^!j(j6Mc2by-l`i0pDG)1GzQetxdG8p_$|+_KvG*jhf*JKo z2(dXIoNJkJ{G}Io-9G*eQ#-1tUE^Fxj#+(+B5Y=RXMjF+%!ILLomv1(_7+>(w*Y3K z*0}YLq_9F7DrA;)cqD6A*99G}C%#;_UmWW+g`3G4ncEk#wG%kL33T2%T&N* z>#n-O-7+69)9sH4OCoSz0WUX*KvUPkZJecS#1kUfS+4sL-rhzeFB|C!22*3L9esf* zqPF2CA=Rtrx1*K^g+w=Q|KvEmp$ag4gUvE@@27Rd+(m}weZ)L#=3@WSi>PB2!5hE> zR3ha&5xok6XIa**;u3x*OCd<##jXNU&2i$}0I?I}uXelr*`;q5zO}6SnvJ};zokyX z#nfK>-}zvm;V#1M32buG=a%}l!r?gE%s0?f1G2Dj_||KV1=+){mjV|Y6Ua%0jpvyp zL1NjTebl_cN1}z9i7K1ChnmVCs1S7GEzqTddu!K@Sbu&Y%ynZIu_mMmbk@U{0y>~J z1Cbne|YX-^kyfX|}TOwsKkg z<@2}!zfbEOLq2ax8WZKeJfYpQ0#V}CjKFO8Jgt!jagGCM+vee%udIXca^96;+5jg! z5{rk8zncbX{cvhUNGx&5i#ZNP27m*X3DvMmISG)-8_irFhB(pE%EzJ-@1(#98^$+i#ix=RXXRe9~49x`!XN zosk-vl7r3TD{XM(FL0Yf=w|2c(%!&gr9d-=@RuH~7?I=Y!`VYlz31cLSH-m|>E+Ma z94c9=<20IRh^2a*$d9)|X{}-|4I1336ooX}@^ceDtrM+rB)L)l?{N#D?kJz3{Nk2k zL1C2d|N5AsT$G)@2G`G+FlT={Ef)WxK=(CimiDl<=IMW#3U^<$(OV_^OoVb6dRgd3;`HhPg_XNn|qUU+?uVlBo$E#9-YOo-82 zi2dsd-aX`k&3=C>)EHL|)j9MI^9){`F|uT!fJT0IBA%wh06P$vmHdCpt809m&&&I? z!8k2YlANR~rJj=y*RlPHb6G2pZGqSA^kQR(+EE$zzM#;DEC_kcb&?SU#@~y$63k}%?aXyL_3A_) zY0aA27Gk!1J@s_Haj|CbT$moKq>|CO+t4=g%Ins=DPF^MvuT%Rf(stY*&c;P!3~@mV>J{8NXV;_JXs`@CmQ zS)=-JFu*5DA5(E|D6^S9fFB79%EzI_!xMX}HJ>v0Eh z@!3gmdHLmvJllQCb4kUWUvZ$@@5~{fqxh9O(|^iMIc{FHLZfb`U{;+zUn)Y~fBfsm ziKZ7MRJQK-tFYkNowrxk?c|iNnFs@u$W^Wp)p+13s`sI=u*~#6a4)H_Jnu@}2RUpk zSUCr9OuGlz>_G$iR34CW^dgwrKk}|f#wF<`Hz%xGkuT_MKBt{b)HE8 z$}{EuTou}%uk6|*z(YStV?I5WzA>*WHAb&6-)BdD135QdjHJhqFxlpz>M z$`!`upD>~H!1x|GLhYS2q(d{=p;sCju?)Lrr4?wsP-rQQe`*ME( zHMtDc{yv7-CEV@*!K0Se%YwD^)Zfa zfwDZCH)PSA^jPdvsWzl=_SRZQh%-2u^kORQ??=KCwly4Z;^XHZ;5-kQ>^~79Mp$O2 zlW_7@HF)P0@~peHag$)22G5rZZi`C;CFet3NaL-7>FOP4i*+>kT)*W+Il7HSxAw_~ z4GK!WL~qP+HB*_ke8HpQgM2JZom9-#joIl=7@>FE7@_@z1V@7R(~U{wXJ^cb4a$BA@b%$$K&aCx zJ?p+Fx5D)s#XFuej)+cv20%f8|E~7zMJVV#xK@i!iLqd37!pMy6FXc|cZM|5-Bu#PRojmN4&${D9lOQIE|!~VoWSwv>cK6spU+EJQY6K+mp!&c;?+mh z?nQIPj0GhJN|t==&dU(?({Ufm|1QZTI(h(J$;tULl}>fn^iM-wBEMF6NF;C zyjea-fD8pmxcDdQyDCff^aW)=RI!bbODV5ap769C z-4na0z!|jb-~rv|;@nKko}(g0b&mN*a6M2V?@6{xjzL<0`608JSWekpGPbQf^OV+99HjVBFxmLwkRi4t$<(ki;#*mEzak#gI5;5PG>t|HRK(Y3l zjr2$?m1wPFJj0(9b&mwN%!!lv6*-aZ#RSxw!{Oj-(DGmUeKZ=d>GkEm)ut8YxMBGJd_pIEnxJ309}oy_Zs;#``VsdgpY?- zBzwSh4sO&+1!B%eh4fIqWjtZ4InuH2J6hBk;m~(NmfK#^6}3#ve{FJz6Ez)E-^k59 z2jy&Qno)>^m=MF4C(6?vA(X9m)_#i2(%hVq5y$_eEPC&fSrTCwt4GPEs>Qo5jp*na zO#>lSV=dw;7w!W*A0k@g^Kwr0@;aoiY^5XWo_?x;ldt7;Q<58U8i25eNm}0jK>f$dfM$?F`;_yI1-I21okFe`!|Z%(HQ* z72R>j7wd?h-)Fgf`(|Y)E@ig5QY{t}uLz-u%3T&9n&nTqUpevB$*{lVaZ{mvE;Ahae=cJ*5-t`XPh+S8NMZefPaXy}E{Tip#RIB#%v5|8!)wVxD z4|n>bv7y=6l$CA$^xns3TTHCv=y?S|F{-RthHkksjBuJfuO6&gam?u2NjoIT)#BqD z#)?{yOV@Lm_Lh!7kN(GKY@HNbzZaLXnJ~v$Y|B>6aHm!6J(MB@wKT8f+t~H@_v$ow zxxcHjQT&|jhIwNRdZu=T!~@5gr}i%tM;b?VPc$X2bPcP1v9G1-Q4;pu77GGJc*EQj z+IHnaeN{T=UzC@+2TE`3MrebtoH0j(ex*d24k7ALkaw+9AERc^)z`vs{TS~2-8@@X z+SzOWji0OHNkKmQ7}~F4S1s03jkYu<#55=b+y8$QopoGOZyUxDR1_6NX@Q|M6Oe8u zA}t`@AYD=-oubqzY3UdZV-ll7x}-}QM)yVz82i2F{qy{>&u2S3=Xvh?zOLtc#k}Dz zf;IVRbhhis+$!uFC$l>Y!i9wFnMDUjcCMezqn7t58|vI%JM+`fLvw`I=+qDa`5&xm zB-UNppM#Ry{{W}c#Lo|zjjLvIPo0cDM_sPFv=uM<`qH8I*NlaBJm2Np4A3GH@gn$P zLj{1~NRE|ap7fUq8EIH}GQNLm8R|Xwn1w5rE@y{^s* zAGXT4j$0$;>8}jR>+&;)R@H4ti-aDoWNYSY#%@<%=rFUS-ho-qaY!H?7Eu`gwcNJi zZGn+#K5l;}?sh{uJbucueKRf&h+oK9tE`@9k35Wa{$i2l1GU1Rh`8;E$FAy~d6Bra zf%rmxw9c1%U!I!GqS~p^X({SlJU4yqSD9)AfC1t(M>%?SzRpLoLfzNU*y>aTI-C?v z1br~%_Tg?e*GDcRVJH=pqvZ4SdrmTCL)l}4fpmSfsWQcmvqBQuYMH zlG*}_@cWOqvKDBzpg;^5#rq3HDa7^|RMwBbGh;KK`0RpoJM`ztxb5#i*orjrWQ$9& zFlAZ0q28Uo>gmp)>)_y*2hzE-iRr7%5e2)g*S=X4w*8ZkkBgK}#c;vdC+LMf;gzTU zybL!_n+jw9A+(B&K(B*+Av<5djfg?6$t0FFA)9(Bg{6mbLb1dY!&^0kAD|B@8SMq$ zrn9hR&@=i6Wm!V;;L3jeJ{= zSGUpXptNhGhyuc-iP01ia2d8ylBV0#Alv5Z581@k(K%NelXCwNgXrv5Btkf*vfUcg8X<>`uYJ}f^)=t zc$X@~Or}W{^zmIEK5da%KIriwBCXxZ#CXp8*leuOpRM#$&8zEy&%oBRUQKs%x*inX z7b#8dU9JiV_?(S`Tpm_&;Y-buA;d_mzX-;?Griz=vrnj_Cg@!~)ROj^?|uu9#1JfY z?L*hyHXbFRz2tQ)Y-#OkkY-N^HjX$6`Yt_iTO^p$gjUO{>}_;nJNE)z+lu~M!J-YZ zDn0R!VxAc^&o_x)+uGwjuKh4t3Qk^Y+Yx8KuH5ZeN+1j2mr>X7Kzj}VGN_tB)ytC) zkX=b3hImc17UQ0EW*pvR6zv(5!*@yV6uQy(F8@XaV>TiAwSh&*mpb^EIMv6Z@91}O zVDy_HjqBMIl>kR)|Hguc9HRexH#~cM!@EWwVvh}H@)KpyGld>5T-B1j^NMQ(CuG0Y zv`@sh+FgeC#*5{eCN%)MWWWJ>-+WV7GF~rGVdg)A>Z0G*UOjQoy>&JSSGwo5^?vvI zE&i+PV+Ig}K#I|S1PhP4;>vNa>M^V(fAQ3<(B0qs{*mn-ysILFfyt{Wfuxv~j$;aM zcGOD5p+!yAB=Xm1o}bDHF2lV~3zBj5TxE~QQ2Wr^hnTqVe2hq#$MJ(fk)m>YsfN*t z5*_tbHOH$&XwAP*qEW=zpBy+9VL<)suJQ}ZsZeJ%U^$FOC+7s>@w2U4SFPL(Q5u34+wsNR{jJSo>E|xhuZ#euF}u4 z;u84P@0^y#sOUxlXqms5z()%Mr}w2_p|^q$8Jo)u_4&7@1G?R^MI{AD@S8(bG!ea~ zlSX?PdpVXHrw7{h(<%?0jSPoihF)iqa?(`ZwI>$AK?~O9_Gm=UTlWjA>%Wj(AwRv_ zytYSnKcW!Sa}o&cZ^K=I8uu&RyV{Y|odA)?dq@?n7jE0!(lj+U&3YpidygX;m-@1C zA+CAXB;jvCz2o&&wxpZrOkK*Vjh5&0vcxbOfsTN{b?DcUzGXoX(I5q z&Hg5pS5=G|dE$W4vRlV5bo&JKCG&Qf0HOte6gY+fpPB6IQnl*+xNH<0H1C2&=r0{A zt;B1bM<(AQBaYhEd)2$1SbB*km))x5+@{n7GGW!WWv~K13>z5Z7T;R;`{ABr(HX8@ zcSal+R>F6C-2NkY9&d}4LD?@L2BQUryJ z$(L$x*GeWQQnSyzwo+*ezxdpJlD=DO0Bf&lK__2GO!l+Sac&HZ)J+>cc~|w9;K_O~VgB zS8%4Ae%@6PODsp+8nLkqLF*o<#R|lvqLp^oN_b>{vLP5`WXVk`3MPt5MFr|DomK-G3 zczO54r{lA`+0gmUV2W)d463dW0I4iD1B1qW-Q#wk;uP_v>z*h0Q`kp?AYVgm6muK?E161Au99Q(Gh`CYFdO4}6SqYLBkHIzBwKuGMEf zfezIsxRT0G$crI#EIGxqhccEATyJPg{N!uJE$+SdolVBBqpW8`k%$9FqX$Q1N!7HB z@5DZ1f~8s=%h8Yso)(?8exdUIXUhhra-6iRuCdx*0^AJ7|CWr@OrU@nImtD?QYkrS+JP*FLf*k>I^8tpQDG&Z6Ne@Tsm8+z9itd{R5)+ncXU>`c=iV&KDql|h&|B6c z5_~Y=ELz(6Z6aCbqk6H>F8kCLguR8=XA|dzxf^hA!IXWfk7pIUbA2V-H5t^ilYogO z+M})lexNnx8r6m{O}^0!pLY3{aL9he98RC{YtQ_wc;b`ZVz1(w8?7AgnhI7oGOeNN z-pk@Zimn+rzcs@ny3nhMKn8!gsc#)oUA{j)m*!k%^%TXdE`GOOw=w7s`o`ma@STJI97{RO^w z%$#{B#OZts7>S-S5k4$MMQ+D4g{lm^Bq@WjBmy^7;^G|91hI(=iK<&csYA{0zF9IS ziW{^c{*QnjcMIM>&D_c+{ay-5IJyA|$bV1NP$`3v(?PAH9%p!G z#M7Kv;uO$_X0pZu)yoR(N2QQLW&Ih(_7Wold#@+13-2SwOO6b_5fQdO=tgm&9L13g z8-Q91l9A_1R{~{|HzxYWO#iFjvcDBQM`){u-Y~ycH76w)uzLWo=ypMCG2M2P!Zn|( za$ol<4sv}PV6As0*iXMUbA~lupod)-#P)aoBj`Wb)Kmn{te&R!xWu4$de^%cA12#Q zQFFTcO4}XAy^Fu_QE&1=dQ9Zze4FH(zHof)xr}-z+#7`N6Bb!hL_rEXZ&a5G!X)0 zC`7~;&mF}Hk5M6AgLiqBe<`^Dix~ReY@`y%=3e zSoI{X4h&50i0aWQBo=FCaU0YX`;Xv>w-6#f+z@m3a|%D`%+EZYdv4Tx+Tz@~7#Tpd zOrP@}eYgepAzX5-r$UgX7Au4*8p_RA{<)q0u#vNGVwwyjmbhOGm9*F56quHf+AX?~ z{}>v3B>)X>wn*@ck%~~yxDQVy>26WH>+nvlr^Y{$b1Yd%m84um>?=fbcsS~YC+5Kb4LZ(`m!(3a#Rj%eKYDhIG4`?_O)$ra!I%trru9 z=M8(5(QKl1;robnni74|{Nh(I_BsxJB=m@sY;1N{?@yGa zJvy;VWBneyKIg{nU}dIVuu@6i%B`1Tb`R1eLz;nJ=vsDv0E~-8Z^aji^SKE<^i*E@yAv@kKvh zvZ-07l9D&DZ>GX8h%U+k`o6O%ADr}0e%Lag-%N{D63nznh>xS(P5aFTd(BxGS!6X? z#Z8)@k*Jb6S;N*jE=T!_2R`K6k6`bR^~Zk%RTBRZ7|*)P`{|X_ zdwB2C#U0ziB%TNBtNd=GD7`UP#5p0V}(`EkH5*1-wayT2I^9c4UE#T zXBV*1gw==^cVWf+wc|JV{aHaL$K~Gu1{D)N51>S8xcq2pgs_!5tmwoC|67m-dX zD~u&znQUnMW8LH?KhftvIJ(1ipbMXo{k9`MJuN5mBi70`uUaavhoe+>G zqB03D9Q6!!zxtMb_M3JpAUa?K;54i<=Zp3*fa14;NKZT@#=M8y@qfY7l&)N{S!=D- zij4`!Dm6w1HYRb%414447fMX-JK#_6wsR|uZMXzVHlM^QpHpo9jSwD>Q?7pX`Gj*> zucXIK>{laY9hBr$_kzR>%nqgR`;xt=@jKgLC0QX^<(Hgl@I^6AjPXgI;m6|KqAjKO z-R`^kKnM7jk+lXoU0J*T-8=RP=Vj$LJVFe)mBP~jQL+<*5h_F%u$vXCoMnLR?m<*po5o``QiYT%r$xW6b} z;|wt+mUi(o&XuSU09-gbT2W}Ee}KNPZ%l5|>J$Cb-lql$DHFh;{skc=XQiBJFiFi#hVThGXI0v~Jbh*J zeU)_CVfsRSWeylr>@6TpzrG_!4E^}^zfZ!a-&P&9Lu~kb|A$+|-f# z4{V+Zs{Rys#YZn9W7;7C$Xws*gsB9~yqoU0yE#3smZR5F9v^sh-^%=%Fs&L8%wgqk zf|VlcY0jdWaMIs}SS4s}y$-Z|V|9~jEw7|8a8~(j^a6~QG&1^#(S^G%(a8VqEk4y1 z!rkK6PKTyaG7;VJ+mx62ExB}zEaqY^8K!jwK{;51axbkEKybg*?X_LCmgab1ZVu0E^|NNhK2q&*gWDQ+((UvedQQ~th z!e6VrrjCbCxRff-q}VPFD4Lq8>(f1~|7jX9>w0jxh0y#|a86zwFTG%Gk}gZitjtC| zz|s_R>14q~3rX%&hCfReRga{TZmh&fV(v$+>3JiYhn(tMC_0yBKJIlrOUV0j=WfFN zC;nXR)h>Wc_3S-aB+}1Ow@+nZ*M)@`Pe7}%s(6W*_|#DRDZ8kVYmHgNhgQ4mlpjnh zMDx{2K&>@XMJTJ{Y5!1G42xT+jqz!S%&Qhz6#K#H>IyWFlaU+4SXyFeT*3F&0?GGa z>D9=?*U@uK*R#z|xxBt}<7$$vPozR@dC^1{9Jg65dh1WrJC|QhA|LkjCWB?Z)}P9` z-Q9hKKNqHa_l|EZIW;yJwjPINiOp~whIEcbKDj8m=ZS?5ik)U>ICD>1X-Y8sxPe|C z&u#4=oyN#@>uf zl3{IS&IL6K{gk>63WFu{yxwk1FsMA-Oi+RsgxH5fPAw$A)J}OT{F??*{=v#sLHWuL zWqT01dK?J?wTRp(BO*MWfP30rWOvLE#H(7mQATYr!|uhayyQ@B6Q>9ET7$)E6|iDd zE#ImKs-=g`Qf=7T=E?>T8FV|LTF!}muepDkkc5y znkOH>_#LTn-yrTc5nEL)gdA(zbA4|#qJ;=Nt-hw~4wsiy#)ypDLK~%idW(R6cWoz{7|iew zoz`20XUORABW4s9u`BxZDmUJhKjs70yS|2K#~o7rD8H{uw57v1#m-*+CTRj2XVZAw z4!~k^Vt~TUBaX;BtWN`-QdRN3Zv5X~O0T0|X;v^fV70dDaOPi=a_oKGLb>KgLB3!w zT*c+eN3wBeGSjAAFt4s{TvBu16h=LFTpJ(*E|G zemm58kC^V*v(aOJ3LE$)PH^{>n{u$U@ZURvFbeftqATrvdy>cBg zn#pKvEFIV`{|;SjR9xW;B~*nUDv^@0s3j}nou8(YR2WyF9D|skv77FDhX#s5fav0r zpD1e=fpAbQaDyh18FD-#Ka@^#l1^r&IG|!C@zX$-8Ebt1;Q8v`?as*nJx24E(^d73 z1$RZmtBQgV*wz-TBR3jtuW#2-&CJ+xgVBryi_ouXO{a-z88jrN!Lm8ZhD3L|C`Dg} zX;1t<5}nYHds)Q$S@To6Ru4r5yG6|c^U3`u+TIM}(Qyt_moTJe;yb&(1*K{vBZafu zcO64&vx{(~VLEHFCkXyhVOMKCr;w&h#-d91fGhMiXCB_KRcF`lo#~-qsvVby+t-eE zPk*po=)Ugy6QQi_P~NLl)Lh&+EN-0z)-+M;lqkJF(80-nX+RwUDYMYSO$hs{q#h`}?-u}$pYjM$(98JSp5cpFl0yky zlkLy6hkM6`I9OkWexOu1f?M459G)6M1ijm}?dPw9%x3fYyyMo2gU@+sm%OvdSNB-=Q-!aBG` z;0Qruz4T|6OY>=^GxLth$15zMpP6B*enUF@{}J5X>z?v>IUrKJ;l?p2VMz&d;ZVZ~ zSuFI|bk-EhkI3xtbrA1(m+!3bswHEQI!?4hY8ms2%cQOXTBRdomIgmn8a|!KnaHoR zx4QmyE1Ikdps}Tdq(xhXfCc^%SjVX=yUf$%q(ZlCb+}Zs-BEF*OGf*k&y(DAs3(dE z8hj^eVio{FmVSFyI=}X;olt(k7Q+(qMU3iq!Yjs%h4Py-#_9R+&*%HP8V#ss2OAGD z3hLzmR(caNTVTTRY`96*>Dj`9e3kt7J0H#8UJzCwhM}V=D2}I+!@v3dFc`$;mu%z| zdeT9@pT}718r6yGz#gxzE;d{+JOu>?ly}b<;#sxw=Rzj)73=okLS482EHqCjHO zznLt5Uwz_e^2GzR__hI{*uKb~7+ScU#_iWk_zM-;lV6EjJm|O?3ACrbu3=R5%w|-q zEnn(=$Mh@or{^{-kQjlqG_;p%*yj}}eb+yY-v04f@P$r&EXxD26W9w&;;Bo^BULZH z)l5R-dga6eJ2Fa}`Js^pMegyUbT!JS&K7|oZF6xwH`#X?Fk-Z!zPGE6MpgMSorduBD^PRV|d@yP>Jg_8Ffn$ z|6Ms2(K2)EGNsuv$STt|9nA6J^|@9ku4n_;rDV*>jb%H{G>lItc+b?y7Umq91OKV* zD*yC#&CT8*MUK;FBdvcs@V6!3s&}P5p|2r+Oa)qtTVBz{ElgbtQRKrI;!P8>wZEx$ zAijS1K=Ogakb^mi6FneRG2~^l#rYFGgo`VY>fwi~8jGoGk4YMk<`3DIaMU;Vfff^Q zC-)CcehQtAcezf_?F90s_rr_I(?0D~pIdh*QQ=y!aWU)l+$&VIyH-Vh zi9!sI!;!!fO0*&1UnvM1B`&rcC5)c|&dM*9g3o}X*%86OJu3Y?_nuQgMO6wow0|H< zKXJH~Oid4Tn9Ta!K;0MS@Ds&10TmOpWN>8b&9yf&u>#DZPwf2k#KquX$1K=TVlkr7 zZ7QZt1S%*MbJzWQzaT6-bK+a#@RxxM*0I6HIzABJeLzlChkx~^|K2Z@>h8Gdk2wiH zE2hh$Ir!L7Ok&7Y$gK0?9)*TMBh^n0Ir=GKho3E)n(pa_1JwxrEoe7={OkzfYxn5XI7j0M7z~ zWAdF^FmGV3Fo}D}xzFfFe2AZ&X%mV3GGcum!L5tWL6a^DwslG8S*BZyCBZ12ABYys z_Auy@+#y&!6VyuGbnVM6X6WdVA{!EeIK@Sa%pO`6?D+Bv{Kd#u)bOz?5wR8!)gx_8 z@&EoK03B3X*ED>8SDVc?KRA=ns0lf&18+tiRI*@LN8)XoE4QYOoYp?NegOQ)cMjF2 zYlju&r;olmxjhz7u9MtZ+?;joXF1J;-o8sw`0g6BHg?OCqVsNW%aTQ3`z(%u#?Fxz-cD-odsGZ_)U-Q!S2x2K-uNu20z5zn8LTznc>*r8 z*FPvsUOPUPaolL{KZm+#=TeHJY!N5eq3E_U0q1+a3GWi!v*OLI8i5Mp!`6KN! z%uh^g;QrDePtfWMRk!)@Xh~LdABrrhLejFdufWhTY(cNa=g#fAxQ{^Dw}iiRZ(Q$4 z&R*T7esY`h1oI94{*vU-se9?-HNaK@uN-cb0M%>eh4w0tK@pT33kVfJV%&f)BYXTU_8z#3g#I>NOLTC#nhbU-ed~ z-J!jn4RL6%%?WYBj-5H_8H2jSA`Fc{So$6{sf*c>AWCUb1&sJ|Ul$#`GNXta-k(0r zUF?{V$TN{=DtCwAlj~asw_!h8_As#&3F?P7$_^RW49AS40ns$gt{ZaR?POLE5AB>ERsl`A*y}!<=$T<_C0kW z@!;lgSH)sVnnm#W?x3ncE45k<^LiWN?os?xnpM)|15gdne%fGH)`+=-FXf0Etv-PV zgk#%~6IifDq8w%=R45|D_8&5emi}5{tE?@ENJ%NbT&q<~p(!Z-=AfAAnf)p%ez5#$ z`&T-9q4SHqKwgHZE{0OSZ8#sPb^zyGVTrI_(lal;M;JDf_QyC?fCiBg|Nnu;NLX{< z4RWiUV#THF)?6L)+VVewSA~w@F?$Nrf!3N{3DO{jd-NJ-F!t7fS-yh-2MUdepPE}w zYf0N`Nd%x`k(-UVzn2^hc@V2ZelvjNy~b4UXqBd)8|!}*8AbY*N4N^q*<0-|g6>`T zfaad<+CPck^ZoTqi4XV&bU%nM70IkMg+(fjW-5q6_~u)`Y_0BzYkH>-`lG!YSshlozA= zu4%cCQ$CNWk~6Y-^0E?<^C;)qme3z6CD1VQO19>;_>>j>^T;gynO-3pxR1`!tr4K= zOpb~kMHO?2D%LHh`ti!)EvEt!nu5OZJpRDHG|OB@|dNW@h+N zMnc79Gnzf6zbt*u#W&c^_Ejc;(6wu@Z1?+&{`%}3@+v8CeHTKE{W9ATNLP(MFA^X* zN)Em`seiqa15&<9TxY2Ij$$}#=vV%`$*JxPGw;C0}!_-)}n{-2tCo_B0`9!*ND1 zhl}rSA^*bFLKNplHX#~#q3aiDnM3E6JESlhycKn#3YW+YJ3YuPntb0>_!*$=1gf7& z(G3S^3InNrwy2FZ+t{P0K|`XiKt(}tKiiH?2pVyqep%1}xuOg|Lp;P51fkaUOfd8W zK`Vub0PNcgX@=e0SQTT?h;0SGlRMYS#U(x|h7WO@k~(xEMMmt3_}QY}ss4O#bl;tQ z$8qP;N+jd6>Y0)6QtJUoR&omcg3r=!(V=~3h-{&h)AOjSBn)f!58K;yGbPFQ>>dx< zoeq4;l3*3_kFtRL$XhdYm1a^Bwa7OdwV@|<<>tUDY!+%A4lNpI04BqBDOFd5il%2Moc}l*y zKq&KTDl2YH3uo`_U_K;ngz28iLkbPJ9^CU8^#wRdaOopi9G@62Y0~B0#1zi9ChgGQ zL5Lhj*TBAWvI=+^Z{83*WK71$&Dj-B@n{{h9CBQjTj|XUpFD)4etltm+qEO_ciPrkkbBn2nVC+Y1{i!RDfX#XncF^td5SNL`{seTu@S}^2vuoTK0 z^!ETf+}19Pir$VxOLTKVkK*3M4LGiY=9J7>hDUR)F4()}m1ALD#*e z%peDB{tWXGQ-~5I8$2BJ={T$@y!$vxuSo~yqg$6ks8R|L4%ftG14gFVPmq7&?zn{}V`Xu9skeCEq1M1( zz3~gZDQG6e^*cIm|LD*Y5IYQw)k^Vp(Mi>X!rpNoazA~=1WJ=G$W}UXl-tnO^?Iq} z!*WKTH)Lzj`oYQB$DXnp-_(vJzQ?6Z((P66s=pw$H1rwDmk^YDDTJDzK}aLKE+X&f z&qhq%z@~1p&t19$UnUwhoj45`DXRBFT7p`F3f&8SN;~L(fV^s6^kSO!2;h~Oa2A5w zY)OD$yysUD3@3p~WLn_~tbl{9yglvFUds|$XK?0#;_xY4NuZMREu+R9q#1--L`yee zk#27GtLA>6-+Cint_53v^0L-ve83z6M)6W(#lhtUIGr`chha=sZNJsi;C()M3~T2M zTDQvzUD%k{ouX>~61OuGb^j5dmjJ=``sh*5sxI%G9-a-;wu0VHr5$CCsUZ0&uU&ee zQ0QY8DJgcra2=aoFqdu0Hzd2KP!?wA~B(L;AAGe)+3UYV#pfkWCKq>}Rjhjqz3KudTt<;}Tp9Wln~ zy&LBMjd5C7!NmEWyw6DDRo4X2#>&{NyushdyLJ;Qe2?guULOsQYm+KT1tWy&kxf&G zmy;FT%SU^gb#jj#8<*lfLDD2i&oAmb;_fKH<|8WRgm@`p23U_*>L=D+TH^Uu`-ztm z(Plx}qkLzsKQC(bC)>}l^d9DpL)TZp!kC03N6+uSZw?7i9v32`7?cSa!=Jn$@Kxfq zd~?uBy>F)GZPb}4>&9Ia&pv;i>T*)7fJCIz!b=-@vQ~AYD0ots{i!<+d;DzqWioF7qAF zFPm((v<)X`;BF#C3pJc$h>yMS?L1i?O-y68+>ow170V({f~*O}ZGPqLV~KiAC0rB9 z0*|^@4qUb=X8P73ERRHcT{%ISPglt-&EZtbUqqGeDS5w1rkQFUUI zCZTh#?ROr)Cy%~>SG*hFvC1LE`us;QWAE91xe4hk8(1AB9=bg9b3c0vNde~$6b=-R zvet0xB+RQH+cNNW<=jyjvg;?i;a6-QDEpB^((;7!>UBZX?cB{;quAA=x+Tka*USyJ(cS z75lts^ij|KD%Xq-TD$40oIJ|2XX=m(-5gj=qEoR91xM?hlO!ILl$yZdN6VS)H}~&Z z>%y`ckyX#Tr*ith;WF~}4~wWO{wh$F4B4bB73318!dWRMpTApQPJnWrJ{w!*(-tx@ zQyx12Jc0U6D*DthBf!f}Li}1icMB2hJMD8Ic+(fFvM*X0ZSc?f?Tz~^?XL2Aw8i}P z0=!;Qz1viH>io}4lRiU)W;YxkCW)@ScJ)3KlDBN#0G!aRA;82V9e3n_H@{ z<|6+iu%tA=yK-WO;>@m2Frp!%O2@l!|kO!D5<8iFwU(CF&azsz;-#G4Y z`rljKm8TliSHF1E|lA(Fa?J2pWV_ z;@ba1a$b9=6aqB@WqS%&bUO(Qf;p_HgzOfufX2&Gcry0vYBIq$LEnK=shnrU zW_W5EUgf87Wz6t@z$RdDhq@OU{}FHlF#yNKZ225={F3#@FUr5TYh@?jiu?%Y=IEc%g^H%zX%+)6ST-3XV`k+4 zAqqrtK6lIWS`TC6d&L>JbFFu^wl3TL%)$&Cbe7~ht=F}qIuYw8M|tJ*n3$L?=1;op zMC=(!_s|f@b^Ds^`kHf?6x}vJ{bNBSS&BJa=d8VtWbbT!47)$Hcy@O4J{g6%Yt+Sy z;T`y8Az1UKjh76Is7wWUF8>SK;4^jKER`fV8;u%@i%3t1@9g;uIUxbK_&)P&J`|re zZz6n|X;qf1h*Rv>NWnxsfjncl=hlv;-cW_G`6Det@s~lFJV$T0X)26B&~{RfS9j?S zICHM{fBi>r#sr=WBdN_hWF~gL5Pun3%5hqlw|HeMc?Po)E4!HQ3qMX0N-1}g4r=7e z**6?tpbguJaKjJWZ_H$==S!xo3ALzopJ2FHOR!uTbcJ;3(B(cmBtCF23;XGS&_NB-}}a zCY#&r_ZX58FVVmX1;3{@(sh4F2(d|Vz|uXbbCWc^3i}E_hH1!}%W(Gy-xtx0cs+hp z2dc_G? zSm2yza@*%UxoI1ky zz+7hGYGl_^*o)KW*WYisSd)Zmo$X4yH?1-E;k-}24&iqm;X-XQhGxYQwzPn1TWDHk zmEjBDO-fi0j1q5Ay@@W4SQ8D9mcM*g0g*MhRaQBm9E|&Kb}AnVz6r*C*7&XhrG|?K zGtYBIL)C>ib`{_5%MI8KG(;=-*mB@$r$8{m_L=(4=<=WUELIRlL~&O(nEKn3)mgJw zF&lT%aY3=WMlWbDfw{rg@K| zR=Pmp^T896W|fsWf#VhB#EF8H8j*LZ=tS;*ztZbg)`+e4lpZmzvh}Hl5?&(Gg&_0C zYxi^FtsTo9`eq)PKf`~i&YAdqhL}GE!c!x^x{%pR(@dGuAvdVP982?LK-p_VPO6(# znd{8^z#?W+u#||S4OJdf_qc@Z>u6-6vg(k$XS-)NY23D&v=2eaS_{x!l`@e{CK8~) zo-cl6Y}-x7U=MmC$qk7{E`ZB#Ug*WJ#E#mgOLoMqv^o zfeH`hRqEwMMM+O4lK=l*J*&2Ze%Og7<0Hn`#?AVpJbh#4Cowm`l0-Tz{8!gi%Y^F;)+6NRjOgMBkOH&qkurEbdQO!fR?ZUSc# zj8Ncb*Im7s!j>lM#d(UuHWkUw!1Rp!rAxy~_>wfLRrl1Ss+OBP@oU2x1=W|JrH`s# z(%TYC_O@2Eg?WkpE%?20s{TJP#ei|SC01`d7}4o(GyEmp+inEu-CJu4-A^lSs>27C zoVd}Xdu_xb+_Sz}H8&bb&{#D#I7iZ@Kw?FS7H17gyhSu8YvreT^j}0o*w)K56a+k? zqPOxQ%H0;)Gt@zb)@9M6&)gOa7CGV#Ep(TBXXn<|WF(X|*@2_oiW2U%oBI%97d8L$5LerD@V*E0`U# zLs^IKuE@%;Scx$Z8#dHf-tn6;taFQ<78kw`?n@h|qf702=4!vXYEcHx$z^cxqd_1& z?ambC{;1K}N_>*VUt&Pk!=FY5T@Q>-p9%5*9ik(x=p)cQipq0rd&PO1cqe38_M02O z2tdm-hTV(=j1bQ>k&$A}6p?&Od&9Sdfvu0CWoZFWjoTScP1Vmf6zMU6{MzsXWhQds8AWB;nSPv8k8!~N;n zy6@^gSblFQVg@Y4D}A%N&U?OczW`^o-#Yw`s@Zn0Z)a;WZIiA@FXeQ)e5hLQ>Ak^f zeJ9*aFNuKgoIRLW({c7I;=3s~Cb0aXm5$~2#P-YVt#z(4_2qwAW>Vhk%WVZ{_0BkA zZ!)Lp0?530F~-!m#D*B3R?L-o?S5?L_R}>RB3N4@n?iP|B6o6pCA~fUsT!KBnN+I~ z$wH{5#F_sx{C?w!jF=mJyIIJ;72vUGcCAEUj# z;;~P9N&aToV|#Qben`1-+}XpZ9GhzYsiE+#q zbb*NvNBN1HlKi&$5O{IN;}cwcc!O_68&WH~>Ew&!xZx1`l&=?a-yl_~)BSSxeI-CV z2VC_6XMBZ6TyL&~FzkQSq04+2>TWQcy63t(J;^Y+T&k(5z@C2WXZNf85?ajwbq2>n z_mkOuf2l0<)5qf7=99{Y4i#rtHM9K!^I|EWeQloP`Pl&J&w&7gX-W(hXLwoR_v9I_ z7#bN&uMy{~{ERXzQ%jArTiYf>#)_MgRxqzhDNj&B@Zfq1s193q^~swH9P8&_cMV88 zRY8C=BGc-YrpO|PI`LYNU3H?TsI?@2&O2~tjpqE);_fBkNv9c?`D}yvPFF+aYu$(H zI*qOw3S(l&W45fO0B2Hq&TsUcKsa&bDFwbT{R|>F4Flg#JIx9r&VJy& z=n1A3^UA*3S{af^JOWLW&c1sqrpYe_=0eR5-fjf(gZ^rT>eO({C6&OX*cGQdn!6{@ zeyR2hg}fsqCa}s(ASP^O90e-VNLGS95vm!}@`l0n+0Z|uIFgo%z@Q&p(uR{t7yFma z9vWTBhJ|?>#Zsj~;q1_BlF2pRa3}P9=w{D4*_vt+R4QnUB8eZGyKr|vPKO_n{UzCbg{F>7r%ti6 zM3M;H%+jvkCJ*2+rtk`X8T4?h1zki#Uyesq$YD6S8>;qQcrS_1_iX5gO#V!|~` z+=F;{acaOkxbm%|UH#w-P*=Us$#V>&$DX*hEyYZo+9C4wkC*wUh3<{&GH8EGHVf z=FeV@l{rH+t+n7q2LlITx2Tn2FW}7@$zsXQ5I7m!)ysUTe( zjB&UVaGRr1@>>3@c7d~7J?#mGjR^#6jnDm=H~r%&jN$gQl)3&|C|h8EDqpI)%vQ&9 zHM-FYv1_V5QJYz-4rr<~PifX~pCU5Dl4gcjjB zdX~42>|GN^qrikBM1sx7G}Ll+lnk5NL+!rz)XYR0?%iW0(=?vq^+)qQFy0RDehzb( z&l<^-8tieAj7Op6M@BmMYA>1eel0GV?gqY^4pH3E(rB=!W}zmO?b1G`LauXbs&G>+ zZDj{#OV~wzYL*BNGQ7dPG9Bc(>2bUS{a)KjX35vBN8=Htc66zcpDmu-8(M{MZa=<1 zBicut{RKQ$dkob>@4yRMvsySAsyoPIU#2h8Vpf6M2g3n}?{DqOH{>W2G-URcQ|`N`y-;hgPe3a%F(J40dK>$2SMZT#jm(Ce5C>dRhOZr#C3PQWT+4Gt)xp zvZ`vPFuvAeZ+jm$e-6}e-$krFXO55oqS zvr5>J1cd4zekvkL$hy@D;;v#Ba$vSsBv_IWBcr%*JYhqqqQUEe$cJJQA9?Nwq8n%s z>7!pFbP$L1>7KX@Gc;K+%9ur$?REmk)6SCGe>01xQt!$%NA9%W18SSe747*1ons)nXL&+(mdyhU{*d@doK=-$bPogD>4F1WFa6$~AHByD(atW2<%h79nvb zr8mZVg!^2QW>QID7GiCpH;g`xXb~Jvt!|Fpr5@*b3bfOGFrh88ditiVkTw!bO1$_V zcE>Cpo0I8(R8f=J9^I0?<^|KwDYyAwot0b>iRr zegHo1z)~B}6ctHCI@0`YolHYyKdluiH#JzczL;1Hk40}y@jsj@SiMhI3_|vm75B{W z=3x*YE90MRj%=V7olK@vU2LCUi;$^$aN-`b+5p&0+Xr%p))TW=rqiRzU9t-5Z>0Z{ zyvn^!;IT@4{yw(sg0+gDrA1T`qlk7f(G*li>E&*v%Pqb#V#Zp1E-RY6gkcg~haHO4 zvv&Um(3q?|`Rz2K_t2q_Za;|51!L(F<~i_TMq6>8v91R~Jh$5@G+!;s@8)N2rLpw^Efg4WCQsM+8XVKHH!7La<-h);@}TbN=<4B^Rj zMV-UT0yo51xwx+>&DruLSfDf@FX!4_rmGfnwA%muV^uS~-#a*3_e-l7M$EOc1snjp zKp%zM)id0!h0U*`O`i`I+7$**W%((<#4vIMIQFZ2+#O*4(`^IHsw+WQ)wS?pa?Q%7 zAfK4uUW63%CITZ*q#t^&&~$%lO!x|UXB~7u*+agQ=)ooTPMm-tkmc==E%&s0%z@8P z?~S6QDt=(J2KVPRS>85=S)vX9BngSfBThWu1TfJDCaGF~blbP;BTR{0CIjPI=egs< zi~H64?W4+OE@v*J4wR`r?j>9l10RL%Wx&i9eW4><#Tfo?{%rQQ2><&9 zBBk9kGRW{N`RiW!-UVTL#g91)W=1yKUl?5dl7A)q*=3+}6whIp`i-}al>HSg-_hxP zs}sy{k<-nqDK3>Iz^3g!EXiPzV9D36$d+tt?a(me$=aD@#xYx3-SlyZ?U=%w_)4bD z8G2r&G*K$V$Op(IX@bV@<1aqq0IJ_$!sqVX zr`WSMo~d%GYf<5|4TBL0rKHVIHYnFXjCQh>{DE4AcPaAZvNGFf$k zBG$)@8LXg3xv6gLHR!TUMI&S>gsZlkXox%%q-e6i2wD8-dL6|jaHRb``JH&ys$y6o z9|N75&X~V~?RsB&FDaU~4ys4AXA9Q1z2&$xnKkO|5U{2mzHKV}nz)$$z3y2*X2TI^ zhBDtct@I3g+}_DviN1K*8i~=Ulv^Fb@^V;4 zyo6@h(~JGCdi@OH1}S~62xiB4LfZr-@%&d;CGx0f>(~!$g<#p*+Y96muB?( zl)yC@V2w%^&s@a8XX5~F%Boiiv=mTdev15co$I=PqqtIjw9ve|vt0cZwG-kyXIS0z zz2RTSDCzmwB}^V^jX`N9`-9vdMa%DI)a)gRlTze-Y!OEe1;8Y6gKxNHPLQsQ)7`>x z)rEcuT7{Q5e!TrYhNDF&N_BE*&bv!3K7_;L8#FuX#^)FBlGi%q9Nz_K;KiVj87k+L zp)qU0D%U4n@}NV#d#xTZLvN*yLLA{IS>RWfk^E-3DOBhFLhyWP(m(=yFaPbzyVome zR=+mHZG?Ffkt`J$pMGWgk(ADxN?VXkrId74TI(J_UI|0HcsF?{__?E z)0a0B`|NL|suUl~P7jsVfy$uMzOH$skD<Fz9&V4WK7WHq|Pn26Z}hF&t7+^n$rz9cevE~a1e9}U@K&3{mN zFauO3+bUrlx2?Ib<;Z_{HcQMvBDFsZG)b0Y9ByiHq7(VSiN*L`#X!!f%tya}2i>j^ zlq%#o#-S5TDW$&VQ%HZb%r%(>6}1pKuKem{!E_j90>-YQyzIEc_M{mq9v zb(n7SX4(D-$Zjy}^Vxi|uBHVol5GD_u znC_z)EgBpRc5K6~uq!CdbTe&wPH#Q8x*x(B6#EDH7`=tj=1*py!8}?DGUt_|@8yp* z*G4JlJqg;DSuvT#k*pn@Kc78nY)xXB8Da5?Q#gh<25ws(#mTHqOA)MT-p6uGNwKdb zL7}R7A+|=TNc4qoTLh36+WRBYleV`I(cuDP&JNfL_eiac1|~QB(POYtI`6BB?5AI5 zE6rejNxQy?HSiPT-P$WU$j@!n@ITaaiIpp_j&Pi%X9GRro6buIN1&i|k}wFN!Cvi$ zn!KPtUW`F=h{e=InOJuwq{q)huRowUF$XYgCNxHsU z>S-s|8_TU0C~BE_7@}to9e0zYS5Ogh82GPFEQhstjz}7zfJWVWTbj^@tJphKkV z99sEqE-(H-ZJ5txCQ6G0Fa|n@z6Jtm%ex64{Yp)?qxfmQ5#}f~<|2Qe>2dDjE2&$p zWWxHpsmvm&Fb@^{-#}yTt&THSUo`i_NDuOVx}1SA*-v}8_Q*aBcVs;s8hWdX-@xI| zJ?!fQ1pH-?&KS6H$NLq%SRYsnXuCY4&mx@#fC}_V517{XEkD#n^q)`%%vja8hzvKk zzq5$bJ--G$v8XscpXCAnIXX3k^9v65ZdVs@t1n$b5SIHOM%I4yL>yM8n1w!p(0f+(GV!6OSLU(G1^v$V*jYyYWsiQ5Jq_EOVVC=^V+myXi&H;d zMLkg=N`?*6WW}U6V)E*KH1jt#!rIWm-{ARpO}T_pKOHL zu%+@F5pS`HmFGoNbZlnG>aACQ?NmLD*0}IvRq7#gGE^xtCSdjX#aiKhohzyM_dp+D z(e=q9KBifh+2}gB+tB2eyJaPFxqK*-jFjg3l@s>WY4*Eu_hXawq&lgOq;I0A<8^al zLl`FG#vWplY{6^qp;uOj%f#f2KXI*mI&Iy4ooH1u*hu4~+nKPR0GwZFw$r>GFand( znquOq_mStn(`UF0b)!FvuNWe zvCb!%=y7GNxj~r~3^92eL25EP7RDSVR$98Q#2jtQ=2Y-ep5P~-GU5)wSU90p-r_f^ z{)d-s)bt~AJ6UYw;qUSuf)-x|tB^wN>TJ;*)1;N_>r|5Cp)+teAa4H|U3^Pq{vTe< z^JX-4i#KVvSLqi_d}&Btd1+bGRvwgV;ThJ+e@ z{5At)ZeUitZ(2!;^`yIdf~r}($p`|D+@t3H}YqNN4IYn14fBh&k&&oJL!_uUew z!4Go1=cw*kR1p41L%`cjP6i!|YBUVL`Po4|@|Mpm-6-3@XCv|so3`j`$2Zhr*o2Vg zr;YOpp~0>e$bm*l5Uppg`ip-DN#z@P9L``Cbw4{Z0=Px~8o&to=)R#@PzDvi-v8Nh zk`0NPzMlJnMyw~qnn0t)81v-e+k$2ptMGaJUI}EVYYV<^z=$=X>^W78f>xVlfC32+ zM;;rtMy$rNSu%0J!OGfY$ylgm%q z47`QN7=YaB0(yX;%NR6xD7NyXf#(x8PBn+C2Fy8znKJeYvOnRv^D1 zCs#_p49ku&|AMB1&ecfneLLcDzt8-^bnetCag`Xb2>t9Bsgc)6KRKT&a&mT8La*=t zO%!9g;4N|4e;)29@`Rrq+(~T5jQvG-cW+$g3HJ5a9DB6lt?yt&%fTN&Y2~`6xMh}U zWoMNqziE?LVc0$SS)c?OodsZw45IwkUi?ircO-qEa<0a(*m2ks?1x#EN*mDJ7*giT zL5Ko)X$z1{T&S~Nnl)O`cfnXsTeJrlaP+|FwGKTn9X?1lAvddG2qtBmzJnJt65&5= z*k}Zx?4QZZ%EoGbdTSCb=cSw+cO&EgK!MPoED^e6;pb4QtSgVs`^RF?i<5Y0ys(`5 z(EHbJ58;^JxyVnNQf(SpX|HU&k7;7rc1A*gBT{OzZL!@?3G^o)Pq~e@_{02=gWadk z+}ndRv`jg=@}^;$su|C*UJjz`%XOq$P{wMx1u|~<;q<1A;RtlHm1i+haXlmKcxuzH zVFRS}HUhpYywgqMtxtK3SlmkcmCSVwo9d)3UV9=&xruv;X<8d4@(kPQOu7_x?uSGJtgwmfazXAys~MWH#n~AJm5q4hOibC_wIp1*Fao|5Tjh21_m{BzbYIodpJ@PA`d zS&4DTdeWX=ej~Tn3_82_1k{B#+VV1t-VS&NkXwqGwHlLM;IlLqC?`Muiq9gihwXu; zG!TDs%n6XG_BZ^x{@sr+W%&}xg{CX9 z8Hm`LIXDF?uBzDvR|lTnX^tPhmIC6PLcmok0WeCiX2s8!5bUc_H%Zl2#ETE&`l_8k8 zNaI#ZHy4eoBl|}X^U2|Q&R7f|q;ew)MC;Wcj*zPSBeF^;uaFzaH_8Q`cnRxx(Uo7{!w8FLn z$cCQ({Ggq_m!j^CysO%qzj}|Oa^JnSClQ2zQ$KAy2CA1JvFnzOWy$(h(=zvG?R|{_ zM7qQ5P4iE?6X82n%_*x;{8zogOO2{5oDRPqetm)8hloeoN6%q>bumPxgQ$)6OQYy$ zUcSSV()5|-#}3D8&fni$|I>H2{K~G%_-|K(`)D#i8IMm>whddTaNN`CmmO!6eNBvp z1U0L!{xKU}=lFR6BEfFIN$&npyE`J`f1E;(dWkeguBO9lRyye+c%Jx|+S9xzN!^d^ z-qEY>PKdkCc?=5A^CwqQ>z$4)Qrvbjl0YvI+O-ukhbijlhdFe)DG@@{S|#Y$=bs%v z;#itx4la%FQjeu-5B>#u^M3{SMH(B+QR_gZ| zB(IFnd^mp?9oAMPI92&SJUE`ifmsWB^l~C4$|Ea-Sbh2*#a z33rWecZl2w`C>@7RFxh{Ow69UfJM#aDL(oGc^G*A??Y!c_f5Kl?)!p3*WA2|)ybkT za+zO*1S%##7X!flNRqSO~$2_-VtWY@#L(7}tJmGEPm%4w& zv)}#b-?;mgGd*0D^J&;PB@H!4fl!%0TOn9^yS+x^?1vLVSHw#bFL_q<+Bwj|7%MVA zmR)(_1oOp}GtDK4a}(TKYT!?{x4glSYMS?MV%RiG$lXj@82L#ZJu-p+QgwJF-;ZZ$ z$cdZ+J{PP2FBYRqMMo7=}O`PNT5Yy{CyUN^8fHWXO2cnOGcrW zoIC0gL#}m|viD(7Abk#9Vrpd5T7s~g%v?obfc-b9kUF?ou6ATaTR(k_Eu57t{7m^t zFFxxI1VbgEciL@5O4hwihShet(L;t{1s;{=3%wo~TTXhhS2vTQsHpj7`2KoNXytU0aauw8-~^-pvx@lCHW8xw=faP=SUOdNv1XXV zx~$|J@zZ^Nr;AbWu+g-tdd9v&==5p%8~BswOhhCq% zH@MDq?A>+F&&E&y%iS1nxI*lfWbAfJQNZd#enCI3#AltiM&4kf;rBw?wBvL%PL720 zmyn|<#ige&N&hn7iQ)MWMtgBc7n63HaiT^etI-7*;}212yobQPlRat`aVpcoZL(!d zQ0n)<5jT2eXS5_zajESixdh^X&C&_fqgsR44Q6@s%!GIpv1bjMI9{q&wi>tiR_Fz2 zee|al8qv7~?VPdMHXS9obz>tQCV%qm^D1%W2B0lq!1!E3FexsTrYfpWUvadv*cchU zl@9B93|N4@k@wKlflj}h$MxFBowvi9OlMN=dN-QwI>o{;|2}rg{SijQi$Q|tt6_}j z#PG7jxl7f8tMrVfm_uMpVYGx48YeOME(cHP4@iIxo3a-~?%>Ah2hx^xk)L#q>XU45 zk{?ejKR((R>5J8Jc6Kjl>z4*p8OKsWYt5Z}ncC^%Y$@t&$Cp(|jc~LK;(KL;0^yIn zS>>0_86igwFq{NDpWUPvDpSXR7IJ_?zGpZ$EE8 zZb#{fjI>c!*3$!{K{t$wSe0^XSIj0NDT9gb_xl54On znC$i4Y;cLr{scL;+qb_q>fRnimxOpQ=^qwyoo;nG!|sP@yLk?ox9t7WIseblI7apX zciiJ$1*7Kd9-PQbD>Kw?o&Q+~?%!@~{PQF2XP=w^=fB~3w(wFwpW|=a^qB}=-ZFJH zKcm>K)avK{g7Q*?7ZK0NnWlbfxM=&(*?rwKv0yC7-TtMgo}43+PT*~wA2y&Z6!Cu7 zEB8Rfdf1YSUV%kR#fF=jrQ_Fq8$}g2xi39%iu+;nsA~ zChBiF$0;S2KOM7{w(VwW=&aLN*I0AA0Jw6QAngVcT6Nq>#djn!d(Ap-v0f!oO5VLT zbRFQ6ELx<|HV5kH*MwJH!EEH+L9h+{ozsoJuFpCz6Vr?5OQ1`AYwUc3scCB@(H5vq< z?0;8hXVNLqR4kL{k+P`mnZ<yV3Dk#J$X zwAMbjf_HHe*N-HU9}v=~YUH7+0t!UXgSKjKSn&f0EC08^gw@?@_=3kSMjtGB4rk33 zS?{NO)1y+B8`(9XL^xK_8yb#lcVEr3! zWV>%v37g-sdkbkNBIL$yI;k5@WkX$7%mVfJ&=hfKItfZi?-ZArK7^5tV|mvAupTmJ zw$gE|e%D~3#43r8-`AV#b<(R!UxA)n=lLMZHsDq`I*=QDLdkY0O)uPIjVRuW2ywm6 zt0o?@I87n$vJ8yC>0Vub5tU-3EHWwH$-LR7%%!ik!DMJoQmsS(4nn!@KfhOC`Ib(i zsj|uZOO{7eY|-4!kyNgFhv47LVk0=rO?)g(=2$OLmO*8A$z<)rXBg#qus;lt25VN? zxoD3Wt@|8!{e>C_J~N;}F)G81BX0G-vNQ&_ymS?6ME3M1*n*zh*O;=e+sO-y{r)T+`>_jvL7=udz4O#3@?7=6^=V) zT$yMb!z=QL;q7|4(;ybqWw7UtJ)`UngXE zttkXPDH+2Cx}nt7?Z1yS&kv2XR;YH5(}oDU>~Qh&F31hTIIQ!|-bI_#fc7LTzl1Pc zzdr+dr&B?BP#UZ~CKL!btU>(LKMWcf|53;h|0g_Xxiob{vZdj~Y?k5KaO3=*XeTG< z>iH{$$Kap7-37bDnYMM_JgMROEN|dpB3q=J&||T8iPRKn#OlHvjFxV9b~hQIZo#gx zU(gRJVTn<_dz$T#pbSEspf7kF#*Z0Ppvw<+xauu@!Va%0U25{u(0lmFUuJCenNw4Z z_sa&(X@ZJ)h*MECy3%aklOy`re_ntw`l`ZGLNENq;OA8R1y(t&*#-q^@)2 zh45QwghB)lPN*<+`~(<|w}EQNmTBJe-o*Z6hwZatHl7|gJ`7%r=`RLXJeROj(WUxY zK@-h(cqj~viO#|7lQJDl9=rjD-7kv^*{>8SnCGoC2BU;^P0||Lc3Rwf(^I#RIiC1W z!aKGqIxIJHjZ&O`CFvuLJ!H5gE}ct{3Xg<`uWDb%E!j8xOL+Y-otf4P4W2xi8$%o zdyC0iJzs?!r>sJ{)D_6oYtNN=7X*toD7JWG!##on-m^+hl}VnNeaWAv4=Lq&Nn9>w zvKG>=74vp4HSBVf4Etufh7=#VJ#T1iQdcJ`vOc3AA3bpW$0fGDGT@EVs4Rrecwa`O zB0#KrdP)`#-#f?Oaz79G3xnvEYLeJhJ7xP!X)B_-iNbtbLd^|5!O6|FF5UcpAPA6$Pru}*Qm8{(K9W@J_q z7n7>sy4^Gy+)CAmhJ+jm4Z4l~-r&8PW=I6f4m<7!DSpI1ae}yE@1s79yXvFa=k3xl z--20lR&FdIC*!;Sc(R&|1PND;CyKI}Bmx{HKH7_7Wgf}06r^QR^ zrACH}OP4?5ZY`WD?Pu}a??C3sZ!Gr#QPmIhZIH^c?_vt34FN=x=h zy=#*-Qk-Z`4PiZ|V?9c*JQ_Bc#EWZJYQRTa`Tu(Jt#RCykl|xB_%eM8+=Z@>VpbwUb!GoLskoOX*jTdC+BI z`aX0H3)?o1aNx?H>(LnhKG6M-SQm-_%y8A}H;<27S@xS0X@ayK_hRt~*B;!@d8VBv zHn=XQ6F0FHinT-Q?L{QX2+e$njZ$TN=akX^Q`2Ds8Rp*RWyU|_ECKK8S^W=Bw8&y* zKIfSp^ojbnaJSsWeLI+d@3$(NI&^J`z}-Ou2f*y88&CGmiuLngGoP(NzC{ILUPGxSm<9B?iIL+@@`pE7orXZLqg8 z^^6CHEmRKV7@3)^GGBGJ#IZ-|b~7SvYmW?2f6pneQ|$&3eLbS@XlXsad~$v)9fByi zSP4B?=l?!wR^2W<7vb$T5E#U#IsAet%0KlcPExPBP=TK8UN1-9HD8LfjoSU6fj>?ZjxcX=k4VFGO|LMEI5m(&c5!0X(d*;Tt&rgJPZS?!P44}jvZ}yB}fA^kt zcbJ4yNoB+?y_V6n>_2=`cD8;JlwmmjV)oFI-z&TL$-4_5ZRbS~fsEfi_K4K(tfAlL z4JI&#@$a2lh4RMZ`+6)nYuclmewOPeA$kyEDdp_e?CjApbM`dL+E{YS7&3;WRevJO z<)VX(>Y1cF_Oy0nd+@ej#GPJR-@mu8?Fq}ESN&4_2wY7IW%2y=x3 zSB--N;Zy5$qLz=uaMZ#x(qg=T?71 z1~JFYAZz}^>oEpL&7yrl=*XU*YfCT5zEEf>Qj{-h(vwM*#AO#s_Dsu!_ZnT38{hqf zv3VA)`ylDMb36mq5B_Y|Pi99MY6v7*hi#(wAhDk)RkY%Mxw=DdV2WcsaYpI~ch;~d zcsm&PlCNp?NOdjYeCrr1v%NiSu$dwm#2#?Vma_@31(S|BMH}JVrAX08LyXh9L9_Yl zFoDLTuys}(NQCvZUKFIcl-9X~gg~nu?}GB2Js3;Dzrox3@Hio7$+98pBN%g-FzM!# z>Q5#!bt?MTo^Qsx)tytmP$dL!n3sEc&C1+BD}2uR zfh*r7=!28HrMyyU-L{c=_e*)}OSV&Ba)lFrYj$}&U0s<7W3DqTkW@g*o-v^DaUNh!4{tu>(E5+^to+m$`^ChHSJOiU~%Y#}LHX>35xtK0bp zZOV+wc#lIQO%<_~ze$^eEB5pA9sLedMDi7{JVVap>h^YhXMEyR$p~JNCg#Iur^yYw zv#ghCDjOzUF|I!9>XyyvM4t&~sgqtYju7z8iD`|I${N#Y1`nUl%;$$NgLWggjf|cV zzPrnTpC}P%3@#*3PMouTwmZI{Rt@iCHn5y0O}Nk5^Y8nyPsQf>p7Fk)7$tUiZT5#_ z`?^)iHtfPivPZIjFZm*IS!LcdXLQnq=Mr^EA;W|y$$%Vq>}3k@bk%8lPv9S@FDeRW z6s*YQ&zV4$7|V@%&XoL~3-k1zQr=dlB`o(2RxeDtD22X$o&P8$s8o*AIgSe4=;AIWju{~YiLFtW_s_A{3IT8U7!8!U zAHE~;jza$QYq;fwjXPpLj<2IU!t<4YnVLP{^eEx`1 zPV}g^cgEK&onTlyM#Qwso)g;o4wfa>ZTq>!E2uxX)z+V^+SYDl63;Ip@8Nu3jAv2x zD=4Pm@5i(XwGpmJc2$r2@7g}9T^G!qay8_|N@ZUW+L)`2*3e#-zE`44tN$pTyCfgb zbzA5`he&p`RL&%~zW_u%yi@kM0}tkgDcYPAHnaxbymiv(D`?Nn9pEO^u;I)qR9G*s z&&7+6`1-fGv2v0pXX?T@1n-$BbrXFFHz&{P0p^A2}SdV`HHR%3c%OHH>(tAm2l@1mX$9y z@4WSU)1~jSd{q&luE-|}u6AN^d2eOwV|n z%bmI!?Y1LG#b+6iIqK+RH|*53b>Q0V`mVQc0~3@p8OiSk1EcN*N>)z_#3_pN^s>b- zCVNl>E>4EZEhFD*slNA}@Tm89>gF)nPIA5%V53h{43#|%?e*ruoadQ`w$YB-{^9t~omt5b&?yt);$dN^mPhAxze5~c3YqHgj8d2PqtAHCJ=@g%@J(9F) z$ZbE7Hy3YsD$n~9SOq12gzPX>R0)$y+TUl{JzE( z8C40u<&@rd6ae(p2n;~i#@^DLBkE)XMH6l%IYFnhT~z*eRJ4)4vgzvZ)MfrAG!^fE zcs}Qf$S4SK`VrnK*AZJpOv=;l2wxY8|5BF2zsS#a=~gs z%oNs|8$#)#HCgkNR3}4eT8p)*rY%+r+pRr7PakZ8KO#9{XV#T(xepJr!tpNqBM+5t z`lkCaqg{;$Q1!pY8g{&_al#JVuTJgTuQSTTcVA1pnCf=|vSun^nCZO&Mj8cSuamHL z2f86mEY>)BoO=s3rrkZItv`*kda%MnZU%|%#q3jt_s}KZ^Dm_V!hWD8Lv}1c(TsD3 z9KfA3N484Ld~}ZmpBv4-NY$S(KRV3ZMY1C1%B(E)7^zjz-nB{V#pXqGZNwGb0v5#_ z?>Y%ytZtNAnqePdbbP@sIm(}Vn?>KiU-EDlqk3R-{C6(kA${xggy%y76dAm)BKi9F zu4ra@KK^L1d>Nh22W0Aj@Kyit3b!asU23z&DsJDqgC@4zB_g7+0aEHDl73lp!P{EV zr-)L4ghBvACEU;LHrVxRy2-^U#Eftk8$l4;zU&3Kp$u{sy{Ha9u-4-12hP=>@9+zN zvUc6o7Au8uUxD0Rx|;a~TO(IhI~RH@TUF|TPWRVdjEaYL5vu1k(=4=YC zKqUG84&74R(zA$Lzb`W?6brBKhR2pEZ_dm^g0bXjt7W74bkz9t8qf`g`8H838xtLG z?+pd==1mLTN|zaP4XA$VD@YHxsxrUU9rlZS`!@-p(vNnx z@XtQLjaDyK=B)p{vzqYLsC@ahySqVWv{4ZL>b8i%*grjeVj0+CVo!P{I%~_0?z~K{ zMS*6HrnNop4M5+NaRUSN;Bu6^)NnjsJA2JJzC#CsZ%X6wm^XmjJ-8E2B*SdY#65oD zr>Z*l<%`FIDwViimK@vFL`gtMke)EBBhoSTBb%G+bIsfssHjN)Jr9^DG zQ8)?dto(slV}=;mkI3tEw$0AB6znO!mgP2*AO(uW@cjiK$Q)Qm*_GhW2G;HMTmwsu zxx(A^WSf8Gy|xa%+7nFG9c$?}Px-`$AAJ76{7ZX1&YwX^XgQxkYi!$}=6cvK;a1S_ z2db>pQ}JeDDI?$oF0WPO+ji^GU0Ue_5b=5j!{U;OI9P$`j(bn+t7w<#9C+-ooqw6> zpMu0>oQTU$S|L!b7rbYQzPptLBe$+eiXr5BiGGw7)BUYNt@hM+lH*UU!WW|3mc>K{ z{dx5{lZ&()M$Hhc!z%xy%%tvRj!B`&K%V#|g-Xl$*Bh7B)*)j9i(_k;Xcn61i_N032;ELqcjB3_ z=b_boS3r1gHNC}8dM*145PWS`gE3rfijJ(K<30X~iUU0hm)5H5zl$eE%M1Ord$|4f z5#E1zwn!%_!Cd7mj85l`e5d4+p-rvhBCW|F^B-odmsf0wc60q4rEca2%Eijm8u~(n z$?7>V6rZQlT5CG6I#V_3q-*dnH@}A*@j8K;^;F@uk!(1hOr;@P|B6pTkK2xtcU>x{ z8kp2?jXehe*$6T0Sy{zeaf7R!sy4E&I&AA1HDUWw2i1f7x=!hTT@eCz@i-YzqINJT z$4sjv1_LDVnnI&p=*(1wk5=ZgA}K6{NcwU+pS`|^h_62R4zF?OR{C~a6DevqW%1&5 zQWxF#FwVJPo^qwFDW9X!Qt#uyTYRjn2;EMIl+Ai6NFh*m0#PGwSsSKW_}j2;;>Th; z#Ut*3Ik9u$?U7v7#!xuqd%t$pLgwhA?^ggOXY|mU3J%GNydXOGGp0^a;Txwi^oQp! zaUmn7tgQ3HcumMqvP`A}tmuvJA6=BR5&=*=hyn#P^A`c1T)$?_wJ z`NATrBFpEnyHR z&(Sxsk$ym4fscy!I4hI<(!v7l|TXfMJR*bQEEe*Ym%tlw*I*Jv@o@hM% zz#T`P&PAFl>Uf|4fG831+Yh1bIASa_8tyN{2}P0tNJL3bQZdnimIJ|`%Sgm)Hy)+O zuRngi?}_k+)wn2t*%#%q1~YE=WN~iSJ0UcjQ`s8MxBO>{%bu|KZVmoQxCd6eg>9IX zD(2X7R+w%IaC8Sk{+upYUOotw)JnH$coZ@AM(u1i%w)>zJZG4Nj%`WfQC9xfwB7-= zHm}b<`nx-A2c1Q}w5aA1=TFL?z?VJxntPDc2b-38(38=*D3qx$yNFl+kHI zxewBPcXU4U>}{N29kf7=@&PA8VY*oWo!^0bh;q-kl^x{mjV#S<4BGZ8%WmXS1I8E=3F95>r#66_37uHI@O{|tD_*XoUI=P z8PJU9#7s6eMS{aJ$RfjQ=XZdd=m!wog!lhFF80*&qY|`Uu_`_a@Z<99e(_-3vHq7< zVBj!^bis3Ns<3yY-eNgaG1V=Ft?Tjb1T5i+g#)wHtYDQ)uM(S?7raC5w2k=7IgLcZ z%@v=^wpd@e$EL4d3U4G*jkC#HJv=U5%JyPNjHzUzsTgs74X49qbUQq^MIAFGsEEO> z_eE-wUa4~*WVhVfR86gRPZw*5hDpEO7p)^yW1(TjVNboR_Ni&X2_h8ZJATPQT-kat$oML8K@oEdBoROi)z^PMe*}9@Tk8;6hboURon)DxWOe@xZe#3!Q$sH4U zT&AUiBX3RvL~P?5G+^=O1}2;&+!f(G#DZV3Iosi6vJuo(Z=Ta&)SB4n=S`BGhTt8Q z1wWTk34^s;ndg1SD&z59R|toCpvimcY%P+E>p9^;#P7lAHtv`=MgwxntRsqLj^KOU z!eu4d5Bj!+do?IvHK^YaUzDYd#l?-ME+PKuz?$a}VZ^v}cb;J})4U`Bf1~dXwuUUF zb)nvWcZAhk*d6jacsV_t*-7X4;=Eb)oa71H?2h3~R{86FDy=G$Ii>iHxi(T-;`}qp z8#%8cW6vKp%Uu3nLC*|ieg87ySAJ*NnM0;|(WDHB#x5u-IcL-KV~GZtI^(s3$Ui@M z94`HP3(0(E+ZC^Zd}Z<4hqGH724;fx6+RqWGEM!m6XDuN)SGW8J+-TnR4@6FtoJdv z5Fyeqb@FL83gN2tVwzCSnU$W5jY0mU_AgQNt5pr<-c9rbJ&X;0P}p|| zO&!iSkwRTew5WMjDFkUr4Ob( zcM7}-yD|9eu)kvX3o+Aoh4PeeTG4X{y1iU%(}~M7RGN&%(v?3oMBoRxwQD=|52XLt z2s`}^W0-#@B8`3lcW1pb&>uIW1KW+&3^OX2lJNtpiZ^);`ae9pZ#c@fA;)gTQn;}v zdpBTotCT5KFlimpb61^~Zaz8%Lk66&e5_A3ob%EWn(TJHR1)-=Q**sjb|Gw z$p?0l;IJzMiLY@}9T#+cw{;pM>p_U!SXR2d(?3Np+iP(BX-Du=g6#NglUsM*mkTwB zQ+$iAIuw2i)~lgTR_`!vTj{1HVCo&arK=i&Zan4Jo;v4VIbx*RD&;t-JFY)>+f|%t z8SR8q3jsn;Fhv&+9|>N%)Nf$fs+P##2drrUslrhOr?0ju1Do9x%dlC$Vz|Pzm1e9> zzeM)vP6d>ZIw~4Hq3L$Y4vik+5)4#-6qeX+p%wjz|6 zc3E%?-snTuzi8v3r_1W7m}{_X1W|0AZZ*FGx;HK;3n{%AoTZ$3#u}8Vv+?`NCRNZ< z4&i=U?*#TZL0o{!&mE_`qxW~Fpr&x1nUx0d29tvF=m1k3irAv=QnW&yo*uq^qBDnV zZS6&Md%GH|1S=2&-LrixrCi-EYiME4DP0?t31cDhfVi!=MlcA*j(v&t=vaE+UTXa3 z{F;QhC4`(feGUg~sGraF(rdQgazE|NL@Y|{s7(qF%h+)n)J7eTthx776ww;^1VZll zn%pG{KYh078N8NvFnBL1u03im>*6#Tji_6`nLS(-UT2h;$&9+PTFJfuYBoswu1jEQ z8z(x`08XJ9sl*y)hMn9T(iYNYLBQ^49(MYTV)ELDz^$t1wI7%BKJ7=|TlgFepZ@_6Tqe6e{l5a;sHvc7E(x%KL zQMQ17V%51MU9a)3+HjwB6Hr!Rn%Me{0~XS|n2&CdixSW_ld@TL)Oo#LL^-k>E?6=sWWNQ+FEnb!XsRfa7&}lcB zlBj(()C*L}S}d05XWSDn>laMmCVHEb|J_i;hJu$uJ9)Uu^+#4xF5cJ7DuXT+&h4-f zwdwZ>?AE=Eq*`W$D5OqPZR3^jp6p?EF3CS)4iE>80v> zo}ne}*e2?EB=#(E`3@_clE5A#LQ8rK*OKc003RnMm&3jqKso*8^u>t(0Jd>}2mSPG z&b5DwpA&UB-_6#e(XCHWX&PIFAMc-={{SAZ^YaCf*L&G~{{YJ8^qQpCL+ROkKd1#W zO$fJyed#7;i3io3fGdX9HU9vI`Wq3g_^!q|2T1KVUP<{zFh4r@m&86f@WK=S00}3F zTT+v*)}5|fKSeEGe|7O=!xrnaN7C*tm~?42!iV$R-=%$?1BENkSjGOIh`z5;?2olI zAB&$4VrGj=(ltF~j*mN~lr8uT&-z!p_ygh&m+?D6Tk8u^rfQm8X$|I{A9F&eNQ!8Q z!FZT)yQn-Kai7h926)9hS-Rg)woN|lm-0~yF~RPe8ucyfv*__=CgF z@n6`sP>hvzKYA$#baBBSk6dAAoFuAKcxsXNme0(_y(v;_pGWzl_kZCX{P*4lu$J-n z%oue^2_{@s&Pi`yGG6?*dur8hw_%b8b;1 zTA(PV0CoU$s^u&52R7Z= z(R~li-`aEGhO-yN9WTUs4Y+Gh8OQcn{{VWhBsccSg_WI%#^^xzK3U+JT3oqeTx8d7(#F~Y=3+o*J z0MFI0&a3|bZ5MQZMz6~M01wRf%CQY{Ee6i^EX^g!a*6-|ph1!!(-`{K^e>0Gr14lN z(sdH{P2F-w>!j}&-+P)Nx0uP4f+V2h%~xX3}1 zpF@m;T6#sMqo!+eS$NA?)VwkFHc1*ivdb)K-}O+Vts=E#>CLWVDBK$jiVyWr^r9(~75~8%;9R{86ju@oM^1QY2wo z=gQkAH+<2qS=52)x7V#ndGQCs$6lXM@PUuQ=JQ}BB#~oALC$ugU?28J){akqMB7T& z)5}M1@*ze}e9>2FZ~9qX>%PYIm9C>@;Z1A98jZBJx4M1xrRauKDC)9d8|6Iu8tIO5 zLG?Ao>wYMZYnpbr-Y}2Fb6v!3?wKKYl>z&);l^A8%CW)q12x&67=za}@tjM0)066- ze=g}iy&oCH^xAZ!*7tig**zqa-CbQP+*)Dhig7$sYvpCJ@ya@$x8O|&z?wgY7sPVu z+FqYImHyFtaIr?lUD?T&Q^6pEfJa)TtbBO*L8e_vXD^Cv@9v@nj2eD!-wW56@`LeV zU!R|}MuQyM?w6?CC!23^14dQ3K_pF_0TwGyc$NQXLYj{$sk#jahRKi>F7E9 zEBS{w@Cyrw$6|5#j7+7>b4m$!vqjAd+i9 zC>+zn;ptGL3KUc2in6}0$Jf(?)wg?w2x{QIAb4czP(`oW{{XGD0y)My#K+}qf3n9P zL+xIO05xh4CnhVU-EBIU=3iLcVZTmmzbnk;gv3J;4Q%yY*H!aJK4pN8CXA@huU^li z`5!pPqFMO`8h3eEWpRuK?ZszCjQ;?_Re|IYj2`vrb7(r1j=6K;*eq@?t}Wkcn^;;+ z=ze5ycAgnXIrpxLPlMkPFXUUvG~;1yAp^|2kx+&MBjjAL;CB`F*{_PYOg!9OqkU79 zlKYnK{zsdK@PaU<3b?#nbz4PWeYO7pfOa3V3}v-X9$U5nZ4L=B>S4Q#{(+5qNyuPp z2jG9h4NJiquDz*h+O56hnud*c2!`G_8+Niv33kSQ?@U)hR1gU7UXKK4^s^dOu@t1F zxw$&kfWtUOIVqrpqykiu8pZYIfSK!aT6s?%t6A&Q*syK_i^wy)^Anih@zO3i{WBpX9iF z%L7|4#VgaP7Z>RJ8P%kv7a1)X`RDsa{?b1i{77|69SY^V6QbyMzDw9yFfz}BfcJ9_ zWhW!#2g~#at$d;X01NNqEO9>lab_V-1KPpQ9-#$&d-1!&8hYz~F|@t7j?yho2yS6% z4l_2~06j@~{{UL}cJIS_jlHnDk^*hFh}uQv1Ghj(;1gdvKMwM2H7ZzaHWD0>in8TS z-QC)`@D$|XQlu(wq}NTlA7%c~_ueG%U%_vKe;qy-T}`EFel)O}-%W-S7L* z!FT-s04Ts4PZ`a3KMVXw{x0w*hrDxfGCk{@-59`8=Ud6LP6q>U!(e*X)PJznwWIjk z_9oT*C*o+WCx3@p`^NyijyYmj?%YbhETDmtEPxz$#aj5I;jI$bY1e)qw6e9fyNXEU zO9-Qe6CpX@vLF{E9(e6vh0{YvZ%ln<9`DD8lE2T$kr|sZQi0jT#OMA>Hh$I9)6gwX?$1Zxwbp&7>6YES})Y| zarHI!Yp3Qw$Gt>(sT;jVT+v6C$PZIZ^1jg&_NS%9ZyEmpfvF$XoO;yOMUIYLza6#RFsU2@&6we3>ajBL7;W7~1XX8~Mn?isd|Z?#E976LM~ZT6`# zMN2|=7#5v@P6IUT3K}5h{8QnpKNom<`@^^31iMe&zx?x+d5y>WtOwzdU!Rvcb>^RU zai;1K7_RK(Xr)8h(BS?DsP?bYa4>UUHvDq%f<

7>jHNYMEdIpskT>-xVU-`%%m*7^`od2R0X)E{CvKGnsI!+TmzA91w`zRpTV z&|Wdt=kVu-?=Nij{iap&A`CJ&OY-tN;AGd!@Y-yRP!58(H!xAYaulwsT{UQ1SVC$@HV9e`YQ}zoRhA?91|Q13-W;pRjimg<_Gs(r zao(H-CrbU*Nam8V8%-k>Ho4+06TsdJpT+vdlKRw*eFcrZy4-!43reo;?BQIUp?U$& zt$3!l`#{M(l-8Ozf~>3_MImRF_Gz9ne~18xIP^SMoh;@mq@beRw7!=7%^`$tDsAq2 z8GhBRbK;499r!0!@#VMtgwOm-w4vYaw@OEw{{UfR&qZlmJMJsKO~s*-J${31n zj8pUU>(sbmT{)@A?fLXRfB1`KCh%D!(Lzj)4^Hn2n{n0kXxG{e@o(lux6_xuxsU$b9^-YL*7uOWf$byb~K zSr$K%cvNI=Bda0lU!y61q@VeT+JE3gfBaQ`4F1YGgC?)yEiH~#SRkBzV|HKU_4bsq ztZTTF`PaRG$iA@-bvt~o`kqE2afI4x=jf|mX~*v0+c(l={{R|bnm7LdS1Lczfgh!Q zYRPdU9Pe+=nzuJe!c6P*V_u5kH~#=XKM(jPBd)t2rNTL1l^ec>o-M9%{;b#Lta{F| z5yV&Xu014S{c7ZXDDgah>h*iI`cD4<<5KuaquOXs6AsM%9x&VigBhlPz+kBd>0h1^ zcmrrLZozPGshPL-s&xZ28$)2oHtFi08e{VVy#n(E%C z?=@?!5=WPOSaN(dqv;VHv+GyQCOu(?2oKXOk@T-Wy`FCc_`6BD@crBA+Ks!c*0M%^ zcxQ}(xqsPg@(%})cs1U9SD6RI$aF2hL!s$$+S)PUB)DWQNIfuQZMo@@Up;82Q@+%# z#;o#7aizs7m6MqjlXela&UX+wuUiKxMa9SZ{;l|)4kg8@DSuzp{{RE`-{5z|YySX> z-wpKN80xk{%Jx>43BrG>l3RAz(nbEmk&rtaj@9c}!Od`A3A`o$003UKs4N% zUDOP9I2vYYawsK|W{uPUNymEPykqeX!d?S`n_IdP-R;U+Lx!2N(>;`XwQoZWI+9SQ z8@JIDIi$IpvO9>Ql4cutG@5_U*>imGx6t&JbQd^d_#6*MJLOJK1gH#0Cfig>(}16-~x}7Q-)iPlex&zIxtkJMMW-%`8u^~RFjmQ*&ip`q#FSFjZ157cYSh}b`ZVA#D@tSYF*g+068`F zr^8R#FT~y=Saok6%ye5O!wY1=xpC-vgwMWDu&+Bh;v6PlPugNC8%Wi>MW{!mXjW5S-OO;Y!iCu9z6U|>c(11+{g-te3&D5ZEb;yGS=`2{*max%r#%{40|9-6lKe%s zXZhDvEuzxc9Y>xhfn&e%ccJSdt-k=RAl%~Vt9j6bwBOJpX9>3>G9?T?|-=9sld3! zvR`@PPiU3Jpn&< zncD)N8(`wUI_h#pDEgz|^ibP;HqvE=B#L{tou`ph2bF}*0e0$eFa~SDz9D1&6a8== zf#d%G(Mw+Y@S(Br+dv-IANoD($b3WK?-h8%;wHAb1({27@hq2@IgZ*t-6n8)$iQ}A z#ES5+Gyec?uH@{M_w`4g&+fvonE)po{^<{D z^Rd>wFXJ{J;?If6?{L4QYAenH;=YRs{2G7ZUy?fC#QjaZ3hoOlsU)~KR*a}1XPl2p z>fqJ%*>D8mPy1OP%vS;c=~93(Q#vY7Q!2C+pE2ocb7tKxBR}vVR-uf`{;Lo4Ijw(LOHLQuj4DM>Y2tq z*FXM-YQ!4Ww`qfKs9Y&tpS)xI>s>rOr0Ov)p>UF|&Cl;8bd{HLvB@~jIr>*t4-NHq zCnuxqZAyxUw>f?@PagHqw}owg<(2Kqf4s`y&1ygRMlP3#Z^0w}dPEQMBE1i;)9!4l zBNX-Gy>sD{X2Sh@`Ko{M+VfZN?!DGItz-lJ^!|psZw6{m_+A^hd`d;DT{5&S9%WVv zFx-KW)O8idn94Jx;}>_^=4Xktp!r$r#Ys4<9}%MsGbg6?{->;6la=)P$TR_f3~0c6@4xiqxR5VYx6pA zw3jqk!Ho2z&lGc-UA%K&r6cVK&lJjg3{wdd08vF56aY~`BNT>2UZ#K+ntJh2na5#K zG>qL(d_OJwrL0p$EXGM#A^`Ha z$z~t{k5gZq39)2d;1TIuCxyNbd~NvW;+;>xeivKadVL-*woh#}>l;fuDO?DgN4o%F zhsvOzYWfU@s)Xd$_P1lR2`*VS&)JPTRnfJ$q`1(v8*Neuq%l0Uvn27Xi-J_T5;8I} zGg=`S9+l*Ov){uheh_>uvhXIMb8mO4K|J>tO>C^F9#-Id;4#{u43I0?cGua)rP1qJ z=ANc%UuJ|U< zF74c8jIF@($jJ2uuk0eSJ|JS(;Gd4~KZ-@LwNgLnwZZ~FBT-g9F$E|u%LTdj2myiJTr1FJr2qdhLgkjvyF&to0 zc~9wDeqU-&DxV=nca;8=h7;*pCM;5HG@M6dXL(FJQWQPKX?bRl#qClx6aUh=MHE-} z4>9=1FreV(pHb516x4>KJEb8fG?}9m*x0V$jZLFY7V7dQ{w0ma>OZAbe@cf={{WE5 zOyIqz*QC-tIz3O|?hRspVIHv~N(!U>ZKW~#R=9~zs zf?Rc{309Sfxob3JXB7}^jMC(1nwE#^NDVk1aY+nN^LYlnJYWS$bbLg)be$jsn{1dI+_-|A? zh2E#9kSBuL-|i9vDBmGfIRs#giu?Zn{t5H(cSHEM;LjaPplSDZFzK4Lyk&06ZzfB9 z#Nsw!I%BPWJRUajcCX|A02W^O+r&1BWvJ>)2zcUGNVY=>E4Q{g0A-n2sR-xhLJ0#0J-%l##?+}77qRoWtaRqN z9@n}3Uj-+%J7=|E{5|mbHy<@2 zIQFZNFUlL#QnD-k;a}JM$K&cc6gN`!8LI{Xjwzl~0nIy##8gwr?b4fyVPPnumj;!B z!cj#Oz$l`MLJBCNfDW{zVw0sWUbSen1CD76g#?~ODd|<`1i=3QLs2IB2{nCBK!f3* zjb~Js$$}{5Q0iV~`>Zk#%eu7T_*JjBc z)qkGq^$hb#yR~`sc#9t|`Fj5V5Ar&xX?A88%=5R3lGw{4zyIjxy^nicpDl0-i z0r);LGo^_StdhvzrtDW;mY|dATyxCvGWe=Ly`8l3+&q35FSy2s!(JMI0S=RDfPd2< zPwQVRd|dGKS~rdw?$#BC(^$7z6;VQ{o(v=44^%3D3i|-&yienx7VXwzK?K{ zu^;osPu)xS1#RDjd>`=g#qVR|?n}#xY5)QQ|%x@Ri23X(ilO zP{kZ?<*+Ku;Nv;$In7(~AA)DH@b05~r(Cto<*W@Xaj*#+`F}Gh3(p`BIjg<{x+#5k zr;~-5cLD0iaDPKvo+REF^oerkKTd1$tg9-**1{^2R(96! zul1?s$pYQM(+ z@0@|0W2QYTI!#UTz|NBPljMrj>-5$$>G1Q)4W}O5e=Jwse*?9q)&3Ddr^(E|64Ye1 z$>jZ->~BxNF~Ir?{MPVaik{-)>c;5-a6H6S7(tE*9nVo;W&X^5E?4p9qu^Vpk|;b^ zrpS>fCkFD=8DnmHk-HV=V{ns?G+OgJ>EWdbxXAi?;zV4M!nm&y_|wDw8;m}yc^phe zRx;U~C2)aP{$vBE~xRv89I`=~!N=qld9$v8C)g`KXOY%Oe}5X1>M zUw*acyhki~ZF8=jM7|;IZF7FX^?6m)bhe8t06*<_bMD-G3X({|J{N@mV0IO8c;wVK zBjs1^oL8SJl5WMimq$GzTVD~9yho^8hWg71!T$iqSbkO8cyi%l)Z>Vp34ufS@;^~r zPPrPSHX4q0{fgyxA4sPC+n>WA{uSI?>6Va8j%JX&WH2KgM2>#> z))N?_uO@p}gYRRp@hyu?NQQQ3B*%1^6hF^3hpbr3VW`|$$}nM(kpBR{PCr_`4YjnM zRm4oga1N(E3Ff{2FAVrQ#J2YSA=I?db8&eZw2WktL2>f7R4^SuuhD!G;srx7t%RG> zak}-3*YJ`0zX@isg*n1nt=G@)K3r@L;-G1wUa|rB*Vc*P&jj1^28V7j{pBzJ0HG6C z6G+gYAGp$N*B|u35AzlMMzaWXO!{Y?MQsLrd*|D+z>!J%)5KFsa}-Y}`>Id!ud}0g z;TvGLf-meaD|L|6yvkq7V=UgI`zZPA!x{(0&kaZp zi>Ei)nC0!RWI+>s0bo@7j90nXPpVCB4zs5nHtYDJ^HOE{tD!u7aBA_%Jd%Ep5C>aAJ7LsRLkjH5ez-q`^I^ zTv4(m-SYEK8$Pta3E++?z=6e2DDHYE!X^pvA4&s_={o-aH%3#~isw4l>;C|cz8-jI;x>%hPlzDP z08BHW0%%*+5Az3uUzuMLei!(Q;f}5LEmb`G8HxKn+5_dvJrDO^)lcD(U#9T)7E{J0 zPOrOD@P41=`JWq#!MN(SJm4)9rjov{=PHeYj+C^On`W^=N(XvMdi1Q@Hb_I#fC(H? z*NT-!1!pE&Ll@GhU)WpfHt%h6o8x{6Khx9eS_9|gaf(xnS2a}`MZ#+7=+o+rAEx_BzQb9w{Vr%Tl ztXl5k)gp=Jl+K$K2Iees1$d9`E8%&dyztGwu@l1JTu7_9X9r-%2h>;cPZfA=gvxRA z#o=muo2XlB-$d`KT{O`j(7Xr67>vUaLj_VRnWmht*>7Xwh;-y%l~gacde^kj@8{w; zSPxeDN9$gJZ>3t>99zT_`I@>T01)3P9joT5W>?uC(b!%WNm_Ebf5Kh~)mbzPt8$XN zYS5(C1P{qO>+>6%o4 z;6tmzbM$0AYxP3rP13uLa!q{)2V$o`Z?4i`k@&BVSzIv70Vu0pGhcL<`JS2Ke~1=- z2Kaq>G*NxCRhsTW1ScU26knal{t?^`PeJNFJJ)qxPhI%0;q7n4m#uMjH49lLoR7I0 zN*`ouKI$mwqxth**Qha147`JaF9-+g7(ddye@D^Ix8waD)n8$j<}?o6jl}RzwrlRG zr5JN{KbzWZv}^onPZ0b<@rI$}p9bnv_6LF=^eE7yN# zZ`vIEPPbo%zCVsVC&yYU7}7AJC~u>Wf@59+$Olv$9;@6ATV2rg4Ifv!(sfu(z1$2* zUd*}qT@Q6`p#K0`@$ZN~4bO_qULf%O!)bmj(}IolN5OniEA2N== zcyW+En`PPZk;vfJEa73Iw$4p_!F~H5d=b<&JH1ltPrP|ACX#kRdm_ZID;8~txWLIc z9joe4ySme`1$JRGEJhyggxqhvosDT#lrEy$x$_;T?2qsk(p8mwHRd|>GTmIi>E>d) zy({2n!aW&wSa?rP9rGj$DdXNv)$61aOe$-7+1@sywCm0K$-mUm)suH}W;|L3hXNR5 z)9scQU95`=DwQJ`AOYIGK={A#qU*yl%i=E&hnGi@zI7a%a@~$UgVDbO@AkGSlUhS_ z43J!+$poraL~)g5VS)(s#bJZYYSEuMcka81ySCn^zI%h*q8#u<=#x zpM)f7boko=nB;H%Fo(K>z$UnUwfb%zq-6y-t6d#Yq@u2sh2YZSowpRAdQ~|UW5vEY zX~OTpt9GlGv7IE(`!Dhz<}2g{F@@UX4EopTSBUhBU2jIa(=QWzQ=tqx6Zuz>=-v*} zqIHWz(o$&;Lb3Uc_1xLV*ERX49A~vKRVk=NzI60m*Zev^M&Q3-=+E0m?k`6ACcn)y zlJFL&+Kzyhv#W`$50(q50pjYVN$ZZEm3HccitTj|0qgMS((5-<+QT)d+?Ik*HsVlD z7yy}7z~__Ny*I;Ov+sy+mOHN(UZ#@+fZjwHmf(Zfx7=UDb$)GLbt45^5f5k0s!aQw zE;f~AFJ|v&Z`AqtkR2fzX&m8EL1ts<6mo0Vye;ro#-1ltp7tT3*taH2r9Nvf`w;!? z{{X$TSJmDX@Hd2f9)Ng%Pe`XD4RI&$CvRNH*&{xK1KPU~tj(O}yc)T!^rpUV{EnF^ zZ1g?}9PVlGBPG36ir0@y!5Yvg@O#^+1EpHIC_mln>^ zy81Gl8us`vfU=yzrCdfPE!4f*>91eD=f?3abBx2uQ>QCMuj^B&J|oby^ptpeMO%4g zibxH`#LqmwT&uOPK2!vcL0l^ge5=}>$P|DaSMIq^PlUkJbg-1vBHiqd&+#~k)t5a- zb{IceTpAZS=AF%YPDW}YaC%gcxs8GMrt?v|1j{kN^{H|WL!wd$1b3+Cbc!~XRtSnR zNjLy{lU{kNd_&W;*nEvWe%*8iI5>?@`%V1@YsfXtds^`q{DkMsS;&Plv5)u$ebavdo!@26-F0 zx6pC!D~r)S4*Wm(jil-~7joX(YIf4I*=aVih_t~g4ZxVAReUg0f?S;On#^lbM*xc2 z@XoP+;Qeab#JYIgU}ah2JtTymoAF<~4Spw%@n<;1I;s^p6q89swafGB{NJjv@_39& zt2Zev{{XGdcf(U^z6;XtJUQW8v!&^m%v$E&G(xjK_oOTfGY-U)j@7RQt*ykMMS6Jk zjX$6j(0GGNj?2V1@yCM28+Cd#b>s}$F+DoH&gHq7vB&eB;V4j1h$FUXlm>wy|PLigp%lBHvUFp+K z*3j$v-AxxZ$JAaK_=9b!Y_!&qY;H~hI3&X%K7{?#>`&0wt?2AN-w9!!LHgIpR?x=4 zqYQ)85yg6Ug!SD|OT2xRfmG0i^|gi=h=A-EKn z6(JPd8ZJIj0L2viW2GkFS_o)uSH08hwTlalCsB{hwY)}+*V%_r?mC0(R`4l6!OaBN z_;Xg!F0>6=>qfi(09k6~bN%J&tUZ`u)pfP?udDoH;2Uoi%?wU3{{W@wbAUgI z7tCMHb6E{;Vml>M8+tG*e_B^9Gay0jQ!T%>Yb~(qD#}{WHe(B3dR2wB^%b(VwJ5bd zlnBN9FC8jXwl%UX&U#eKZ9vRHYwc34w;d~RT8f!%MIji1*V?38ZfkjMhowli!_tyq zVXdhnrB>8nFq;dEW6QZfevS(N09xx7)oPZvYzC6zY#0QbuZ>%dmz#8a1MzcJfJ zMkpt}Cwf}_{Y$BxNSsHToEci`Owj7xv0-)am$*td;fPt3^Qm3*-kt6kmO+IE^n zQ}oVH>0fks>%txZ_>j6C=Bun~dNuBeVfG|QB8Gcr4CY62t%nR)_F=#rSCfCjfK<7A zy$inuxn0|DXKyWb+Q(ape5o~SF28y4CYxwA z?-t%&w=n&s*n6mMKT7tz)o4Br__yIynekP{mZN2KcH7?R@-&dN#1dn0>a35n5;8DG zYIbi*%LSx$+?-3hIS(F2Wbm}l*U6eU{LbI&UJ`j9TKW&g2yQ$tt3o=6)&BtRh7J8I z=f2g;k6!X+;n%uGX_z^upGr#e?psH_{>_?+v-py2GxFY9PPq3%u0KF)?z!(boVRW( z^NZl^^qOzPZ6@kC+EP5_+M_4uiI@4B`e))v*0js5Dl5sU^~&eV3yZn84%?Ti2i zx#qCUJV(Oh@ux;{bDW!esimiWy}KR;V?J5f_s!eADd4YB&3uavt9+kw>S2#bTz^{H zxA6|LiVEM)_{zum)Y0+N0c+0>HR;O#05*>X+iuh8#dIF1}v%pbG1yYbq};$0thopYz55nj1uf!uN-mmhggs0628nFrdw5c~|( zJaS{#wet2l5}bpKNpp_T=c4+z(%*WK=5=rFO z=Ui=^;p$M93B^kL_K*A@$n>(WYfh|c({_KE{OAFc77;A>dzskSXzm;PyYOXQb=kN7ta#2#6*58Tc z+CPjgpxfa;9UazaPuVWd%+{>@@_Mu^dXM2ej^e(U_#xp3@sGwI4CuOqYv#4oPjeu{ zdDhm0&&fR-DuF|H7_Fa#-?7c-$IlBtjPHC!{e@{1?`Llx**x@Z|SQo?jj#yw}K?8114mhv0%JK@cojTl} z?bH2#6XG*0{*+--inPg5-V!>t7`6{v_9YS9;oyh%X-MW7Bcr zN1~2^dkXtJmx8O}Z93lMe;4S#R(SaA;+-3)`_lYA^WQvm`$g(nfSXJ3GzU+%Y^l1C z4?V~GBcgt%+*i%A%RSW3cX=aEG^|;j&RCP&lS~6>_N64(>p50ShQY^{gl*^5{Lhia zLrI*Ib`JVp(CHlX(`y3Yy zr1`7AlK%iR&b}UeBJelEJ7At3u?=`eB)ppsJNo>`?vG`_uTJwh=Aqq9-CvYqYt*kd zRYCa*;^z5N;I&u67&pzj% zuQTx-#jlFiD7Cv7=7!zN#G9Q;u^7%(hE6Md4GAf!wW2!kc$H41XDyRHX6Z|jn)b)= zZ-u1)0GCIP{n=ak=BXb7cx{Qp-6zr1{{S_wq58x+-1=po;oI4-m;4jRGwnue)#Uhl zqaI!DxfwQ96GnykYN{{ZFU{C`w%PcyOOfl+dL z*Jm%o{{R<9k}V^Uf3Y$j=0#`A;U5z~GWu+%{lFjj6Iv>e=+V4!R9@3xdO5rd(wfkh zNY)|Go2T3+Jz{V0sxFd6ku*&i@3~0+b#d2Yb#9Dqaqml-h{+%KsmjXT4PCSwi`o}ktg>vL81NuQATh2m<+v2?J}r_SXU zCa-3d?R}q__m)ca%_t+KespNQJn{bkhFMOF;v1tH^n&P%sD7A}ECi!K$9`NCn zh~~Vz!=D&F9(b9O8!cWdYncuN7hmY|=hG5kAHbUQ(8Th*gV2tjokN4z{Zj>);qfr% z!_-n;(@gN++b!7q9`LCwer7%K^k??Xe0&)2bB>2!aDVU_{{XFi zapMfezn3`S{{X_JRpaoEZK@@JPk;b7BR3d-^uL8a* z7|L#=r5)Di%*J=oA3^*t8-5#TAMQ+l@-x?dCT;;d4%NVXHEeu1&;~!{oS&?WYqSkt z8;(!g)PLT8n>_U`SIF?qcjoaHqTlq@tJk~+L)&<2G08WB{{TXI@x5ig;(e%p*JiI{ z_;;6I3W4761OEVhJ$l)Ky$F22^p+T(>+eU7{92kP__5-mWL&M|E(sYJ0CcV)L1B@| zs^1scCit`B`Fj5V#LDCJVk>}Fk$&S9`VuSY@OVe{dV2ks{z&vC4|-mwq=jI46(9#4 zE6-Bu=|1ew{{Vd&yqdP782M%=>H+?huhiJRnbc}~hMhityZvj3tr>NT>xf?7a;%Op zeqL)krFBuJ;QPPplRIL!RfG9pRP%+s>@lmhT1E6Rrey`k%A3jii>MNnrBBw za%(X)jWPcK<+k6dky?Of2dL^R&XVqP$uu~1u5-kgD{qmUZmy6F9&4TPAKkbA0D)Gx zZ{j0JtqxoTJwcN#-^^EjBSu-PN5O+4_%8M0MNxKK_z4D(Nn zpDlisGys{cJL2lx{!%S{UR6m?7fvr{CHb8cq}7@|LhImfi%??AP}8rQt`=s9{!%Ec ziGB=xPJuku(Y)04isBhF`95pu@Q8c!O=V7@uzihxXyQB;!Oh;CX-Dz=&rRp94X#W4 z{{Y}08(;V<#oBWbd!pVZJNYcbk9EypO=Wd$k#z)+5!y)EKbWt%gpQ|bxdZD|?jVZe z{yr2<{`bvZRwKbFr~9l;{bTeuMa;h)fJRMY$a0Lb}4+_3A# zN5y?5bMSLXe4BfjBPX!|hxDuqpMy6;Cfe~&`^rc1&3Jf^46~YVN<7|;ezs>_EZ+|$ zy}eHj7|tnhYtrt%75Ih*D->dUcUB+Q6^nD=KNMSXY$ZQM&+@MlzX)cuzlnyQ{sQ0S zb48fp-`ii2#uX#2GGs~xbK zYernstY1~uw7nW3nYBC1dr0%pJ;aL`C)g8TxnHt(>|=fVH+b*G-xBrj6h*D--YT?$ z%j~wc%?-V@Ffd?4yR*9;pncT?-!=Hx@Lx`cU-;kf_d<=sy!!2fl?NvgOs;tREBbx8 zZ{8dpE2{ytj_fovxyE>h!2SyHQ7z&f8s6$toJ+UMlzWL2YksxjTKDYZVB6;KjL9%ya(x7ghaZS)`b_$YjMu446;dAyJGR*PFI@Ovt9W4UjVeZcS+%WPD?lE5Z!+IKcHcUAC7Cub)SYlInaa2d8Qkf zQ~p}oH=MW~%8WSttG=Ep64{iRI1rT}T5^;uP#>6e2Z9fw2bz>FI@Zobq(!#5Ka4Xq zm%#g|TY;*M$FezL|D!8kEJxlT6;aI3}B9w50uUFN$pKAky@J$I#L+3 zO5w<6c}9;av`k!5c~rSeQ9u9Fwlhmy`uL|qmQf5>k-UfabA$O~HLV@OOrevIYx{Q% zh^1OS(u|asbnbpTTMIaQtrmUhb4}j3i(#G ztb82PY`jaQX*SxUPVZ_~<)l?z>Rd4L$Rzxg?gW8e75hN=6JPj;;VnN!@XRqyewu_5 zD4IbU;|#5qKF90nUpd9Fa?7hI?{CT6^>P}n7}`>c*8c#bJ-b8K>@{Bq=zc2HFRZLT zwd`%KnPi17!tqA@GGt5+a3@$6VySmnYVxI@5ooDtR7cYzMo@X*Fma*DPYKpi} ztV@Si?pGN1ufZwe>R$6!NAFR^&rWz-`t&~C=8D4brnmn92|t8%9~2ctZC=U?rdWw6 ztttj!q>^wBM<%yx)Qe4B9ckSrhXY*Kjx`?;>0S!gbzcgz4PwsDX>B${CSx)wWdbq? zY>|v$*GEdM$a1FyccnPpG(^*A{PO<*guC|A@pYc~MynWn;g-_hPdPq>#~TcNYwy1d zXc`uu;q44vcz;i})nc)<7j5=f8gI1-SI%Q9u1gs=65EbXdf1ZUX-?@}xjoHB7>ebg zhER7gqlLG{sq?qSpV{wO__O0nZx!j9-JX>KT1Raxa>XOa%^Nuk0^xcv9G=zKdU_bdf59;S0BQdK05#?B!as;wbgd(A z31N4CpwBB0y38VHgtzvu_7KR;5O1`{~O-f>g z-Z??f@54Y9_o*a~PaxOUg?7kx=dFBsSZUAqdPu<$&WS6=z)1BG+^+PeYi1nw6~f zq>+#W$WW3#hQAN=PuNTL-uN4Nq#uVKG(INLZEm2B<4>{G=C*=OKg|97Qd!^r;e*6T zAy^Ji2B}}6%_Y6ht*8G0f{uR9o<8_%t3}~&6<&BF#u|o~b0(J_rD8;p-I!RwRm#a~ z`H%pipA346`G@vr{i{AWe$Y0aIrwMsk5RJKV6yPA)-9%v#cb}D>KWk=8~J1@3K@a< zMo9Fpw7h5k00h?klDtD_eJ+jRo1Y3^O042dBKCOI9D;sWU8INre}zyEFfm_`Kd~0S z4X4M6{5yAY1@^C?>l%EKzso0^0NsaEf(a3pBD!!@IV(bU*`Shb^7qs4x%I-0paa{A zekoZ*Zqfe$b%^|^1B(92r0*2Z$oU*}V`!xmVzd()R35a$5*m`0f=W#bFHUK@SXJsU zDf0@(c=V#Kn3cM4Y7+x=9MvgYP9!}j>q^S&)KYfIszMTqD5M~z&q_)vkw`EP2a0Pb z*x5a)$2h>G3G(`8j)cW4pH6r_f3$c@NtB+D1kd|~uk@~i+;d)G@ax7N6}Hl?{59f> zskG5$h#sGtMbG@m}7a4!kvNe|;s)r`|w6lGUW} zUxgz%7A5}x-jUDs#dyyfPo>VOW;Elanc(E(sl`X;CwCR}Y(5{*E%}R0fPGhY^UY`_ zwUj(VX9Rfu>}o%yN7rM~7=LOmkv?Wbl0*BXc@O(4TlrOeW5W0T6rF`zQ*R%}QBVW~ zq?@61i-0s!krqaG3P_9&895cDVbanf-O>%x-7&g5M~|`X_wM}*c3o%ZJkK}o`~I*^ zx_LW_-KT`27@j-7>yMx=K9oB;go%VLKMxL}-VnV4)csMYsrSS z10wI0aX1sT^PTAT@mpnslV9F<}rTu@(3ZCNFR1TJB$WTaw0?SWmnZ1Q{ApO z)RAGj?E_exDIk~h4t_buno^Y%NN^kF64{V1=#}>ZSq^@m+=9{$T8Y$8 za?z#hsG!3m`2}SuNH6F)!TG9bH{|jA^V0KEDRhjNy!6yq=?3XL$rMqO2R?LjRE@A+ zPhKo}qv(DVJ|JyMDI7yS0!*!F(`Wgv6Wz-fs1siIY1KJpy=E}2XDyu(B-w*8E zNRI2RJsSX3>L)>Ixod1l?{2AS;t)e&iHIArqOa8W5g zD3<{@rnMjEQ1cj@mD0sg7jRnbX)X;Lh1#~UEid9^;K`NS4CAer3E$8kY;*3B$IOgQ z+B`<>T%+tYYGuh7cl4GI(mjit=zmGc%eo97&!(?U@XcKS^K^(OuA2Ub2bY3i=!vfZ z500`C$!KmwdmVpR%k-uT-#*!t2{Tu4)lVmKs)MLfq_6f=|7sd7KNf`^Mt!x+o`Z3M zKt~s{s$C})d%bY&0T-5&SgtV2o*<6sQ{B5r)X>zOkGb@)0qoSWlldR{%?u?FKh9EM z;cu30ZKRY?g>C<-l>$cw>g)8OJO94$gM;QV*oL}`u=#h>aW{?@_EnZ-T9*O(*ar;z zM7{3z&oGM@_L)QyO>^W<{;}T@@%+Rd#3o(TF-+h_t25P=rwjp`e>DG;@jiVCjyFwL z{57>Glxy#0g%SD#x!gB$hvFN0lfJFEN|#9`vlaOfh-;+e4RDX=YtfsR9e^${w~{ru z$Z6415$B?}!KRK$K=-Xn42>VT`9R9@ls zX7s1p7h61|lEpqviP9*xG0&)P&`+loG# z83B>@cvc>&v)P|NohJb^F~_4*Xy8T+omTzqRXOMLw%U)ADpM-klrJIN%pn#FxyVqs zz9naek0&8dz^@FC6UQvE03bfY;LfH0T==5djhF}>e zo|IGWD%Pm^IYg*%*2{P zzTG-vCYX@ti-FsKY`|P=;mxqf{2Ii@s9hlW_aMEk~mu_+W^fE4y;k(_JP<^;acK(^UTBv3xJ|bdxT5%W;d^ugPD&0r+zJT>e*uj*RhMzh{>? z=9LrG=BO%>i~QK?IQxUhRLi4e$?*+yU@Vq-bbV>d!?D?%Y-ix>uM`N;w!-s%$^CM) z!o;?rTp`5NVrID@y>R@c9MkY~sAz96xh{W_bZkOYGqC1wpuRFyNa$-C%0kW-N7H8C zsiWdar0}MEaqbZ<0$JeOHYFtV+>eDqVd>T1$BJ5yca}O?0%9S+QaUIdmZ7I=d46pf ztTiCPJ^%5}7^JYPS_h>_LpRkTfm=*@00$$1w}*B?^8m%s0MIvC_96&SBOX_y>d}41 zrN4;j@x@UMI}1Q`;Vh!#T!CHroZ~z^9TG-DV;$Q>jSU2+r8U z@hNL^Ilk>vM!y`=<=DHZOqOL>k5S)zwAER77N*uIVT-c4mo zD}I3=>#NCUGD9{T3>-3fo^cH$Co8e9K;=R5oJAEX+28V(T(aLl3cDFCqmFyCie#H| zIF^=+Fxwossj%D(BDfVkoOTv^4J37P$#<$cPGHK7^NZEP;?yS6vDepkY?0+94$YPk z-g8qvd26&@%pnoaT}Q&IFy#Kb`36gIqs4cV=X4w2kONCcD4NbGv|)Zy{MhO1i{aY9 zlA5Ph|JTj*29e*(VJAq=wLt42<{q)ia7O8qEIKCIJDc@^5Ul@GyTl-6hVDSs4MF9u zhP96!m}6YUP}HRTBPj^O><`r#qsh211gfEYjMY ze`=g7w4a>vN#@29T&%xkMDhGX|4@aeX&Viu_3cBr^Mjt_e%Zjqt=oYs=$4jrTQy-& z>s1W0xPKkId-}E_s@nn=ef6F8B1i{pEbL+jv;Eui##4Ej-2?xdyXvtP%nHuFRt9Kmw83s(q3Rz6 z7H_UaVa3mhk{7rc$+A3U(+%W3EN}KCfm-Q`UKR`piAY(scr&DzT08_XcG+<{IdIGY zZSOC5z%7pSk$o6j$C?e-y&mjs`*iHD94RjYsWgf?X%(e(pe>Kvdud!T5BPvR6-83Y zIjPj~%GzwG3)9Ke^CH-@ttfW0q1Acj>gyWvG1}fMFb^fc zorY6ljpTmo?HU>~5#4LdeMB^Q;{j`}Yr0TYc(daK4!5mOQ{j3S|Gt+&rPF|r0O0OK z=?=bi^}0wQ`@>3T;>I26Yaz$`kKDr1iv~>JJXS*+<;EP{?pz}oD>lIJQ8XU(hLHQ} zaqF<&jE~@l>Q#+<+>}uucc>7V-Pgw5!^q*k68=4~;o0AKI_!4T1V#qnw4ODk9O23; z_`OP%@Rrje_xw<<^sb@*>8*ITLB3k1GPCS#+lsKDYx?S_bEUog{9Ojt$L3RZvuEG* zUwYPW3CCHp)0hPIeC`F1MQK`Pjf;U~A=yX`+EJ?aqM6VY?tD6RyrOLcn&?nd*% zOZvRm3mYU(d_Hv#+dM&!4=BAT;@OXv!j6nDA-pMAxl;w3nhKz{!UwZ~%lvxQBx{$y zk~H(cAJ-~j6apcbP{6=u(O}`qI}b5(z^sNw&_!~Bc zBOUR-@w_kOSd4;gsPoE8gkIG&?Nk9?i9yeJfSD?iqi=2VU2>HUa~;u=rHWt_6}i4z zhTRHz@*+`OtOIw!BW5iRlRsRzfT{5M2p@#0QAVt@j|@@RU+61Q)ZfZDV+{YXCEerf z_3>CiJ-c`&^{>ZlO)>L0iUb?HPnes5HR-YvnM9YpA63#eZ`yD;AoM0}Si3>?E;(WA z@L8Ot1n-Dd)@FM`t-Rf4-wMamu>EeD%MjsY3--BJdKhJz?q70I-Pde#PU-=Er-iX| zbFb6 zZy|w*3gO2N!=+9hVRJr@Sp44=W`4;+d!6;#1-eF?g0q;PzHUu(E>n`)+(BV|uG$Z_ z>P+3Y-{%483E6W7Z!5E*4# zLSt`d`@Gg`89pbuq)Uxr_qVJ0wy$XaxuMEM>C)hJw5#2t6tV|*Acp+_4~y&dsFoJY zLz(o050^EKLLM29|1tYNePAhdpySxDQx7wYS2OW5f1?{a!;Jq9UD*>tOcZ~9i z5IhDM6Y4t#QD2j+b>s&oJfzEf=4%Bs!30iE^cO62Nm6t(;@%CuN68T zc*@6xiJ$4TYV}Qxb+TKO`5W%RPkcA8W_>j9yXsT#<^HHc*kUe}RUm1!Jridvdi!!{Bf1dWt*65jg4^JiGx%r|cq(-|X>Q~u0F}e1$PV^F2 z8`gNMI`T;G*MIQe(bQO` z*j|~dUX-t9`=M3?jNh@QVfv3dI$^}nE+QQFe3jm3NvfxM5iNNP<} zW%5cmI?&$j_|2oM9T~YFOwPNi(Vmwc!xSSSmR_3k!U#QeO2c~UIAf%6!RsV#B7l$E zRcKFYc78OQIxXsRoN0RBZ>#mBU_yFEl5$%Wr1y!9XHY&VWRslIft9KnJ9$pJx%B?+ zVy5lS*nyA)bQ^ekoCWRQDo1$+L7x5)VWz>(x>@qc&)F_X#-mQKj~ejrt#;a1bU6ID=J zFK7@lf}AGB%y4oMx&WCXJVil7>hh8Oh! zQmN8%*i1fM5X17J^i6i*t`uM-5&F;*%G%W!qtIg#gSAn*m$h8cEIeG>FrvrLN=*N9 z=w*>4%WPAzePQ&|Lrqnj=%=sP^a~VRnOERfox}(W-^06tgi(_M4x?32opR@G!PDJr zh452u77sO%*u;;Sy=rfEh2Ag|d)DB0Q)mq24HOJE@hxkTQ#S0W-$cZEE}$;8&SQB+ z4^(@J@@^Fd4L4P+6M(A`rgxFWT&ekHwxEK=zqoKVK46j&0b^jmLKn9I8+mb2y!I?( zmsyKwr~FIh-2FvoT7Q?a&ozg({oe;h9EUT!S>9A8$y(;%_$Q{LlpLcyZ_G?t3rC|e zI}p#4(BS1@Ua&Se>veM|Rr{K@$BLBN*Rrr1ZErDE!WTzo1}v9=nq7VTP=BC=%h!0Q3q@zQfC z^A$-eB{>nZp@9l~Znj%y_&!qlyqwiR=Jp~8HeRJ?4>A^%_WR>lezM2(DaO_!`{k%N zBW=Sbh^(YCv~GA*TyI+`-tXr*Zke68-0`#Q*#aJ!lhGi7$eCujxFAD;W%|dLH8;PU zVuc6m?r@gpj_@p5By2sCzcY*D+uVV7z->q5>DtR-uKnl$j_93$JwD~Bc1Uw|Q@zce z(!|%9zyHuE!v>D@lvrLy4!8E-A&VVyAhg;C-@k6D|_Zz%2;5rO+lyw?$e1Z(f5zf=V*#YZ(HjZFO|3(CDc+&wDbl=6? zm?xvEH<&c0y8QKh$H+aCQ<|)Vv6VPR8QUTr0PWhj6bj^}(}CyG|A*&yG$2%~p{qY9 zE;DQwyV%7*KkVzd$pkrvd&hi5dOD5vpD-cd8?Fg3caBI#Q+YRM&|5q@gys*Psv^LbrpQ@PkDf5wo<9XR)awLLY-ltyGwqeY zc*mIDg_zGk4oi>+g_jNcezT1)GTMog@1ULXN`@=bX$b^AG}n*%L(vit5wE<(hN+if zINV3?M-9Z~ziCQnO&b0Y^+n~rQRJ0%zjyHL>o$S^@N~yh@C__U-(y@mlmbt2F=Dy4 zF|d`#Z9{H9W%TW zvc%SH5nvc)W6GaffBOyd&++)}XvlUq=RO2nRCL=jKY@Q0uWx8NyUfz#SA@zqcFARxlvx z!pEN|%+5$SadKg_)vX)3pQDPJnXN&gEcUSwl3h$k{+o6gC)>6G77)L2^sddLC%xX zLtE;qlz{l_`QrDXD&rjApEkOnFa1S@LdYgIOetimLh9?HkXV>}c9hd)sd@?8Le!a>)iw4Q{ zc=Y@D3v7lmLAu4FdzjH9c+5dwU!Xi_+(zor2TU$nm8CBMXX@<|*?8++PKOc@4w4R| zm$5KrQ1E9ds4EOFCjTG|krPm_~DMw9%>6*JU}pXo9{zRico3gZ!kp z$I!QUMIMSPIfMBFT@W9^04j0q(!u%%yOkYf0?BtE z9aSk@g~UCRh6`J0tK5fccjr2#q=8LMlsaAPA^I6R4X=vvviz?P%&$Cjq7w@%ubVPu z)f3YK0L2<4q3rbe6wjn+$irk)5W6uuP85eaBus9v^;186{@Ifs^_P2h%@>@e!p4L}VfyjUC4CUbnGvcN7n%$k^G;ZHpKtTJCXF}DuCD$$C=JMSV$(dqc) zgUDW;I{tq%yB3Pg3Zye0BVk2x_^=s$kGuo*o|knktO&L9;7AXYf&ACf!7}5Z60olQ zizY_fUZ~g5#-4zjmg& z4<+^vnih9A+sDfHm3Rpgd;FO*FQVg_FZo+y`SP4=g`qcx=ac=;uLK?k zrdWoS=EkX9iH66!?jz;TwemwD17>7lG$IO?_G)!%Vmu_}na4_(sw+KLYnz!|YFDjr*#>cgQVAt9+Tj zUZ7xwtwWK~9atxB@{A<|iF7ijEzSgHvB-|jEImoSa>lE}>9o&|EeE%5D3**JbrIQF zhGQ|DBZ#+7h9uzJb!p4=myVB;tSun{e&lF8kg9Jdsa$C0M&1^e22cZ=`z3ts@T#F% zFBi~DWURdJ8=Tagf-LQxPqB;8g05)-W7i^3dHF;>68{txW9jF3CakroU+^5$DB-dG z`xyUsx<}e@ggfpe@|#K;FZ(~{?AF{mztht5`}K+tcGE*F|2N)*5)Lml2o4fM8dSj~ z(P;paV?@NC=4Bs}_Bt#a&F5{y$Ih{{z$Zd=>MYzU$+*)rNNJPbXghK;o`PeH!gHGX z)YDzsaLVxWiMG`!baX!eC)H2}ww0%C!TDukscorzJ z6%a$!+fLpRkj@n3G)FG734x_DS5yrk^#U54m(L>kEnhSpfLN_ZP2C5-_Shlm6XN7AKDDm}J zoZjhEv{XYgy-TN8QbGdyMSBdw7fr){qYXbXu%xkY!jd#{;IkGNj2$H$2$qceV$F-J ztD;O$-v33tk=0L=w2}P`m&=pIG975SA%ncuU^4nDAqfK98G?!A7EU z_PYu9oy@QQUYTTv-xf_;YB0I$hhvzac72Cu#XeO1Ez}k+-o$oG;EGD66nE zY~wgcFO!in>7G44|EAPGHWQk=GFaf`z~21jB<}6g2dHB9>ynW2pj~_>1)6vC8DcXV zVi~rX)1Xh;dUO4x{#5zeQ$^*Rx#ro{jgQl8qGOeKXIfe7=2Al<@ZZ9kp~OG7Z2SBf z+m)$*ye-k8A(%yE^*OI#pp?n<*{(K|5?ysvxj-Iv!F~tSM>`-^`yjcI34rD|-JXP+{VAcS)=N zN(sVXJ@}S&N{DHRep86=Gkgu%|sQ*D*<#mxblL^U7 z#66w538k*3*ME4zJt$@g%8BfAI^oTZ%D9`aV)(Ij>+!);SUi;wq4eNWW&ZF!Amwa` z&%NgU8^B)&iKhX6)qMZqv4K0oNOoVDSnnXAsRtnOF2Rz@*1~%&2l-DiImj;RJ+;;g zE;g@i3mDJdyy{sE|(+VNHPF3-4H;=6MNCb#$w^OEUiTr(K~o%nD(AX@|G< zFz_9KDE*^Dt4^h+;nesQP^MKOw=&dc#j;CcDo^)-PIocMt2R;TIa5~eOK@1tjNq|Q zZX=yGzo`m6%N7zOj`Arw+%jxW(4M(!lRrGwO-WNXT2*4Ra{WjoiO&oVgM?}R4eLo6 z$9V>$f{We1vc>2dzfs0}(EkQSF7uE|E*al@J61;##`#;+3Fq z**RYdB7z)HQX_LTaD_Ww#3F-yxL;e_wBAZ3_W`^1mqj}(!?aRz+gYk$LvQZ-3c;>-EXda=~q*z5NdW1Kp_xMi;aLk&Ilf)jcfpO3uY z$(}okI*FeT13<@HQ1_c)euQ)QVOgBh&t#q4k?=du2U$B$4h}33VzZA-j8&gL=t78~ z;G;ltaVS!${lz}~qKUS8$fW?#Wz%>^7UZr1{1Ig1<209`YP;@Q{~6@W`wtjinc6$! zvck>-T2xNW5`COvw=rs}HFY)YaPmAAeO~2mIkJ5!r%C2?ZmDY${IcPt`TU{6*90o8 zEbK_{$-pRtzPEfKG53sOE90h+W^XZNG5V&GD^H!mlg~%?T;DDZCk8W`m(M)RY5Gk6 zwk{|DE>N6AZQi7$>IqqXPUZ+V67io%BQ;*yE_3+6$*3t191^+>Rp-b&-8roz-EJv zojI$ZjZnQUNFKF_@n2G@X7(la#HsYM!`)>JGSr@k)1%)Xt&ARl0iY0z!D59Ol)rg; zL!T$?Qn)$mExLqxD{WxnjPx|j#=T#F{bC93t?Y&k2{Ey|H@t0l^yvtYWPJ`kQ4GR> zW|UAdK?rwd-aY_XjET08wxl| z=(QWmJrd8AOPuEGCStFoPA^_!KChjgTiggWc%>-zt%tLiX!L_iK8qm@e)u%TcCv8x z3-(ijqYwXrd}nr7^rhZ*$J8HS+f2Fc(JS&@9CM@Bg1T-zyL_Plcu)=4cFKK>Wl`qS zaP?-s%H=9@l&(mrT&z0{^J76jSu)Qp%n;yWLZopKr*iE{MtI#Nw@1^KI`P|R23 zEW{;N3pDETwXU*OoB4l+n{vgC)U(L_4iCCTU+Cw z*y~3&{U)wwYMHsZV@`xxZ9FEC|J=I?+`;*ifvR#}Xd900>h~*WC!pFWxByYET=7tR zd<`kgIo6>d#{Nf7T;qWCfcQ&m3%PtJxWDZ8-unUioj=^LqAT$O|KLJ!vFY7SOosaq z#7_RZVX7(F*1pcnUFjCx@tw;iU3SkZv3|phrUh7&R%%R&cfkzhE#KEp(Nx=9ouzI% zV6h5`FpO2?JM1oEyC4njm!agp1a57{om-lWdptmIsNGjkj)L`4W=mx9YR~pPXLDI5 zw`|H~qR&;;&n&%o>6O?BB@3?#u4Q#g`P1@n%yGZGe%ZPDgj^?+oqx4c<%@nqco~6F z*}51)@&3r(cZp_)GVFFt`_X00F&&UA6g`z0<+iA>OWQ<1XR zmoNS5r(RylD;l=gC;ZWjXt>n)MszJ(ZPZ!^8owlyj-+8Gb0gGl@vyoFDf?xpyGTAX z`$ZAx*LTHJ$ireI$F$^ZhKBE@OWDnp@i_O2d$fBH-&NX&TLOl$do`z%v@-$5gFvTJNbXF#;rS9fILKthb z^iw~K#vhjmYxxV;&IZ3;3uJ4Q7Ak=Stsf0afF7c!jAeY%9vrMp_#8?dp$zSOZHnei zju;iEEDmlb%O*fnk5$=S-x3(gzX%Bt5vvq97B}S;TQQ*Ua+Fxronaj*EV7=Dyu0mX zC2mEu-dDSd-Dy70o|dBpDc$Zv)lj4W$732Bj&2J7D-9CM3{gsAcz%J}+%pLqopepiHAXu<40xj{` z8_Ia_$fWCT>uH&x<3Pe zD~mzD%0CX7DrB+4 z{xLb4UaBuIdd@4c*zO&xC`T)Dan(ui4Ls4RelF2zLH2kKg$DUPxpO|AQRnGbFjs5XAw0!P5+}VdS{R$IN>?gy#sh7 z#nnMPR?R!rD@_rVbPX#!u(@unp#V{YqQLR%E*UrRXee z81oUGl&g3XxXH}N2N%J@&6S7#?K!0?93730L(x3kJ8$<(B(;z6Xilvph4NDF-fRocrNcP)_3C7gmxq*sD4M8^u2FZ9T948+#Uu$j{`X`S`B)iRks z=C4`S;B(;qC3MZQ#b#g81TS)KO4voPqU+F|DV?Y@a`&0L?GL0Cxy=gb6W&c_>{b?I zs9TC&XGg>l-J-f478eG%+q*%dB3UzB=521MH`5o9Z!{VZJffvYOy>-@7ctD{#Evmy}N!dsTc&=YcYOTMW7JuEF*qGDva`S{_Pu!eEB1uJyP6Lk?wMsKPP-?2B0USB!p4t2UOQ|?xr?O>o51v@w8 z%+djlS%8`-<-hphf_*D%*OS<*u$734fNtMH5WGzI*SdnNGjwHH6!ocGbqL^Lb{wvk zWI-Bypdge3B16<#<&uco*3O`WOFMOMv<;Na&%G?NH}<+an85WlnAkngkB!(5C$^$$ ze@nkfQ?#(5E-kZ~346{M)|SN+S@~O%y8ONf4vX{$@6B~+$m;DGrgRD3I8>CO->^v( zOB1nwisJiM`jGxi3&#iD#Bw6tHcm^|%L6nKh>2;lhKrGRYsdAs#OFipA8lQ%FG3$_ zK$#Ji_)7G$o|#Pt`WY_Z1A_RD;tSknXM>xrZv2N2kOguCstIU-S9~iJ`0%6gy3iq* z_k29Uh4xgK+U3iKk`H0KT6wRpWdeK%SnFn>T@0H>wz1X)WxKBp7ueaA?o}kMG>J1j zWg?I3g6K|ASGWGJe~P6CoKy(L(Tb@)#9M!K=MmS(KefKhWgJzJYu=wKZdcHO7~drA?&W?zo#1@EPExhv6Ks>t2qzLRLd4$MD<$b?qmf1@i`l#CyQBZ9qzHf zp9|z0^mwkJp#BSch+PvTfmUO-p!!zi1&U5cH;n}KX&r_y#`LFFGF1QJ%~kY`>&*z* z#Ph3;c#Mg>@KFYw@UCNDbl7gfh!Lq7a%~dFM?Q`Lm2=WTw;i8vl|CM_wPjSY$*ihaGe6u7~n<*M>2hq7XWhuSzO<30S0$4T|{A$m;7=h4g(U zwdoFfXD_{cB8ng1)%v&9bkU!*kB!=jl4LxMW-iodVl!eP{|C6Ci;UbWjVUA?=5=z^jNU0xHjbYD2A8s{?Dz9d z9wsS3Z$!}3GgKscUzIx;#wd=`c{;t*{`~O)2!HMi?|k`pQ8#fmNoum%^+#e~cs+Ug z^M%$-0{_fBD72k=Fe<1XDZ~?L3O4TJ`T;onQ-nLe9HY1I>~L)xYTF=F(uYQnI&iSW zh%2?&-1s@CP13({Gg_{qYg`=ot}uVk?N|TTI6!@SA{7eGx!3_7bh@;5?BFm<(H7}t zGjQhFPID*7Age2PbAHaWmIBQ8Z@m$9+UYD5gnw;ywBZ@de&5CxkY|SzYy<}?3a2{9 z+n@1>qky@ez(FSc3=$?TS8V>NY(9Xo4l%IN}{k!}T`HeTT)7 z7wun{etTGulqy-xi9b=fZ@`ID_m$d;^N5G~s|c?vInM(>0;mp&rNz=a&i*o?;E$y& zcw=Lu_kPV4hTcNE)brvNFV+e%J|=?#MCCA&ylP0PVkC{wS%CAykmzg?NG1MDECB|v zt?vU-^#lC;-+Md-hB4v!`Yg3vMKxY~@u;ZUII%unp%w>1xexbCIp+7{sL!0f={} zq~N%NCi}QPw!eq;X8NUFR)8a=2<@tkoq6|XzcDZ;mc^n^^l^h7@Hf#b+;2KB? zgvoDx-JsUBx=!*vT7G-*PT?itgQ^Ku4?~yoN3*RzJKZQ{@rF6Oo3yIFMq6e6w!J>o z2;#0Q4%R!nh5lntsg^BS>ca9*TeW^0JoyxP{!sl}^ylK0ixj*<%|C6hjg!n-SZHqK ziEM)-OUh5ru$-;A6ve7EgL~UA{jP6`7?>M@J*%tVS{$AWe%Bti;zy-)wmlzww$uLtTW}IOAt6`V{OSGKe8=7WOfytFtJFP`PJG}+BS zHUXK2X76d>BTlAO8^>izh+bQgBGh!sBd%1j!dH@j@K>0SPTj4uL%7Hi+o=w46}XW> zeElEZe0GJjIyQ+6$65TO5uhjuzQr5Z#ykuiB1ljj+XYT& z+`~SkZEwb;pnW!gVI5?y(0aiN+ZpkwJNuPqI_)2<6zR7^&;n4#O9;6@7CRNIN&~n} zeg|u>%xI(MICKiH#(+AEs~z9|tE2(J|$tdJ5F)^O=G2GQ9^}RnPS|4j z3(0>J8#rZhe!geWR1@(`sFss9wyyuCURq51bReHW6mO=5$@tAht!aRf68Nqv@ zLIz#m7rWx}MTKWUAlAP2;BzPp4chX%J%m3J>>M)fzv$^euP`A3EX<0GeDJZ$&MRVoMYNcKy zTdQ@LcCVIv?U!?XY3&R%+lo|?gy^P(nG+W*7h17eCU@?XOjGk#cpOb4-a*(8i1;f+ zmRF67qmdDGRIp2c}Tst&E;H@$JpjO7!7a`^m*=LA<*gPqB303D!! zrLB_}bI?!WPjO;94*e@kVW8$Orjsmd0qhzm6h9Yv=Xggf zbt>pk0!+P@wX8jvhW5k5HgGIz&m^o9_|w|WuL*i+!9Er5b*w&cgcy0H6AhQs#2>bp z08fF?qjVITsKKCtdso1s@;~98=AbL?{b-<4xr`eOC!TjD3#LTrlMYEmIkG|v(>q_Y zW957VmB_6<8iPNO>LzsEe(nIGISjRht@--)F8-C<7zkTw_y`Djofx;X&xM7_$`l=S zW9;%Qn{U%V$s)LWhS3x(gFKxbD{(!UmPtC9C1Lis8JFMP5Tf!*Jz>*B-J#(&%Y(J)iKZ`pUJxa6dxcpXZ47j>)^4atx$1 zI>>dC2QH!dT>Exrsu(~GFrzQT>KV5)Hf&Jx?v*6b#q?|!Pfq!1e0a+{ zw%J_|J1(c#Y++Y-VViuIBuwoEZNpEA28BCT@YP7e>#og>(a@s2Y_|*l?5y1;1AbEe z*$@pDcsDY6PdP=w_(in_p>HZ@PZYDG>vpq@jMby<3XnVa+c^xU$Qu4P)s_;1yCc5u znMMC}WfF1JmVlpWgv{fSmwQ@L$%@cL1j@+ju-7PG(iqs0m5$Nlhjwn;ONw8lLHyZd zlLWp(`T-l3<4y4I&kBFd_9z&QUUHx;eQwd{@qKy1%?u&_M+}A?vfj|bRg?3Mo=OI^ z;*80Eczx11g+^~~-FLs&Klko`R?xw86VA*chw@OwEAlK>WOI92DrGf!%f(}QdpMYj z=VrD4FHNHv7~g(yTg#O|*-xJYz(IzVkRQL}NA zw~ac^S*4DRPvaA%7W!Z1Tt@XRp*ZRD{ZUOqw&|~$=-EdU=Ni9(jgR&A3ETMlm;#&7O_vK^k5lMLPJmS z|HFIWfj$aQIH&vmr}a`uoPo_e`Z3bz!UMAmk1G9CK**lPXZ2$1)7tfgsF6yxdVe{@ zX?=6aq~7gztQ!}^{;}e;E}8p6xP13c^R-%G!(6-BA~}7&2LT`B$0E_S$jd|9$$Mc0 z=em`DSO06sJLNjd>8S2v$BXJ`l&32&=JOYG{oR0twJq*mu1HJbeJ>NoDT>T@IMqge zk)^(X1gO%6MrU8i?Yt-aC5jxwc#@eVy7&tkT|1Q`YKpIo?- z_steF#xhr(UZEv0vSZ7yjk(2f_PffXU4O`(yl&@Jp}kz`;tm4%V@=>iGS!;xcNjWA zd{4R{eM1Hu)=<^a-jr#Kqq9t&KCzQz_vF$3CX)q24kDixk|T2gHkA5Nk%MZ|CZI zV_=B+?gjutN*!0X2S%bBq)_kNE69$HeBYFgr?5LaTap1wz!&U6V8x+bLvwdE;t z$KQHH02|5naJ`w5hW`h^Q}lCM)*Gp7Zsy%X-=D!e#5rh#=5#?BjcN9at6`P$6m04r z9C3K>ymCM^J3n8}CVAcTc#orlATK~Nm?B`zKD^VT%n5KoIO*nEGr7v{0>E6q9^Wy2 zh6MH-2AF*MJN@%|dEAQ)^=Q5c4Th&~dxE&HaYmrJl3G^Q*s-C@q2@H7r&a3pZ_-Vr z@V1HnN6~qQv;DSFT&ty2tFMDBTeXYYo7$`Pj*+6M8C&hWMeI#%wQKLax0pfX z_vXF)i^~=H=DDByobx%TIm)Zx&5Zs!8;uOMqLb4BlXM5P{4MV?UzooBUNUm7XK`A8 ze-a9i)V>helzuFx+=qW{n4BQweK&lL=v}XC1?+^)=M2vMU%wLTD^d4|C^omAVl1G`>H3SXGn>ED# zC5XZ^({Wm%1&W=!WJqV2ANmqF=#2^64mQAclOx89?&%dmhZ!k2oQv|>fw0bN7!fjv zOL{{%+)vJd+TasQcuAWL@>#&9j7A7X{p)q$l~3mV@s}DGZ1+ftY~9KxwOM=Sr({mL z?5Q2M@bWu>%TB!Z+oa*(+rR;ud!1b^4CT7-DdP|49CJ>{j)~<)gMyf#{jidv;%dah z<6d;eh9!OkKPXe8#Upe5rH=z?C&tqL8wwdwv&=oX_ruIplgY|L;JX8B!Q?TiP$B(6 zh;a+|B&AvUa0E?)c)FcqO#@HSlo4$H4?}&=MV5%pSCi z2}5jBc;3%$b_Mr+ul=e_OR{)3zgwQ7kupGif9Ri7^dDB&N3c!0Axm*Sre#WP`nahc zBa!&k1{xRVfxBcqEVWEXQ39Tf+o{k2g4CvSw?^E&_Rr&NcOfq zc%{6-P_IAPzRpOHuQIkvag)FNO!0e%x3~Iuxyh_G9XI06pUZ^yMfrLEL`(s_nvoKu zI$ebw)_NiGAJ&ZiOI@P?VCoI<_&=-)l-OhAmGAg(aD!BOjw%dkuh}#u$i-gl%LYAO z6tVCL7@Io%Gt5d|K!El2$!td_vi4lAOB;yg=ThMJ6zPy>=$)cI>G;EaySkw+LN@t8 zB6HJosn)FeM>4IXJz{ZBe$s=uRjoScq{T5FV15^u)>?QFSt#m&b9yUxh7R^|`2l3x z#=ffji;T%X9ZJA(&0v2o%bY==e$YLsA2kLm1AnXlEZOAFak)F;$_9Q*e?a)KVm`PGh3JTbc$_nl1qi&84y_--$x|-?9dpni`orF;xG)a{XexefAtp}K!@*^pY@PWw} z^oS>#ziOsU!FKCt=>ZV4`mDZT){5=4Z@%?26Ho{X>tu{yU|MHBP=SHige_pogrI}L zPj=3o^FJ&C8^n=zno(3Wsc&LPydD!*vsS9t_vh<$%KmldE`?{^w-SALMvMnNbRRiG zHU7nb^_@~Mv^hd=U$yh`ZpbT}v}rDeB-XsqqyC5v<{{rinG*iJ-cA2;(^!4C=rJPfk`+x5`98Z( zp<<2lpe4CaBScB$X zBryS5n(5_PP#s$J=7e;)|7Om;J-AeR5L_Jz&&WM5iB4IpVq_fusAFSyo^l$G_dgWM zJJm)0ZtH*9!J-grvBerYf4$kXnmJ#pP;eA23sGw%Wt(g0rq7s+EqtDfgj?@0Z=L5H3>4vQc zF>d%KwTi3kIttU_t~1+Fqz70sjiEtrDl693UR{vjJ8m$C?6e&}fl@nAQbmHDOJ{uV19;;02P5V9=wAS&+&gdln^HT` znPPKvtv+Nn1+|`G!*SHE zRUZ{C=s-GZMM&b_G^zFyWR!QySufnFk+RS@sh+Iy;Vb zoL!uHDqMv&%=1eY-(GT>2Vp;bO=!kvd}GkR(EkeK*W4^>Yfd+DYu^4>?Bn`*2;;H( zyIr74Y>=T^=u?Kf8rVZW7=cbL>M32SD1V)ouuv^=_fP((?T6hl z2Oeo)^deJu&@JwFe4n#GBTy@0VL)&Y;z+WmL-CL_zz~X>yQ95}AuFP)DA8QOfFFV} za~`S>i9kH`Q*|Lsy2;K9aFycCf25p$7#EC_=5KQyNT}th@|1KIg$bS$M#F8!ararf z-~<%_10~<#q?WgpF)2iVeo|>XDk01MKE{7hbH}7JF)5`dV$o6L1O;yh@VYO74*VzLG-GcxDwpl9uN0in7`Lv~Gdi59 zYNEu`eV>JmS#Lk*c&tRd;bw$aO``l-{0nr*?e5*Hmh~%1#7AbMNOfGgKIGv16=5WO zIKfZ@dNG^-bnllvbV3(0)ri+AmL-1zS9a2yh#XQSGFz)NbhhTY;4g(coqpe+^$~_S zIQf)#$B*b8lC}J_Y)tfte3$6s!}*SLuuG%4L;r#E>tm%yI@=^IxYN-*@@1=HON1r9 z2(@|Q7nhiy_&2;A%oa1pxx(}a);oKr!+~ec_1f{aOV#O^zxv5z24-s$4a5n7cLVX} z?E-`wW-89GA+`F3gU`%l68(#eQvaG>r0o5RhY@~VCY?UsZT#j>kgg9zf!fP^)``0#n zT4a7V-_ihW#l3%J@vgFid(aRb>n9gm3F%}_4o|qTfBDTwk@#+H(LX;RxODNTu|>IX zEZ;)OU`64XlLT|1x_KgI9Au(-J3v~aNf+AAek=3opwtjsS#(i;&q)H4;%n^XA#Dtl zc(SQV-Dl<@bdJvS0N+@~ez78(5G=%Q_?-MQYAXWmdkX(?_R@Rc27a!3B2ysMx&g$I zwtWr{|H0g{%=8xT`{l2<;&{2L{1QJ?nei5>(|!-HR`aKw`YR!fmyW@2QF2bv%^jH- zj(!8zqkSL-VLR6$VPp$x>@~Gl6u11R}x1(>nF}&Wp9YG2n5(P^WbPpR&QRgPc zwyX3Ixwpd)C0TOfUhe9h=9H4om<(g&c`KUuL~(L|dBB0Rz!Lw&f5gjiS*RxB!U(qy zh%3k?pwc|!Fm3OQHO2p-udR<2*U|HyjIFy}+#{=_3!inWg;EBAfan_UunMQuBY z36){Z42||Vjny{cVx^q@WRUpi-eZzbSQq6UV~_%UY9;RGZMcNx#)=fCi{~L~QhxA# z!Al|8CzQ{1V}22swZ)ua{taA7?W4U?)H9XFB%WF$c&#>LxMQG40UeW@im3(ogr9(` z_cQGGS@18gkn@CY73R@euRj_?0gY5{8l`v_uA}3r4~z9yr#A=Hus6uC*a7_W$$>J% zp@~E%565z)eV-VZRI3ljgNH~KVMqR5B9bXU$$kB}vlFP8CuwirM+cAs&ZHRIV;A%H zR#Ob0vO3DM2F7$G{T9=CQ0sJec{9ix+h;n1AAu+4Qv0nTqim3qId6h~tm%Hb%#2lx zRI= zE`49jOgncg)fO}7T`6NKL|h_lxAA8N8UarN6}GiXZ&_266=nRDMI30$tOLVVfSI!9 zmNc*(t&wmw%8VdOV{=-0^5={@$~{)PcPF>6ygGJd-F5R$Tw86Nfx$}5Me1}Xc#mqUx{ z2`!oliLs}$G%<6jG<|e{?`E&jhcewm=Uw+S2e1dGPDAcOv>Nz#1$K9ZIZ9H$R3D21 z`jGu|{`HkPIIrqDF>6ILlUVeZV5CmYYS;FNydm~@BOt6ypZO%DFENbH@5j%FxGvNy zu!xmM{-5tm`8Ts!@bkTNMZdALi7a0Wp_Jcr8IyKigN3$@`W64$yY+J8@}n&03BMu+ zG`?1OT10Fhttp(W=s5{TwAUl3GiTkvWlh@A|6#4T1Y=r9ZY?U?Rk<{bCpqku4!v9b zl$&{WAH#`ox~Ta_$`5tVRV2%&&88^|0 z=Lttu>-4>D#>F}IZ~LUe{XHKPF~LWLtOEZTU^3^#h%3?+eF8QD!UXpD{!p;3;tx!a zbIiRdm^dHFjlE*HCcOzrFN6Jd=i=;@U-}QL?u60H^gk@gSRdDbJh&u`E8pQ9K7vbg zX`+c9yC1j-`w2q{3s^beT#iQjM*3#Kf4ZdI=}${875Py3G~B5wB51?gCo0XbKSZ{J zSB>q1YE#DRV^j(P?`~n=*+kX(2~GOj;@3XSI&wR@KTt$D_Xxl+9+ydVrw&PJda65g^ORUQw{OFyF(S4N%@-mr8XvS<3u~*!HS^pF!s%_)wni`%=TVM6ux?kB(d5746^+>F7Aj zaI9%!LzfYFfp9HmMK||NKN?^!*17Xw+k>{(7N50R7p|N-<394M`~*4BNBeqnla~Tc zT+}r;RxpxfR)nENJlJ50PrNe1Tn48!R{a=3f+w#pwscJ`&Eh}Ajf%gR`GYEWIzLWvaG+Hg zz~LuU|AA#Lceyyv@&EgOoDL!cFtQ#_yqq3wc5?dXiXcGiMvLajF;=DAcVpi)%(t}h zpL6?g>(G!1ke)E5gJcIy@>)T3ZOG`UU#2R;H4Vl4&j$wRLxt}8%Pt;15P`pO;)553 zH!@!RoO7eUF~h#A_ha|qUoseW*|`g4=BhJirP*v2*=d^DQVO?`8GZEYIK$ zhzO`}q}lX#3q9!ef$FV#?Wh@!X|E14BkLfnl>POl7Ku)n&xMgk{Dhm7b9wrvDJQ1; zW0OOBDb+=Tujc5m-0G`K7VJBASV-NnuF>Of!$B8fG6U2EexjJ~W!>sF-xhh+n% zP(T-TkI^Hs>33%?e|V{P^Y!rLmQNV>v14UIF`B^Ii|^+3z;V>*tj}+--I~kErJpY{ znDxH6A@Xrv4%2rtma^s4+uU-fn0?wqfzd$ihX!DFuN8L7B!EE>R5N5*Y19RHiO5_T z=;QhA^}Ql(c`*Cb6dX%BYJ?mfs8n!pJ(<4l9RWfu31<_ELHjWVp)u3>w~WBbIp5L8 z9s6i-ZwePWu53j_s#X+TpLMx1h_2a`9WN)==OU6{C)ITi#9I?Qp zR4U5jgXunxSFmP96v|J7rv7rwn+8eRRHo$dY0jKRN5AY9I}33N&EGg$_^^lO3fplO z=g*?>w?7=mMg>e<|4ai%GgA34bdswDZ&&|^G6umr$U!uzW|&;au^=UqmE<+L_m z*j)7$@`s1m+G|MT9bIJB)l|HjM!J0>>&WjYO>TVuW7g?F6QIZP>5c0#i@$B>R-u+H zl*BRqVwDd)T>>AOQ2_XpJxTR2kfFa`!4$NUI90hSRaZi@iV8+uF1@F^h1QPhhh^>+ zi|@2>kg4bBx2b@66<}*J`s0khdif&Hsm0*@S&i>ht+BThCknGa%Gt~=a0Md(&*0=8 zbp6f%;e^Ne?E66Nn!__2U~|Ga9n^m>KjXS&aJQ{SH97!W`eM+9;U$L^b0wB>D9<5!Vu7*)#FKnU(Vl>Y?M{OQr2-vpwst(uTO zw00(sdx6ssqZ$FeI0>r{yJTFsJX4pJ94*+~9aH62>z8AId=`(m>)zVhu8y9)f8QWZ ziHBCw;}4k7?~o6ZX*9V_g&?&f8GDD11ElxD+)knNMqwJC2$~`e8Lvgj*M%!2tH8#>7a^Pun$*H%OpEwX=p!65aCE*s$E71d@^irg|v=%uQ#rR^>G z{pEW|H^J+I%Om4t>3HoPGg^gmPTo>=ZXtt=KCxJCcDGUgU~B&07Wn>c#4BUTv(ju^p?fAMVtfCJ3QvSSxP9@qd&zX1Xudw{U(bB#M zEOGPoJ6#117o7y1|FFV@3qK7ThhggdHi#)?%eOe=iWgy0^1wE*c}(`F1S8ZcD@3MN z%p~YX%Gcko;I@`HZcE#^?*fWhDvV{^+}P6&qG0n08UDC-18LKjeF1KfBb+)Z09%vc zJn=*#?eIO&FLnOqm66d8V-*+j$42w(kGrBQ&6%tpb?efZmx4?`dtZOEpRBi6MWz_L z42BQvzJUG_G~t+BWoRp~gU9x{wj|Hn(&-Ay&vPQjzk5x-JAV9;iYsE-`p=l2(tL)^LbPdNM-wgTOEG%17ZpECbeZVeBf>u{C9%5)u$m*{k3jM&E79*VIgNG!VL_VSVYCm-ToFbB_HBP^(g-D zI-0Q;q+I;lPtrRrG}SLi-YROFR}zws-Gv+L4cIYba}^M#rhWh1uN>=dXQ(>=3&~HU zJ@jD>6UflXixJq389bfogUbFwfaS}>!s-F1okiL0?-7%MwSyI!%bxGbv&<1r8{A0< zfv?sOhsx@w+3)vg!SjBun&`~d;JXwIAH<%HWad;*(sKa{<4U*ZeEs5h2t>*lD~AKO zFc&f1x8E{Z+*1k6`F9P7!fRA|>*uNy^aPmcd-vLuO;ZyD>^$z$^jnTXI>r~g|0%lJ zvGMnsfcBTY!W(yZ5i#E>*_sDLOY3GPJp6BR=T!;}yl!owjpO3#z@z5-E^S zU)QM4YA+XNlp(_V97jXxW+1k%H<(|Lrgzh8y#y@AgQt{L_nnXWHwar!?MC5{i-W{9yi?1^@ zK$*tXF8nB-iF!`YzW$`rVJhKU=}oo!h&#}2r?c^>yl|Gfpxy*OfuQ zwM6V={`yT7C|y~00UbDzIQP3$gcRk{uXiK-VcaFQTQx~hp`BbaThlMuG@@L#E>~gc zlc{E7sJM6N{N}d^NmAM8+*>#4R+7(tg4aGqn2tNMN4e}&AJ)i%5gR72dJ;4*@kyB{ zUBGwz5)~Ye#}@YjRWt-%#XKWjBo^6<-$vtcI9rhZ8`lW>@Z+nKO(0;JOcIMV4SfgI zQxDF_3a8H>B9qEOzFiN$f)asAS1Jc-<3-#aCwqL**_r+nGIqlS`nJYYVwMrdp^FS8 z;O4P_`ZQn-Uqm5S42} zK+6{aVa>(|?U0Hk`mNgZD|P!V?{q&!e;NN)2l7k-gT|FiI-z^sKEH4O{xHWcUx!D~ zmt|1x_Hq~go7`fzoiAZ-YW#hYtR+47{3qIDIAC`qJ~LafU~ZNHJkSfi{Z~!~3W-$2 z;#*GI8|Lc5p2HPv&i6Is;{2f;toVdR$G+%@o-i8^YNOumJ4jG*gsfcue~)zG0sTvl z>;#1ii^Qww9ghUyX)CtA?%45pP63r6-SyS4tY_!bxWu;7bj^NXF`M(SUphsp^qaaE z+ha=39V7!Op-u`bkPGqSkUC8z+`)jkOTimd=aY~-oR8pj;3G)qW%fH{?vt8u$R`H0 zY*RwogOeuGXC109nP8hIkg>~7%j@rw5s=h%2z*-S6DX#P`p1W+{1^x3tHXGC5Y_W| z)1~9Y6WyDe`9tFwb)HKlO9?9jN5&cTW=HEOQOB$bSJx4bZ-WC)ZW^9k`DvU9uIMP5 zrDbKi+2H!TbAaDYZU@v)B~K=aG!0UkbFd3;>g1&of9T-DKX8*+a~$2#U|yQb2Ln^e*UQz9oY#(2u+iYKxJm?5WBo;k*TX<@ zy`H2`aY9VJiZ6t7W&D%zmBs3I{RFMtuNAp)_j&q)$sddh(-EklWcn%vsSNK2rb&!u zAFvF&f=!sbGJMSLP4id|6X#}R!q~KdehyA7*;{ZjT}7e#j_QCvHk2-dVuf^lMl#3UY~1g~F65?ZU(5jr)hmf*Z8Dk`#{IwzVoImlLt(zBZjhw@fM43_`>tz-UEJkv zH%%vr&s5pXpTSl>kcSR5RYI~A+np?1_x_yw;@=l`rZRE#w>ej-s~k=ke$sp58 z-CQpk$U{nWY`+)GefeN5K3@zazM=MY{$$_W$a}WaEgT>dG0RmLKJ8)8iRH{~rNuh& zX_&qfe`>~LWQLY6(3a>kaMg5I82zh0!L0=R9SQE0Vly4fG-vvTc|d$&D=d=zZYF=} z7_A`W88bUxYPUMWsw3!-^<$#$9bVT}#WT!Yhjo|T3rasif0(Urg8ST*7r)uc$jgii zL}FpVpg-!SC$3s)nLqxAB^|;jLf?x!0wzwov)^cl2N04RkU0v=5I+`u1h-nvb+8-v+m+4k8Jr{%2u@t0JSzcceF)2$acjI(FBj}2~*_Sr}Bb^+xIXU}inJ?KGGXCS|& z#NQ}5J+``=r-b52XZV96wpH{Z`G)xhSxZ?m3+xg660Mqq_|d^pg-)88l&RBvnYe#H zd)}8-F@|n7DocilIf@Cgiu8S1rb@@bP$>097cKnRPWr?t*YVYxj{1JY*46h%s7~g_ zKd3gkkoK+`2=Qf+XZ^%{El5mxP%_v5dJ$^%C*(I>2t>8>wQ3bK7sHWgl$4NCh$t7+ z+)!RE?s1>=@@#+b@_j$YxSD|nR2&LY{!IDQSfr_8=2U#zwnoTG3+d z^eCtsEF5W0nz$KI9#MbdKGkA>X&lYc`oq2BMdJe|_$KZY6%L@rTzlfk;1x?a=`QD`@=h#5B zd$hAPn&_oyEauIhC1~=n$B^9*?E}>HJW_cV(I1OdVOl5MuG!_bq>A`jT7;};xp$Zh z0-rv(4j_P&H8py-e9NlpK7wZ~=0k;aUpp^;X_dL?=eECmlSm-3`?eP@P`zK$R*ib@ z7raSptPIwl1`OiNv?WEEKUDJ!1*7ktb_64%%Nmk4^m4Qpq)OH-RgE$&8PV7wDEr+( zZLU1N42C|xoA-Xm6z(`}i#1}`*OI>mHeFVmI5ue&^8HfFy|AC%2B@jzy&ppqarivNTZW1_Z=i2pK5 zFr+R&D2rQyu}qx<*l6aa*^TIFikCr%zom&pq9uXFq3i0!$A zW%-%rj_2A>O23DtxJt~YK3UYv3YH3s(wWHh?sHq^`$yd?_VUk1VKMMo-ZcYZrrfmq z?Nhk)2K)_T8bMOB#nC+3-oEDvoBa>V>o1}(*0i*AoKRU-*?scn;?!@mMALnJMm*hN zyNPr{#&I%An6h%~%Xv&{rJf@>4-JC0hHU`cGA~(v`{9fwS@`vVhEDuUK=GKIRN=3H z#qwKEkRhVJad2BJ?nm>1@u+ZjdSO?j8ie%$k-cx1fa04oAH#$z?giK%wc$9)mde7^ z*{&HDi76UG$j?_rsJQ=_0dt z(vl!WtMRh2*4f#w4#r-aE};(??=*3{g;ZRblBB|bW39ziEg`jn{Br0EUUXvK8?l*3 z+w>30GPTxOjlorXuLf_H^Y5vCA%U7|52c&nO1UeTs{E6Ej-hTb{IE!WWyU|VM~nm6 zJz^-o$4m?Che}{2N5a8khQzQn&w5DMLE5e5Z#qIyH4TFtGpH5JB_Kg*Cjd1Ip<&q(F^?_&i#6voAgCj)18KVf zmBkI@imq)E@t!Z$I2M3Rc32_^Lmg2(&}Cclqtsl+*WhSfOI^%FEYbA3UKliG*kN-1 z)m25jYil`I2EYu>Y&dP?5USh#LWW5C8a*=qs!`$uH)^ZM{>pb9w+}v_-PKsibsGlY zR5k{tKWG?+c+k9E@ryqqmlW!k91XAwymWw`&|<~d5+Zoju4vM(R$qsj$ZC~x>FAVW zmcSR1_muTAYMflE2jpFA*S*$vvKqE)mjfXjGD=ot4Z%08Y8$acwO-86? zo;dIFHMzc^_%7!x2RK?KM2;gOyW;!kf1!*@KLuYHwDliiT!TM~R{4-RQsgnEC;G2^ zU>8#Hlz9>LkbQmaI~C6KN$I77xV-F+3;3LGEm$A5aqe^yjR+c=e7BV;poDqXw|(ca zl#)d3${L`bK6e^?(6LP`7_>03^;{j{5Wa1g|NRV zRB|)v#=Xb++M0o-7=*iNzn(rG11Ds)asZyJGXC-W^_=l=iht{((oA&`*Kp!=ecCbKm-jbQRi7hE;h3c$ULZ)p= z|83ai#PI7Il9V@ITa?;CGmZ0DWUZ;UZ_4omTcDcK@XUT>?~@M4iTm ziZ0Et(icz9sP-&vEOI@20QTGM<*0_@j0dS(=KacTd`|Y}#Y*iRmwnLr-NqhObBN6+ zR$pQG+7O(~O%~dShQp*++oTb4Dh*7?g#Orv0n+{p{~+nKi}dm60GtLdD&?5m3Wdk3 zUthw|&)G6j<~&x7q{xS&JYx899|%Q3GDokt=m z9c1Bo_GU)*%X&<9kB%PhE1uGzd+LD~^w;YzevKTXqj=b7T+kgUrUdGsX~nYSGZvcE zlfKCQCitcXnOSUSf_GIeDK)!puyTm#Ryr~2UKL`+mK4+Js`ifjYKZIg6I@q}eK8sk z)`gOZEDyR#vMKZt=N?u)vhb6&O;Oa+EI2+vgJ<^!hbz59`x*v4K78>^1ESfq3t>@! ze{w>%SfCU=71yQy{KZ`nD<}6KG_->ig*2GCjZUabg2UF zWn~RGR0^l%F1hRE&uypAI6#C2YA0br{4A}YlP?%DzH+&>9{4oZOR0UdRwMj zc$0wckWSg;`B_=9Co^pkq#YepP|cqaH^5+4Blrj!ObQr=%3blF0ON`V&&Ad=7d>mq zeDQKI&&ns3s?!d9?=IcD_hYwHKSBgB83KH7j}*?58I0wyzzKh56XU;p6qtZsL(Q?e zyc1xk=V5#L2yck?FDo0yg#9KMb`#QcE1jUOg=VlZ-=JkM`(dM24~iSE$KnYb0e|Uc8hXoz7wz zI?FX*N_t<<+`~fjX_}yk?=}^h8uFyyi$ThDB0(~KYRR;(zJ8lhv?}a{Tc!RtdlyG( zKmd)EBQugH2izxSYuqTV zGORP6ub|)~YhTjOo^M^1zfi$=BYEA8q@>g10ApfzHLZjXdm@jV2ZUi+*8-q zY^z3@+YSbLfnbi^@BrR=RAgKgCCrT;D?3w zy}I|x`{Rm`-y-csJP}CO-)L4Je?xde zU8LEP&OFQO*9JKaaup#h2KInSx`7^Ch4XY#2>R)__cM%n$SEb|c-weEbF>fXp8yKc zN6zwB;kllLNV;Dl>0B8Z``S?!w@xc?=p_HN-222hG8x8vtey)14M-}MnOM#$R=$At z-!75-^}G(_JmOm&#H!b*OA^*r)q-KlX`UaFsaI(>N2mYe9p5obuD(Y4SyI8f)4YyH zcbYm>v$}7T@F1wV-<&icQs*JL4I{dATbGi~6j0wrOIvhPBE#=DZ6y{eB4;u6LzQQ$ zy;}2*tdK_o(tE0*s4Yt0i55E6XT@h4H7Q{3IWc*ZWu+WOE_p@jNZ}z=8@uk6ZU~Aj!ra_ zPLg(%!G!MJkG&A~GC!p5!>v&Ox@aue!@=IY|J3_rV){8h?>7SpELFF8t@NW6vn7L9 z4gvZTs2T>FEsFu!#hzDV0)rx~5XBuM(-sJMEaw0>UU+wvA*NfECO9U^YLeQQt*w=9 zmZHaLJ{X(vpC^hlO|5472H_{F*ubHis&~n$2Nhbr-nfXz+P8gpJc7)gW%)9vOV7l! z=X-FQUhMwuqNY@lrs)9DhT>Jt*PS`fQ7X;yc7{p8)|0bI4bgx={GB-KZ6XiH`q1p%`jX{Q9$fyWWlxO?~9VR`M z(9hma6f;@9s}x#}-V|JPE%$Y-n5pwpYK9eJ&J)*9tRWJr(EX17D5qZBs20O#ob~cIV%~vmHr`kn^MJfEl*koUDLPnK(>VBp5vHk`Tj!Cm&g91P zk&5`o&~yI3QM{ibHb~ycJtNy~1NPF9KK=|~+(vyxYDR>pN%1k`ta-&>wDT7iQ_$Umx0!7F$@Q|72LK)b_X;eB zibzxJ_VM3hvRsB-~~wAaR*B zTxm(Ako6I1d)qf?a9@4tA)-M|y&`i{3*SwKhouf2iWP;K^_nOVDay%qF=1Y?ya&a0 zU$5#o+L^9*ntL>tSxFQ2*)LJ{9wmS!gQY*&)TtUL6jIHG^0=sHn{A7`W0;NbdoI~A z1G#a9x3TmtP57wcK6-lA$R4wcisx*axjJe1dD#Lk+H*-f9@jeHxQqEH2&wmUxl;11 zF9z|WZfOdS>od{QSB`}UeU{fU0kmd(cZ(Q__-w%8W&{tZi}R~C8D|}Wwh>@wLjTeA zM4+U*1D^NqA@5A964av96VF(w`ws-6ltxE|=t~|a4k2Zr&yvjH^^)@IlR$f9@&MdbBqag(z=;sjmzoMlw;KF6`o+iWM=c+tiMY%5*PtSDxO-2riwy)z% z9dX6Ob~GwVR$c(IZ2CbFH~K>AR;hwkwMnUBL>#8#F-^ciZnaC@)-;uRBW^~Z_Zz$K z`)eM7r8``F({M$b!W>f>l5bl7)S@P2uzwiWy%8A~EOiOG}&1^4+i+ZNiVWtQ85j8k228(>u>vNADWL_{$;YJdCVWeamkk}|9< zY|7C9(}7j8?@hsPLwn7v*;;gvUS76^UiE4IV(kzC@|4f5Z5W3QQp)O8ftT6rTbE#7 znM=O$QAj%7LK#&aXV)I<{d|Yz^>Q)e_0yL1645d*GKA{O{-M{O1~7nMhv4GKp6MP< zq*Yo#wC)lyp+|ko(zfNQLvtNDUkrXY?!Tz5>q>`ehAs-yDVxETF(qLjpdy+c^`yh{ zk(xfl?V{h@>o=rD9#xJMXD^iyFAstuG!j&2#DRTuT-wP?z&VXE#~<2jYORfGzpO8H z=;AT9gLT^n&&SpJsY5ZtMu7nz`}a)0i-d@tY6iKAf#~=22IOXO4I&LayW0a?(ziXb z-scz6d$i`WPiztobAU)9+_7AN-RQAEmH-EL1+{bWXpz308d+AAL+p8r$>PCb( z<%@eVOmmd`E!kpH8T!V+806ZCmaUCij5VVYsS@9w3;*4-$#^=h`e#pH{njV)o8Gd? z2tzQP>9eQ|_ao!+b15$Ap0zjc8=ADp7LSSa6{W3TMNeda+xU=kQ47mZM1Yc-wJBDyJvAJv9 zu3j+CD6s?61f%5{&JS*7c=c$92|q8-1&bV6f!@fnR6lE6)H=y9;T$9;6&W9a^COnG z@z2)?k*s=idH_ahOZd(%GOlrfu^)_uJKGUEk1JACJxB@YAg06^Tgdc+{XZ-#sqC-2tqha$UZN{89IKf{YllJSZ;we6RO5!G`#aQU-Bmn~S1|pD{qQmwA-JPc`oX z^WhZJte(Wd4;wy(j{m5cf;kNX>bzs5jJ4mHe>xm<#@#dA`;BJyChP*YD&)_4yz8_Q zsh5?C>`{Pdgxt>!-KL~f!ttdm*Ou2lcy}ucv^*6L8T;qonfqfsbfx!AUbA{@Q1miH z|Eh*&_(PY{lG%Ao>zo{b;&HhKer*Ne_Orz2ZRb!PZv797doKr>FtGX_VbqzR(aJA3 z!)VB4M`)?liM`JWiT}K84K+L9Z%cMgmiYN??-7zX+a!T7IkF~H?-_V5t82s7)hC!7 zZbbj-!`Ct?ueqkl9P>0=MuzLcF*oG_(9KijC=VT3S*+mMwo}oZNxvh?9jyzC?>{qW zf1Rp9ZslQL7$vAm+=qpmA54P;M4$L|=XBtH%4$ElFd=Q@E$&6yX|n1pG$|e$wk-mz zpdKA*YegfM2wg8VZmrURE>E8A#s>nT>d=oHLy2EqUko1d8UM;4xSv)aumibrI17w| zI_QwT!5eMFU8Wq03LRG4vo{UlHp$uNDpAkbr@}s@-Ejs~xY1oxc5TgO>_y=_N$9Ky zOf*Zljgr4`?FcJ0iMS&qyvQEW)?=kh^c-;0RW_Vt{a8VrBwEPIQl*wEWhnvxiT58c z{G}bx-^+NF&pbULb5~=h5}Fg?U9dr&t(cCev}Md=G-h`8ipXaUKYELLWit^q?Ix(h z!Mi9NUbP+OBt()TCm_WLJ+tF@NX9_-_1Fsp>e1B@k2KubUotCFXD3erQ;A@{kQ zwUtI+?qQPeKH=*^48NT!NZ>?$z&?8EI)`I&GfMl%%sb5|f6Ea@q!h8%w0&xQ;`QcC z?!BuKx&@Rf{$<8VU$=Hw=)BmNTf}=LJy}auxZ6U6kIGp4wAGM=l2h zGcOf8gS0|l6g_-fXbHEQJUs0`6d$J4U-g!^hZN9!eX*^=W294`M9gUDUe-a`~mFani4BgK(Pq8Zy* z)QVWdC~flOk@@I#ByEF=c&lvo?NS*=kiA;Sosi(N0>Frt3)|RlNiI%%ax{YaS0~3rG_k}^j@prn^qd?=ArAKoeyzL2kGgdy8Nf8q2u;ke> zsViCFI)|^94)r%<#|I^}NW{9fZ>A?#7ebm=j0pwfQ$*E@2X zPPHl0sv(N+H)T%L#4$+=D)=0KC+7q#AYLjnT0d#+W=?vw`6w$X-rrnHb$Y?XW&nEE z1O<8#JRg0WO3_Id85;df5@q!5Ug?XRUPlW)@TBqFzuxA=kd!9q>l$Bhmu0(2FZ&My zG6p4KMwf?jG+@u|ZjMOsmcSzJKEc3BYBdS3k@o1W{5KLtnL54OtF&@JiUEfR?~X6m zNYVFvHyxJoT`|$NMcS)f{IL~w_`QtY8GMKW|NIZD#ur3&6p^JFT|h)Q+je=}j7z$B z;8kPNbK^DG8sdAMgo_>ls+r_?TMoRA+DbOdZb9QNV9eup{wt8Utr+r~@U0RaA3r7d zEqwg!bXeSF6cpACjMSg&S~1;ekeike57>ZRo%?9~8+Xcj_S3@{JBCmMu&X;hVhip1 zdtw3~Evg5;PZHk`7{q9)RICwKN~*-(Rwq1l!`o{lC18N*jGpRqRfco>g1`%2`wQTP zYM;OF22LY0IxE^gXSGT!COy0TKP=^(@t17$!I)ppcn$1cW%=i4nWOJr+&yRSTV%q$ z9S16q~7?AHO>nl1H^7Y-9;)&+aSDZiase>AhfqZ-vVADK5!!nt*OI zIYop^4BH-Meoi4RuN}2u)lcG&8d>%Kr&(2rXk+%C>za|Kp1MB~@QJzmv^&4yDE>BD z6Vu4P&%~>f@v5hiBZRNbt4SWA`22~!g}GR`g6ksVARXE85!Kris0g9rP3fkoI!B?f z`?YF|2(v=B3eiMPANP~I48GOyuXgX%*QYv*pI=uL3dp?8p)v$Tc=AmqKDEcw0(>KW znw%M7NZ4P4F%Q|$Tq?>53hkij*n_@iV2%le@_hM8FBa^{Y|0lKVV@$*E>-)!?`njG zt{-SQeR=`iw;N$~S6rPoWQbpR{5Za&J7gg%WUW=0UFXMvC~V+2HxLcm5W=__a&-dP znF#K6;NIjK|DY9s75ZLc3a)wstuSZx<#23ITv`zTA&McJ(6Rc)!Az6&VM!Cb)v0p< zkMILPpc!Kwn69m^k|T;0;3K~k;Do+!f!bCz8@0@O1rnBcwwheCUC`X_U-|{^3Uii zRl2uS9nlun*Q9@!8XmltX5=oQ|6viTa9?q8?@Fow=XXY(xz-P zE5$m?MPY8aJdYUw@8{;qLy`!;ccSYr>HFBEMzloSmyjIsZQj&%yK23!8k7z$9$srcZ2 zxPVZGi|i1_%5H(a0q7f)`w#3x2T-KcHxY5Qw#Bv+*oP6|_=x@%OdEVEdRj-`oJdzT ztSTD(KL9a7&c4FF1_@3HnidwHpWgof%zs#^-|$D)dav!p@%LJDE!37Vx&A0)@|*tp zKo9F#F@D+~w1tlOY&F;yjO?Bjop|l@jPv!cnRfe9?8>mmP-^{*f2PCQ&Am_PPvF0c zb$^H-2mBx7pAZ<%r>)o|_dZH_arv1lO0G@;$4u9zDB`>m_EJ7)?9K4)k0e;gzxT}Y z3iqaPiq6~x1fQU!)wc-CM8^?|D<}JROgxFR{{R6r)2`{G{#qaVIciLvJ*9Fs$(%$o&p6Q{x<0Ol5sS&1k}wj?@aGTR2Z2Mh%Ueq-PQl`H%j!d4>jS z*ZgS~{Rqqd06b+@{PI7oc+~T(029l075XO$)V>|ZvpQ*O5w|m}Bj)9gL0HV}?YWLk zER51QcR41bJw_|*;i005X4(9!m}MPOMQ_N1Un2Z}wvNl=Z;fDxZCP)Y;1BxwhB<%s zYWmA|(inCkzFz&MqABsKR=wz&m4=maJ@aUhlljJL&BuQdH|+laG&r`QCFZ|V!kLaR zqjMer^*yVc@s^!^9nGb^#Nt>4TkV*D2G>72=f2hifBNgS({0;vy>arcLVKQs{uQ6& zZBpAx&@3#j;ajV_>DpKk$p$DFhkTAk@&F&5a@U9M+b~l1(mZ-wd7}il;CCF=7OsQ) zPfC}cp5474=U@BibNSX3`xZiDVCvk0ry{&&jXIUd?_Wy(%XeEmZK&9fy>(*54__`t zKi0l!Zk6o+0JFvUu=uy5eAru&JPqz1HXwdrS7u#1SA$*5tNbkeF1NEQhnJJ-SGXKv zl$;JuD>CBjoTNt{r~E7Q7`G$Ks84Zru>z~}KeuYfa`HCzIi<&XE2ov*l|}iOg~`ClJ*%5DuIS;aRf@Zc zZ}2z5P*rO}oSlV6lw8i$9pyEyD^ z=2c`SMa+{IMgfy^HyIp(U&Oe2b!u1kwIbE--P2#6nf)P|RHmpZbflk6Kd&=?;MSZC z&Z(`+xe&&d(fb!W-}DRl*R%3Tu2bROnl#S_S(EqgOWe2pv_CKYIt6u={iDHIchz#* zG`iaF)$0EMJ0H$mL5ud-%9C4O%jKYZ(+SNu(<|$fJUI`@6mAsL&oq^3oa`yB>rSAK zGg_u$Bu7OQR;ieaN!p4t$2F`>rtCDDLUGMniq=_%N6NmYw4$?uEF5lcQ&;VhGP7n} zR&&kB8yD3707}z}(ikv6+}@_FoYC!4>o!LsLrL|fpK4a9%K$wqXb~nUIHnU+Qt&q6Y2eZ-e0Nv{{R{A&Rtgq=UP0xi$<2ZXqTU5bAoj!mG1GPtQs_EC4mpW~Y(o1z_ zcFP){nNNSH9SIx*#dkL1xl+1Y{{US-%>JX{ZwxST%A-RaO-iLpHvZCk**(>~otwSx zv{kI_zRzzky9+G0hkWWr<>X){4;xef2j||Z#bs}8Kb;FZ1yZr2xpas(Q5Za~eKU_t z3elC0NUnQc*6qAGrcJBdV6$^VN9wex#tJc3j@oUd zwC$#znipZYvDCF0bn98s-$(xdSr|BTG^z_oSNow((c3w#K-)*IE9Cfo+a$l*#(4$d zV(TnyR$cCT0zbTX3(~%%(&N2dKG zicB#Wr2(rq1os-#RBK!Ki^SHa{U27lhz`p$kK}8*@SltRA?g~fwwI^tHJp` zwXLYy-Pvmzg`M_%_^xJI3GTtjC-FSi2CF=FkUY{76$VZ^;=hyd$wmvBSJLhNE&iwT z#(9pZiN)5fQG4!BPo0we4lh)cUFrq8rJ~~}{(3+7E#=izJl8;Y~9ujUmo9WQ0*wm+sgXD3(;4LmyCEqvSEnX_~zvrMue2L>2jTNqfguEBSJ2^VU0C?cT`o$G%#?MhuxcdW)qoG- zU3WR|IWi4cQ^i+awKCChT(Y%t-bRJPjw*__4_dOPaIB`29qx|@@!YcbD_K(=lvg@- zp_w9P$SB=fJdQZ+?Nl@$h`JV^sKtGEr_CNdR}iwWWj#g?KBB#!#-1II#a({|?J zKY0Q4kw*jZ=zg{F!laRt6w9^HkihUk91&mA{upsg@mNJla{c8!FTK}Ct^FUGK5ID1 zFx3^OA9h=$+V$A`4lj(K2`>uAcy&87$IKu{QS||du7=yjKL|Aj3vJ^#;3xQQ?WBtz z@GN4!9|#XL?=4?D#%oO1!)y*Jys-_q%1-XqOYTV<;PG+8(v29#>N+d7-`>yAJ6#{c z`iK(gJ}uGi_kGp-TE@rlY*xzn7sXQi_7<_;C-<%9jzRi0er;S?+Bn3qE1txPTUNiZ z`Lz4ni!XQC$dxatv2R7 zb;=SyOx7fpsyMr~T>k*mNQd01ri1&faLzv_&2`qlAO11g5FcI_ zia&_1H6ynfgAl<~sxs%UyXpgWUpVq^HJu8Pz4jL_c)S@2SiU$Na4{DT4f>zU=)$aH@u zzs$~AWloIk8^L-r^1f_;MhE$djQA$Wc#7gdpH&C^D<%(){{Rbah!+}mt*2-Gl$A&1 zO>e>RQ{es90(g#VW<9PgBwT$+82xJrM-?3!m)!pV%k??sfuBYAqc%?jOg?CCQ2jw4 z%QdB@cy~~EK{u9<;%F+BoWpvw1k-upQI+71SD`qPbBL=gU zE1-ILGAsW8BUOdX@mNZ*#M70WUGMsSjde$vjljmeIpOO@>PpRjU3(c;{v5oH{bO_w z5FnP%7_JB7zr(ZQ9UJ}<4Kf_+8funcqnRNbu=UC7a@iS>ze+`$ulfIYk-C-m%}x;X18A__wTbml&j-0nI>oN{8Zh%q`%;=>qGJc za@^NhZ3u$W6&+c3AFFfxtI<9+d^&#zc%`mv;_~#}O}E2=!Z|-Oeg}{}54CX;s4NtJ z2*Agn;=eQFdAc;!2fhCQ1^)mJe^v1IWlJ3hVyL^ttNi}};A(B?J{+H14=|Bpf+hja zR_Cx4>UwsSWu-;s*+c^og6EfSss5G5cpM_Oz<#mJ8}uTqkTLB~PynSWl5ucvN7JX=bm6geXw6imHx|05XZRmbcw6?j_=BOW zcDmn(biGd6Fc)=^YbgScL~k)a0gCm3`(t<>SfX34F8=^expVVJA$~p4@+Ti+zTrm9WtkOJL0jI-DV7b z@#^z4ehpPHQo&&-%NFlvdtT}EzPfK?^gM^gJUu$rl;HmWardG8#ZTgY zi=Gv^@P4s1g|4Be_=e;$w|9?nt2=gh%Pv$Cfq#dYefGb}vzGm)VwT(C{{Vt)b};j`8@Y%hvPF#kweks%AgNvtwL|f9 zTGwvm@XV2FI*sU#&R3R3mRZ6|@?#xYQy_EzSC&QMzY*Iu#?)UU_+~)g%mZK0+&_}e zmJ%^iTesVGd071BjHBeJyL^O*lgwFAR!NLh*ypVzpt`^oJbIQ2zk5zu{ju zTln)`AL}BuxF15N^sQL@em~{+N-d7@$Ul)3R>)-bSNWiZKMyV4zD)W~3!Of23SC5g ztOxV0c%u#gLL2BoubJWT=Z56RpKo<<^+3b)HM?!&4+%a5TBneI+YKH+5(RmcvMP(C z`;DPigtnUd&vVu;E;O4pyN)o;yeQq%pQU8mc(P1p;?N)WA0NzD6K$+nU&JDhSBmL6 zg);-s(3;a&W-FMZ&!JWa`qwNl5R7?hE3>|x6(yrPm^@pjZuza|L+yqi%vVXK>(*M7 zqT1ee^9UznDI70eyjPNl<{UaOKSNtO4fVd0sQt3$CYd&s+N>0e_vX0qd1M?Mp?mZ$ zST$`C>eJY1cZ;_3PqnzuUosUZ^Tl9X{5JT1s3~i&55c(ih_pxl030H^T^91+TDSX6 zs=6-Xn0#j=o(SvKohGlNY`M0M@qW<|kxGz9&Y+9}Ml+iE$~c@vTZK06yB=(?iRjj+ zC!>5E@Me>&+3UJ=cQ&)w%Ce$)tnre)m@zz8weIsyMh$#_@ekq`iT)jUHct}hH)XHZ zXkm?R2b~jdZ=Z6X_Ce@DuQ$?vZ4VyXm5){MzLS0j-Z4ofv$y7056Zu$yeh3Kd2HQj zxi>fYM?>K_zMN>{)hqp!`t@(f{RU+sxj!6V-st`b@eZx0?-w?DebkVc##~6THo~B1 zkk}%w_yffU$G!s5JaE=u+2>e}Ne#aL05i+kBSt=a4Ciio4z<;Ru;pv@g{si`mbB(S zCUl?LcjK0y95LMM(cUtF`@73H_i^e}AwN^idS;vZXL$AisjT>KOTGiEM$$mX{{TW2 z?H{%di>_|n#-{t=R4t@9Gf7<^5K-TqJ9T!yamF2a@qdZp8 zwY2fdo@CAuavy2NPip%LOLG*D8+B8U%D)kOJ>naE2gG_$h^&kkbLjVQUPla!L~=d5 zhgE!F;E~QN^ylD@$GvCcSBSNJGgR>Xwv{HIXt$3ot-+Qy-ylG{NaLwLc=YLASR7-P zl9k`c-w#pCeGhywTXF_Xg@ zggv0VJb%86{uT4!0Gt~44}%k`+iO+>sFE4K23BAFmb`8tjqtL!krP^Idoev}%6n2^ z5l#ENb+6BN88A5ZrR&z1r=MzG{2D_f$t$q(Ju5zaBTBpn?N%^Lp6?p5{Y75sPY1n9 zla9<>t9u-b{sZu=-2IElZpIgQvHt*k5I;)He}`5;?z7bH;Qs(+nI#|i^5^xgt6JBz z{{RnI{{U)gf^V}csxg8iD+0%YRe8s*GhFl^6TTkYw#3%sjgM*3;XnBiR<)DJKz9*&qw|clK^C*6 zhS>LO1OEVaY{Y*j#e1TX4ngLdEKuBJTt>2k!Q276Gqv$aHC*vZmQj}k%*ZH0~ zX=kOG#qER^=3~3}u&VAkl5%R>+g+^RXo~*k#mE5pG986ST*lc<F zw;(C1?V)(G6UW$Ym$nR$xAKsFwOHO+&zhRDw~_5-ejsJ4pDjv`_v)U*5n)Z}E}ho@0R`Bi6!U4-I&7E%XO45=Xe0V;n=p*E{) zY}+p(xwJp@)(I7V@2nB})-<`HH8ie$3gSGo5eF4sKQ_MmGosYw*6cM27fIBTD`FI- zw2&;3MlsP@cD4x_~pMkCx9a~40fsONO_RZUkmlkLK721-R*xz|C<+@k%|vBh$<>NK4z- zf_(F)-S2*qTHMD7SNya@7%4_gmA|AyzZfoA_ zNWV6(zk3^+UbA9W~*8QUW~Zju0S2-?JgdfF^ty`-HZINCo6z zstM`It`tqF+mUglv>$80S9^tGuDIv!6~QC}>Frh@R@N?|`wppLZs|8!VUu#EIujg< zGCOchMvYw_wr9X~KD3@4e(Bo!>bvg$09X1Q&AjnlD$Q!^G;hkfFlBF{92%Ntmv5CY z+=r^L$UlMNyzl!*O|!8c8J5my(%@{3;E9AKoa7DBa))v1a69IZr0Xf zOUE6|;hdv)49Ah2_o{STk2=`!Gfoq)hNn7`Pf1xOwQ>G!8~OC}7g6x9gf#&3YFb1t z6-g!(*&zUDf}2i#1#;SdgM0<7+AYV2E^jTPfb21_bTPI!Z7CoJJmjZyJ^$QW5R|(g~Z@-6{^8W!NW$A z_?ulnV{(C0Sz#L^*5)bE^-dqlA^=zBzAI2C)r146p{d|g-<;u^K&3HbQIl47z zxHaR4{{U-6Et`&_Se<$HC2>x**DUV<`!ji&^aj1n9(LhLBhxjLbERn4 zg#D{ew{}0ppC)bu$m_sApEUKjVZ$zdm0vVu^rST+;GQyY zP7l3I8BZ#6S)z`FV~ROSQZ%zmR7B1@VIrn*A01acvN9@!y3V60C3H)ORQ3|a$MC~f@vn<~517Y0sfxg1 zr#f{KlW|wHwrfp#U*>rgsJA;KO5)p0jepXu(MNm|59BM&^_#1h?iG+wsS^r`J3x@%zaNWVE_lelneoxQUACG3{T_XjKf>EwP@(CGK}ky^_g z$d)z_xkE!B;EqQktLqnfe60-0I)VXHycIs(Yvk(X_?mNWq$TrD^)snYM42_dkEY(+ z$0$ZZj82JxH`>6++}MB{SIj6Ikd z@vHN5T37j%PK2Mm6n!P|>r`!9!M2Gp%-ULKA|xF7%{MYT=Ds4t0&>Cc4>@wT)va3 zKFMwC(R}Q6#+z2ZQ@w)P=I&7iv`s9HzzQ=}bsb~E9vO|~*Dk-bZqVUvEm#!{Kj);m z!1oK2UJ0!HL-8KF0kVn4pJLhl%sdtyK6lRM{$AC_dI8tgzpS_q=(7~#h~f&D{{W|P z+y35{Uqr3ydvqz&ryKlF@c#fm%hdHh6?{(C{6PTIZQX1%<@>hKC`kQ_0m~n9O?bAs zF*4gtAtZ+?GJRC%`3lrIC!Q-8Shi^+Sy!Jc=Kh`Q_a70sTL(jgvDiE%H3&wOnn`~u zzvy#jxQR}Typb)enFrj~gg+?2>sYIE9liq++zjmnzmPR4)~zE?k~ySea0isbpQ*)v znpsy2W*F*8;BfP){usCVADlW$E8lkYB)3T-xtiCFsO-!1bG!Q1vG(G#5!QRdbg{Hv zVkjYqJdFBgv0IrHZG+m>M|RZWUurF_M;zHKxBMbMzbxCG66F)`I7XY0hbI zXdu_!9lWdutw?}lCZkYulhUV9am8aXsEPm_;+Vh=L8oUF#>R75H=(LX0Vl0Hspqvs zUv5e1?O79emq><1iRQsS?6*%|mB(KjMwH_Uvq{Tj+E=#r?`xwvn5uO3sVjP$q*D(S z1UGQpF8EI6J?eDBHY=h_t2$}TOIW*-f$PmYQUC`^QTo;moe_u*zL={Cbt)cyVeeJW zC>4~FW|2m!UPRaj^`8ioh7oDrn?*&&b%Wc|g@j+8rt4s3qe4*#;`=vargW zisq8ByjbG77$6Z9yGP;vR{bj{nmboLNTzEOqy!Pxn^>_=BwuvodK%G+<~cY&DfB<%Ty-MRv|24@ zoN2V-&2vcEA+G{TFyry>S<_j(Uo!m5{HtG)){v496_jc;XqC1waX=NREvi0B9j*FR z2@#`ht}to)OYCbKF{vc5%9GoDv2P(^ro##0PTEiKl}>Zc};yXLbf#aVWdUS;Ed0Do?{ z*R&5V-(H1wuz!{-*w>_UWB7;gKUF*&u0=QD$Hch2K(Tmt#M2116ag*n8-5 zJ^r=pGut#yRg|kKX^Zjr;)*iAVOvkW$lMw@s-laUH@Ae3|jpf4bdN^y4DD5E+(ftuHO!((=@W zxVMvsiH}V7DtfC920 zhbnyya8ucsq-fl7y=(b*#C{p6fyO*a)}2P$eV=a3|5T374_a!;_Wd;Kac7Tg>Cvg%W)DF{C-D1VLp z{RMg==@5K9&{tX4kR59DmAADAEHXF;&~pG2_BVQfjo-+0n0<&lyxkN zYq5tBF$bnO{#6?+h}&gGL+Hv^`PVaXG|{*gmT$7$QsUOzA zn|MdYoL)CPSxzSoqMW50r~J3+Z!YKUoKfMG92Qmmqeibbk<#DI=YP<%pO+Mz3UUVM zIi)M>U(p`fAC2;|wBzLmo|T-(4WCNUxa1#US(~tV=D*M1qz=4GRd$R&Bl_dQ8oaqQ z7o5K{pp+K4k&hpD`3i9Q^I6TQMI)DzE>1c(psUu`HnS@PjH?cxHf#6J4)L2M_83gI zE?&~nf?T>^WvTe~dEvHYPF3-PB8EYQSpB!V(}1K;UU3%f@c7Yctr@UM`^alTcD zt11=oP>j~;$tJ$`d)QtW$5y7RQllpq)h5x4F1e}0BSwyaRFE?ollhTaT3)etad08J zD9YK-?$jPV2m-l_lfGxiBXPkUD@#QH-zIx2AIVLB2ja6TwQ=sRwUbVF-R;bOQ&!8c z^zbtM(oI41T_SY}(;2CP;*nOR5u9Z4U+9vTx~_jCa;;4p1Gx(NALm!jIRy3XSyoT7 zO}@0qHJujbHJv7sMUVmtraJvie3un*zFm_>6U-*!rL~pYw&$yp@b)VmDJ(QEZT@6r z1(lc5q*lPDhdryehPa~(n&B2R1t#FzTB|#)^bFG}t%IxV zD=SLZAH9b;JkuNI;Ed9fNsjgIIwft`D9idh^UoI zlHz`&{5iS%UxGBLGu99f)p-7ub`>G6RlkN;{dxYi#{3hz7hVLl8Oe>Ee_a0nTJB^8 zFK$zyZIAh{{{ULPQZZ5XYqi()_aB<$q03sj^S|T&00e=BM8d3)dYXmDrFWyvWj60n z6*#L*I5i0ssv<>K7zEePpBfuLV4@mz2o>!ytyqdm>13l}WZv4yF^Ddr>K8x}^r2MV4_NRUnT{g?P zqsQHLAFVTRHSiNjGw7{-PeIU|cAFRf8P<0@FHPOsvb0@@+nY=rGgOU?){{VoT*Ul}LB(cdo>*=eGKN!-+=}~N46L8DhoRkvPQi{vA?r?{iu=gO*~E_-KCWJ1$%+xkBa;T1=6qa^y*qIkXKeLmB{_6KI8;zkcU0n9cL zob&0tj(vr27PnUZBJstBou{ZeE`qHbDnQumqXo$CmOqtciPqG=uj~E^$4gIq#_)ED zX?=Y67kh3j;YRZ@-y^rbaaO!R;ScP)xh>W$(YOx`$~bgxRPo=j9<|uqqIh3d+XP@~ z8oLs>00`v93X(byPvu^1HPw7OyVNW^ZKP^CSzokS%qCAWK3OOU3gvkLx@X?G+dDyj0lS*w zP3(GSBz<$5?fe}Lt*o~&%OQJ-m-l%X`B#!S?_8K=`^d7ZOWkMH8gK0_JetbszAn+z zPeqfj5D-%AoS-I^ukDzs>>S(M*)?A$Y6N{5!4#|!}0#JYYf_Dy!LV3 z>Ma2?O3XtD^N7P^t6+6Kv0qny%^oDzwciO|T51s!r)je+R+6zLxfytdda7}Q+c@oC zSC(cnbzK>1dGm{FnEFKYq!`6JzciSyrA9IkoSajYiZEsBI#L{Trex-}TNt%{&KJbL z4Lm>MjYIoQ=bufuZ}q0ws)7>Wc~)PJ(01Nl^VrOrCi zX1=bIva#XFjMHiCIHp%hk&K1UH0zDvPs7cSyy)wbM?KgO$tD-!Dj4 zC)D(;_mW9YT<-Sm`BGw(S8O7Ygpk~E(-hNB_NE>JAeTG7AMUst`8Stl__JgZgy@ZxjbKZra0jZ4X-45 zjKdD(4yrl~^*>7Nd{5$CD?+!mx76kPFx8}JUVMM5gb$dp{uOdU7U%&r@p!xOAK4ESVRP_0dF<&_}mlMxD{qfy( za>hwdx#*+WMgaB|@9^#pH6OK_(SCpM55asq;eV`SrH!p0a+}eAd#ByMt&drMiBPtQ zaWmT+ZD~MAkw)lQn~yLzV4ZfJdkoi&*ruZTm8=>>kNu}SfJW*_ZSDL-`gd<^)Qs!P zKWg-khng!}Tgl>!rNp<2VzmTvv2=Mptd{k0ML8=E5X%PBnZ6Y*15QI4a@$~E&6`1^!v@Rb?vdWplbHo zKDB3msM|Hwlm(cie|==e6~I4xZ#;3&J*(*bCt1^V%VBw>PA7|@#|)>@l=2U;ua(0f z-0~bE0>qDR>Us+LN8$H{?sT0W?N`VnzSJ=+tBiSR{{SrG`hm&!CYKV^rOK;G``a%+ z>->-B?-z3XHzhn&r2V7!Qtq{H?!E8#?vAendm2Cswg)HLwS}B3%bmo2RWHhe2)GsT zZn|3|@L55#G+Ih}{G;`&32PD@;Ia3s8Oi2>p4FSE*tt>%4Vh;k-rgn_1C@9{a=2x7OsIL=W;z2e9F*%HCGFc`sb# z$Q2XW5Pwohui;-7aX;9&O88{`=O-BYJ#X?_A1P0TU0x>kw*AwJtL1yOZ~O^dt1}iQ zt1jn-tr>b|vu<)hujE=gpS4Zd8B-juy=KezPAf)03B_m5IK_ID)~5womGs?E=$gH@ zskeHgZIL>LjksWbqPbrX{4&(6b?rjK!_vX2Dz~1t@ydecC}sJOJVb`*alq!gT?*RH z=U5YJXJ((L$0g;{o{I&#?`-{XiuiBDTK)e3i#%1S_}b;(3(ZdDr;U+{JW-A!K)~c} zKm~mcQ-PgZ^1EBg{{S!RvEgGNrqS(}R=1WD8+-U3+EwbZDMCL3QK{!St{+YK!|?}B ziES_MBiF4IW6IQH6C8R8IDgF7Q)BTX;qIzJ>-t^Sho&P2TQ>VezI}E!7<~nLHLx|I ztBw6N{5Sj$GNv9^ynnCL@ij$AzLl$MrC93gh@Q9{{R@|!OzmD-CAC0 z@_9G*@>@tdI+gu;8s$@3*_`r=i`59Z#Z4I%Sx#zIK9ww7#*{CHAJV>Q_^sie?2UM9 zHtUN`N0vR7U*aE+U(UXbR{ZN9#Tq1DB+#yOxj^#>-w{8nL^*%l$pO&s#Y4hJXjuL^82E#C1k8JfcM+A|(#WIzWGF5@< z2;kMFflK7`U)K}0N^M2x*XDf6Z8a{ab;pJE(4t1PB~L~LPxY+JjV}7lLtMhqk8y$d z*6J6J?lE7XHBS2GX(JIltjpJFB!Fx4JokmLxI82cgAw$HRJTr}sCl9(zcc{Xwf1J_a{8C|dpo zw?n#2F<02UoB}J|mk4GNYCg~LR{YPNr^Yx5rqupcf8}7&EC!#YwZo0KI)KL<5db_7 z_!%7g3bPb0kC=%@^#=yEW+viRn{L%SDvpYyfCu4GM-kte{f_~Hr-z{&Jw2oSelK5{ z`4&4JQoU(blhy0}dTr>9oioH9DAOboS?aej?F0elu}}|Q2_m|iKZ?FRw*$)5cWv(R zqyGRgpVqjvT$~Y0u_29%kg7Q(_O6;RRPV{Brk|0)io?^yTiZoS-O_)Vuj9{&z9jKZ zn3^uMme&gG91#dsQk?Dt9u0WRaeUlW*)L9~b#Z!lP(mQ|QA-?SJXhmfcbNNJUn74C zx8JGyrwHYc!r{4N8(rT|xY_XQn{8s*+wQsY&-@Hxy<`K5@yLrt%OZW`Adjf8XNZZB zCQgzlVf0~|{IedlQa+ZhjSo<9QYbj5DvqM03&nZ2F5?1w@*Q`@Zrig+HClV#ij)aV-+2N|Y>_o>l96`OY>MpTzfv%ZldxPfFW zzz)rldlAif2aRTzPw{r4W2MWRdsbM&OsjDo^-;TtGJ5B|eG8!olT(iFb=?h`z08CC zm{hs`Rq~{_FR9&JYIA-2xn5KCb}jvD(aIOUEi||5r{vE^1B|Ol^HQ5n$edm2lb`sV z_;XLb2dhG+-cP8;N9rnN(moSkoJ(nYqB;H`&dNXFN?d+mRWO~wr>bL|hPRxjil0@ro7krSl5T)CxmsAyFl?fN>4>&9KWXxP~3a~*S4RtYW9+j!dRIJ z{EG_9dn;*(GQ_2a@nZ}=gmYUK8n=ivwg*ntZX;Fbj^1)(=^$^?vZ;v1MSkwD-oN3` zrowoMhJWugTK@nJzn#BRnYj2Bug<5B zY=K;rbIg4udazPnU-16`q4n8reU;**qlc;QK91@BU#an;Y92JvPyB!Cb{l_&;Ts>0 z^{oqkh`ujI$M&7A>mJg;qxA$==yMM=Vr3GKM;nLhRHyLohV>QOd!pG+4_(&jpU)Yu zDm-OH{{Rw#{6EPbRH#&s!#DkW+47%-{ycb(L9&j{{{X{QH<6?2_%S*!B`K{v1a|7{0Vd! z?R5=Gz@Cb-EQ&wxp_+<(L6oNC;Nsr<{{WjRH8C-Fa=-QHkCOf^YSVm2@P(d>;C&lZ z)Nd@Vg~j9%+B>{Z?IB$Gd$J20SE~Nbp9B08;xC2QH$N0SC+F&OYEyYlbdy@##{wb+ zUJ5u}*&@D?_;K*sKZSSNh31{BOMG=G<4n9_H$_5D2~u;@lgY2? zzYcJiXO&Z}LAM#U_4T)JBlDgjZnid6C)qFR?8edbeJ{hhC6|YENIuP{>CuR7;@r)$ zHRv+LvHFrtUE5X)@kxsN>mGWD)M;a2@i9jA98;%rbfqrh-gn2H>x=BgS(~Rv=-|Jq_`#JcBS=KyjtXS$I7~=lT^FqX=Ws+3` zB$J#5HS_Md5szBB9XCGj`{-u9Q}%kj7Jn7&?aN?lHn%qa08_L$ANcJV)dcWWEV^Iy zKSIW2n0J74r3*e(@2U4Xu1*hn^EtjG3pyF`AFP2R#Kl*p(Hz1oj$z z+U>iwigEtjYTwLOJf02k#K&^jtPSe}G$;Q6A(34Q)QT%lZ9e9-rzktqZF*>MH#$su zGnlR9mPjTclg^OH%0~nSZgJ4m0jsR>6lCb}Tmhdh;iLZmzL!7JxS+#vsp`5c5ld%NVgQN8?1XUv{tHc;FZU);kZrtVHzV^-Vs zAbn|<0_sK+X#ah4KJLKVtal!j-&bq)?V+=+okDE^Ce%bsE$^m|BNCPU z(}3%{B;~sk#&KMtayYKlZ6jZYT8EOjTU|;3qa^J~B5(lDY^`%zCYO1yYBt(7o}sR9 zBuJ1S?>Wci>tE@o(bqSE;JOv6yC^xQ@{>QA{7T7XM?IDnioTIc?q91YaqC7sO??i( zvyX_8%H3;Pd|P^jSzW*OQTo-$zh~<>)cwC+XFdF=0+0N8+P_Jch&U#%8~w+TPl9oc z+E)9|n({M&-jL*Rw4b1_sN?t};R|&QJ@wq=b_}VX$!g+!GVm3*Cr^2gtdmH8Fc=Tg zy!zfEMSlv5ey-o4=~3V*weQqR(O>mP$);3K^?;AUoE;felaRwAzP_^XM}`g57FvFf zY-PHUV@=V9`IzIrX@6+Xgm&6Rou`a^EoiY@TgI?nMH1}`w}q4_;0$ozpL*oOc$FWm zQ=y2b%Nc2V?D}6#x)Yf2KeD3gQlB)JOLRU$$n8t*OjDd5-M#7F;=ff#=gkX)P7f;m z)BR~P(vv2Jim@=6!Q=z_RI6~LbCFejs5tA^rNpGoC3RzkIj5ZTtehOwf>{>;G5&R% zrcAXog&S47b5y?2z-5MNWw?y+P7l3Ase*|ijx){$AcS_sJTjh??AA>&C@#bwXH(aj zV_Dn35-4HlMrzSMl(|vYy*DN&MmyNbmqXGR7JDckxh6F~(zBPt{v2$d?ASjDg=)=Z zYpLs6J(cuAdub+P&^#Q3thybuFHiMjKdpWV$NnqR$}!IaEFZPe zJM&w9mgRq$89v5b1lp^vBN3?11KPXMh|>dq&h$w`3KjU^vJw1 z;JH(Eu8nE#JeAE#JU!vqkIAIk4F3Sr1pfdE_&g`lE|wbcqn7gDw9@|o+YV@EP7d*> z8+!i$_NS81bzr=#wOH!b0FY$^9<}VNqv+__i%qm&aRPsZESi0SjD3pVkNRSt;a=T8 zq-}YvFiZac27mU4OfyQ7*2js-tm%qBROx-N2yA& z!3yxCVl^F9A4;Pt&aodRIL9B|74%T?lQfiLQkD`HYWmcF{De?hTXazL^TUQ2v*O~AMmC&ro9zezKX~CSFfH0N5SWjT=hJB&327AeA9ob5A6!w z?e~9?ra$(^U;ZY&bP}AYAk@n&xX$BVO>ZA_>-&nD z{{Xii{{SRy{h?Lv{{XMJ@)eg;?PoXr`Ir9yiK%?eOcf1da@in!mh!h>!o7G)H}|=# z7E;VFt#IUi`ab^v?F#X+KS(|$j^4}v00qDPNF|M`zpwZwlPt(tf}HIn9ta?gpo7r+ z8k6q~*U{cE@IIB|sb#&lWz%(5KkGvN?qWI#RAVQ$;mG&tUp(p>ey5_}Hk++m8%v;0 z2PQcH{wUw0FX}siU$}U2<1P<8io;?Tv&1j6(pKHa{I_0vp3MwJ^pi3*NzJC6J<9co z3lDNS55(3kojjKImiD}zh^lgbI_NJgt?ytqw{a}7k_lEEWlll*gIME5XpinLre96? zk$#vw{xgHwvtA2K4DN} zwRD&S8m#v5N%x07w3TkIJzl(`wjB^;gwXDJ)S z3yjo?6me0GwD8`Ql-Z!TJ*dVg$t2>QsUZc5@$X#K9hKSB8f~qKqq&kU2h6^PwP2np zuoU6F4_fBsxrQJ0vLXTMz<*lJmtWFgIcvy ziL+G`CR-O%zAu=1cdEX}gIoP-8Exa2lk%TYQSE!0#^yI5hEsw$2ELv6VevcrZ_4n; zilz&8pe42290voWavxtt{0)53EH4T9#z(Jegc7H4?cThs(=Dfohb*_~`yEu}WAqwZ ze=UkQ!5miw<39jL<3)~J2Nqi8x|O!O+qp>VAgsqI{kiM}y-i(8GX^xSDSXCSO6_l!TmVe~j4ipSL~MBluR?~m57 z8LkDuytkCabcF4GxzAAEs(X*et#z7x{2Hup^7%JVjLR2a_!1uKe@=$H{wd)oR>R8= ziCoZ<*4kP3TiN#9^s?N-czUs|M@GM|xa38XyL_W0RL!SISIdoZ=cWhgT~UTGK2-`2 zwP|Tcq{#>acizeL>0g3pJQae-@=Qh|tQ~rm)$Pi*yjQ(zCw{wj(ES4q#Q2PMKAd4M zX9V`%{<~i1n25MJ80rYF_u>AvrTBYVO-Ed``%HGRxwn3%CnJmwoc7N{$nR6?ULF!j zE%mzqc9wL;az|1>3gtv9IqTlO3&V~RmUOCCg?>m!-Ip)K)VEzMpLczFXniLZaSc2@ zHA&Ova@AjC`}(gU_P?v%c$-+8Thxwaf0i`<7~>rje_vBrt@8|Hnz~eNs_X}9{;bQe zRB*H@;c)TggcXuq7xlk)srcP$lAN7MZp`@9;uw+}=&j%WB~S!AfsIJavkU-b;c>vt zVd@?*iW>pp?+MwiiK8LOh9kOJ&-dHh!>cCcPrO)^6j%I|Z47!XN42N@N31CyHb+etjTxMe3B9b57>>5@VgLY>8b zL3lgjE;lx*UWPrZ#U`w+-uKn^KN#^hgD|;tXwL_GNxR*rwYIWd%<1DKagqqdXMK`Z zI6>07NUem7Lv;79nh%G%)#P!@4Xf>wkapvW@_bg{z8}Qn{=J#z3lCChB%dvvw9z)7 z;&bq~9A+3CBEwT!#Dw$}RgJeELFiZ=vSrj3@Jhf$2wULmrVOxCY7>A@0PzS2H| zvC?@WJELNU)Ermy4hFPv8H{VveCnL;p4`bjA6s8@`Ub5hj=(x{e6FN@ZFTZHpmK5s zMMy@|g1GiI#D8htAjqA@J?qx|KjD89c%MVJ)pXrH;`SxXW=PPm+@$1WcdzRn2l0>l zUmCDa4ax4eerxSzYjUpr&%n5=!`S6`U#z;yTBU1Sd-=Y{Iyt9cbB@*6zks}77vItD z$NjN?@Ty0`UL}l>+Evoy{+K`bSM2W<9<6=n=YH+?Ipsp#25C%edM!(<+UnXKn>GBl z(m~~w=19mi&rz&{+loSS%GRG~TgV*l`5b&W42QE64D^{(@_ulSH-$a)XZCkMOB& zbnmlG=EO08M?CfXEAMbV4QCEi=X!R!e-qmO0O39(G4Uraohp=9HQH-#>8|Clq#Be0 zDRGV~4(nL^E~6!rTdazxtQe9GPI3sZ>MknctUgnNc+5rIRhEkHQ}G<1CaI3Zdm2{0 zuTyD51}E6ppJ^C6Yz2A|n(5B4r8cYzGwx6ItSBOe))cn4U6IBxqpk1*soK_~DGm7XCag5jQ zz6CpQ^gqWx%>3KLU2{hN0J?_kKT0vm@FrNjj1%9wtu{{YsnWx5#DTm7iP{E4rezh=wK+iDiM>JhQ@$giX?a=L^kv!k!& zgI_sW@8vJ+^FKPyVQgJR-Cywk0Dwfp^{82S#aknuY6l-a!PNAxM(5AARB?e-$UO}u zjs3Q%1J7!AByvd+KCKzS$G4?Q4c)ww6`lTuOax*v9^JvxBRqAr(gIB zCV!x>V!$V!mGh^J?k=?}KM;%DZ_~91)-l;(#?{ByIXDJB}%&b+6B+ ztk0!d>UxHf=4~d`$7I|80DSRR%KKJdhRJOvH6OstKV1I+TG}Xc*1t!=&zg)S7L&)WI=b&6uP>7M>QY1jRTdCdx>IWjrNW2~r5hT2~#4 zyA4YmM?Zo7RX*QZmNe(^G@ivR(fd*R9%ud;TjBTnnuqCoHGLjBSI%Ft#BNM zn`qqdq`{+}Bh(daeR4mgb`^)=p9pxW(&|ULu<~Mb#!QL#f$4z8yMf`|TmsJu;YN6H zPtaG?WvQwWjFp+?V$$}F#prWaY35qZANP@m>0WHO3I;i^PPdvXxEA69NqBl0!m=igrL0>u9STKZoM@gMDVAo{P#{{X=|sx1PRk9tv(c(2>Cs7feJSZ3tOIqA~=petc}{Zlwp-FL*8s5gLBRa$&Sra$5Nh0vJHh%O7CO$R9d6^C*~ama zzF5NU0fFnVai2^I(;H1VU$M_gzHJW?iR8AE%#0Or_lINbYoLPhXW1kq$08j16*Swa zG#iuW3#N)rpFC&!RT$!cwqUx5pZB((*1VUavNxWjdfXS+x6TlO@%%W|c0@^%ODTG9b|s!B{{U;cp8StW`EgZ_3t_$0ffT_V#k8w+X3Y$d zuyl1hSsi%{r?Cg6b>Oiwr%_ai%_}u#ez$b%N!q+~;OE4(_<^7ceMQu3nsSA@QOBCS z7G)iSe-IrT*oyW)t$u@ruNsk3iOS<(z!ZdfQqf(Bn9UIoMF8#mYD4eRqiWJ7asqSG znNnjtDUv);p^|3^xdSJrPeJuPhg#d6Em_N2QG{Hi?1UqVjMR}^>sJ6U(q@fVWlKC` zBew;C=~b=XYm52x!EHC%ZIDSKd1T>9035DIQ(kQjBf(ACR*&9%_xi_xZpyOz$!S0) zmKAxf-o;~)XN^3@R)#=A`u*C{jyqUzWSJEI0JaMM05B`jhHZv_4MhHDTygXz@Xa!$ zRY%|hCio}4}8JoFpKH1)rZ1Zp3PXhuZe72 zH3=B0dFhI&Jn_Z%+n+(nt>T2)A^hqIBuJMoas29pm$Ekz{Dar2s)UyLiODt72pG+l zq`h;*LumCqYN0IWbDWdwNkw%<9P^0I`J|`Q4_fMjF=zz_l8V_Gv@R*Qrw;U$qGnP= zbWufYnVS@KsbY4GvcHu^Fb6b&;zfIlhY>*vmyz3|s*I9;f~{TXiREpm$9J*Ac*952 zG(%%|q#q((LPql}y2cSkUTFKE&H-P#N#hmB$VoUIKgOrMznfRSpI4j}^Zr;+{{YJ< z7w#|A{pR;3s5=!ouh1}1l{!&`-QQpG`5)OFJ%g`@!BeG*pEX3aZ(VzN+iUf;&fCKN zJ-yJ{*F)4Hd+j~kMG_#)u@^W2Jw{JY$k!|5TfJK6#aCxrf$e<1-N6Kd?!Y>G9mgb* zk&|6-!)q%Gjbi6jo@6(+Oa6-k92RB-82~+Y_Vgz{mA&yBQL?u1@wL=qTZ_ww3uqX8 z@xF5M1Jo!Tx)Xt3SJvrM$MRFBcJy!8TdtdK{{YF~)3mLw$AU7jjyq(UQEL+!65P2k zqmhlur-8?(6%405RFWPJYO5d0zOFMl>EYo`IKf$Y8X69a-Xzu|(!nbPf4#YJ{4lRj zJ=pLo-R|z7h8b?|F%6}pc{t!CQH~Wy=m)KNmx4S+;r%w+T_*7TlJ4?N-)M9DxiP|_ zKf{jM;Qea9#GP%!FNLjNB}&P3%hNqlK0QA1{43{i^y*?UQKa;W{JOu;?4>(LnI3bj zYWEuMuRg1CHkmk$J0xT7sQfznRU@Yo7;%sv_SLD>l`S5idNu$ z3Np>;1$|qbX~89;Uy;Fy#6~f4ld@O2-~2i7vDUSfy1QN0T5Bw_@$$U*%u#(1e9iBQ z`dPdC^{h`0=vLkv(QUMgbGX8}l0()>8<%tSKak?B+3H$;qvnl1;U_RQ$u^voVc3fN z_cX>!6IIk-3ekUFfABgV$y`^R&ojkO6qDqRi%pgEZ?lvC04{~kVbY^)ZpV6%W1yyx zGhTI%kYf%-0GdW2a#(@&s?Bj4pO@yLLgdOttnp0q034iRsGEr!h2uVz3CS5_T6+^d zbK5==_=3kz@m;-+qI0L}s@69^ANgf)HWT<~HB(#B=kV^UdEx7hWYcA654b~se-X`j zW}k4@_X#9NYa;>=rF{+YDn!-WLGiQ;@@tkzv!D8VwZw9MC3d&tU&S9wej1XchRoo! zPnG=kf091GBC4fIeb@E)YI$s=VzX{O>8sLrsuTU%{wt)lJF3jfdd#>y6Iya~HC8)G zwF|f`Erw;dbw&EI#(xU*>F9aYC2dbU_^GWA4g4^ZPlo)gH-}1&&e*FjZLg>KKlmps@OIedz9x8mY&B^0t7O{^@Y^Oj374t= z0D%sEpx2!$@I7nj+uPp?_}fUh(V^553yD`Tt;g<|NW8CJI{F&<=-SN$(py}BC4q`K z2P1Nv9z}lt0O4+S@fbIQt)%JgezEEIZTBB9oMGikN~N@anCNXLx;|C9?{m%vrDoh| zT9UbDf&Tz1)>vc*Amiw3LJ4jm=_?P$zQ;V_xZ}Bb>Nwl7zxjS=Hd~%l!ner;@7


)>giq_e)-$m+p8xNyR^0ry5k^;}xsD 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�QVw=NF3sfPpFvd*P$TWaK= zU~5P*<29VT%^=fPM4f@AoM#mN)YI0Y=IIT_I(487XLUN5e)VR};y7bI)ZlxHXV#n# z6_kx^P1~1{`V=KXcD~4q(-Z*IeWvhEjE^AuWzWW)&2@h%Q8q`3&>GYjV2EK+zrnVcJ zaHjof-X~V5HoI2K0H;9InM|w(f^sMA` zBq8Ke(D6((_2_FEn#3u?r8IS?4wcIzq6{0QDxN71N>%Mx%;-##iYTmEtOU+QJ$h3B zsQ}`+>NBD!btpI#ohYtZ8%8Hr;lC4Vz8Sch^GCSyCAf&f=W4#_$pnlrJ;3C1UECiQ zd{r9ld7zXP!QBAlf51qu9nTct#8;Jy%V<@XHA-o}G>sqRpGY4Hp! z9jq)Tb#1`9%D*re{ukqpE6JtTJWU?7-X_xGw2M^|O*C_fonAMblK9-N28`7_kDbTx9$Q|{rPZ}ocC+v5WO$oU@!yLt zVbuI%18VnH(Rnb)m#~J8GD{GuRd7ip=c%p-#CD!8n@f(zOR?2ZG~46}EK+=-NaJLg zJof2cqS~H`0|5G!yD{jf;hXRalSwA9v7viyFIK-Djjo+>BA@sk(7&yAePT&+`!ttp zG`>l@9~X@9{{SJSK6&C?W!=qR=IopKo(&I;{ww%vNs~ji(e(*nc;-U#+DPyF&_?~k zI4hj5I2GZy1I1T&5NU~Nd3k?zBE$WEA$4+&*E;S0l3h99_sVP z26tUa1&WY6=4TYt(*FQxSrzVVG%YJYcoC!xd8&z|P=J!aLYM>(_N$TNDXa3vK6ngx`8CHU!W9;>WeFB(L9MYWQgb2BLY?4V~V z4#ocfX!1I3U*Mm@O%qa|NWSo1o8~}4jsDJrOA8N@LB~6nufA*8{uOxAFNppDx79E1 zp-pE=#kQ(cksp|&iI+U>k$L*$cB|eg)qE$aLu>u1Zz#3WV`$d(=Z@IOjma2noq+Vu zKb?FYBCU5`RO;@u?dfmo?D8;`V?TLIODjJwKkGx~4-$Me_%-3Hn_m+@gDhv!@*vb< zNLMP3epwK9Ao0dCpIZ6%T+)0w;p^>3{uPw+X=x~7Z*^qPX)KTvxnq<@S8-ke$j(M< z>yH)q=izi(T>4&*VGgHpb_{JT&Z3J9QJ=hagUfnx!LO4%ORo4b();XoHuiR~AqB1? zyL*W?_X+{VanmCiudT@N-sS42f0o{A_tWuRG(K}U$5mBaaPn5t_P&qF+S>jPs=GWd z#J8GE)-Zo(n~O<+3w3YixQV1uk1+=BGJ1~3rFk9o$Pe<%F#iCy(tq()?p`z3ejaKQ z?$LClvWR`@XO)^+o7DWYze?tP>#5#qn zQdv!?-Opkb$hCWk8V$#ge6rhfxaT@@#o=fRPSlbN=drYwFXh*h8!ju|@v?68jj1oti1ZSRxx# z^Zn!VAFWnjNjcuze_!xE^S_DlP81a;{po%QZTi2F_1Em_;g`JCZ@gaC>v0`|eTvB) za<6kR+Q$cvln_4?UtD;z_I}VWZ4>?xX}`0zl#NTo{MyeZImSQLu|J2ke9QYRc&k&j zzMo9IxBF&;q=RszC|M%9l<*aX1ClaGZ%z+De%;l4bD&xHKU49hk$oNHR*K33pPg

$ba}1L}4+T21Z7 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

i+sIRq$!gw^@^*tZqoyIjiS_6-s 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�}{DwhXlw{ApKY}O|421{PZ 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 + +JetBrains <> +Philipp Nurullin +Konstantin Bulenkov diff --git a/packages/oneclient_app/assets/fonts/JetBrainsMono/JetBrainsMonoVariable.ttf b/packages/oneclient_app/assets/fonts/JetBrainsMono/JetBrainsMonoVariable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b60e77f5dbf5505436c1904cb7a9ac4111ee76d0 GIT binary patch literal 303144 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

p_r)^;F^SB&oAwAUvat>AaH*WM-R9KsutQD*d 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%0D_|qoNNhGUnvG#&**I3n#=L$~UCJ(F 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 Z%h#_b*~^!E-WSq^XhsVQb2wuQ{{qd0QA+>- literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/assets/fonts/JetBrainsMono/OFL.txt b/packages/oneclient_app/assets/fonts/JetBrainsMono/OFL.txt new file mode 100644 index 00000000..8bee4148 --- /dev/null +++ b/packages/oneclient_app/assets/fonts/JetBrainsMono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/oneclient_app/assets/fonts/Poppins/OFL.txt b/packages/oneclient_app/assets/fonts/Poppins/OFL.txt new file mode 100644 index 00000000..da31af08 --- /dev/null +++ b/packages/oneclient_app/assets/fonts/Poppins/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Bold.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..00559eeb290fb8036f10633ff0640447d827b27c GIT binary patch literal 153944 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%0AC7VmlS@LZ-TEZV}oirP&oT11@@uCp8_l( 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

iaL9w>qKFf3RqGIc*<&o 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 literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..14d2b375dc0c2c4aec35b3d9d4bd89d7291c52dd GIT binary patch literal 173916 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 aY8oUeaqiTv3)-Z=xq_K?cQ8w#`2PT*u76+v literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Italic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..12b7b3c40b5c8dd7d90968d43bac7bc673a6c221 GIT binary patch literal 182012 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
    ?;_d7QR+e3!@*M~gh!9Dwgr*NHqmUgQ}MAnciU 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 YAF!D*rK-^QCO?2lK6UusLzJKnj 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 literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Medium.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6bcdcc27f22e001e46defdfd9e23f224ff65dd67 GIT binary patch literal 156520 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

    @ZQd?iv5?<%R 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 literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Regular.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9f0c71b70a49664ced448c63edc9c4ff2bf8cf4a GIT binary patch literal 158240 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

    E%tAI_G0HKgxIl#ah%hJc_I$<1_9kD=R-Ou{ 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%0DystwuQjt>olwA%q^lUOz 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

    rBW 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

    b)Jnot5>bk%0 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 HuIc!HkmGku literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/icons/icon.png b/packages/oneclient_app/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc4ca47a8eac0aa50392f22cc83d557635e5d3c GIT binary patch literal 19730 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 XvtNe8wYze3VFQ}`*Lwx7jFW!?NpD~= literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/src/assets.rs b/packages/oneclient_app/src/assets.rs new file mode 100644 index 00000000..74287862 --- /dev/null +++ b/packages/oneclient_app/src/assets.rs @@ -0,0 +1,15 @@ +#[derive(rust_embed::RustEmbed)] +#[folder = "./assets/"] +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::debug!("Cloning asset data for '{}'", path); + bytes::Bytes::from(vec) + } + }) + } +} diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs new file mode 100644 index 00000000..84dbb9df --- /dev/null +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -0,0 +1,244 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, + router::RouterContext, +}; +use oneclient_auth::MinecraftAccount; + +use crate::{ + Route, + components::{Avatar, Button, Icon, IconType, OverlayPopup}, + hooks::{ + SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open, + use_accounts, use_current_account, use_dispatch, use_set_default_account, + }, + theme::colors, + ui::divider, +}; + +#[derive(PartialEq)] +pub struct AccountSwitcher; + +impl Component for AccountSwitcher { + fn render(&self) -> impl IntoElement { + let open = use_account_switcher_open(); + let dispatch = use_dispatch(); + + if !open { + return rect().into_element(); + } + + OverlayPopup::new() + .position(Position::new_global().top(72.).right(40.)) + .on_close(move |()| dispatch.close_account_switcher()) + .child(AccountPanel) + .into_element() + } +} + +#[derive(PartialEq)] +struct AccountPanel; + +impl Component for AccountPanel { + fn render(&self) -> impl IntoElement { + let accounts_query = use_accounts(); + let current_query = use_current_account(); + + let default_account = try_default_account(¤t_query); + let default_id = default_account.as_ref().map(|account| account.id); + let accounts = try_accounts(&accounts_query).unwrap_or_default(); + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(200).ease(Ease::Out) + }); + let progress = intro.read().value(); + + let mut ordered: Vec = Vec::new(); + if let Some(account) = default_account.clone() { + ordered.push(account); + } + for account in accounts { + if Some(account.id) != default_id { + ordered.push(account); + } + } + + let mut rows = rect().vertical().width(Size::fill()).spacing(4.); + if ordered.is_empty() { + rows = rows.child( + label() + .text("No accounts yet") + .font_size(13.) + .color(colors::fg_secondary()), + ); + } else { + for account in ordered { + let id = account.id; + rows = rows.child( + AccountRow::new(account, Some(id) == default_id) + .key(id) + .into_element(), + ); + } + } + + rect() + .vertical() + .width(Size::px(300.)) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .opacity(progress) + .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) + .background(colors::page_elevated().with_a(220)) + .blur(12.) + .corner_radius(CornerRadius::new_all(12.)) + .border( + Border::new() + .fill(colors::component_border()) + .width(1.) + .alignment(BorderAlignment::Inner), + ) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .child( + label() + .text("Accounts") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .a11y_role(AccessibilityRole::TitleBar), + ) + .child(divider()) + .child(rows) + .child(divider()) + .child(Footer) + } +} + +struct AccountRow { + account: MinecraftAccount, + active: bool, + key: DiffKey, +} + +impl PartialEq for AccountRow { + fn eq(&self, other: &Self) -> bool { + self.account.id == other.account.id + && self.account.username == other.account.username + && self.active == other.active + } +} + +impl KeyExt for AccountRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl AccountRow { + fn new(account: MinecraftAccount, active: bool) -> Self { + Self { + account, + active, + key: DiffKey::None, + } + } +} + +impl Component for AccountRow { + fn render(&self) -> impl IntoElement { + let id = self.account.id; + let username = self.account.username.clone(); + let active = self.active; + + let dispatch = use_dispatch(); + let set_default = use_set_default_account(); + let mut hovered = use_state(|| false); + + let switch = move |_| { + if active { + return; + } + set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); + dispatch.close_account_switcher(); + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_all(8.)) + .corner_radius(CornerRadius::new_all(8.)) + .maybe(*hovered.read() && !active, |el| { + el.background(colors::ghost_overlay_hover()) + }) + .cursor((!active).then_some(CursorIcon::Pointer)) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(switch) + .child( + Avatar::new(id.to_string()) + .width(Size::px(32.)) + .height(Size::px(32.)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( + label() + .text(username) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(active, |el| { + el.child( + label() + .text("Active") + .font_size(11.) + .color(colors::fg_secondary()), + ) + }), + ) + .maybe(active, |el| { + el.child( + Icon::new(IconType::CheckCircle) + .size(18.) + .color(colors::success()), + ) + }) + } +} + +#[derive(PartialEq)] +struct Footer; + +impl Component for Footer { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let open_accounts = move |_| { + dispatch.close_account_switcher(); + let _ = RouterContext::get().push(Route::SettingsAccounts {}); + }; + + Button::new() + .ghost() + .small() + .on_press(open_accounts) + .child( + Icon::new(IconType::Users01) + .size(16.) + .color(colors::fg_primary()), + ) + .text("Manage accounts") + } +} diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs new file mode 100644 index 00000000..14cdf34b --- /dev/null +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -0,0 +1,151 @@ +use freya::animation::*; +use freya::prelude::*; +use freya::router::RouterContext; + +use crate::components::{Button, Icon, IconType}; +use oneclient_common::parse_mc_version; + +use crate::hooks::{ + settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, + use_launcher, use_version_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::utils::sort_clusters_for_home; +use crate::view::app::launch_button_state; + +#[derive(PartialEq)] +pub struct ActiveClusterPanel; + +impl Component for ActiveClusterPanel { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let mut active_id = use_active_cluster_id(); + let dispatch = use_dispatch(); + let game = use_game_snapshot(); + let launcher = use_launcher(); + let syncing = launcher.fetching || launcher.syncing_bundles; + + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); + + let sorted = sort_clusters_for_home(clusters); + + if active_id.read().is_none() + && let Some(first) = sorted.first() + { + *active_id.write() = Some(first.id); + } + + let active = active_id + .read() + .and_then(|id| sorted.iter().find(|c| c.id == id).cloned()) + .or_else(|| sorted.first().cloned()); + + let dep = active.as_ref().map(|c| c.id); + let intro = use_animation_with_dependencies(&dep, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(440) + .ease(Ease::Out) + .function(Function::Cubic) + }); + 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 metadata = use_version_metadata( + parsed.as_ref().map(|p| p.major), + parsed.and_then(|p| p.key()), + active.as_ref().map(|c| c.mc_loader), + ); + + let Some(cluster) = active else { + return rect() + .vertical() + .width(Size::fill()) + .main_align(Alignment::Center) + .child( + label() + .text("No versions yet") + .font_size(24.) + .color(colors::fg_secondary()), + ); + }; + + let title = format!("{} {}", cluster.mc_version, cluster.mc_loader); + let subtitle = metadata + .map(|m| m.name) + .unwrap_or_else(|| cluster.name.clone()); + let cluster_id = cluster.id; + + rect() + .vertical() + .width(Size::fill()) + .main_align(Alignment::Center) + .cross_align(Alignment::Start) + .offset_x(slide_x) + .opacity(p) + .child( + label() + .text(title) + .font_size(56.) + .line_height(1.1) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .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, syncing), + )) + .child(cluster_settings_button(cluster_id)), + ) + } +} + +fn launch_button( + cluster_id: i64, + dispatch: crate::Actions, + state: (&'static str, bool), +) -> impl IntoElement { + let (label, enabled) = state; + Button::new() + .primary() + .medium() + .font_size(16.) + .font_weight(FontWeight::from(450)) + .enabled(enabled) + .padding(Gaps::new_symmetric(8., 24.)) + .on_press(move |_| { + if enabled { + dispatch.launch_cluster(cluster_id); + } + }) + .text(label) +} + +fn cluster_settings_button(cluster_id: i64) -> impl IntoElement { + Button::new() + .ghost() + .icon() + .on_press(move |_| { + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); + }) + .child(Icon::new(IconType::Settings04).size(20.)) +} diff --git a/packages/oneclient_app/src/components/avatar.rs b/packages/oneclient_app/src/components/avatar.rs new file mode 100644 index 00000000..e1d5c488 --- /dev/null +++ b/packages/oneclient_app/src/components/avatar.rs @@ -0,0 +1,86 @@ +use bytes::Bytes; +use freya::elements::image::{ImageHandle, image}; +use freya::engine::prelude::{Paint, SkData, SkImage, raster_n32_premul}; +use freya::prelude::*; + +use crate::hooks::use_player_skin; + +const AVATAR_SIZE: f32 = 32.; +const FACE: f32 = 8.; +const HEAD_FACE: (f32, f32) = (8., 8.); +const HEAD_OVERLAY: (f32, f32) = (40., 8.); + +#[derive(PartialEq, Clone)] +pub struct Avatar { + uuid: String, + layout: LayoutData, +} + +impl Avatar { + pub fn new(uuid: impl Into) -> Self { + Self { + uuid: uuid.into(), + layout: LayoutData::default(), + } + } +} + +impl LayoutExt for Avatar { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl ContainerSizeExt for Avatar {} + +impl Component for Avatar { + fn render(&self) -> impl IntoElement { + let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone()); + + let mut cache = use_state(|| None::<(usize, ImageHandle)>); + let src_ptr = skin_bytes.as_ptr() as usize; + let cached = cache.read().clone(); + let head = match cached { + Some((ptr, holder)) if ptr == src_ptr => Some(holder), + _ => { + let holder = compose_head(&skin_bytes); + if let Some(holder) = &holder { + cache.set(Some((src_ptr, holder.clone()))); + } + holder + } + }; + + rect() + .width(Size::px(AVATAR_SIZE)) + .height(Size::px(AVATAR_SIZE)) + .corner_radius(CornerRadius::from(8.)) + .center() + .maybe_child(head.map(|holder| { + image(holder) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::None) + .sampling_mode(SamplingMode::Nearest) + .corner_radius(CornerRadius::from(8.)) + })) + } +} + +fn compose_head(skin_bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(skin_bytes) }; + let skin = SkImage::from_encoded(data)?; + let skin = skin.make_raster_image(None, None).unwrap_or(skin); + + let mut surface = raster_n32_premul((FACE as i32, FACE as i32))?; + { + let canvas = surface.canvas(); + let paint = Paint::default(); + + canvas.draw_image(&skin, (-HEAD_FACE.0, -HEAD_FACE.1), Some(&paint)); + canvas.draw_image(&skin, (-HEAD_OVERLAY.0, -HEAD_OVERLAY.1), Some(&paint)); + } + + let head = surface.image_snapshot(); + Some(ImageHandle::new(head, skin_bytes.clone())) +} diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs new file mode 100644 index 00000000..8699a010 --- /dev/null +++ b/packages/oneclient_app/src/components/button.rs @@ -0,0 +1,452 @@ +#![allow(dead_code)] +use std::borrow::Cow; + +use freya::prelude::*; + +use crate::{ + components::{Icon, IconTint, IconType}, + theme::colors, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ButtonVariant { + #[default] + Primary, + Secondary, + Danger, + Ghost, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ButtonSize { + Small, + #[default] + Medium, + Large, + Icon, +} + +const DISABLED_OPACITY: f32 = 0.5; + +struct ButtonColors { + background: Color, + hover_background: Color, + pressed_background: Color, + disabled_background: Color, + foreground: Color, + disabled_foreground: Color, + border: Option, + hover_border: Option, + pressed_border: Option, + focus_border: Option, +} + +#[derive(Clone, PartialEq)] +pub struct Button { + size: ButtonSize, + variant: ButtonVariant, + + layout: LayoutData, + style: StyleState, + text_style: TextStyleData, + + enabled: bool, + focusable: bool, + alt: Option>, + on_press: Option>>, + + elements: Vec, + key: DiffKey, + cursor_icon: CursorIcon, +} + +impl Default for Button { + fn default() -> Self { + Self::new() + } +} + +impl Button { + pub fn new() -> Self { + let size = ButtonSize::default(); + let variant = ButtonVariant::default(); + + let mut layout = LayoutData::default(); + let mut style = StyleState::default(); + let mut text_style = TextStyleData::default(); + + let (padding, default_radius, font_size, width, height) = size_layout(size); + let colors = variant_colors(variant); + + layout.padding = padding; + layout.width = width; + layout.height = height; + + layout.content = Content::Flex; + layout.cross_alignment = Alignment::Center; + layout.main_alignment = Alignment::Center; + layout.direction = Direction::Horizontal; + layout.spacing.0 = 4.; + + style.background = Fill::Color(colors.background); + if let Some(border_color) = colors.border { + style.borders.push( + Border::new() + .fill(border_color) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + style.corner_radius = default_radius; + + text_style.font_size = Some(font_size.into()); + + Self { + size, + layout, + style, + text_style, + variant, + enabled: true, + focusable: true, + alt: None, + on_press: None, + elements: Vec::new(), + key: DiffKey::None, + cursor_icon: CursorIcon::Pointer, + } + } + + pub fn variant(mut self, variant: ButtonVariant) -> Self { + self.variant = variant; + self + } + + pub fn primary(self) -> Self { + self.variant(ButtonVariant::Primary) + } + + pub fn secondary(self) -> Self { + self.variant(ButtonVariant::Secondary) + } + + pub fn danger(self) -> Self { + self.variant(ButtonVariant::Danger) + } + + pub fn ghost(self) -> Self { + self.variant(ButtonVariant::Ghost) + } + + pub fn size(mut self, size: ButtonSize) -> Self { + self.size = size; + + let (padding, corner_radius, font_size, width, height) = size_layout(size); + self.layout.width = width; + self.layout.height = height; + self.layout.padding = padding; + self.style.corner_radius = corner_radius; + self.text_style.font_size = Some(font_size.into()); + + self + } + + pub fn font_size(mut self, font_size: impl Into) -> Self { + self.text_style.font_size = Some(font_size.into().into()); + self + } + + pub fn font_weight(mut self, weight: FontWeight) -> Self { + self.text_style.font_weight = Some(weight); + self + } + + pub fn small(self) -> Self { + self.size(ButtonSize::Small) + } + + pub fn medium(self) -> Self { + self.size(ButtonSize::Medium) + } + + pub fn large(self) -> Self { + self.size(ButtonSize::Large) + } + + pub fn icon(self) -> Self { + self.size(ButtonSize::Icon) + } + + pub fn enabled(mut self, enabled: impl Into) -> Self { + self.enabled = enabled.into(); + self + } + + pub fn focusable(mut self, focusable: impl Into) -> Self { + self.focusable = focusable.into(); + self + } + + pub fn disabled(self, disabled: bool) -> Self { + self.enabled(!disabled) + } + + pub fn on_press(mut self, on_press: impl Into>>) -> Self { + self.on_press = Some(on_press.into()); + self + } + + /// Accessible name for icon-only buttons which have no label to read out + pub fn alt(mut self, alt: impl Into>) -> Self { + self.alt = Some(alt.into()); + self + } + + pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self { + self.cursor_icon = cursor_icon; + self + } + + pub fn text(self, text: impl Into>) -> Self { + self.child(label().text(text)) + } +} + +impl ChildrenExt for Button { + fn get_children(&mut self) -> &mut Vec { + &mut self.elements + } +} + +impl KeyExt for Button { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for Button { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl ContainerExt for Button {} + +impl StyleExt for Button { + fn get_style(&mut self) -> &mut StyleState { + &mut self.style + } +} + +impl Component for Button { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let mut pressing = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let enabled = use_reactive(&self.enabled); + + let cursor_icon = self.cursor_icon; + + let palette = variant_colors(self.variant); + + let background = if !enabled() { + palette.disabled_background + } else if pressing() { + palette.pressed_background + } else if hovering() { + palette.hover_background + } else { + palette.background + }; + + let foreground = if enabled() { + palette.foreground + } else { + palette.disabled_foreground + }; + + // Child icons would otherwise inherit this through a `styled` event that lands a couple of + // frames after the button paints, so hand it straight to them instead + let mut icon_tint = use_state(|| foreground); + icon_tint.set_if_modified(foreground); + use_hook(|| provide_context(IconTint(icon_tint))); + + let border_color = if !enabled() { + palette.border + } else if pressing() { + palette.pressed_border + } else if hovering() { + palette.hover_border + } else if focus().is_focused() { + palette.focus_border.or(Some(Color::WHITE)) + } else { + palette.border + }; + + let on_press = self.on_press.clone(); + + let mut rect = rect() + .a11y_id(a11y_id) + .a11y_focusable(enabled() && self.focusable) + .a11y_role(AccessibilityRole::Button) + .map(self.alt.clone(), |rect, alt| rect.a11y_alt(alt)) + .overflow(Overflow::Clip) + .layout(self.layout.clone()) + .text_style(self.text_style.clone()) + .children(self.elements.clone()); + + rect.get_style().clone_from(&self.style); + + rect = rect.background(background).color(foreground); + + if !enabled() { + rect = rect.opacity(DISABLED_OPACITY); + } + + if let Some(border) = border_color { + rect = rect.border( + Border::new() + .fill(border) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + if enabled() { + rect = rect + .on_pointer_down(move |_| pressing.set(true)) + .on_global_pointer_press(move |_| pressing.set(false)) + .on_pointer_over(move |_| hovering.set(true)) + .on_pointer_out(move |_| { + hovering.set(false); + pressing.set(false); + }) + .cursor(cursor_icon) + .map(on_press.clone(), |rect, handler| { + rect.on_all_press(move |event: Event| { + handler.call(event); + }) + }); + } else { + rect = rect.cursor(CursorIcon::NotAllowed); + } + + rect + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +fn variant_colors(variant: ButtonVariant) -> ButtonColors { + match variant { + ButtonVariant::Primary => ButtonColors { + background: colors::brand(), + hover_background: colors::brand_hover(), + pressed_background: colors::brand_pressed(), + disabled_background: colors::brand_disabled(), + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: None, + hover_border: None, + pressed_border: None, + focus_border: None, + }, + ButtonVariant::Secondary => ButtonColors { + background: colors::component_bg(), + hover_background: colors::component_bg_hover(), + pressed_background: colors::component_bg_pressed(), + disabled_background: colors::component_bg_disabled(), + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: Some(colors::component_border()), + hover_border: Some(colors::component_border_hover()), + pressed_border: Some(colors::component_border_pressed()), + focus_border: Some(colors::brand()), + }, + ButtonVariant::Danger => ButtonColors { + background: colors::danger(), + hover_background: colors::danger_hover(), + pressed_background: colors::danger_pressed(), + disabled_background: colors::danger_disabled(), + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: None, + hover_border: None, + pressed_border: None, + focus_border: None, + }, + ButtonVariant::Ghost => ButtonColors { + background: Color::TRANSPARENT, + hover_background: colors::ghost_overlay_hover(), + pressed_background: colors::ghost_overlay_pressed(), + disabled_background: Color::TRANSPARENT, + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: None, + hover_border: None, + pressed_border: None, + focus_border: None, + }, + } +} + +fn size_layout(size: ButtonSize) -> (Gaps, CornerRadius, f32, Size, Size) { + match size { + ButtonSize::Small => ( + Gaps::new_symmetric(4., 8.), + CornerRadius::new_all(6.), + 12., + Size::auto(), + Size::auto(), + ), + ButtonSize::Medium => ( + Gaps::new_symmetric(6., 12.), + CornerRadius::new_all(8.), + 14., + Size::auto(), + Size::auto(), + ), + ButtonSize::Large => ( + Gaps::new_symmetric(8., 32.), + CornerRadius::new_all(10.), + 18., + Size::auto(), + Size::auto(), + ), + ButtonSize::Icon => ( + Gaps::new_all(6.), + CornerRadius::new_all(8.), + 14., + Size::px(32.), + Size::px(32.), + ), + } +} + +pub fn link_button() -> Button { + Button::new() + .primary() + .child( + Icon::new(IconType::LinkExternal01) + .size(14.) + .color(colors::fg_primary()), + ) + .child(label().text("Open")) +} + +pub fn open_folder_button(folder: std::path::PathBuf) -> Button { + Button::new() + .secondary() + .icon() + .on_press(move |_| { + std::fs::create_dir_all(&folder).ok(); + crate::platform::open_url(&folder.to_string_lossy()); + }) + .child(Icon::new(IconType::Folder).size(16.)) +} diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs new file mode 100644 index 00000000..b254b010 --- /dev/null +++ b/packages/oneclient_app/src/components/charts.rs @@ -0,0 +1,553 @@ +use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect}; +use freya::prelude::*; + +use crate::theme::colors; +use crate::utils::format_duration_hm; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ValueUnit { + Duration, + Count, +} + +impl ValueUnit { + pub fn format(self, value: i64) -> String { + match self { + ValueUnit::Duration => format_duration_hm(value), + ValueUnit::Count => format!("{value}×"), + } + } +} + +const DEFAULT_HEIGHT: f32 = 150.; +const Y_AXIS_WIDTH: f32 = 44.; + +pub const SLICE_COLORS: [Color; 10] = [ + Color::from_rgb(43, 75, 255), + Color::from_rgb(27, 217, 106), + Color::from_rgb(241, 100, 54), + Color::from_rgb(198, 120, 221), + Color::from_rgb(97, 175, 239), + Color::from_rgb(229, 192, 123), + Color::from_rgb(35, 154, 96), + Color::from_rgb(224, 108, 117), + Color::from_rgb(120, 129, 141), + Color::from_rgb(152, 195, 121), +]; + +pub fn slice_color(i: usize) -> Color { + SLICE_COLORS[i % SLICE_COLORS.len()] +} + +const PIE_SIZE: f32 = 190.; +/// Fraction of the radius +const PIE_HOLE: f32 = 0.58; + +pub struct PieChart { + values: Vec, + labels: Vec, + unit: ValueUnit, + size: f32, + hovered: Option>>, +} + +impl PieChart { + pub fn new(values: Vec, labels: Vec) -> Self { + Self { + values, + labels, + unit: ValueUnit::Duration, + size: PIE_SIZE, + hovered: None, + } + } + + pub fn unit(mut self, unit: ValueUnit) -> Self { + self.unit = unit; + self + } + + pub fn hovered(mut self, hovered: State>) -> Self { + self.hovered = Some(hovered); + self + } +} + +impl PartialEq for PieChart { + fn eq(&self, other: &Self) -> bool { + self.values == other.values + && self.labels == other.labels + && self.unit == other.unit + && self.size == other.size + } +} + +impl Component for PieChart { + fn render(&self) -> impl IntoElement { + let internal = use_state(|| Option::::None); + let mut hovered = self.hovered.unwrap_or(internal); + let active = *hovered.read(); + + let size = self.size; + let total: i64 = self.values.iter().sum(); + let values = self.values.clone(); + let hit_values = self.values.clone(); + + let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let (w, h) = (ctx.size.width, ctx.size.height); + if w <= 0.0 || h <= 0.0 { + return; + } + + let cx = w * 0.5; + let cy = h * 0.5; + let radius = (w.min(h) * 0.5) - 6.0; + if radius <= 0.0 { + return; + } + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + + if total <= 0 { + paint.set_color(SkColor::from(colors::component_bg_hover())); + ctx.canvas.draw_circle((cx, cy), radius - 5.0, &paint); + paint.set_color(SkColor::from(colors::page_elevated())); + ctx.canvas + .draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint); + return; + } + + let mut start = -90.0_f32; + for (i, &v) in values.iter().enumerate() { + if v <= 0 { + continue; + } + let sweep = (v as f32 / total as f32) * 360.0; + let is_active = Some(i) == active; + let r = if is_active { radius } else { radius - 5.0 }; + let color = slice_color(i); + let color = if active.is_some() && !is_active { + color.with_a(120) + } else { + color + }; + paint.set_color(SkColor::from(color)); + + let oval = SkRect::new(cx - r, cy - r, cx + r, cy + r); + let mut builder = PathBuilder::new(); + builder.move_to((cx, cy)); + builder.arc_to(oval, start, sweep, false); + builder.close(); + ctx.canvas.draw_path(&builder.detach(), &paint); + + start += sweep; + } + + paint.set_color(SkColor::from(colors::page_elevated())); + ctx.canvas + .draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint); + }); + + let (center_name, center_value) = match active { + Some(i) if i < self.values.len() => ( + self.labels + .get(i) + .cloned() + .unwrap_or_else(|| format!("#{}", i + 1)), + self.unit.format(self.values[i]), + ), + _ => ("Total".to_string(), self.unit.format(total)), + }; + + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .on_pointer_move(move |e: Event| { + let loc = e.element_location(); + let next = slice_at(&hit_values, loc.x as f32, loc.y as f32, size); + if *hovered.peek() != next { + hovered.set(next); + } + }) + .on_pointer_leave(move |_| { + if hovered.peek().is_some() { + hovered.set(None); + } + }) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + // Childless canvas auto-sizes to 0x0 without an explicit size + .child(canvas(render_cb).width(Size::fill()).height(Size::fill())), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + // Sibling absolute rects on an equal layer paint in undefined order + .layer(Layer::Relative(1)) + .center() + .padding(Gaps::new_symmetric(0., size * 0.25)) + .child( + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(2.) + .child( + label() + .text(center_value) + .font_size(16.) + .font_weight(FontWeight::BOLD) + .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_primary()), + ) + .child( + label() + .text(center_name) + .font_size(10.) + .max_lines(2) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ), + ) + } +} + +fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option { + let total: i64 = values.iter().sum(); + if total <= 0 { + return None; + } + + let cx = size * 0.5; + let cy = size * 0.5; + let radius = (size * 0.5) - 6.0; + let (dx, dy) = (x - cx, y - cy); + let dist = (dx * dx + dy * dy).sqrt(); + if dist > radius || dist < (radius - 5.0) * PIE_HOLE { + return None; + } + + // Clockwise from 12 o'clock matching the drawing order + let mut angle = dy.atan2(dx).to_degrees() + 90.0; + if angle < 0.0 { + angle += 360.0; + } + + let mut start = 0.0_f32; + for (i, &v) in values.iter().enumerate() { + if v <= 0 { + continue; + } + let sweep = (v as f32 / total as f32) * 360.0; + if angle >= start && angle < start + sweep { + return Some(i); + } + start += sweep; + } + None +} + +#[derive(PartialEq)] +pub struct BarChart { + values: Vec, + labels: Vec, + highlight: Option, + unit: ValueUnit, + height: f32, + gap: f32, +} + +impl BarChart { + pub fn new(values: Vec, labels: Vec) -> Self { + Self { + values, + labels, + highlight: None, + unit: ValueUnit::Duration, + height: DEFAULT_HEIGHT, + gap: 4., + } + } + + pub fn highlight(mut self, highlight: Option) -> Self { + self.highlight = highlight; + self + } + + pub fn unit(mut self, unit: ValueUnit) -> Self { + self.unit = unit; + self + } + + #[allow(dead_code)] + pub fn height(mut self, height: f32) -> Self { + self.height = height; + self + } + + pub fn gap(mut self, gap: f32) -> Self { + self.gap = gap; + self + } + + fn readout_name(&self, i: usize) -> String { + self.labels + .get(i) + .cloned() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("#{}", i + 1)) + } +} + +impl Component for BarChart { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| Option::::None); + let active = *hovered.read(); + let mut plot_width = use_state(|| 0f32); + + let unit = self.unit; + let height = self.height; + let max = self.values.iter().copied().max().unwrap_or(0); + + let focus = active.or(self.highlight); + let (readout_label, readout_value) = match focus { + Some(i) if i < self.values.len() => (self.readout_name(i), unit.format(self.values[i])), + _ => { + let total: i64 = self.values.iter().sum(); + ("Total".to_string(), unit.format(total)) + } + }; + + let mut bars = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::fill()) + .cross_align(Alignment::End) + .spacing(self.gap) + .on_pointer_leave(move |_| *hovered.write() = None); + + for (i, &v) in self.values.iter().enumerate() { + let frac = if max > 0 { v as f32 / max as f32 } else { 0.0 }; + let h = if v > 0 { (frac * height).max(4.0) } else { 0.0 }; + let is_active = Some(i) == focus; + let color = if is_active { + colors::brand() + } else if active.is_some() { + colors::component_bg_hover().with_a(150) + } else { + colors::component_bg_hover() + }; + + bars = bars.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .main_align(Alignment::End) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| *hovered.write() = Some(i)) + .child( + rect() + .width(Size::fill()) + .height(Size::px(h)) + .corner_radius(CornerRadius::new(4., 4., 0., 0.)) + .background(color), + ), + ); + } + + let stride = label_stride(&self.labels, *plot_width.read(), self.gap); + let mut label_row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(14.)) + .spacing(self.gap); + for (i, text) in self.labels.iter().enumerate() { + let shown = i % stride == 0; + label_row = label_row.child( + rect() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .child( + label() + .text(if shown { text.clone() } else { String::new() }) + .font_size(10.) + .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ); + } + + let plot = rect() + .width(Size::fill()) + .height(Size::px(height)) + .overflow(Overflow::Clip) + .child(gridlines(height)) + .child(bars); + + let plot_row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .spacing(6.) + .child(y_axis(max, unit, height)) + .child( + rect() + .width(Size::flex(1.0)) + .on_sized(move |e: Event| { + let w = e.area.width(); + if (*plot_width.peek() - w).abs() > 0.5 { + plot_width.set(w); + } + }) + .child(plot), + ); + + let labels_row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .spacing(6.) + .child(rect().width(Size::px(Y_AXIS_WIDTH))) + .child(rect().width(Size::flex(1.0)).child(label_row)); + + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child(readout(readout_label, readout_value)) + .child(plot_row) + .child(labels_row) + } +} + +/// Bars to skip between shown labels derived from measured plot width +fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize { + let n = labels.len(); + if n == 0 { + return 1; + } + // Before the first measurement thin conservatively to avoid an overlap flash + if plot_width <= 0.0 { + return (n / 12).max(1); + } + + let max_chars = labels + .iter() + .map(|s| s.chars().count()) + .max() + .unwrap_or(0) + .max(1); + // ~6.2px per char at font_size 10 + let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0; + let max_labels = (plot_width / slot_px).floor().max(1.0) as usize; + + if n <= max_labels { + 1 + } else { + n.div_ceil(max_labels) + } +} + +fn gridlines(height: f32) -> Element { + let mut overlay = rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute().top(0.).left(0.)) + .interactive(false); + + for frac in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { + let top = ((1.0 - frac) * height - if frac == 0.0 { 1.0 } else { 0.0 }).max(0.); + overlay = overlay.child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .position(Position::new_absolute().left(0.).top(top)) + .background(colors::component_border().with_a(110)), + ); + } + overlay.into_element() +} + +fn y_axis(max: i64, unit: ValueUnit, height: f32) -> Element { + let fmt = |v: i64| match unit { + ValueUnit::Duration => format_duration_hm(v), + ValueUnit::Count => v.to_string(), + }; + + let tick = |text: String| { + label() + .text(text) + .font_size(9.) + .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Right) + .color(colors::fg_secondary().with_a(160)) + }; + + rect() + .vertical() + .width(Size::px(Y_AXIS_WIDTH)) + .height(Size::px(height)) + .main_align(Alignment::SpaceBetween) + .child(tick(fmt(max))) + .child(tick(fmt(max / 2))) + .child(tick(fmt(0))) + .into_element() +} + +fn readout(name: String, value: String) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .height(Size::px(20.)) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(3., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .border(crate::ui::border_all_color(1., colors::component_border())) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::brand()), + ) + .child( + label() + .text(name) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(value) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs new file mode 100644 index 00000000..cde8c815 --- /dev/null +++ b/packages/oneclient_app/src/components/cluster_landscape_art.rs @@ -0,0 +1,45 @@ +use freya::prelude::*; +use oneclient_common::VersionKey; +use oneclient_common::domain::GameLoader; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[derive(PartialEq, Clone)] +pub struct ClusterLandscapeArt { + art: DynamicArt, + selected: bool, +} + +impl ClusterLandscapeArt { + pub fn for_version( + major: u32, + key: Option, + loader: Option, + selected: bool, + ) -> Self { + Self { + art: DynamicArt::for_version(major, key, loader).max_edge(ART_PREVIEW_EDGE), + selected, + } + } +} + +impl Component for ClusterLandscapeArt { + fn render(&self) -> impl IntoElement { + let border = if self.selected { + border_all_color(2., colors::brand()) + } else { + border_all_color(2., colors::component_border()) + }; + + rect() + .width(Size::fill()) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .border(border.alignment(BorderAlignment::Inner)) + .child(self.art.clone()) + } +} diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs new file mode 100644 index 00000000..1e7e794a --- /dev/null +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -0,0 +1,383 @@ +use std::collections::HashMap; + +use freya::{prelude::*, router::RouterContext}; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; + +use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem}; +use crate::hooks::{ + package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, +}; +use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 420.; +const DIALOG_H: f32 = 400.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum UpdateTab { + Updates, + Additions, + Removals, +} + +impl UpdateTab { + const ALL: [UpdateTab; 3] = [ + UpdateTab::Updates, + UpdateTab::Additions, + UpdateTab::Removals, + ]; + + fn label(self) -> &'static str { + match self { + UpdateTab::Updates => "Updated", + UpdateTab::Additions => "Added", + UpdateTab::Removals => "Removed", + } + } + + fn empty_text(self) -> &'static str { + match self { + UpdateTab::Updates => "Nothing was updated.", + UpdateTab::Additions => "Nothing was added.", + UpdateTab::Removals => "Nothing was removed.", + } + } + + fn icon(self) -> IconType { + match self { + UpdateTab::Updates => IconType::RefreshCw01, + UpdateTab::Additions => IconType::Plus, + UpdateTab::Removals => IconType::Trash01, + } + } + + fn accent(self) -> Color { + match self { + UpdateTab::Updates => colors::brand(), + UpdateTab::Additions => colors::success(), + UpdateTab::Removals => colors::danger(), + } + } + + fn items(self, summary: &ClusterUpdateSummary) -> &[ClusterUpdateItem] { + match self { + UpdateTab::Updates => &summary.updated, + UpdateTab::Additions => &summary.added, + UpdateTab::Removals => &summary.removed, + } + } +} + +struct ClusterGroup { + cluster_id: i64, + cluster_name: String, + names: Vec, +} + +#[derive(PartialEq)] +pub struct ClusterUpdatePopup; + +impl Component for ClusterUpdatePopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let active = use_state(|| UpdateTab::Updates); + + let summaries = snapshot.cluster_update.clone(); + + // Hooks must run unconditionally so query every provider before the early return below + let all_items: Vec<&ClusterUpdateItem> = summaries + .iter() + .flatten() + .flat_map(|s| s.updated.iter().chain(&s.added).chain(&s.removed)) + .collect(); + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = all_items + .iter() + .filter(|i| i.provider == provider) + .filter_map(|i| i.project_id.clone()) + .collect(); + let query = use_package_meta_batch(provider, ids); + for (pid, m) in package_meta_batch(&query) { + meta.insert((provider, pid), m); + } + } + + let Some(summaries) = summaries.filter(|s| !s.is_empty()) else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.close_cluster_update()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&summaries, &meta, active, dispatch)), + ) + .into_element() + } +} + +fn resolve_name(item: &ClusterUpdateItem, meta: &MetaMap) -> String { + item.project_id + .as_ref() + .and_then(|pid| meta.get(&(item.provider, pid.clone()))) + .map(|m| m.name.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| item.fallback.clone()) +} + +fn dialog( + summaries: &[ClusterUpdateSummary], + meta: &MetaMap, + active: State, + dispatch: crate::Actions, +) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .overflow(Overflow::Clip) + .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), + ))) + .child(content(summaries, meta, active, dispatch)) +} + +fn content( + summaries: &[ClusterUpdateSummary], + meta: &MetaMap, + active: State, + dispatch: crate::Actions, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + let total: usize = summaries.iter().map(|s| s.total()).sum(); + let active_tab = *active.read(); + + // A batch sync can touch several clusters single-cluster keeps the footer shortcut + let single = match summaries { + [only] => Some((only.cluster_id, only.cluster_name.clone())), + _ => None, + }; + + let subtitle = match &single { + Some((_, name)) => format!( + "{total} change{} synced to {name}.", + if total == 1 { "" } else { "s" } + ), + None => format!( + "{total} change{} across {} clusters.", + if total == 1 { "" } else { "s" }, + summaries.len() + ), + }; + + // Every category is shown even at zero so the modal shape stays stable + let tabs = TabBar::new() + .width(Size::fill()) + .height(Size::px(30.)) + .spacing(20.) + .tabs(UpdateTab::ALL.into_iter().map(|tab| { + let count: usize = summaries.iter().map(|s| tab.items(s).len()).sum(); + let mut set = active; + TabItem::new(tab.label(), tab == active_tab) + .count_text(count.to_string()) + .on_press(move |_| *set.write() = tab) + })); + + // Clusters empty in the active category drop out avoiding empty section headers + let groups: Vec = summaries + .iter() + .filter_map(|summary| { + let names: Vec = active_tab + .items(summary) + .iter() + .map(|item| resolve_name(item, meta)) + .collect(); + (!names.is_empty()).then(|| ClusterGroup { + cluster_id: summary.cluster_id, + cluster_name: summary.cluster_name.clone(), + names, + }) + }) + .collect(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text("Changes applied") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child(tabs) + .child(change_list( + active_tab, + &groups, + single.is_none(), + &dispatch, + )) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_cluster_update()) + .text("Dismiss"), + ) + .maybe_child(single.map(|(cluster_id, _)| { + let open = dispatch.clone(); + Button::new() + .primary() + .on_press(move |_| open_cluster(&open, cluster_id)) + .text("Open cluster") + .child(Icon::new(IconType::ArrowRight).size(15.)) + .into_element() + })), + ) +} + +fn open_cluster(dispatch: &crate::Actions, cluster_id: i64) { + dispatch.close_cluster_update(); + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); +} + +fn change_list( + tab: UpdateTab, + groups: &[ClusterGroup], + show_headers: bool, + dispatch: &crate::Actions, +) -> impl IntoElement { + let accent = tab.accent(); + + let mut scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(1.); + + if groups.is_empty() { + return scroll + .child( + rect() + .width(Size::fill()) + .height(Size::px(80.)) + .center() + .child( + label() + .text(tab.empty_text()) + .font_size(12.5) + .color(colors::fg_secondary()), + ), + ) + .into_element(); + } + + for (index, group) in groups.iter().enumerate() { + if show_headers { + scroll = scroll.child(cluster_header(group, index == 0, dispatch.clone())); + } + + for name in &group.names { + scroll = scroll.child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(9.) + .padding(Gaps::new_symmetric(5., 6.)) + .child(Icon::new(tab.icon()).size(13.).color(accent)) + .child( + label() + .text(name.clone()) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ), + ); + } + } + + scroll.into_element() +} + +/// Multi-cluster stand-in for the footer's "Open cluster" shortcut +fn cluster_header(group: &ClusterGroup, first: bool, dispatch: crate::Actions) -> impl IntoElement { + let cluster_id = group.cluster_id; + let count = group.names.len(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(6., 8.)) + .margin(Gaps::new(if first { 0. } else { 8. }, 0., 3., 0.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| open_cluster(&dispatch, cluster_id)) + .child( + label() + .text(group.cluster_name.clone()) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + label() + .text(count.to_string()) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + Icon::new(IconType::ArrowRight) + .size(12.) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/components/context_menu.rs b/packages/oneclient_app/src/components/context_menu.rs new file mode 100644 index 00000000..a2d1a3eb --- /dev/null +++ b/packages/oneclient_app/src/components/context_menu.rs @@ -0,0 +1,212 @@ +use freya::prelude::*; + +use crate::components::{Icon, IconType, OverlayPopup}; +use crate::theme::colors; + +const MENU_BG: Color = Color::from_rgb(25, 32, 38); +const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255); +const MENU_FG: Color = Color::from_rgb(155, 161, 166); +const MENU_DANGER: Color = Color::from_rgb(242, 84, 90); + +enum Entry { + Action { + icon: IconType, + label: String, + danger: bool, + on_select: EventHandler<()>, + }, + Separator, +} + +pub struct ContextMenu { + x: f32, + y: f32, + entries: Vec, + on_close: EventHandler<()>, +} + +impl ContextMenu { + pub fn new(x: f32, y: f32) -> Self { + Self { + x, + y, + entries: Vec::new(), + on_close: (|()| {}).into(), + } + } + + pub fn on_close(mut self, on_close: impl Into>) -> Self { + self.on_close = on_close.into(); + self + } + + pub fn action( + mut self, + icon: IconType, + label: impl Into, + on_select: impl Into>, + ) -> Self { + self.entries.push(Entry::Action { + icon, + label: label.into(), + danger: false, + on_select: on_select.into(), + }); + self + } + + pub fn danger_action( + mut self, + icon: IconType, + label: impl Into, + on_select: impl Into>, + ) -> Self { + self.entries.push(Entry::Action { + icon, + label: label.into(), + danger: true, + on_select: on_select.into(), + }); + self + } + + pub fn separator(mut self) -> Self { + self.entries.push(Entry::Separator); + self + } +} + +impl PartialEq for ContextMenu { + fn eq(&self, _other: &Self) -> bool { + // Always unequal the menu is rebuilt from scratch whenever it is reopened + false + } +} + +impl Component for ContextMenu { + fn render(&self) -> impl IntoElement { + let on_close = self.on_close.clone(); + + let mut width = use_state(|| 0f32); + + let item_width = { + let w = *width.read(); + (w > 0.).then_some(w) + }; + + let mut list = rect().vertical().spacing(4.); + for entry in &self.entries { + list = match entry { + Entry::Separator => { + let mut sep = rect() + .height(Size::px(1.)) + .margin(Gaps::new_symmetric(4., 0.)) + .background(MENU_BORDER); + if let Some(w) = item_width { + sep = sep.width(Size::px(w)); + } + list.child(sep) + } + Entry::Action { + icon, + label, + danger, + on_select, + } => list.child( + ContextMenuRow { + icon: *icon, + label: label.clone(), + danger: *danger, + width: item_width, + on_select: on_select.clone(), + on_close: on_close.clone(), + } + .into_element(), + ), + }; + } + + let list = list.on_sized(move |e: Event| { + let measured = e.data().area.width(); + if (measured - *width.peek()).abs() > 0.5 { + width.set(measured); + } + }); + + let panel = rect() + .vertical() + .padding(Gaps::new_all(6.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(MENU_BG) + .border(Border::new().fill(MENU_BORDER).width(BorderWidth { + top: 1., + right: 1., + bottom: 1., + left: 1., + })) + .child(list); + + OverlayPopup::new() + .backdrop(false) + .position(Position::new_global().top(self.y).left(self.x)) + .on_close(move |_| on_close.call(())) + .child(panel.into_element()) + } +} + +#[derive(PartialEq)] +struct ContextMenuRow { + icon: IconType, + label: String, + danger: bool, + width: Option, + on_select: EventHandler<()>, + on_close: EventHandler<()>, +} + +impl Component for ContextMenuRow { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let on_select = self.on_select.clone(); + let on_close = self.on_close.clone(); + + let base = if self.danger { MENU_DANGER } else { MENU_FG }; + let color = if *hovered.read() { + colors::fg_primary() + } else { + base + }; + + let mut root = rect(); + if let Some(w) = self.width { + root = root.width(Size::px(w)); + } + + root.horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if *hovered.read() { + colors::component_bg_hover() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| { + on_select.call(()); + on_close.call(()); + }) + .child(Icon::new(self.icon).size(18.).color(color)) + .child( + label() + .text(self.label.clone()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(color), + ) + } +} diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs new file mode 100644 index 00000000..5ebecd2c --- /dev/null +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -0,0 +1,248 @@ +use freya::prelude::*; + +use crate::components::{Icon, IconType, ScrollArea}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const OPTION_HEIGHT: f32 = 28.; +const OPTION_SPACING: f32 = 2.; +const MAX_VISIBLE_OPTIONS: usize = 8; + +#[derive(Clone, PartialEq)] +pub struct Dropdown { + selected: String, + options: Vec, + on_select: Option>, + width: Size, + height: Size, + key: DiffKey, +} + +impl Dropdown { + pub fn new(selected: impl Into, options: Vec) -> Self { + Self { + selected: selected.into(), + options, + on_select: None, + width: Size::px(72.), + height: Size::px(24.), + key: DiffKey::None, + } + } + + #[allow(dead_code)] + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + #[allow(dead_code)] + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + pub fn on_select(mut self, handler: impl Into>) -> Self { + self.on_select = Some(handler.into()); + self + } +} + +impl KeyExt for Dropdown { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for Dropdown { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let mut button_area = use_state(|| None::); + let mut list_size = use_state(|| None::); + + let selected = self.selected.clone(); + let options = self.options.clone(); + let on_select = self.on_select.clone(); + let is_open = open(); + + if !is_open && list_size().is_some() { + let _ = list_size.take(); + } + + let trigger_bg = if hovering() { + colors::ghost_overlay_hover() + } else { + colors::ghost_overlay() + }; + + 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 offset_y = match (button_area(), list_size()) { + (Some(button), Some(list)) => { + let root_height = Platform::get().root_size.peek().height; + let space_below = root_height - button.max_y(); + let space_above = button.min_y(); + if list.height > space_below && list.height <= space_above { + -(button.height() + list.height + 8.) + } else { + 0. + } + } + _ => 0., + }; + + let list_width = button_area() + .map(|b| Size::px(b.width())) + .unwrap_or_else(|| self.width.clone()); + + let on_global_pointer_press = move |_: Event| { + open.set_if_modified(false); + }; + + rect() + .width(self.width.clone()) + .child( + rect() + .width(Size::fill()) + .height(self.height.clone()) + .horizontal() + .center() + .spacing(4.) + .padding(Gaps::new_symmetric(0., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(trigger_bg) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .maybe(focus().is_focused(), |el| { + el.border(border_all_color(1., colors::brand())) + }) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_global_pointer_press(on_global_pointer_press) + .on_sized(move |e: Event| { + button_area.set_if_modified(Some(e.area)); + }) + .on_all_press(move |e: Event| { + e.prevent_default(); + e.stop_propagation(); + open.toggle(); + }) + .child( + label() + .text(selected) + .font_size(12.) + .color(colors::fg_primary()), + ) + .child( + Icon::new(IconType::ChevronDown) + .size(14.) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_open.then(|| { + rect().width(Size::px(0.)).height(Size::px(0.)).child( + rect() + .layer(Layer::Overlay) + .width(list_width) + .margin(Gaps::new(4., 0., 0., 0.)) + .offset_y(offset_y) + .content(Content::Fit) + .on_sized(move |e: Event| { + list_size.set_if_modified(Some(e.area.size)); + }) + .child( + rect() + .width(Size::fill()) + .padding(4.) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .height(Size::px(list_h)) + .spacing(OPTION_SPACING) + .children(options.into_iter().enumerate().map( + |(idx, option)| { + let on_select = on_select.clone(); + DropdownOption::new(option, move |_| { + if let Some(handler) = &on_select { + handler.call(idx); + } + open.set(false); + }) + .into_element() + }, + )), + ), + ), + ) + })) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +struct DropdownOption { + text: String, + on_press: EventHandler>, +} + +impl DropdownOption { + pub fn new( + text: impl Into, + on_press: impl Into>>, + ) -> Self { + Self { + text: text.into(), + on_press: on_press.into(), + } + } +} + +impl PartialEq for DropdownOption { + fn eq(&self, other: &Self) -> bool { + self.text == other.text + } +} + +impl Component for DropdownOption { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(6., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_all_press(self.on_press.clone()) + .child( + label() + .text(self.text.clone()) + .font_size(12.) + .color(colors::fg_primary()), + ) + } +} diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs new file mode 100644 index 00000000..62f9a1cc --- /dev/null +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -0,0 +1,126 @@ +use bytes::Bytes; +use freya::prelude::*; +use oneclient_common::domain::GameLoader; +use oneclient_common::{VersionKey, parse_mc_version}; +use oneclient_core::clusters::Cluster; +use oneclient_core::images::DEFAULT_IMAGE_EDGE; + +use crate::AppAssets; +use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; +use crate::layout::HOME_BACKGROUND_ASSET; + +/// The size cluster cards request so it is usually already cached when anything else wants it +pub const ART_PREVIEW_EDGE: u32 = 512; + +#[derive(PartialEq, Clone)] +pub struct DynamicArt { + major: Option, + key: Option, + loader: Option, + max_edge: u32, + preview_edge: Option, +} + +impl DynamicArt { + pub fn for_version(major: u32, key: Option, loader: Option) -> Self { + Self { + major: Some(major), + key, + loader, + max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, + } + } + + pub fn for_cluster(cluster: &Cluster) -> Self { + let parsed = parse_mc_version(&cluster.mc_version); + Self { + major: parsed.as_ref().map(|p| p.major), + key: parsed.and_then(|p| p.key()), + loader: Some(cluster.mc_loader), + max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, + } + } + + pub fn fallback() -> Self { + Self { + major: None, + key: None, + loader: None, + max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, + } + } + + #[must_use] + pub fn max_edge(mut self, max_edge: u32) -> Self { + self.max_edge = max_edge; + self + } + + /// Stand in with a smaller cached variant until the full-size art downloads + #[must_use] + pub fn preview_edge(mut self, preview_edge: u32) -> Self { + self.preview_edge = Some(preview_edge); + self + } + + pub fn use_bytes(&self) -> (String, Bytes) { + use_art_bytes( + self.major, + self.key, + self.loader, + self.max_edge, + self.preview_edge, + ) + } +} + +pub fn use_art_bytes( + major: Option, + key: Option, + loader: Option, + max_edge: u32, + preview_edge: Option, +) -> (String, Bytes) { + let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default()); + + let art_url = use_version_metadata(major, key, loader).and_then(|m| m.art_url); + + let image_query = use_cached_image(art_url.clone(), max_edge); + + let preview_edge = preview_edge.filter(|edge| *edge != max_edge); + let preview_query = match preview_edge { + Some(edge) => use_cached_image(art_url.clone(), edge), + None => use_cached_image(None, 0), + }; + + let full = loaded_image(art_url.as_deref(), &image_query) + .map(|(url, bytes)| (format!("{max_edge}|{url}"), bytes)); + let preview = || { + let edge = preview_edge?; + loaded_image(art_url.as_deref(), &preview_query) + .map(|(url, bytes)| (format!("{edge}|{url}"), bytes)) + }; + + full.or_else(preview).unwrap_or_else(|| { + ( + format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), + fallback.read().clone(), + ) + }) +} + +impl Component for DynamicArt { + fn render(&self) -> impl IntoElement { + let (key, bytes) = self.use_bytes(); + + ImageViewer::new((key, bytes)) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs new file mode 100644 index 00000000..a363dc2e --- /dev/null +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -0,0 +1,489 @@ +//! Freya emits one `FileDrop` event per file within a single batch so the shell +//! handler accumulates them before the prompt first renders + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; +use freya::prelude::*; +use freya::router::use_route; +use oneclient_content::packages::ContentType; +use oneclient_core::clusters::Cluster; +use oneclient_db::models::ClusterId; + +use crate::Route; +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const DIALOG_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 480.; +const FILE_ROW_H: f32 = 34.; +const FILE_LIST_MAX_ROWS: usize = 5; +const TYPE_DROPDOWN_W: f32 = 108.; + +/// Worlds are directories and modpacks/datapacks have no view so both are excluded +const IMPORTABLE: [ContentType; 3] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, +]; + +const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"]; + +fn content_label(content_type: ContentType) -> &'static str { + match content_type { + ContentType::ResourcePack => "Textures", + ContentType::Shader => "Shaders", + _ => "Mods", + } +} + +fn extension(path: &Path) -> Option { + Some(path.extension()?.to_str()?.to_ascii_lowercase()) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +/// Precedence `.jar` is always a mod then the current route then a name sniff +fn infer_content_type(path: &Path, route_type: Option) -> ContentType { + if extension(path).as_deref() == Some("jar") { + return ContentType::Mod; + } + if let Some(route_type) = route_type { + return route_type; + } + let name = file_name(path).to_lowercase(); + if SHADER_HINTS.iter().any(|hint| name.contains(hint)) { + ContentType::Shader + } else { + ContentType::ResourcePack + } +} + +fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { + match route { + Route::ClusterMods { cluster_id } => Some((*cluster_id, ContentType::Mod)), + Route::ClusterShaders { cluster_id } => Some((*cluster_id, ContentType::Shader)), + Route::ClusterTextures { cluster_id } => Some((*cluster_id, ContentType::ResourcePack)), + _ => None, + } +} + +/// State lives in the shell (owner of the bubbling `on_file_drop`) so it never re-renders on hover +#[derive(PartialEq)] +pub struct FileDropOverlay { + pub hovering: State, + pub pending: State>, +} + +impl Component for FileDropOverlay { + fn render(&self) -> impl IntoElement { + let files = self.pending.read().clone(); + + if !files.is_empty() { + return DropPrompt { + files, + pending: self.pending, + } + .into_element(); + } + + if !*self.hovering.read() { + return rect().into_element(); + } + + DropVeil.into_element() + } +} + +#[derive(PartialEq)] +struct DropVeil; + +impl Component for DropVeil { + fn render(&self) -> impl IntoElement { + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(170) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let progress = intro.read().value(); + + rect() + .layer(Layer::Overlay) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + // Purely decorative the drop must reach the shell handler beneath + .interactive(false) + .center() + .opacity(progress) + .background(Color::from_argb(190, 10, 14, 20)) + .child( + rect() + .vertical() + .center() + .spacing(10.) + .padding(Gaps::new_symmetric(22., 28.)) + .max_width(Size::px(360.)) + .offset_y((1. - progress) * 12.) + .corner_radius(CornerRadius::new_all(16.)) + .background(DIALOG_BG) + .border(border_all_color(1., colors::brand().with_a(120))) + .shadow(Shadow::from(( + 0., + 16., + 44., + 0., + Color::from_argb(160, 0, 0, 0), + ))) + .child(icon_badge(IconType::FolderDownload, 52., 22.)) + .child( + label() + .text("Drop files to add them") + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("You'll pick where they go next.") + .font_size(12.5) + .color(colors::fg_secondary()), + ), + ) + } +} + +fn icon_badge(icon: IconType, size: f32, icon_size: f32) -> impl IntoElement { + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .center() + .corner_radius(CornerRadius::new_all(size / 2.)) + .background(colors::brand().with_a(46)) + .child(Icon::new(icon).size(icon_size).color(colors::brand())) +} + +#[derive(PartialEq)] +struct DropPrompt { + files: Vec, + pending: State>, +} + +impl Component for DropPrompt { + fn render(&self) -> impl IntoElement { + let route = use_route::(); + let active_cluster = use_active_cluster_id(); + let clusters_query = use_clusters(); + let dispatch = use_dispatch(); + + let files = self.files.clone(); + let mut pending = self.pending; + + let target = route_target(&route); + let initial_cluster = target.map(|(id, _)| id).or(*active_cluster.peek()); + let selected_cluster = use_state(move || initial_cluster); + // Absent entries fall back to the inferred type covering drops landing mid-prompt + let overrides = use_state(HashMap::::new); + + let clusters: Vec = settled_or_loading(&clusters_query).unwrap_or_default(); + + let body = if clusters.is_empty() { + no_clusters_body(pending) + } else { + prompt_body( + &files, + &clusters, + target.map(|(_, ct)| ct), + selected_cluster, + overrides, + dispatch, + pending, + ) + }; + + OverlayPopup::new() + .on_close(move |_| pending.set(Vec::new())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .max_width(Size::window_percent(94.)) + .padding(Gaps::new_all(22.)) + .spacing(16.) + .corner_radius(CornerRadius::new_all(16.)) + .background(DIALOG_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) + .child(body), + ), + ) + .into_element() + } +} + +fn dialog_header(title: String, subtitle: String) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + // Without Content::Flex the flexed text column doesn't resolve and the + // badge stops centring against it + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child(icon_badge(IconType::FilePlus02, 38., 18.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(title) + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) +} + +fn no_clusters_body(mut pending: State>) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(dialog_header( + "Nowhere to put these".to_string(), + "Create a cluster first, then drop the files again.".to_string(), + )) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .primary() + .on_press(move |_| pending.set(Vec::new())) + .text("Close"), + ), + ) + .into_element() +} + +fn prompt_body( + files: &[PathBuf], + clusters: &[Cluster], + route_type: Option, + selected_cluster: State>, + overrides: State>, + dispatch: crate::Actions, + mut pending: State>, +) -> Element { + // The remembered cluster may have been deleted while the prompt was open + let cluster_id = selected_cluster + .read() + .filter(|id| clusters.iter().any(|c| c.id == *id)) + .unwrap_or(clusters[0].id); + let cluster_idx = clusters + .iter() + .position(|c| c.id == cluster_id) + .unwrap_or_default(); + + let resolved: Vec<(PathBuf, ContentType)> = files + .iter() + .map(|path| { + let content_type = overrides + .read() + .get(path) + .copied() + .unwrap_or_else(|| infer_content_type(path, route_type)); + (path.clone(), content_type) + }) + .collect(); + + let import_list = resolved.clone(); + let import = move |_| { + for (path, content_type) in &import_list { + dispatch.import_local_file(cluster_id, *content_type, path.clone()); + } + pending.set(Vec::new()); + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(dialog_header( + format!( + "Add {} file{}", + files.len(), + if files.len() == 1 { "" } else { "s" } + ), + "Pick a cluster, then check what each one gets installed as.".to_string(), + )) + .child(cluster_field(clusters, cluster_idx, selected_cluster)) + .child(file_field(&resolved, overrides)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| pending.set(Vec::new())) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(import) + .child(Icon::new(IconType::Plus).size(15.)) + .text(match files.len() { + 1 => "Add file".to_string(), + n => format!("Add {n} files"), + }), + ), + ) + .into_element() +} + +fn field(title: &'static str, control: impl IntoElement) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(7.) + .child( + label() + .text(title) + .font_size(11.5) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child(control) +} + +fn cluster_field( + clusters: &[Cluster], + selected_idx: usize, + mut selected: State>, +) -> impl IntoElement { + let labels: Vec = clusters + .iter() + .map(|c| format!("{} · {}", c.name, c.mc_version)) + .collect(); + let ids: Vec = clusters.iter().map(|c| c.id).collect(); + let current = labels[selected_idx].clone(); + + field( + "Destination cluster", + Dropdown::new(current, labels) + .width(Size::fill()) + .height(Size::px(32.)) + .on_select(move |idx: usize| { + if let Some(id) = ids.get(idx) { + selected.set(Some(*id)); + } + }), + ) +} + +fn file_field( + resolved: &[(PathBuf, ContentType)], + overrides: State>, +) -> impl IntoElement { + let visible = resolved.len().clamp(1, FILE_LIST_MAX_ROWS); + let rows = resolved + .iter() + .map(|(path, content_type)| file_row(path, *content_type, overrides).into_element()) + .collect::>(); + + field( + "Files", + rect() + .width(Size::fill()) + .padding(Gaps::new_all(6.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page()) + .border(border_all_color(1., colors::component_border())) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::px(visible as f32 * FILE_ROW_H)) + .children(rows), + ), + ) +} + +fn file_row( + path: &Path, + content_type: ContentType, + mut overrides: State>, +) -> impl IntoElement { + let key = path.to_path_buf(); + let options: Vec = IMPORTABLE + .iter() + .map(|ct| content_label(*ct).to_string()) + .collect(); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(FILE_ROW_H)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 4.)) + .child( + Icon::new(IconType::File02) + .size(13.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(file_name(path)) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + Dropdown::new(content_label(content_type), options) + .width(Size::px(TYPE_DROPDOWN_W)) + .height(Size::px(24.)) + .on_select(move |idx: usize| { + if let Some(content_type) = IMPORTABLE.get(idx) { + overrides.write().insert(key.clone(), *content_type); + } + }), + ) +} diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs new file mode 100644 index 00000000..fa5667d0 --- /dev/null +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -0,0 +1,175 @@ +//! Fallback renderer for prompts no dedicated overlay claims +//! Without it an unrendered prompt leaves the core blocked on `ask()` blocking all later prompts + +use freya::prelude::*; +use oneclient_events::{Answer, ChoiceInput, ChoiceStyle}; +use oneclient_java::JAVA_CHOICE_DOWNLOAD; + +use crate::components::{Button, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::notifications::PendingPromptView; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::updater::UPDATE_CHOICE_INSTALL; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +fn claimed_elsewhere(prompt: &PendingPromptView) -> bool { + prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL) +} + +#[derive(PartialEq)] +pub struct GenericPromptOverlay; + +impl Component for GenericPromptOverlay { + fn render(&self) -> impl IntoElement { + // Hooks run unconditionally before any early return to keep the hook count stable + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + if claimed_elsewhere(&prompt) { + return rect().into_element(); + } + + let close = dispatch.clone(); + + let mut buttons = rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.); + + if let Some(dismiss) = prompt.dismiss.clone() { + let cancel = dispatch.clone(); + buttons = buttons.child( + Button::new() + .secondary() + .text(dismiss) + .on_press(move |_| cancel.dismiss_prompt()), + ); + } + + for choice in &prompt.choices { + let dispatch = dispatch.clone(); + let id = choice.id; + let input = choice.input.clone(); + + let button = Button::new().text(choice.label.clone()); + let button = match choice.style { + ChoiceStyle::Primary => button.primary(), + ChoiceStyle::Danger => button.danger(), + ChoiceStyle::Secondary => button.secondary(), + }; + + buttons = buttons.child(button.on_press(move |_| match &input { + // Answered from the dialog callback not the press since no folder is picked yet + Some(ChoiceInput::Folder { title }) => { + let dispatch = dispatch.clone(); + let title = title.clone(); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title(title) + .pick_folder() + .await + { + dispatch.answer_prompt(Answer::new(id).with_folder(handle.path())); + } + }); + } + // No picker here answering with `selection() == None` beats blocking the core + Some(ChoiceInput::Selection { hint }) => { + tracing::warn!( + choice = id, + hint, + "a prompt asked for a selection with no overlay to collect it" + ); + dispatch.answer_prompt(Answer::new(id)); + } + None => dispatch.answer_prompt(Answer::new(id)), + })); + } + + OverlayPopup::new() + .on_close(move |_| close.dismiss_prompt()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(6) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child(buttons), + ), + ) + .into_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_events::Choice; + + fn prompt(choices: Vec) -> PendingPromptView { + PendingPromptView { + title: "Title".to_string(), + question: "Question".to_string(), + choices, + dismiss: Some("Cancel".to_string()), + } + } + + #[test] + fn the_two_rich_overlays_keep_their_own_prompts() { + assert!(claimed_elsewhere(&prompt(vec![Choice::primary( + JAVA_CHOICE_DOWNLOAD, + "Download", + )]))); + assert!(claimed_elsewhere(&prompt(vec![Choice::primary( + UPDATE_CHOICE_INSTALL, + "Download", + )]))); + } + + #[test] + fn every_other_prompt_falls_through_to_the_generic_overlay() { + for choices in [ + vec![Choice::new("launch", "Launch anyway")], + vec![Choice::primary("verify", "Verify and repair")], + vec![Choice::danger("delete", "Delete")], + Vec::new(), + ] { + assert!( + !claimed_elsewhere(&prompt(choices)), + "an unclaimed prompt must render somewhere" + ); + } + } +} diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs new file mode 100644 index 00000000..4eab4960 --- /dev/null +++ b/packages/oneclient_app/src/components/icons.rs @@ -0,0 +1,152 @@ +use freya::prelude::*; +use oneclient_content::packages::ProviderId; + +use crate::{AppAssets, theme::colors}; + +#[derive(Clone, Copy, PartialEq)] +pub struct IconTint(pub State); + +#[derive(PartialEq)] +pub struct Icon { + pub icon: IconType, + pub size_px: f32, + pub color: Option, + pub rotation: Option, +} + +impl Icon { + pub fn new(icon: impl Into) -> Self { + let icon = icon.into(); + let color = match icon { + IconType::Modrinth => Some(colors::MODRINTH_COLOR), + IconType::Curseforge => Some(colors::CURSEFORGE_COLOR), + _ => None, + }; + + Self { + icon, + size_px: 24., + color, + rotation: None, + } + } + + pub fn size(mut self, size_px: f32) -> Self { + self.size_px = size_px; + self + } + + pub fn color(mut self, color: Color) -> Self { + self.color = Some(color); + self + } + + /// Degrees clockwise + pub fn rotate(mut self, degrees: f32) -> Self { + self.rotation = Some(degrees); + self + } +} + +impl Component for Icon { + fn render(&self) -> impl IntoElement { + let path = self.icon.path(); + let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default()); + let tint = use_hook(try_consume_context::); + + let color = self + .color + .or_else(|| tint.map(|tint| *tint.0.read())) + .unwrap_or_else(colors::fg_primary); + + SvgViewer::new((path, bytes.read().cloned())) + .show_loader(false) + .width(Size::px(self.size_px)) + .height(Size::px(self.size_px)) + .color(color) + .map(self.rotation, |svg, degrees| svg.rotation(degrees)) + } +} + +impl From for IconType { + fn from(val: ProviderId) -> Self { + match val { + ProviderId::Modrinth => IconType::Modrinth, + ProviderId::CurseForge => IconType::Curseforge, + ProviderId::Local => IconType::Folder, + } + } +} + +#[derive(oneclient_macro::IconNamed, Debug, PartialEq, Clone, Copy)] +#[allow(dead_code)] +pub enum IconType { + AlertCircle, + AlertTriangle, + Announcement01, + ArrowLeft, + ArrowRight, + Brush01, + Bell01, + Calendar, + CheckCircle, + Check, + ChevronDown, + ChevronRight, + ChevronsLeft, + ChevronsRight, + ClipboardCheck, + ClockRewind, + CodeSnippet02, + Colors, + Copy01, + Curseforge, + Database01, + DotsGrid, + DotsVertical, + Download01, + DownloadCloud02, + Eye, + File02, + FilePlus02, + FileX02, + Folder, + FolderCheck, + FolderDownload, + Globe01, + HelpCircle, + InfoCircle, + Key01, + LayoutTop, + Link03, + LinkExternal01, + Loading02, + Maximize01, + MessageTextSquare01, + Minus, + Modrinth, + OnboardingAccount, + OnboardingComplete, + OnboardingLanguage, + OnboardingPreferences, + OnboardingWelcome, + PaintPour, + ParagraphWrap, + Pencil01, + Play, + Plus, + RefreshCcw02, + RefreshCw01, + Rocket02, + SearchMd, + Settings01, + Settings02, + Settings04, + Sliders04, + Square, + Terminal, + Trash01, + Users01, + XClose, + X, +} diff --git a/packages/oneclient_app/src/components/java_install_manager.rs b/packages/oneclient_app/src/components/java_install_manager.rs new file mode 100644 index 00000000..16e5987e --- /dev/null +++ b/packages/oneclient_app/src/components/java_install_manager.rs @@ -0,0 +1,275 @@ +use freya::prelude::*; +use oneclient_java::{AvailableJava, JavaVendor}; + +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{provider_versions, use_provider_versions}; +use crate::theme::colors; +use crate::ui::border_all_color; + +fn providers() -> Vec<(JavaVendor, &'static str)> { + vec![ + (JavaVendor::Zulu, "Azul Zulu"), + (JavaVendor::Adoptium, "Eclipse Temurin"), + (JavaVendor::Corretto, "Amazon Corretto"), + (JavaVendor::Liberica, "BellSoft Liberica"), + ] +} + +/// Calls `on_install` with the chosen `(vendor, major)` +#[derive(PartialEq)] +pub struct JavaInstallManager { + suggested: Option, + on_install: Option>, + on_close: Option>, +} + +impl Default for JavaInstallManager { + fn default() -> Self { + Self::new() + } +} + +impl JavaInstallManager { + pub fn new() -> Self { + Self { + suggested: None, + on_install: None, + on_close: None, + } + } + + pub fn suggested(mut self, major: Option) -> Self { + self.suggested = major; + self + } + + pub fn on_install(mut self, handler: impl Into>) -> Self { + self.on_install = Some(handler.into()); + self + } + + pub fn on_close(mut self, handler: impl Into>) -> Self { + self.on_close = Some(handler.into()); + self + } +} + +impl Component for JavaInstallManager { + fn render(&self) -> impl IntoElement { + let providers = providers(); + let mut selected = use_state(|| 0usize); + let suggested = self.suggested; + + let idx = (*selected.read()).min(providers.len() - 1); + let (vendor, _) = providers[idx].clone(); + + let versions_query = use_provider_versions(vendor.clone()); + let (mut versions, loading) = provider_versions(&versions_query); + + if let Some(major) = suggested { + versions.sort_by_key(|v| v.major != major); + } + + let provider_labels: Vec = + providers.iter().map(|(_, name)| name.to_string()).collect(); + let current_label = provider_labels[idx].clone(); + + let inner: Element = if loading { + status_text("Loading available versions...") + } else if versions.is_empty() { + status_text("No versions available from this provider.") + } else { + let mut area = ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.); + for available in versions { + let highlight = suggested == Some(available.major); + area = area.child( + VersionRow { + vendor: vendor.clone(), + available, + suggested: highlight, + on_install: self.on_install.clone(), + } + .into_element(), + ); + } + area.into_element() + }; + + let list = rect() + .width(Size::fill()) + .height(Size::px(280.)) + .child(inner) + .into_element(); + + let close_scrim = self.on_close.clone(); + let close_button = self.on_close.clone(); + + OverlayPopup::new() + .on_close(move |()| { + if let Some(handler) = &close_scrim { + handler.call(()); + } + }) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Install Java") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Provider") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(current_label, provider_labels) + .width(Size::fill()) + .height(Size::px(34.)) + .on_select(move |i: usize| selected.set(i)), + ), + ) + .child(list) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .ghost() + .on_press(move |_| { + if let Some(handler) = &close_button { + handler.call(()); + } + }) + .text("Close"), + ), + ), + ), + ) + .into_element() + } +} + +fn status_text(text: &'static str) -> Element { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(16., 4.)) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct VersionRow { + vendor: JavaVendor, + available: AvailableJava, + suggested: bool, + on_install: Option>, +} + +impl Component for VersionRow { + fn render(&self) -> impl IntoElement { + let vendor = self.vendor.clone(); + let major = self.available.major; + let version = self.available.package.name.clone(); + let on_install = self.on_install.clone(); + + let mut title_row = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(format!("Java {major}")) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ); + + if self.suggested { + title_row = title_row.child( + rect() + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::brand()) + .child( + label() + .text("Suggested") + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(10., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .maybe(self.suggested, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child(title_row) + .child( + label() + .text(version) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .primary() + .small() + .on_press(move |_| { + if let Some(handler) = &on_install { + handler.call((vendor.clone(), major)); + } + }) + .child(Icon::new(IconType::Download01).size(13.)) + .text("Install"), + ) + } +} diff --git a/packages/oneclient_app/src/components/java_prompt.rs b/packages/oneclient_app/src/components/java_prompt.rs new file mode 100644 index 00000000..995e0edd --- /dev/null +++ b/packages/oneclient_app/src/components/java_prompt.rs @@ -0,0 +1,148 @@ +use freya::prelude::*; +use oneclient_events::Answer; +use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; + +use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct JavaPromptOverlay; + +impl Component for JavaPromptOverlay { + fn render(&self) -> impl IntoElement { + // Hooks must run unconditionally before any early return to keep the hook count stable + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let mut show_manager = use_state(|| false); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + // Identified by the choices it offers rather than a prompt-kind enum + if !prompt.has_choice(JAVA_CHOICE_DOWNLOAD) { + return rect().into_element(); + } + + // The prompt carries no major so recover it from the folder choice's dialog title + let major = prompt + .choice(JAVA_CHOICE_FOLDER) + .and_then(|choice| match &choice.input { + Some(oneclient_events::ChoiceInput::Folder { title }) => title + .split_whitespace() + .find_map(|word| word.parse::().ok()), + _ => None, + }) + .unwrap_or(0); + + if *show_manager.read() { + let install = dispatch.clone(); + return JavaInstallManager::new() + .suggested(Some(major)) + .on_install(move |(vendor, _row_major): (JavaVendor, u32)| { + install.answer_prompt( + Answer::new(JAVA_CHOICE_DOWNLOAD).with_selection(vendor.to_string()), + ); + show_manager.set(false); + }) + .on_close(move |()| show_manager.set(false)) + .into_element(); + } + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let folder = dispatch.clone(); + + let pick_folder = move |_| { + let dispatch = folder.clone(); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title(format!("Select a Java {major} installation folder")) + .pick_folder() + .await + { + dispatch + .answer_prompt(Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path())); + } + }); + }; + + OverlayPopup::new() + .on_close(move |_| close.dismiss_prompt()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::FolderDownload) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.dismiss_prompt()) + .text("Cancel"), + ) + .child( + Button::new() + .secondary() + .on_press(pick_folder) + .child(Icon::new(IconType::Folder).size(14.)) + .text("Choose folder"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| show_manager.set(true)) + .child(Icon::new(IconType::DownloadCloud02).size(14.)) + .text("Download"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/link_confirm.rs b/packages/oneclient_app/src/components/link_confirm.rs new file mode 100644 index 00000000..df537a08 --- /dev/null +++ b/packages/oneclient_app/src/components/link_confirm.rs @@ -0,0 +1,106 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::use_link_confirm; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct ConfirmLinkOverlay; + +impl Component for ConfirmLinkOverlay { + fn render(&self) -> impl IntoElement { + let mut pending = use_link_confirm(); + let Some(url) = pending.read().clone() else { + return rect().into_element(); + }; + + let open_url = url.clone(); + + OverlayPopup::new() + .on_close(move |_| pending.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::code_warn()), + ) + .child( + label() + .text("Open external link?") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text("This link was provided by a third party. Only open it if you trust the source.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(url.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::code_info()), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| pending.set(None)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + crate::platform::open_url(&open_url); + pending.set(None); + }) + .child(Icon::new(IconType::LinkExternal01).size(14.)) + .text("Open link"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/local_image.rs b/packages/oneclient_app/src/components/local_image.rs new file mode 100644 index 00000000..8d39fa8a --- /dev/null +++ b/packages/oneclient_app/src/components/local_image.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; + +use bytes::Bytes; +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, OnFinish, use_animation_with_dependencies, +}; +use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image}; +use freya::engine::prelude::{SkData, SkImage}; +use freya::prelude::*; + +use crate::hooks::{settled_or_loading, use_local_image}; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct LocalImage { + path: PathBuf, + max_edge: u32, + cover: bool, + skeleton: bool, +} + +impl LocalImage { + pub fn new(path: PathBuf, max_edge: u32, cover: bool) -> Self { + Self { + path, + max_edge, + cover, + skeleton: false, + } + } + + pub fn skeleton(mut self, skeleton: bool) -> Self { + self.skeleton = skeleton; + self + } +} + +impl Component for LocalImage { + fn render(&self) -> impl IntoElement { + let query = use_local_image(self.path.clone(), self.max_edge); + + let bytes: Option = settled_or_loading(&query); + + let mut cache = use_state(|| None::<(usize, ImageHandle)>); + let holder = bytes.and_then(|bytes| { + let ptr = bytes.as_ptr() as usize; + + if let Some((cached_ptr, holder)) = cache.read().clone() + && cached_ptr == ptr + { + return Some(holder); + } + + let holder = decode(&bytes)?; + + cache.set(Some((ptr, holder.clone()))); + + Some(holder) + }); + + let aspect = if self.cover { + AspectRatio::Max + } else { + AspectRatio::Min + }; + + let loaded = holder.is_some(); + let show_skeleton = self.skeleton && !loaded; + + let pulse = use_animation_with_dependencies(&show_skeleton, |conf, show| { + conf.on_change(OnChange::Rerun); + + if *show { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + } + + AnimNum::new(0.4, 0.9) + .time(800) + .ease(Ease::InOut) + .function(Function::Sine) + }); + + let pulse_v = pulse.read().value(); + + let fade = use_animation_with_dependencies(&loaded, |conf, loaded| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + let to = if *loaded { 1.0 } else { 0.0 }; + AnimNum::new(0., to) + .time(260) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let fade_v = fade.read().value(); + + let root = rect().width(Size::fill()).height(Size::fill()); + + match holder { + Some(holder) => root.child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .opacity(fade_v) + .child( + image(holder) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(aspect) + .image_cover(ImageCover::Center), + ), + ), + None if self.skeleton => root.child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::component_bg()) + .opacity(pulse_v), + ), + None => root, + } + } +} + +fn decode(bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(bytes) }; + let img = SkImage::from_encoded(data)?; + Some(ImageHandle::new(img, bytes.clone())) +} diff --git a/packages/oneclient_app/src/components/log_viewer.rs b/packages/oneclient_app/src/components/log_viewer.rs new file mode 100644 index 00000000..9cad37fc --- /dev/null +++ b/packages/oneclient_app/src/components/log_viewer.rs @@ -0,0 +1,557 @@ +use std::sync::{Arc, LazyLock}; + +use freya::prelude::*; +use freya::text_edit::Clipboard; +use regex::Regex; + +use crate::components::{Icon, IconType, ScrollArea, ScrollAreaCtx, corrected_scroll}; +use crate::theme::{self, colors}; + +const LINE_H: f32 = 16.; +const BODY_PAD_TOP: f32 = 10.; +const BODY_PAD_BOTTOM: f32 = 20.; +const LINE_FONT_SIZE: f32 = 12.; +const NUM_GAP: f32 = 12.; +const CHAR_W: f32 = LINE_FONT_SIZE * 0.6; +const ROW_PAD_X: f32 = 10.; +const TAB_WIDTH: usize = 4; +const OVERSCAN: i64 = 4; + +static LOG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[((?:\S+ )?\d+:\d+:\d+(?:\.\d+)?)\] \[([^/\n\r]*)/(\w+)\]:? (?:\[(CHAT)\])?") + .expect("valid log regex") +}); + +fn level_color(level: &str) -> Color { + match level.to_ascii_uppercase().as_str() { + "ERROR" | "FATAL" | "SEVERE" => colors::code_error(), + "WARN" | "WARNING" => colors::code_warn(), + "DEBUG" | "TRACE" => colors::code_debug(), + _ => colors::code_info(), + } +} + +#[derive(Clone)] +struct ParsedLine { + spans: Vec<(String, Color)>, +} + +#[derive(Default)] +struct WidthCache { + len: usize, + last_ptr: usize, + max: usize, +} + +fn line_ptr(line: &Arc) -> usize { + Arc::as_ptr(line) as *const u8 as usize +} + +impl WidthCache { + fn max_cols(&mut self, lines: &Arc>>) -> usize { + let len = lines.len(); + let appended = len >= self.len + && self.len > 0 + && lines.get(self.len - 1).map(line_ptr) == Some(self.last_ptr); + + let start = if appended { + if len == self.len { + return self.max; + } + self.len + } else { + self.max = 0; + 0 + }; + + for line in &lines[start..] { + self.max = self.max.max(visual_len(line)); + } + self.len = len; + self.last_ptr = lines.last().map(line_ptr).unwrap_or(0); + self.max + } +} + +fn clean_line(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + let mut col = 0usize; + while let Some(c) = chars.next() { + match c { + '§' => { + chars.next(); + } + '\t' => { + let spaces = TAB_WIDTH - (col % TAB_WIDTH); + for _ in 0..spaces { + out.push(' '); + } + col += spaces; + } + '\r' => {} + _ => { + out.push(c); + col += 1; + } + } + } + out +} + +fn visual_len(raw: &str) -> usize { + let mut chars = raw.chars(); + let mut col = 0usize; + while let Some(c) = chars.next() { + match c { + '§' => { + chars.next(); + } + '\t' => col += TAB_WIDTH - (col % TAB_WIDTH), + '\r' => {} + _ => col += 1, + } + } + col +} + +fn parse_line(raw: &str) -> ParsedLine { + let cleaned = clean_line(raw); + + let Some(caps) = LOG_RE.captures(&cleaned) else { + return ParsedLine { + spans: vec![(cleaned, colors::fg_primary())], + }; + }; + + let matched = caps.get(0).map(|m| m.end()).unwrap_or(0); + let time = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); + let thread = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); + let level = caps.get(3).map(|m| m.as_str()).unwrap_or_default(); + let is_chat = caps.get(4).is_some(); + let lvl_color = level_color(level); + + let mut spans = vec![ + (format!("[{time}] "), colors::code_muted()), + (format!("[{thread}/{level}]"), lvl_color), + ]; + if is_chat { + spans.push((" [CHAT]".to_string(), colors::code_chat())); + } + let msg_color = if is_chat { + colors::code_chat() + } else { + colors::fg_primary() + }; + spans.push((format!(" {}", &cleaned[matched..]), msg_color)); + + ParsedLine { spans } +} + +#[derive(PartialEq)] +pub struct LogViewer { + title: String, + lines: Arc>>, + streaming: bool, + header: Option, +} + +impl LogViewer { + pub fn new(title: impl Into, lines: impl Into>>>) -> Self { + Self { + title: title.into(), + lines: lines.into(), + streaming: false, + header: None, + } + } + + pub fn streaming(mut self, streaming: bool) -> Self { + self.streaming = streaming; + self + } + + pub fn header(mut self, header: impl IntoElement) -> Self { + self.header = Some(header.into_element()); + self + } +} + +impl Component for LogViewer { + fn render(&self) -> impl IntoElement { + let streaming = self.streaming; + let lines: Arc>> = self.lines.clone(); + let count = lines.len(); + + let content_h = count as f32 * LINE_H + BODY_PAD_TOP + BODY_PAD_BOTTOM; + let width_cache = + use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(WidthCache::default()))); + + let max_chars = width_cache.borrow_mut().max_cols(&lines); + let max_digits = (count.max(1) as f32).log10().floor() as usize + 1; + let num_col_w = max_digits as f32 * (LINE_FONT_SIZE * 0.62) + 6.; + let content_w = ROW_PAD_X + num_col_w + NUM_GAP + max_chars as f32 * CHAR_W + ROW_PAD_X; + + let scroll = use_scroll_controller(ScrollConfig::default); + let mut auto_scroll = use_state(|| streaming); + + let sel_anchor = use_state::>(|| None); + let sel_focus = use_state::>(|| None); + let selecting = use_state(|| false); + + let stick_bottom = streaming && *auto_scroll.read(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .background(colors::page_elevated()) + .corner_radius(CornerRadius::new_all(12.)) + .maybe_child( + (!self.title.is_empty() || streaming) + .then(|| toolbar(self.title.clone(), streaming, auto_scroll)), + ) + .maybe_child(self.header.clone()) + .child( + ScrollArea::new() + .scroll_controller(scroll) + .horizontal(content_w) + .stick_bottom(stick_bottom) + .on_user_scroll(move |_| { + if streaming { + auto_scroll.set_if_modified(false); + } + }) + .content(move |ctx: ScrollAreaCtx| { + scroll_body(BodyArgs { + ctx, + scroll, + lines: lines.clone(), + content_h, + content_w, + num_col_w, + count, + sel_anchor, + sel_focus, + selecting, + }) + }), + ) + } +} + +fn toolbar(title: String, streaming: bool, auto_scroll: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .padding(Gaps::new(12., 20., 0., 20.)) + .spacing(12.) + .child( + label() + .text(title) + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .maybe_child(streaming.then(|| auto_scroll_toggle(auto_scroll))), + ) + .into_element() +} + +fn auto_scroll_toggle(mut auto_scroll: State) -> impl IntoElement { + let on = *auto_scroll.read(); + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(5., 10.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(if on { + colors::brand().with_a(40) + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .on_press(move |_| auto_scroll.toggle()) + .child(Icon::new(IconType::ChevronDown).size(13.).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() +} + +#[derive(Clone, Copy)] +struct PosGeom { + count: usize, + num_col_w: f32, + viewport_top_px: f32, + viewport_left_px: f32, + corrected_x: f32, + corrected_y: f32, +} + +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 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; + let raw_col = (local_x / CHAR_W).round().max(0.) as usize; + let len = lines.get(line).map(|l| visual_len(l)).unwrap_or(0); + (line, raw_col.min(len)) +} + +struct BodyArgs { + ctx: ScrollAreaCtx, + scroll: ScrollController, + lines: Arc>>, + content_h: f32, + content_w: f32, + num_col_w: f32, + count: usize, + sel_anchor: State>, + sel_focus: State>, + selecting: State, +} + +fn scroll_body(args: BodyArgs) -> Element { + let BodyArgs { + ctx, + mut scroll, + lines, + content_h, + content_w, + num_col_w, + count, + mut sel_anchor, + mut sel_focus, + mut selecting, + } = args; + + let corrected_y = ctx.corrected_y; + let corrected_x = ctx.corrected_x; + let viewport_h = ctx.viewport_h; + let viewport_top_px = ctx.viewport_top; + let viewport_left_px = ctx.viewport_left; + let body_w = content_w.max(ctx.viewport_w); + + let selection = match (*sel_anchor.read(), *sel_focus.read()) { + (Some(a), Some(f)) => Some((a.min(f), a.max(f))), + _ => None, + }; + + let geom = PosGeom { + count, + num_col_w, + viewport_top_px, + viewport_left_px, + corrected_x, + corrected_y, + }; + + let lines_down = lines.clone(); + let on_body_down = move |e: Event| { + let pos = pos_at_screen( + &lines_down, + geom, + e.global_location().x as f32, + e.global_location().y as f32, + ); + sel_anchor.set(Some(pos)); + sel_focus.set(Some(pos)); + selecting.set(true); + }; + + let lines_move = lines.clone(); + let on_global_move = move |e: Event| { + if !*selecting.read() { + return; + } + let cursor_y = e.global_location().y as f32; + let pos = pos_at_screen(&lines_move, geom, e.global_location().x as f32, cursor_y); + sel_focus.set(Some(pos)); + + const EDGE: f32 = 24.; + let region_y = cursor_y - viewport_top_px; + let (_, cur_scroll) = scroll.into(); + let delta = if region_y < EDGE { + (EDGE - region_y).min(EDGE) + } else if region_y > viewport_h - EDGE { + -((region_y - (viewport_h - EDGE)).min(EDGE)) + } else { + 0. + }; + if delta != 0. { + let target = cur_scroll as f32 + delta; + let clamped = corrected_scroll(content_h, viewport_h, target) as i32; + scroll.scroll_to_y(clamped); + } + }; + + let on_global_release = move |_: Event| { + if *selecting.read() { + selecting.set(false); + } + }; + + let lines_for_copy = lines.clone(); + let on_key_down = move |e: Event| { + let copy = matches!(&e.key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("c")) + && (e.modifiers.contains(Modifiers::CONTROL) || e.modifiers.contains(Modifiers::META)); + if !copy { + return; + } + if let (Some(a), Some(f)) = (*sel_anchor.read(), *sel_focus.read()) { + let ((s_line, s_col), (e_line, e_col)) = (a.min(f), a.max(f)); + let mut parts: Vec = Vec::with_capacity(e_line - s_line + 1); + for line in s_line..=e_line { + let Some(raw) = lines_for_copy.get(line) else { + continue; + }; + let cleaned = clean_line(raw); + let from = if line == s_line { s_col } else { 0 }; + let to = if line == e_line { + e_col + } else { + cleaned.chars().count() + }; + let slice: String = cleaned + .chars() + .skip(from) + .take(to.saturating_sub(from)) + .collect(); + parts.push(slice); + } + let text = parts.join("\n"); + if let Err(err) = Clipboard::set(text) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + } + }; + + rect() + .width(Size::px(body_w)) + .height(Size::px(content_h)) + .on_pointer_down(on_body_down) + .on_capture_global_pointer_move(on_global_move) + .on_capture_global_pointer_press(on_global_release) + .on_global_key_down(on_key_down) + .child(log_body( + &lines[..], + content_h, + corrected_y, + viewport_h, + body_w, + num_col_w, + selection, + )) + .into_element() +} + +fn log_body( + lines: &[Arc], + content_h: f32, + corrected_y: f32, + viewport_h: f32, + body_w: f32, + num_col_w: f32, + selection: Option<((usize, usize), (usize, usize))>, +) -> impl IntoElement { + let count = lines.len(); + let scrolled = -corrected_y; + let first = (((scrolled - BODY_PAD_TOP) / LINE_H).floor() as i64 - OVERSCAN).max(0) as usize; + let visible = (viewport_h / LINE_H).ceil() as usize + OVERSCAN as usize * 2 + 1; + let last = (first + visible).min(count); + + let mut body = rect().width(Size::px(body_w)).height(Size::px(content_h)); + for (i, raw) in lines.iter().enumerate().take(last).skip(first) { + let y = BODY_PAD_TOP + i as f32 * LINE_H; + let sel_cols = selection.and_then(|((sl, sc), (el, ec))| { + if i < sl || i > el { + return None; + } + let len = visual_len(raw); + let c0 = if i == sl { sc } else { 0 }; + let c1 = if i == el { ec } else { len }; + (c1 > c0).then_some((c0, c1)) + }); + let parsed = parse_line(raw); + body = body.child(log_line_row(i, &parsed, y, body_w, num_col_w, sel_cols)); + } + body +} + +fn log_line_row( + index: usize, + parsed: &ParsedLine, + y: f32, + body_w: f32, + num_col_w: f32, + sel_cols: Option<(usize, usize)>, +) -> impl IntoElement { + let mut message = paragraph() + .max_lines(1) + .width(Size::fill()) + .height(Size::px(LINE_H)) + .font_family(theme::MONO_FONT) + .font_size(LINE_FONT_SIZE); + for (text, color) in &parsed.spans { + message = message.span(Span::new(text.clone()).color(*color)); + } + + let highlight = sel_cols.map(|(c0, c1)| { + rect() + .position( + Position::new_absolute() + .top(0.) + .left(num_col_w + NUM_GAP + c0 as f32 * CHAR_W), + ) + .width(Size::px((c1 - c0) as f32 * CHAR_W)) + .height(Size::px(LINE_H)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::selection_bg()) + .into_element() + }); + + rect() + .horizontal() + .position(Position::new_absolute().top(y).left(ROW_PAD_X)) + .width(Size::px((body_w - ROW_PAD_X * 2.).max(0.))) + .height(Size::px(LINE_H)) + .cross_align(Alignment::Center) + .spacing(NUM_GAP) + .maybe_child(highlight) + .child( + rect().width(Size::px(num_col_w)).child( + label() + .text(format!("{}", index + 1)) + .font_family(theme::MONO_FONT) + .font_size(LINE_FONT_SIZE) + .text_align(TextAlign::Right) + .width(Size::fill()) + .color(colors::code_muted()), + ), + ) + .child(message) +} diff --git a/packages/oneclient_app/src/components/memory_field.rs b/packages/oneclient_app/src/components/memory_field.rs new file mode 100644 index 00000000..76176220 --- /dev/null +++ b/packages/oneclient_app/src/components/memory_field.rs @@ -0,0 +1,51 @@ +use freya::prelude::*; + +use crate::components::{Dropdown, TextInput, validate_memory}; +use crate::theme::colors; +use crate::utils::{format_memory_gb, memory_presets_mb}; + +const UNSET_LABEL: &str = "Default"; +const CUSTOM_LABEL: &str = "Custom"; + +/// Presets picker with an input for memory allocation +pub fn memory_field(mut memory: State) -> impl IntoElement { + let presets = memory_presets_mb(); + let selected = match memory.read().trim() { + "" => UNSET_LABEL.to_string(), + value => value + .parse::() + .ok() + .filter(|mb| presets.contains(mb)) + .map(format_memory_gb) + .unwrap_or_else(|| CUSTOM_LABEL.to_string()), + }; + + let options: Vec = presets.iter().copied().map(format_memory_gb).collect(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + Dropdown::new(selected, options) + .width(Size::px(100.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(mb) = presets.get(idx).copied() { + memory.set(mb.to_string()); + } + }), + ) + .child( + TextInput::new(memory) + .width(Size::px(90.)) + .placeholder("4096") + .on_validate(validate_memory) + .trailing( + label() + .text("MB") + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) +} diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs new file mode 100644 index 00000000..f9ce07dd --- /dev/null +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -0,0 +1,477 @@ +use crate::state::LoginProgress; +use freya::prelude::*; +use freya::query::{MutationCapability, MutationStateData, UseMutation}; +use freya::text_edit::Clipboard; +use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession}; +use oneclient_core::LauncherError; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{ + CancelMicrosoftLoginKeys, login_code_already_handled, reset_login_code_dedup, + use_begin_microsoft_login, use_cancel_microsoft_login, use_finish_microsoft_login, + use_microsoft_login_status, +}; +use crate::platform; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[derive(Clone)] +pub struct MicrosoftLogin { + begin: UseMutation, + cancel: UseMutation, + pending_login: State>, + cancelled: State, + status: Option, + locked: bool, + pub pending: bool, + pub error: Option, + error_guidance: Option, +} + +impl MicrosoftLogin { + pub fn start(&self) { + let mut cancelled = self.cancelled; + cancelled.set(false); + self.begin.mutate(()); + } + + fn cancel(&self) { + if self.locked { + return; + } + if let Some(session) = self.pending_login.peek().clone() { + self.cancel.mutate(CancelMicrosoftLoginKeys { + state_token: session.dedupe_key().to_string(), + }); + } + + reset_login_code_dedup(); + + let mut cancelled = self.cancelled; + let mut pending_login = self.pending_login; + cancelled.set(true); + pending_login.set(None); + } + + pub fn popup(&self) -> Option { + let handle = self.clone(); + self.pending_login.read().clone().map(move |login| { + microsoft_dialog( + login, + handle.clone(), + handle.status.clone(), + handle.error.clone(), + handle.error_guidance.clone(), + ) + }) + } +} + +pub fn use_microsoft_login() -> MicrosoftLogin { + let begin = use_begin_microsoft_login(); + let finish = use_finish_microsoft_login(); + let cancel = use_cancel_microsoft_login(); + let mut pending_login = use_state(|| None::); + let cancelled = use_state(|| false); + let status = use_microsoft_login_status(); + + use_side_effect(move || { + let session = match &*begin.read().state() { + MutationStateData::Settled { + res: Ok(session), .. + } => Some(session.clone()), + _ => None, + }; + let Some(session) = session else { return }; + if login_code_already_handled(session.dedupe_key()) { + return; + } + + platform::open_url(session.auth_url()); + finish.mutate(session.clone()); + pending_login.set(Some(session)); + }); + + use_side_effect(move || { + if matches!( + &*finish.read().state(), + MutationStateData::Settled { res: Ok(_), .. } + ) && pending_login.peek().is_some() + { + pending_login.set(None); + } + }); + + // True once the flow reports progress ensuring the user has seen the popup + 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(); + let error_info = mutation_err_info(&finish).or_else(|| mutation_err_info(&begin)); + let (error, error_guidance) = match error_info { + Some(info) => (Some(info.message), info.guidance), + None => (None, None), + }; + + MicrosoftLogin { + begin, + cancel, + pending_login, + cancelled, + status, + locked, + pending, + error, + error_guidance, + } +} + +struct AuthErrorInfo { + message: String, + guidance: Option, +} + +fn mutation_err_info(mutation: &UseMutation) -> Option +where + M: MutationCapability, +{ + let read = mutation.read(); + match &*read.state() { + MutationStateData::Settled { res: Err(err), .. } + | MutationStateData::Loading { + res: Some(Err(err)), + } => { + // Cancellation arrives as an error surfacing it would persist into the next sign-in + if err.is_login_cancelled() { + return None; + } + Some(AuthErrorInfo { + message: err.to_string(), + guidance: err.auth_guidance(), + }) + } + _ => None, + } +} + +fn microsoft_dialog( + login: MicrosoftLoginSession, + handle: MicrosoftLogin, + status: Option, + error: Option, + guidance: Option, +) -> impl IntoElement { + let close = handle.clone(); + login_dialog( + login.auth_url().to_string(), + login.user_code().to_string(), + login.verification_uri().to_string(), + handle.locked, + status, + error, + guidance, + move || close.cancel(), + ) +} + +#[expect(clippy::too_many_arguments)] +pub(crate) fn login_dialog( + auth_url: String, + user_code: String, + verification_uri: String, + locked: bool, + status: Option, + error: Option, + guidance: Option, + on_close: impl FnMut() + Clone + 'static, +) -> impl IntoElement { + let mut close_scrim = on_close.clone(); + let mut close_cancel = on_close.clone(); + OverlayPopup::new() + .on_close(move |()| close_scrim()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(420.)) + .max_width(Size::window_percent(90.)) + .max_height(Size::window_percent(90.)) + .cross_align(Alignment::Center) + .spacing(18.) + .padding(Gaps::new_all(28.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Sign in to Microsoft") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + ScrollView::new() + .width(Size::fill()) + .height(Size::auto()) + .max_height(Size::window_percent(70.)) + .child( + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(18.) + .child(browser_dialog_body(auth_url)) + .child(dialog_divider()) + .child(device_code_dialog_body(user_code, verification_uri)) + .child(dialog_divider()) + .child(offline_mode_dialog_body(on_close.clone())) + .child(status_row(status, error)) + .maybe_child(guidance.map(guidance_block)), + ), + ) + .maybe_child((!locked).then(|| { + Button::new() + .ghost() + .on_press(move |_| close_cancel()) + .text("Cancel") + .into_element() + })), + ), + ) + .into_element() +} + +fn dialog_divider() -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .child( + label() + .text("or") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .into_element() +} + +fn status_row(status: Option, error: Option) -> impl IntoElement { + if let Some(error) = error { + return rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::AlertTriangle) + .size(13.) + .color(colors::danger()), + ) + .child(label().text(error).font_size(12.).color(colors::danger())) + .into_element(); + } + + let text = match &status { + Some(status) if status.total > 0 && status.current > 0 => { + format!("{} ({}/{})", status.label, status.current, status.total) + } + _ => "Waiting for you to finish signing in...".to_string(), + }; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::Loading02) + .size(13.) + .color(colors::brand()), + ) + .child( + label() + .text(text) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { + let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child( + label() + .text("What you can do:") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); + + for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { + steps = steps.child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(format!("{}.", index + 1)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(step) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(guidance.what_happened) + .font_size(12.) + .color(colors::fg_primary()), + ) + .child(steps) + .into_element() +} + +fn browser_dialog_body(auth_url: String) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("We opened the Microsoft sign-in page in your browser. Finish there and you'll be brought back automatically.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&auth_url)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser again"), + ) + .into_element() +} + +fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { + let copy_code = code.clone(); + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("Or enter this code at the Microsoft sign-in page:") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(code) + .font_size(48.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = Clipboard::set(copy_code.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + }) + .child(Icon::new(IconType::Copy01).size(16.)) + .text("Copy code"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&verification_uri)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser"), + ), + ) + .into_element() +} + +fn offline_mode_dialog_body(on_close: impl FnMut() + Clone + 'static) -> impl IntoElement { + let on_close_btn = on_close; + + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text("Or Play Offline (LAN / Offline Servers)") + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let mut close_cb = on_close_btn.clone(); + spawn(async move { + if let Ok(state) = crate::launcher::state() { + if let Ok(acc) = + state.auth.add_offline_account("Player".to_string()).await + { + let _ = state.auth.set_default_account(Some(acc.id)).await; + } + } + close_cb(); + }); + }) + .text("Play Offline as Player") + .into_element(), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs new file mode 100644 index 00000000..0b0eff07 --- /dev/null +++ b/packages/oneclient_app/src/components/mod.rs @@ -0,0 +1,85 @@ +mod account_switcher; +mod active_cluster_panel; +mod avatar; +mod button; +mod charts; +mod cluster_landscape_art; +mod cluster_update_popup; +mod context_menu; +mod dropdown; +mod dynamic_art; +mod file_drop; +mod generic_prompt; +mod icons; +mod java_install_manager; +mod java_prompt; +mod link_confirm; +mod local_image; +mod log_viewer; +mod memory_field; +mod microsoft_login; +mod navbar; +mod notifications; +mod overlay_popup; +mod package_row; +mod package_update_popup; +mod pagination; +mod player_model; +mod progress_track; +mod recents_row; +mod screenshot_viewer; +mod scrollview; +mod segmented_control; +mod splash_curtain; +mod status_bar; +mod tab_bar; +mod text_input; +mod toasts; +mod toggle; +mod update_prompt; +pub mod upload_mclogs; +mod version_card; + +pub use account_switcher::AccountSwitcher; +pub use active_cluster_panel::ActiveClusterPanel; +pub use avatar::Avatar; +pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; +pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; +pub use cluster_landscape_art::ClusterLandscapeArt; +pub use cluster_update_popup::ClusterUpdatePopup; +pub use context_menu::ContextMenu; +pub use dropdown::Dropdown; +pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; +pub use file_drop::FileDropOverlay; +pub use generic_prompt::GenericPromptOverlay; +pub use icons::{Icon, IconTint, IconType}; +pub use java_install_manager::JavaInstallManager; +pub use java_prompt::JavaPromptOverlay; +pub use link_confirm::ConfirmLinkOverlay; +pub use local_image::LocalImage; +pub use log_viewer::LogViewer; +pub use memory_field::memory_field; +pub(crate) use microsoft_login::login_dialog; +pub use microsoft_login::use_microsoft_login; +pub(crate) use navbar::window_controls; +pub use navbar::{AppNavbar, OnboardingNavbar}; +pub use notifications::NotificationCenter; +pub use overlay_popup::OverlayPopup; +pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; +pub use package_update_popup::PackageUpdatePopup; +pub use pagination::Pagination; +pub use player_model::PlayerModel; +pub use progress_track::progress_track; +pub use recents_row::RecentsRow; +pub use screenshot_viewer::ScreenshotViewer; +pub(crate) use scrollview::corrected_scroll; +pub use scrollview::{ScrollArea, ScrollAreaCtx}; +pub use segmented_control::{Segment, SegmentedControl}; +pub use splash_curtain::SplashCurtain; +pub use status_bar::StatusBar; +pub use tab_bar::{TabBar, TabItem}; +pub use text_input::{TextInput, validate_memory, validate_number}; +pub use toasts::Toasts; +pub use toggle::{toggle, toggle_controlled}; +pub use update_prompt::UpdatePromptOverlay; +pub use version_card::VersionCard; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs new file mode 100644 index 00000000..76a148a7 --- /dev/null +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -0,0 +1,312 @@ +use freya::{ + animation::*, + prelude::*, + router::{RouterContext, use_route}, +}; + +use crate::{ + Route, + components::{Avatar, Icon, IconType}, + hooks::{ + settled_or_loading, try_default_account, use_active_cluster_id, use_clusters, + use_current_account, use_dispatch, use_notifications_snapshot, + }, + theme, + utils::sort_clusters_for_home, +}; + +const NAVBAR_INTRO_MS: u64 = 460; + +#[derive(PartialEq)] +pub struct Navbar; + +impl Component for Navbar { + fn render(&self) -> impl IntoElement { + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(NAVBAR_INTRO_MS) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let eased = intro.get().value(); + let slide = (1.0 - eased) * -theme::NAVBAR_HEIGHT_PX; + + rect() + .width(Size::fill()) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .position(Position::new_absolute().top(0.).left(0.)) + .layer(Layer::OverlayLevel(2)) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .horizontal() + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0.0, 40.0)) + .offset_y(slide) + .opacity(eased) + .child(navbar_left()) + .child(navbar_center()) + .child(NavbarRight), + ) + .child( + rect() + .window_drag() + .width(Size::window_percent(100.)) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .position(Position::new_absolute().top(0.).left(0.).right(0.)), + ) + } +} + +fn navbar_left() -> impl IntoElement { + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .child(navbar_logo()) +} + +fn navbar_logo() -> impl IntoElement { + let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); + + SvgViewer::new(("logo.svg", bytes.read().cloned())) + .show_loader(false) + .height(Size::px(44.)) + .width(Size::px(214.)) + .color(theme::colors::fg_primary()) +} + +fn navbar_center() -> impl IntoElement { + let route = use_route::(); + let browse_target = browse_target(); + + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(36.) + .child(NavLink { + active: route == Route::Home {}, + target: NavTarget::Route(Route::Home {}), + nav_label: "Home", + }) + .child(NavLink { + active: route == Route::Clusters {}, + target: NavTarget::Route(Route::Clusters {}), + nav_label: "Versions", + }) + .child(NavLink { + active: false, + target: NavTarget::External("https://store.polyfrost.org"), + nav_label: "Cosmetics", + }) + .child(NavLink { + active: matches!( + route, + Route::Browser { + pick_cluster: true, + .. + } + ), + target: NavTarget::Route(browse_target), + nav_label: "Browse", + }) + .child(NavLink { + active: route == Route::Stats {}, + target: NavTarget::Route(Route::Stats {}), + nav_label: "Stats", + }) +} + +/// Falls back active cluster then most recently played then Versions when none exist +fn browse_target() -> Route { + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + let active = *use_active_cluster_id().read(); + + let cluster_id = active + .filter(|id| clusters.iter().any(|cluster| cluster.id == *id)) + .or_else(|| sort_clusters_for_home(clusters).first().map(|c| c.id)); + + match cluster_id { + Some(cluster_id) => Route::Browser { + cluster_id, + package_type: "mod".to_string(), + pick_cluster: true, + }, + None => Route::Clusters {}, + } +} + +#[derive(PartialEq, Clone)] +enum NavTarget { + Route(Route), + External(&'static str), +} + +#[derive(PartialEq)] +struct NavLink { + active: bool, + target: NavTarget, + nav_label: &'static str, +} + +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 active = self.active; + let target = self.target.clone(); + let nav_label = self.nav_label; + + let color = if active || hovering() || focused().is_focused() { + theme::colors::fg_primary() + } else { + theme::colors::fg_secondary() + }; + + let underline_width = if active { + 27. + } else if hovering() || focused().is_focused() { + 18. + } else { + 0. + }; + + rect() + .vertical() + .cross_align(Alignment::Center) + .spacing(2.) + .width(Size::px(nav_label.len() as f32 * 10. + 10.)) + // TODO workaround for a Freya measurement bug a fully transparent background + // measures wrongly so give it alpha 0 red to keep pointer events working + .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(); + match &target { + NavTarget::Route(route) => { + let _ = RouterContext::get().push(route.clone()); + } + NavTarget::External(url) => crate::platform::open_url(url), + } + }) + .on_pointer_over(move |_| hovering.set(true)) + .on_pointer_out(move |_| hovering.set(false)) + .cursor(CursorIcon::Pointer) + .child( + label() + .text(nav_label) + .font_size(16.) + .font_weight(if active { + FontWeight::MEDIUM + } else { + FontWeight::NORMAL + }) + .color(color), + ) + .child( + rect() + .height(Size::px(2.)) + .width(Size::px(underline_width)) + .corner_radius(CornerRadius::new_all(2.)) + .background(if active { + theme::colors::fg_primary() + } else { + theme::colors::fg_secondary() + }), + ) + } +} + +#[derive(PartialEq)] +struct NavbarRight; + +impl Component for NavbarRight { + fn render(&self) -> impl IntoElement { + let current_account = use_current_account(); + let dispatch = use_dispatch(); + let unread = use_notifications_snapshot().unread_count(); + + let account_uuid = try_default_account(¤t_account) + .map(|account| account.id.to_string()) + .unwrap_or_else(|| uuid::Uuid::nil().to_string()); + + let notif_dispatch = dispatch.clone(); + let open_notifications = move |_| { + notif_dispatch.toggle_notification_center(); + }; + + let open_account_switcher = move |_| { + dispatch.toggle_account_switcher(); + }; + + let open_settings = |_| { + let _ = RouterContext::get().push(Route::SettingsLauncher {}); + }; + + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + super::navbar_button() + .child(notification_bell(unread)) + .on_press(open_notifications), + ) + .child( + super::navbar_button() + .child(Icon::new(IconType::Settings02).size(20.)) + .on_press(open_settings), + ) + .child( + super::navbar_button() + .padding(0.0) + .on_press(open_account_switcher) + .child( + Avatar::new(account_uuid) + .width(Size::px(24.)) + .height(Size::px(24.)), + ), + ) + .child(super::window_controls()) + } +} + +fn notification_bell(unread: usize) -> impl IntoElement { + rect() + .width(Size::px(20.)) + .height(Size::px(20.)) + .child(Icon::new(IconType::Bell01).size(20.)) + .maybe_child((unread > 0).then(|| { + rect() + .position(Position::new_absolute().top(-4.).right(-4.)) + .width(Size::px(16.)) + .height(Size::px(16.)) + .corner_radius(CornerRadius::from(8.)) + .background(theme::colors::danger()) + .layer(Layer::Relative(3)) + .center() + .child( + label() + .text(if unread > 9 { + "9+".to_string() + } else { + unread.to_string() + }) + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(theme::colors::fg_primary()), + ) + .into_element() + })) +} diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs new file mode 100644 index 00000000..150c3832 --- /dev/null +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -0,0 +1,60 @@ +mod app_navbar; +mod onboarding_navbar; + +pub use app_navbar::Navbar as AppNavbar; +pub use onboarding_navbar::OnboardingNavbar; + +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType}; + +pub(super) fn navbar_button() -> Button { + Button::new() + .ghost() + .icon() + .width(Size::px(36.)) + .height(Size::px(36.)) +} + +pub fn window_controls() -> impl IntoElement { + let minimize = |_| { + Platform::get().with_window(None, |win| { + win.set_minimized(true); + }); + }; + + let maximize = |_| { + Platform::get().with_window(None, |win| { + win.set_maximized(!win.is_maximized()); + }); + }; + + let close = |_| { + let platform = Platform::get(); + Platform::get().with_window(None, move |window| { + platform.close_window(window.id()); + }); + }; + + rect() + .horizontal() + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(8.) + .layer(Layer::OverlayLevel(15)) + .child( + navbar_button() + .child(Icon::new(IconType::Minus)) + .on_press(minimize), + ) + .child( + navbar_button() + .child(Icon::new(IconType::Maximize01).size(20.)) + .on_press(maximize), + ) + .child( + navbar_button() + .child(Icon::new(IconType::X)) + .on_press(close), + ) +} diff --git a/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs b/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs new file mode 100644 index 00000000..d9c66235 --- /dev/null +++ b/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs @@ -0,0 +1,44 @@ +use freya::prelude::*; + +use crate::components::window_controls; +use crate::theme::{self, colors}; + +#[derive(PartialEq)] +pub struct OnboardingNavbar; + +impl Component for OnboardingNavbar { + fn render(&self) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0., 24.)) + .child(logo()) + .child(rect().width(Size::flex(1.0)).height(Size::fill())) + .child(window_controls()), + ) + .child( + rect() + .window_drag() + .width(Size::window_percent(100.)) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .position(Position::new_absolute().top(0.).left(0.).right(0.)), + ) + } +} + +fn logo() -> impl IntoElement { + let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); + + SvgViewer::new(("logo.svg", bytes.read().cloned())) + .show_loader(false) + .height(Size::px(36.)) + .width(Size::px(170.)) + .color(colors::fg_primary()) +} diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs new file mode 100644 index 00000000..2924ce7f --- /dev/null +++ b/packages/oneclient_app/src/components/notifications.rs @@ -0,0 +1,529 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, +}; +use oneclient_events::Level; + +use crate::{ + components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea, progress_track}, + hooks::{use_dispatch, use_notifications_snapshot}, + notifications::{InboxEntry, NotificationActionKind}, + theme::colors, + transfer::TransferStats, + ui::{divider, relative_time}, + utils::{format_duration_hms, format_size}, +}; + +#[derive(PartialEq)] +pub struct NotificationCenter; + +impl Component for NotificationCenter { + fn render(&self) -> impl IntoElement { + let open = use_notifications_snapshot().center_open; + let dispatch = use_dispatch(); + + if !open { + return rect().into_element(); + } + + OverlayPopup::new() + .position(Position::new_global().top(72.).right(40.)) + .on_close(move |()| dispatch.close_notification_center()) + .child(NotificationPanel) + .into_element() + } +} + +#[derive(PartialEq)] +struct NotificationPanel; + +impl Component for NotificationPanel { + fn render(&self) -> impl IntoElement { + let inbox = use_notifications_snapshot().inbox; + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(200).ease(Ease::Out) + }); + + let progress = intro.read().value(); + + let entries = inbox.len(); + let mut rows: Vec = Vec::new(); + if entries == 0 { + rows.push( + label() + .text("No notifications") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(), + ); + } else { + let last = entries - 1; + for (i, entry) in inbox.into_iter().enumerate() { + let id = entry.id; + rows.push(NotifEntryRow::new(entry, i != last).key(id).into_element()); + } + } + + let content = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(8.) + .children(rows); + + rect() + .vertical() + .content(Content::Flex) + .width(Size::px(368.)) + .height(Size::px(480.)) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .opacity(progress) + .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) + .background(colors::page_elevated().with_a(220)) + .blur(12.) + .corner_radius(CornerRadius::new_all(12.)) + .border( + Border::new() + .fill(colors::component_border()) + .width(1.) + .alignment(BorderAlignment::Inner), + ) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .child( + label() + .text("Notifications") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .a11y_role(AccessibilityRole::TitleBar), + ) + .child(divider()) + .child(content) + .child(divider()) + .child(Footer) + } +} + +#[derive(PartialEq)] +struct NotifEntryRow { + entry: InboxEntry, + show_divider: bool, + key: DiffKey, +} + +impl KeyExt for NotifEntryRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl NotifEntryRow { + fn new(entry: InboxEntry, show_divider: bool) -> Self { + Self { + entry, + show_divider, + key: DiffKey::None, + } + } +} + +impl Component for NotifEntryRow { + fn render(&self) -> impl IntoElement { + let entry = self.entry.clone(); + let id = entry.id; + let dispatch = use_dispatch(); + let enter_dispatch = dispatch.clone(); + let body_dispatch = dispatch.clone(); + let click_dismissable = entry.click_dismissable(); + let has_actions = !entry.actions.is_empty(); + + let mut hovered = use_state(|| false); + let expanded = use_state(|| false); + + let content = rect() + .horizontal() + .width(Size::fill()) + .spacing(13.) + .cross_align(Alignment::Start) + .padding(Gaps::new_all(8.)) + .corner_radius(CornerRadius::new_all(8.)) + .maybe(*hovered.read() && !has_actions, |el| { + el.background(colors::ghost_overlay_hover()) + }) + .on_pointer_enter(move |_| { + hovered.set(true); + enter_dispatch.mark_notification_read(id); + }) + .on_pointer_leave(move |_| hovered.set(false)) + .maybe(click_dismissable, |el| { + el.on_press(move |_| dispatch.dismiss_notification(id)) + }) + .child( + rect() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(6.)) + .center() + .child( + Icon::new(icon_for(&entry)) + .size(24.) + .color(level_color(&entry.level)), + ), + ) + .child(row_body(&entry, body_dispatch)); + + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child(content) + .maybe(!entry.tasks.is_empty(), |el| { + el.child(tasks_section(&entry, expanded)) + }) + .maybe(self.show_divider, |el| el.child(divider())) + } +} + +const TASK_LIMIT: usize = 12; + +fn tasks_section(entry: &InboxEntry, mut expanded: State) -> impl IntoElement { + let is_expanded = *expanded.read(); + let count = entry.tasks.len(); + + let mut section = rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .margin(Gaps::new(2., 0., 0., 44.)) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let now = !*expanded.peek(); + expanded.set(now); + }) + .child(ChevronToggle { + expanded: is_expanded, + }) + .child( + label() + .text(if is_expanded { + "Hide tasks".to_string() + } else { + format!("Show {count} tasks") + }) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ); + + if is_expanded { + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + for task in entry.tasks.iter().take(TASK_LIMIT) { + list = list.child(task_row(task)); + } + if count > TASK_LIMIT { + list = list.child( + label() + .text(format!("+{} more", count - TASK_LIMIT)) + .font_size(10.) + .color(colors::fg_secondary()), + ); + } + section = section.child(list); + } + + section.into_element() +} + +fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { + let percent = ((task.current as f32 / task.total.max(1) as f32) * 100.0).round() as u64; + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child(phase_badge(task.phase)) + .child( + label() + .text(task.label.clone()) + .font_size(11.) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .maybe(task.total_count > 1, |el| { + el.child( + label() + .text(format!("{}/{}", task.done_count, task.total_count)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + }) + .child( + label() + .text(format!("{percent}%")) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct ChevronToggle { + expanded: bool, +} + +impl Component for ChevronToggle { + fn render(&self) -> impl IntoElement { + let expanded = self.expanded; + let anim = use_animation(|_| AnimNum::new(0., 90.).time(160).ease(Ease::Out)); + + use_side_effect_with_deps(&expanded, move |&expanded| { + let mut anim = anim; + if expanded { + anim.start(); + } else { + anim.reverse(); + } + }); + + let deg = anim.get().value(); + rect().rotate(deg).child( + Icon::new(IconType::ChevronRight) + .size(14.) + .color(colors::fg_secondary()), + ) + } +} + +fn phase_badge(phase: &str) -> impl IntoElement { + let color = match phase { + "Verifying" => colors::code_warn(), + "Extracting" => colors::success(), + "Installing" => colors::brand(), + _ => colors::brand(), + }; + rect() + .center() + .padding(Gaps::new_symmetric(1., 7.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(color.with_a(40)) + .child( + label() + .text(phase.to_string()) + .font_size(9.) + .font_weight(FontWeight::MEDIUM) + .color(color), + ) + .into_element() +} + +fn row_body(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Start) + .child( + label() + .text(entry.title.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(level_color(&entry.level)) + .width(Size::flex(1.0)), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(relative_time(entry.created_at)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .maybe(!entry.read, |el| { + el.child( + rect() + .width(Size::px(6.)) + .height(Size::px(6.)) + .corner_radius(CornerRadius::new_all(3.)) + .background(colors::brand()), + ) + }), + ), + ) + .child( + label() + .text(entry.body.clone()) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(row_extra(entry, dispatch)) +} + +fn row_extra(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { + if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) { + let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0); + let bar = progress_track( + frac * 100.0, + 6., + colors::brand(), + colors::brand().with_a(128), + ); + + return rect() + .vertical() + .width(Size::fill()) + .spacing(5.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child(bar) + .maybe_child(entry.transfer.map(transfer_footer)) + .into_element(); + } + + if !entry.actions.is_empty() { + let id = entry.id; + let dismiss_dispatch = dispatch.clone(); + + let mut row = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child( + Button::new() + .small() + .secondary() + .width(Size::flex(1.0)) + .text("Dismiss") + .on_press(move |_| dismiss_dispatch.dismiss_notification(id)), + ); + + for (i, action) in entry.actions.iter().enumerate() { + let action_dispatch = dispatch.clone(); + let kind = action.kind.clone(); + row = row.child( + Button::new() + .small() + .width(Size::flex(1.0)) + .text(action.label.clone()) + .variant(if i == 0 { + ButtonVariant::Primary + } else { + ButtonVariant::Secondary + }) + .on_press(move |_| run_action(&action_dispatch, &kind)), + ); + } + + return row.into_element(); + } + + rect().into_element() +} + +fn transfer_footer(stats: TransferStats) -> Element { + let speed = format!("{}/s", format_size(stats.speed_bps as u64)); + let eta = stats + .eta_secs + .map(|secs| format!("{} left", format_duration_hms(secs as i64))); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(speed) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .maybe_child(eta.map(|eta| { + label() + .text(eta) + .font_size(10.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() +} + +fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summaries) => { + dispatch.open_cluster_update(summaries.clone()); + } + } +} + +#[derive(PartialEq)] +struct Footer; + +impl Component for Footer { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let is_empty = use_notifications_snapshot().inbox.is_empty(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + Button::new() + .ghost() + .small() + .disabled(is_empty) + .on_press(move |_| dispatch.clear_notification_inbox()) + .child(Icon::new(IconType::Trash01).size(16.).color(if is_empty { + colors::fg_primary_disabled() + } else { + colors::fg_primary() + })) + .text("Clear all"), + ) + } +} + +fn icon_for(entry: &InboxEntry) -> IconType { + if let Some(icon) = entry.icon { + return icon; + } + if entry.is_loading && entry.progress.is_some() { + return IconType::DownloadCloud02; + } + match entry.level { + Level::Error => IconType::AlertTriangle, + Level::Info => IconType::InfoCircle, + } +} + +fn level_color(level: &Level) -> Color { + match level { + Level::Info => colors::fg_primary(), + Level::Error => colors::danger(), + } +} diff --git a/packages/oneclient_app/src/components/overlay_popup.rs b/packages/oneclient_app/src/components/overlay_popup.rs new file mode 100644 index 00000000..3b2f776c --- /dev/null +++ b/packages/oneclient_app/src/components/overlay_popup.rs @@ -0,0 +1,112 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, +}; + +const SCRIM_ALPHA: f32 = 90.; + +#[derive(PartialEq)] +pub struct OverlayPopup { + children: Vec, + on_close: Option>, + position: Position, + backdrop: bool, + key: DiffKey, +} + +impl Default for OverlayPopup { + fn default() -> Self { + Self::new() + } +} + +impl OverlayPopup { + pub fn new() -> Self { + Self { + children: Vec::new(), + on_close: None, + position: Position::new_global().top(0.).left(0.), + backdrop: true, + key: DiffKey::None, + } + } + + pub fn position(mut self, position: Position) -> Self { + self.position = position; + self + } + + pub fn backdrop(mut self, backdrop: bool) -> Self { + self.backdrop = backdrop; + self + } + + pub fn on_close(mut self, on_close: impl Into>) -> Self { + self.on_close = Some(on_close.into()); + self + } +} + +impl ChildrenExt for OverlayPopup { + fn get_children(&mut self) -> &mut Vec { + &mut self.children + } +} + +impl KeyExt for OverlayPopup { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for OverlayPopup { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let scrim_close = self.on_close.clone(); + let key_close = self.on_close.clone(); + + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(180).ease(Ease::Out) + }); + + let scrim_alpha = if self.backdrop { SCRIM_ALPHA } else { 0. }; + let alpha = (fade.read().value() * scrim_alpha) as u8; + + rect() + .layer(Layer::Overlay) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .child( + rect() + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .layer(Layer::OverlayLevel(10)) + .background(Color::from_argb(alpha, 0, 0, 0)) + .on_press(move |_| { + if let Some(on_close) = scrim_close.as_ref() { + on_close.call(()); + } + }), + ) + .child( + rect() + .position(self.position.clone()) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_auto_focus(true) + .a11y_role(AccessibilityRole::Dialog) + .layer(Layer::OverlayLevel(12)) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) + && let Some(on_close) = key_close.as_ref() + { + on_close.call(()); + } + }) + .children(self.children.clone()), + ) + } +} diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs new file mode 100644 index 00000000..00b316d4 --- /dev/null +++ b/packages/oneclient_app/src/components/package_row.rs @@ -0,0 +1,534 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_content::packages::ProviderId; + +use crate::components::{Icon, IconType, toggle_controlled}; +use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41); +pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255); +pub(crate) const CARD_H: f32 = 84.; + +#[derive(Clone, Copy, PartialEq)] +pub enum CardLayout { + List, + Grid, +} + +impl From for CardLayout { + fn from(layout: oneclient_core::settings::ViewLayout) -> Self { + match layout { + oneclient_core::settings::ViewLayout::Grid => CardLayout::Grid, + oneclient_core::settings::ViewLayout::List => CardLayout::List, + } + } +} + +#[derive(Clone, PartialEq)] +pub struct PackageEntry { + pub package_id: String, + pub bundle_name: Option, + pub provider: ProviderId, + pub name: String, + pub file_name: String, + pub author: String, + pub description: String, + pub icon_url: Option, + pub size: u64, + pub categories: Vec, + pub enabled: bool, + pub installed: bool, + pub hash: Option, + pub manifest_default: bool, + /// Private bundle dependency only set for bundle rows + pub hidden: bool, + /// Only set for browser-installed content bundle packages use the bundle update flow + pub update_available: bool, +} + +impl PackageEntry { + pub fn is_remote(&self) -> bool { + self.provider != ProviderId::Local + } + + pub fn in_bundle(&self) -> bool { + self.bundle_name.is_some() + } + + /// Bundle packages are never marked here so the two update flows cannot contradict + pub fn is_outdated(&self) -> bool { + self.update_available && self.is_remote() && !self.in_bundle() + } +} + +#[derive(PartialEq)] +pub struct PackageRow { + item: PackageEntry, + cluster_id: i64, + package_type: &'static str, + layout: CardLayout, + key: DiffKey, +} + +impl PackageRow { + pub fn new(item: PackageEntry, cluster_id: i64, package_type: &'static str) -> Self { + Self { + item, + cluster_id, + package_type, + layout: CardLayout::List, + key: DiffKey::None, + } + } + + pub fn layout(mut self, layout: CardLayout) -> Self { + self.layout = layout; + self + } +} + +impl KeyExt for PackageRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for PackageRow { + fn render(&self) -> impl IntoElement { + let item = self.item.clone(); + let cluster_id = self.cluster_id; + let package_type = self.package_type; + let layout = self.layout; + let cluster = use_cluster_mutation(); + let remove_hover = use_state(|| false); + + let icon_size = match layout { + CardLayout::List => 44., + CardLayout::Grid => 52., + }; + let icon_query = use_cached_image(item.icon_url.clone(), 256); + let icon = if item.is_remote() { + remote_icon(&item.icon_url, &icon_query, icon_size) + } else { + local_icon(icon_size) + }; + + let on_toggle: EventHandler<()> = { + let hash = item.hash.clone(); + let bundle_name = item.bundle_name.clone(); + let package_id = item.package_id.clone(); + let enabled_now = item.enabled; + let manifest_default = item.manifest_default; + (move |()| { + if let Some(h) = &hash { + cluster.mutate(ClusterAction::ToggleArtifact { + cluster_id, + hash: h.clone(), + }); + } else if let Some(bundle) = &bundle_name { + cluster.mutate(ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name: bundle.clone(), + package_id: package_id.clone(), + enabled: !enabled_now, + manifest_default, + }); + } + }) + .into() + }; + + match layout { + CardLayout::List => { + let removable = !item.in_bundle(); + let can_remove = removable && item.installed; + let rm_hash = item.hash.clone(); + let on_remove = move || { + if let Some(h) = &rm_hash { + cluster.mutate(ClusterAction::RemoveArtifact { + cluster_id, + hash: h.clone(), + }); + } + }; + list_card( + &item, + package_type, + cluster_id, + icon, + on_toggle, + can_remove, + on_remove, + remove_hover, + ) + } + CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle), + } + } +} + +#[allow(clippy::too_many_arguments)] +fn list_card( + item: &PackageEntry, + package_type: &'static str, + cluster_id: i64, + icon: impl IntoElement, + on_toggle: EventHandler<()>, + can_remove: bool, + on_remove: impl FnMut() + 'static, + hovering: State, +) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(CARD_BG) + .content(Content::Flex) + .child(package_info(item, package_type, cluster_id, icon)) + .child(meta_size(item.size)) + .child(toggle_controlled(item.enabled, on_toggle)) + .child(remove_button(can_remove, on_remove, hovering)) + .into_element() +} + +fn grid_card( + item: &PackageEntry, + package_type: &'static str, + cluster_id: i64, + icon: impl IntoElement, + on_toggle: EventHandler<()>, +) -> Element { + let remote = item.is_remote(); + let enabled = item.enabled; + let title = if remote { + item.name.clone() + } else { + item.file_name.clone() + }; + + let (bg, border, content_alpha) = if enabled { + (colors::brand().with_a(38), colors::brand(), 255) + } else { + (CARD_BG, colors::component_border(), 140) + }; + + let badge = if remote { + let provider = item.provider; + let package_id = item.package_id.clone(); + let package_type_owned = package_type.to_string(); + rect() + .cursor(CursorIcon::Pointer) + .on_press(move |e: Event| { + e.stop_propagation(); + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type_owned.clone(), + package_id: format!("{}:{}", provider as u8, package_id), + }); + }) + .child(provider_badge(item.provider)) + .into_element() + } else { + local_badge() + }; + + let header = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(title) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME.with_a(content_alpha)), + ) + .maybe(!item.author.is_empty(), |el| { + el.child( + label() + .text(format!("by {}", item.author)) + .font_size(10.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(content_alpha)), + ) + }) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child(badge) + .maybe_child(item.is_outdated().then(outdated_badge)), + ), + ) + .into_element(); + + let description = if item.description.is_empty() { + None + } else { + Some( + label() + .text(item.description.clone()) + .font_size(11.) + .max_lines(3) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(content_alpha)) + .into_element(), + ) + }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(bg) + .border(border_all_color(1.5, border)) + .content(Content::Flex) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_toggle.call(())) + .child(header) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .maybe_child(description), + ) + .into_element() +} + +fn package_info( + item: &PackageEntry, + package_type: &'static str, + cluster_id: i64, + icon: impl IntoElement, +) -> impl IntoElement { + let remote = item.is_remote(); + let provider = item.provider; + let package_id = item.package_id.clone(); + let package_type = package_type.to_string(); + let title = if remote { + item.name.clone() + } else { + item.file_name.clone() + }; + + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .maybe(remote, |el| { + el.cursor(CursorIcon::Pointer).on_press(move |_| { + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type.clone(), + package_id: format!("{}:{}", provider as u8, package_id), + }); + }) + }) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(title) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(CARD_NAME), + ) + .child(if remote { + provider_badge(item.provider) + } else { + local_badge() + }) + .maybe_child(item.is_outdated().then(outdated_badge)), + ) + .maybe(!item.author.is_empty(), |el| { + el.child( + label() + .text(format!("by {}", item.author)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + }) + .maybe(!item.description.is_empty(), |el| { + el.child( + label() + .text(item.description.clone()) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + }), + ) + .into_element() +} + +fn remote_icon( + icon_url: &Option, + icon_query: &freya::query::UseQuery, + size: f32, +) -> Element { + let loaded = loaded_image(icon_url.as_deref(), icon_query); + + match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(size)) + .height(Size::px(size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(8.)) + .into_element(), + + None => icon_box(IconType::DotsGrid, size), + } +} + +fn local_icon(size: f32) -> Element { + icon_box(IconType::HelpCircle, size) +} + +fn icon_box(icon: IconType, size: f32) -> Element { + rect() + .center() + .width(Size::px(size)) + .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()), + ) + .into_element() +} + +fn meta_size(size: u64) -> impl IntoElement { + rect() + .maybe_child((size > 0).then(|| { + label() + .text(format_size(size)) + .font_size(11.) + .color(colors::fg_secondary()) + })) + .into_element() +} + +pub fn provider_badge(provider: ProviderId) -> Element { + badge( + Icon::new(provider).size(12.).into_element(), + provider.to_string(), + ) +} + +fn outdated_badge() -> Element { + accent_badge( + Icon::new(IconType::RefreshCw01) + .size(12.) + .color(colors::brand()) + .into_element(), + "Update available".to_string(), + colors::brand(), + ) +} + +fn local_badge() -> Element { + badge( + Icon::new(IconType::File02) + .size(12.) + .color(colors::fg_secondary()) + .into_element(), + "Local file".to_string(), + ) +} + +fn badge(icon: impl IntoElement, text: String) -> Element { + accent_badge(icon, text, colors::fg_secondary()) +} + +fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::component_bg()) + .child(icon) + .child( + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::MEDIUM) + .color(accent), + ) + .into_element() +} + +fn remove_button( + enabled: bool, + mut on_remove: impl FnMut() + 'static, + mut hovering: State, +) -> impl IntoElement { + let hot = enabled && *hovering.read(); + let color = if !enabled { + colors::fg_secondary().with_a(90) + } else if hot { + colors::danger() + } else { + colors::fg_secondary() + }; + + rect() + .center() + .width(Size::px(30.)) + .height(Size::px(30.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(if hot { + colors::danger().with_a(30) + } else { + Color::TRANSPARENT + }) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_press(move |_| on_remove()) + }) + .child(Icon::new(IconType::Trash01).size(14.).color(color)) + .into_element() +} diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs new file mode 100644 index 00000000..ec39435a --- /dev/null +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -0,0 +1,388 @@ +//! Only lists packages installed from the browser bundle content has its own flow + +use std::collections::HashMap; + +use freya::prelude::*; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; +use oneclient_core::BrowserPackageUpdate; +use oneclient_db::models::ClusterId; + +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{ + package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, +}; +use crate::notifications::PackageUpdateGroup; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 520.; +const DIALOG_H: f32 = 420.; +const CHOICE_DROPDOWN_W: f32 = 150.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +/// `Skip` is re-offered next launch `SkipVersion` persists the decline until a newer version +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum RowChoice { + #[default] + Update, + Skip, + SkipVersion, +} + +impl RowChoice { + const ALL: [Self; 3] = [Self::Update, Self::Skip, Self::SkipVersion]; + + fn label(self) -> &'static str { + match self { + Self::Update => "Update", + Self::Skip => "Skip for now", + Self::SkipVersion => "Skip this version", + } + } + + fn options() -> Vec { + Self::ALL.iter().map(|c| c.label().to_string()).collect() + } +} + +type RowKey = (ClusterId, String); + +fn row_key(update: &BrowserPackageUpdate) -> RowKey { + (update.cluster_id, update.hash.clone()) +} + +#[derive(PartialEq)] +pub struct PackageUpdatePopup; + +impl Component for PackageUpdatePopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + // Absent entries default to Update so groups arriving mid-modal need no reconciliation + let choices = use_state(HashMap::::new); + + let groups = snapshot.package_updates.clone(); + + // Hooks run unconditionally so this must precede the early return below + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = groups + .iter() + .flatten() + .flat_map(|group| &group.packages) + .filter(|update| update.provider == provider) + .map(|update| update.project_id.clone()) + .collect(); + let query = use_package_meta_batch(provider, ids); + for (project_id, cached) in package_meta_batch(&query) { + meta.insert((provider, project_id), cached); + } + } + + let Some(groups) = groups.filter(|groups| !groups.is_empty()) else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.close_package_updates()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&groups, &meta, dispatch, choices)), + ) + .into_element() + } +} + +fn resolve_name(update: &BrowserPackageUpdate, meta: &MetaMap) -> String { + meta.get(&(update.provider, update.project_id.clone())) + .map(|cached| cached.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| update.display_name.clone()) +} + +fn dialog( + groups: &[PackageUpdateGroup], + meta: &MetaMap, + dispatch: crate::Actions, + choices: State>, +) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .overflow(Overflow::Clip) + .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), + ))) + .child(content(groups, meta, dispatch, choices)) +} + +fn content( + groups: &[PackageUpdateGroup], + meta: &MetaMap, + dispatch: crate::Actions, + choices: State>, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + let proceed_dispatch = dispatch.clone(); + let total: usize = groups.iter().map(|group| group.packages.len()).sum(); + + let subtitle = match groups { + [only] => format!( + "{total} package{} in {} can be updated. Pick what happens to each, then Proceed.", + if total == 1 { "" } else { "s" }, + only.cluster_name + ), + _ => format!( + "{total} package{} across {} clusters can be updated. Pick what happens to each, then Proceed.", + if total == 1 { "" } else { "s" }, + groups.len() + ), + }; + + let all: Vec = groups + .iter() + .flat_map(|group| group.packages.iter().cloned()) + .collect(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text("Updates available") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(3) + .color(colors::fg_secondary()), + ), + ) + .child(update_list(groups, meta, groups.len() > 1, choices)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_package_updates()) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let answers = choices.read(); + for update in &all { + match answers.get(&row_key(update)).copied().unwrap_or_default() { + RowChoice::Update => { + proceed_dispatch.apply_package_update(update.clone()); + } + RowChoice::Skip => {} + RowChoice::SkipVersion => proceed_dispatch.skip_package_update( + update.cluster_id, + update.hash.clone(), + ), + } + } + proceed_dispatch.close_package_updates(); + }) + .child(Icon::new(IconType::DownloadCloud02).size(15.)) + .text("Proceed"), + ), + ) +} + +fn update_list( + groups: &[PackageUpdateGroup], + meta: &MetaMap, + show_headers: bool, + choices: State>, +) -> impl IntoElement { + let mut scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(4.); + + for (index, group) in groups.iter().enumerate() { + if show_headers { + scroll = scroll.child(cluster_header(&group.cluster_name, index == 0)); + } + + for update in &group.packages { + let key = format!("{}:{}", group.cluster_id, update.hash); + let choice = choices + .read() + .get(&row_key(update)) + .copied() + .unwrap_or_default(); + scroll = scroll.child( + UpdateRow::new(resolve_name(update, meta), update.clone(), choice, choices) + .key(key) + .into_element(), + ); + } + } + + scroll.into_element() +} + +fn cluster_header(name: &str, first: bool) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(6., 8.)) + .margin(Gaps::new(if first { 0. } else { 8. }, 0., 3., 0.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child( + label() + .text(name.to_string()) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) +} + +struct UpdateRow { + name: String, + update: BrowserPackageUpdate, + choice: RowChoice, + choices: State>, + key: DiffKey, +} + +impl UpdateRow { + fn new( + name: String, + update: BrowserPackageUpdate, + choice: RowChoice, + choices: State>, + ) -> Self { + Self { + name, + update, + choice, + choices, + key: DiffKey::None, + } + } +} + +impl PartialEq for UpdateRow { + fn eq(&self, other: &Self) -> bool { + self.name == other.name && self.update == other.update && self.choice == other.choice + } +} + +impl KeyExt for UpdateRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for UpdateRow { + fn render(&self) -> impl IntoElement { + let update = self.update.clone(); + let choice = self.choice; + let mut choices = self.choices; + let key = row_key(&update); + + let versions = match ( + update.installed_version_name.is_empty(), + update.latest_version_name.is_empty(), + ) { + (false, false) => format!( + "{} to {}", + update.installed_version_name, update.latest_version_name + ), + (true, false) => format!("New version {}", update.latest_version_name), + _ => "A newer version is available".to_string(), + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .content(Content::Flex) + .child( + Icon::new(IconType::RefreshCw01) + .size(14.) + .color(colors::brand()), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(self.name.clone()) + .font_size(12.5) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(versions) + .font_size(11.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .child( + Dropdown::new(choice.label(), RowChoice::options()) + .width(Size::px(CHOICE_DROPDOWN_W)) + .height(Size::px(26.)) + .on_select(move |idx: usize| { + if let Some(choice) = RowChoice::ALL.get(idx) { + choices.write().insert(key.clone(), *choice); + } + }), + ) + } +} diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs new file mode 100644 index 00000000..ef5a7640 --- /dev/null +++ b/packages/oneclient_app/src/components/pagination.rs @@ -0,0 +1,161 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +fn page_window(current: usize, total: usize, window: usize) -> (usize, usize) { + if total <= window { + return (0, total); + } + let end = (current + window / 2 + 1).min(total); + let start = end.saturating_sub(window); + (start, end) +} + +#[derive(PartialEq)] +pub struct Pagination { + page: State, + total_pages: usize, + window: usize, + enabled: bool, +} + +impl Pagination { + pub fn new(page: State, total_pages: usize) -> Self { + Self { + page, + total_pages, + window: 5, + enabled: true, + } + } + + #[allow(dead_code)] + pub fn window(mut self, window: usize) -> Self { + self.window = window.max(1); + self + } + + /// Greys out controls without unmounting the pager + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } +} + +impl Component for Pagination { + fn render(&self) -> impl IntoElement { + let page = self.page; + let total_pages = self.total_pages; + let interactive = self.enabled; + let current = *page.read(); + let last = total_pages.saturating_sub(1); + + let icon_btn = move |target: usize, available: bool, icon: IconType| { + let mut page = page; + let enabled = available && interactive; + Button::new() + .secondary() + .icon() + .enabled(enabled) + .on_press(move |_| page.set(target)) + .child(Icon::new(icon).size(14.).color(if enabled { + colors::fg_primary() + } else { + colors::fg_secondary().with_a(90) + })) + .into_element() + }; + + let num_btn = move |target: usize| { + let mut page = page; + let is_current = target == current; + + if is_current { + return rect() + .center() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg_pressed()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(format!("{}", target + 1)) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .into_element(); + } + + Button::new() + .secondary() + .width(Size::px(32.)) + .height(Size::px(32.)) + .padding(Gaps::new_symmetric(6., 0.)) + .enabled(interactive) + .on_press(move |_| page.set(target)) + .child( + label() + .text(format!("{}", target + 1)) + .font_size(12.) + .font_weight(FontWeight::NORMAL) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .into_element() + }; + + let ellipsis = || { + rect() + .center() + .width(Size::px(20.)) + .height(Size::px(32.)) + .child( + label() + .text("...") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() + }; + + let (start, end) = page_window(current, total_pages, self.window); + + let mut row = rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(6.) + .child(icon_btn(0, current > 0, IconType::ChevronsLeft)) + .child(icon_btn( + current.saturating_sub(1), + current > 0, + IconType::ArrowLeft, + )); + + if start > 0 { + row = row.child(num_btn(0)); + if start > 1 { + row = row.child(ellipsis()); + } + } + + for p in start..end { + row = row.child(num_btn(p)); + } + + if end < total_pages { + if end < last { + row = row.child(ellipsis()); + } + row = row.child(num_btn(last)); + } + + row.child(icon_btn(current + 1, current < last, IconType::ArrowRight)) + .child(icon_btn(last, current < last, IconType::ChevronsRight)) + } +} diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs new file mode 100644 index 00000000..23524feb --- /dev/null +++ b/packages/oneclient_app/src/components/player_model.rs @@ -0,0 +1,325 @@ +use std::cell::RefCell; + +use bytes::Bytes; +use freya::engine::prelude::{ + Data, Paint, SamplingOptions, Shader, SkData, SkImage, SkRect, TileMode, +}; +use freya::prelude::*; +use skia_safe::RuntimeEffect; +use skia_safe::runtime_effect::ChildPtr; + +use crate::hooks::use_player_skin; + +const CAM_DIST: f32 = 80.0; +const CAM_FOCAL: f32 = 1.85; +const DRAG_SENSITIVITY: f32 = 0.01; +const PITCH_LIMIT: f32 = 1.3; + +thread_local! { + static EFFECT: RefCell> = const { RefCell::new(None) }; +} + +fn player_effect() -> Option { + EFFECT.with(|cell| { + let mut slot = cell.borrow_mut(); + + if slot.is_none() { + match RuntimeEffect::make_for_shader(PLAYER_SKSL, None) { + Ok(effect) => *slot = Some(effect), + Err(err) => tracing::error!("player model SkSL failed to compile: {err}"), + } + } + + slot.clone() + }) +} + +#[derive(PartialEq, Clone)] +pub struct PlayerModel { + uuid: String, + width: Size, + height: Size, + yaw: f32, + pitch: f32, +} + +impl PlayerModel { + pub fn new(uuid: impl Into) -> Self { + Self { + uuid: uuid.into(), + width: Size::fill(), + height: Size::fill(), + yaw: 0.5, + pitch: -0.1, + } + } + + pub fn width(mut self, width: Size) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Size) -> Self { + self.height = height; + self + } + + #[allow(dead_code)] + pub fn yaw(mut self, yaw: f32) -> Self { + self.yaw = yaw; + self + } + + #[allow(dead_code)] + pub fn pitch(mut self, pitch: f32) -> Self { + self.pitch = pitch; + self + } +} + +impl Component for PlayerModel { + fn render(&self) -> impl IntoElement { + let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone()); + + let mut cache = use_state(|| None::<(usize, Shader)>); + let src_ptr = skin_bytes.as_ptr() as usize; + let cache_copy = cache.peek().cloned(); + let skin_shader = match cache_copy { + Some((ptr, shader)) if ptr == src_ptr => Some(shader), + _ => { + let shader = decode_skin_shader(&skin_bytes); + if let Some(shader) = &shader { + cache.set(Some((src_ptr, shader.clone()))); + } + shader + } + }; + + let mut yaw = use_state({ + let v = self.yaw; + move || v + }); + + let mut pitch = use_state({ + let v = self.pitch; + move || v + }); + + let mut drag = use_state(|| None::<(f32, f32, f32, f32)>); + + let mut last_uuid = use_state({ + let u = self.uuid.clone(); + move || u + }); + + if *last_uuid.peek() != self.uuid { + last_uuid.set(self.uuid.clone()); + yaw.set(self.yaw); + pitch.set(self.pitch); + drag.set(None); + } + + let effect = player_effect(); + + let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let (Some(effect), Some(skin)) = (&effect, &skin_shader) else { + return; + }; + + let (w, h) = (ctx.size.width, ctx.size.height); + + if w <= 0.0 || h <= 0.0 { + return; + } + + let (cur_yaw, cur_pitch) = (yaw(), pitch()); + + let slim = if is_slim { 1.0 } else { 0.0 }; + + let mut uniforms = Vec::with_capacity(28); + for value in [w, h, cur_yaw, cur_pitch, CAM_DIST, CAM_FOCAL, slim] { + uniforms.extend_from_slice(&value.to_le_bytes()); + } + + let Some(shader) = effect.make_shader( + Data::new_copy(&uniforms), + &[ChildPtr::from(skin.clone())], + None, + ) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_shader(shader); + ctx.canvas.draw_rect(SkRect::new(0.0, 0.0, w, h), &paint); + }); + + // `canvas` carries no style data, so the cursor rides on a wrapper `rect` that + // covers exactly the same area + rect() + .width(self.width.clone()) + .height(self.height.clone()) + .cursor(CursorIcon::Grab) + .child( + canvas(render_cb) + .key(src_ptr as u64) + .width(Size::fill()) + .height(Size::fill()), + ) + .on_pointer_down(move |e: Event| { + let loc = e.global_location(); + drag.set(Some((loc.x as f32, loc.y as f32, yaw(), pitch()))); + }) + .on_global_pointer_move(move |e: Event| { + let Some((sx, sy, yaw0, pitch0)) = *drag.read() else { + return; + }; + let loc = e.global_location(); + if loc.to_tuple() == (-1.0, -1.0) { + return; + } + + let dx = loc.x as f32 - sx; + let dy = loc.y as f32 - sy; + + yaw.set(yaw0 - dx * DRAG_SENSITIVITY); + pitch.set((pitch0 + dy * DRAG_SENSITIVITY).clamp(-PITCH_LIMIT, PITCH_LIMIT)); + + Platform::get().send(UserEvent::RequestRedraw); + }) + // Global so a release outside the widget still ends the drag + .on_global_pointer_press(move |_: Event| { + if drag.peek().is_some() { + drag.set(None); + } + }) + } +} + +fn decode_skin_shader(skin_bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(skin_bytes) }; + let skin = SkImage::from_encoded(data)?; + let skin = skin.make_raster_image(None, None).unwrap_or(skin); + + skin.to_shader( + (TileMode::Clamp, TileMode::Clamp), + SamplingOptions::default(), + None, + ) +} + +const PLAYER_SKSL: &str = r#" +uniform float uResX; +uniform float uResY; +uniform float uYaw; +uniform float uPitch; +uniform float uDist; +uniform float uFocal; +uniform float uSlim; +uniform shader uSkin; + +void hitBox(float3 ro, float3 rd, float3 cmin, float3 cmax, float3 dims, float3 td, + float2 base, inout float best, inout half4 col) { + float3 t1 = (cmin - ro) / rd; + float3 t2 = (cmax - ro) / rd; + float3 tmin = min(t1, t2); + float3 tmax = max(t1, t2); + float tN = max(max(tmin.x, tmin.y), tmin.z); + float tF = min(min(tmax.x, tmax.y), tmax.z); + if (tN > tF || tF < 0.0) { return; } + float t = tN; + if (t < 0.0 || t >= best) { return; } + + float3 p = ro + rd * t; + float3 n = clamp((p - cmin) / dims, 0.0, 1.0); // 0..1 inside the box + float w = td.x, h = td.y, d = td.z; // atlas region sizes + + float2 uv; + float3 nrm; + if (tN == tmin.x) { + if (rd.x > 0.0) { // -X face (player right) + uv = float2(base.x + n.z * d, base.y + d + (1.0 - n.y) * h); + nrm = float3(-1.0, 0.0, 0.0); + } else { // +X face (player left) + uv = float2(base.x + d + w + (1.0 - n.z) * d, base.y + d + (1.0 - n.y) * h); + nrm = float3(1.0, 0.0, 0.0); + } + } else if (tN == tmin.y) { + if (rd.y > 0.0) { // -Y face (bottom): same orientation as the top, shifted by w + uv = float2(base.x + d + w + n.x * w, base.y + n.z * d); + nrm = float3(0.0, -1.0, 0.0); + } else { // +Y face (top) + uv = float2(base.x + d + n.x * w, base.y + n.z * d); + nrm = float3(0.0, 1.0, 0.0); + } + } else { + if (rd.z > 0.0) { // -Z face (back) + uv = float2(base.x + 2.0 * d + w + (1.0 - n.x) * w, base.y + d + (1.0 - n.y) * h); + nrm = float3(0.0, 0.0, -1.0); + } else { // +Z face (front) + uv = float2(base.x + d + n.x * w, base.y + d + (1.0 - n.y) * h); + nrm = float3(0.0, 0.0, 1.0); + } + } + + half4 s = uSkin.eval(uv); + + if (s.a < 0.5) { + return; + } + + float3 L = normalize(float3(0.4, 0.8, 0.6)); + float diff = 0.65 + 0.35 * clamp(dot(nrm, L), 0.0, 1.0); + col = half4(s.rgb * half(diff), 1.0); + best = t; +} + +half4 main(float2 fragCoord) { + float2 res = float2(uResX, uResY); + float2 uv = (fragCoord - 0.5 * res) / res.y; + uv.y = -uv.y; // screen y is down; world y is up + + float cy = cos(uYaw), sy = sin(uYaw); + float cp = cos(uPitch), sp = sin(uPitch); + float3 ro = float3(sy * cp, sp, cy * cp) * uDist; + float3 fwd = normalize(-ro); + float3 right = normalize(cross(fwd, float3(0.0, 1.0, 0.0))); + float3 up = cross(right, fwd); + float3 rd = normalize(fwd * uFocal + right * uv.x + up * uv.y); + + float best = 1e9; + half4 col = half4(0.0); + + float aw = uSlim > 0.5 ? 3.0 : 4.0; // arm width: slim = 3px, classic = 4px + float sw = aw + 0.5; // arm overlay (sleeve) width + + hitBox(ro, rd, float3(-4.0, 8.0, -4.0), float3(4.0, 16.0, 4.0), + float3(8.0, 8.0, 8.0), float3(8.0, 8.0, 8.0), float2(0.0, 0.0), best, col); // head + hitBox(ro, rd, float3(-4.0, -4.0, -2.0), float3(4.0, 8.0, 2.0), + float3(8.0, 12.0, 4.0), float3(8.0, 12.0, 4.0), float2(16.0, 16.0), best, col); // body + hitBox(ro, rd, float3(-4.0 - aw, -4.0, -2.0), float3(-4.0, 8.0, 2.0), + float3(aw, 12.0, 4.0), float3(aw, 12.0, 4.0), float2(40.0, 16.0), best, col); // right arm + hitBox(ro, rd, float3(4.0, -4.0, -2.0), float3(4.0 + aw, 8.0, 2.0), + float3(aw, 12.0, 4.0), float3(aw, 12.0, 4.0), float2(32.0, 48.0), best, col); // left arm + hitBox(ro, rd, float3(-4.0, -16.0, -2.0), float3(0.0, -4.0, 2.0), + float3(4.0, 12.0, 4.0), float3(4.0, 12.0, 4.0), float2(0.0, 16.0), best, col); // right leg + hitBox(ro, rd, float3(0.0, -16.0, -2.0), float3(4.0, -4.0, 2.0), + float3(4.0, 12.0, 4.0), float3(4.0, 12.0, 4.0), float2(16.0, 48.0), best, col); // left leg + + hitBox(ro, rd, float3(-4.5, 7.5, -4.5), float3(4.5, 16.5, 4.5), + float3(9.0, 9.0, 9.0), float3(8.0, 8.0, 8.0), float2(32.0, 0.0), best, col); // hat + hitBox(ro, rd, float3(-4.25, -4.25, -2.25), float3(4.25, 8.25, 2.25), + float3(8.5, 12.5, 4.5), float3(8.0, 12.0, 4.0), float2(16.0, 32.0), best, col); // jacket + hitBox(ro, rd, float3(-3.75 - sw, -4.25, -2.25), float3(-3.75, 8.25, 2.25), + float3(sw, 12.5, 4.5), float3(aw, 12.0, 4.0), float2(40.0, 32.0), best, col); // right sleeve + hitBox(ro, rd, float3(3.75, -4.25, -2.25), float3(3.75 + sw, 8.25, 2.25), + float3(sw, 12.5, 4.5), float3(aw, 12.0, 4.0), float2(48.0, 48.0), best, col); // left sleeve + hitBox(ro, rd, float3(-4.25, -16.25, -2.25), float3(0.25, -3.75, 2.25), + float3(4.5, 12.5, 4.5), float3(4.0, 12.0, 4.0), float2(0.0, 32.0), best, col); // right pant + hitBox(ro, rd, float3(-0.25, -16.25, -2.25), float3(4.25, -3.75, 2.25), + float3(4.5, 12.5, 4.5), float3(4.0, 12.0, 4.0), float2(0.0, 48.0), best, col); // left pant + + return col; +} +"#; diff --git a/packages/oneclient_app/src/components/progress_track.rs b/packages/oneclient_app/src/components/progress_track.rs new file mode 100644 index 00000000..742596d0 --- /dev/null +++ b/packages/oneclient_app/src/components/progress_track.rs @@ -0,0 +1,20 @@ +use freya::prelude::*; + +/// The app's one progress meter: a pill-shaped track with a filled portion. +/// +/// `pct` is 0-100 and is clamped here, so callers can hand over a raw ratio +/// without guarding against a denominator that grew mid-flight. +pub fn progress_track(pct: f32, height: f32, fill: Color, bg: Color) -> Rect { + rect() + .width(Size::fill()) + .height(Size::px(height)) + .corner_radius(CornerRadius::new_all(height / 2.)) + .background(bg) + .child( + rect() + .width(Size::percent(pct.clamp(0.0, 100.0))) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(height / 2.)) + .background(fill), + ) +} diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs new file mode 100644 index 00000000..b30fcbb2 --- /dev/null +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -0,0 +1,318 @@ +use freya::animation::*; +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::clusters::Cluster; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; +use crate::routes::Route; +use crate::theme; +use crate::theme::colors; +use crate::ui::{border_all, border_all_color}; +use crate::utils::sort_clusters_for_home; + +const ROW_HEIGHT_PX: f32 = 208.0; +const CARD_GAP_PX: f32 = 24.0; +const MORE_TILE_WIDTH_PX: f32 = 96.0; +const MIN_CARD_WIDTH_PX: f32 = 300.0; +const MAX_CARD_WIDTH_PX: f32 = 480.0; +const CARD_MS: u64 = 460; +const STAGGER_MS: u64 = 85; +const CARD_RISE_PX: f32 = 48.0; +const INTRO_ITEMS: usize = 6; + +#[derive(PartialEq)] +pub struct RecentsRow; + +impl Component for RecentsRow { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let platform = Platform::get(); + let root_size = platform.root_size; + let scale_factor = platform.scale_factor; + + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); + + let sorted: Vec = sort_clusters_for_home(clusters); + + let slots = + recent_card_slots_for_window(root_size.read().width, *scale_factor.read() as f32); + + // Budget off cluster count not slot count an offset-only change makes the layout + // engine reuse a card's cached area freezing survivors at pre-resize widths + let items = intro_items(sorted.len()); + + let display = sorted.into_iter().take(slots).collect::>(); + + let intro = use_animation_with_dependencies(&items, |conf, items| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(intro_ms(*items).max(1)) + .function(Function::Linear) + }); + let progress = intro.get().value(); + + rect() + .width(Size::fill()) + .height(Size::px(ROW_HEIGHT_PX)) + .content(Content::Flex) + .horizontal() + .spacing(CARD_GAP_PX) + .children(display.iter().enumerate().map(|(index, cluster)| { + ClusterCard { + cluster: cluster.clone(), + index, + items, + progress, + } + .into_element() + })) + .child(OtherVersionsTile { + index: items.saturating_sub(1), + items, + progress, + }) + } +} + +fn intro_items(clusters: usize) -> usize { + (clusters + 1).min(INTRO_ITEMS) +} + +fn intro_ms(items: usize) -> u64 { + CARD_MS + (items.saturating_sub(1) as u64) * STAGGER_MS +} + +fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 { + let elapsed = progress * intro_ms(items) as f32; + // Items past the budget share the last slot so every one still lands on a finished 1.0 + let start = index.min(items.saturating_sub(1)) as f32 * STAGGER_MS as f32; + let local = ((elapsed - start) / CARD_MS as f32).clamp(0., 1.); + 1.0 - (1.0 - local).powi(3) +} + +struct ClusterCard { + cluster: Cluster, + index: usize, + items: usize, + progress: f32, +} + +impl PartialEq for ClusterCard { + fn eq(&self, other: &Self) -> bool { + self.cluster.id == other.cluster.id + && self.index == other.index + && self.items == other.items + && self.progress == other.progress + } +} + +impl Component for ClusterCard { + fn render(&self) -> impl IntoElement { + let mut active_id = use_active_cluster_id(); + let active = *active_id.read() == Some(self.cluster.id); + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let eased = stagger_eased(self.progress, self.index, self.items); + let rise = (1.0 - eased) * CARD_RISE_PX; + + let title = format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader); + + let cluster_id = self.cluster.id; + let on_press = move |_| { + *active_id.write() = Some(cluster_id); + }; + + rect() + .key(self.cluster.id) + .width(Size::flex(1.0)) + .max_width(Size::px(MAX_CARD_WIDTH_PX)) + .height(Size::fill()) + .offset_y(rise) + .opacity(eased) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .border( + if active || focused { + border_all_color(2., colors::brand()) + } else if *hovering.read() { + border_all_color(1., colors::component_border_hover()) + } else { + border_all(1.) + } + .alignment(BorderAlignment::Outer), + ) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_over(move |_| { + if !*hovering.peek() { + *hovering.write() = true; + } + }) + .on_pointer_out(move |_| { + *hovering.write() = false; + }) + .shadow( + Shadow::new() + .blur(24.) + .spread(12.) + .x(0.) + .y(0.) + .color(Color::from_af32rgb(0.3, 0, 0, 0)), + ) + .on_all_press(on_press) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(1)) + .child( + DynamicArt::for_cluster(&self.cluster) + .max_edge(ART_PREVIEW_EDGE), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .padding(Gaps::new_symmetric(12., 24.)) + .main_align(Alignment::End) + .cross_align(Alignment::Start) + .layer(Layer::Relative(4)) + .background( + LinearGradient::new() + .angle(0.0) + .stop((Color::from_argb(0, 25, 25, 25), 24.519)) + .stop((Color::from_af32rgb(0.75, 17, 17, 21), 65.)), + ) + .child( + label() + .text(title) + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(7)) + .background(if *hovering.read() { + Color::from_af32rgb(0.2, 0, 0, 0) + } else { + Color::TRANSPARENT + }), + ), + ), + ) + } +} + +#[derive(PartialEq)] +struct OtherVersionsTile { + index: usize, + items: usize, + progress: f32, +} + +impl Component for OtherVersionsTile { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let eased = stagger_eased(self.progress, self.index, self.items); + let rise = (1.0 - eased) * CARD_RISE_PX; + + rect() + .width(Size::px(MORE_TILE_WIDTH_PX)) + .height(Size::fill()) + .offset_y(rise) + .opacity(eased) + .corner_radius(CornerRadius::new_all(12.)) + .border( + if focused { + border_all_color(2., colors::brand()) + } else { + border_all(1.) + } + .alignment(BorderAlignment::Outer), + ) + .background(if *hovering.read() { + colors::ghost_overlay_pressed() + } else { + colors::ghost_overlay_hover() + }) + .blur(32.) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(move |_| { + *hovering.write() = true; + }) + .on_pointer_leave(move |_| { + *hovering.write() = false; + }) + .shadow( + Shadow::new() + .blur(24.) + .spread(12.) + .x(0.) + .y(0.) + .color(Color::from_af32rgb(0.3, 0, 0, 0)), + ) + .on_all_press(|_| { + let _ = RouterContext::get().push(Route::Clusters {}); + }) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child(Icon::new(IconType::DotsGrid).size(50.)), + ) + } +} + +fn recent_card_slots_for_window(window_width_px: f32, scale_factor: f32) -> usize { + let scale = if scale_factor > 0. && scale_factor.is_finite() { + scale_factor + } else { + 1. + }; + recent_card_slots_for_width(window_width_px / scale - theme::HOME_PADDING_PX * 2.) +} + +/// `n` cards occupy `n` gaps (n-1 between cards one before the tile) so `n * (MIN + GAP) + MORE` +/// must fit +/// Always returns at least 1 the card carries no `min_width` and just renders narrower +fn recent_card_slots_for_width(row_width_px: f32) -> usize { + if !row_width_px.is_finite() { + return 1; + } + + let available = row_width_px - MORE_TILE_WIDTH_PX; + let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX; + (available / slot).floor().max(1.0) as usize +} diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs new file mode 100644 index 00000000..2826d06d --- /dev/null +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -0,0 +1,252 @@ +use freya::prelude::*; +use oneclient_core::ScreenshotInfo; + +use crate::components::{Button, Icon, IconType, LocalImage, OverlayPopup}; +use crate::hooks::{ScreenshotAction, use_dispatch, use_screenshot_action}; +use crate::theme::colors; +use crate::ui::fmt_date; +use crate::utils::{format_res, format_size}; + +#[derive(PartialEq)] +pub struct ScreenshotViewer { + shots: Vec, + start: usize, + on_close: EventHandler<()>, +} + +impl ScreenshotViewer { + pub fn new( + shots: Vec, + start: usize, + on_close: impl Into>, + ) -> Self { + Self { + shots, + start, + on_close: on_close.into(), + } + } +} + +impl Component for ScreenshotViewer { + fn render(&self) -> impl IntoElement { + let shots = self.shots.clone(); + let len = shots.len(); + + let mut index = use_state({ + let start = self.start.min(len.saturating_sub(1)); + move || start + }); + + if len == 0 { + return rect().into_element(); + } + let idx = (*index.read()).min(len - 1); + let info = shots[idx].clone(); + + let action = use_screenshot_action(); + let dispatch = use_dispatch(); + + let close = self.on_close.clone(); + let scrim_close = self.on_close.clone(); + let delete_close = self.on_close.clone(); + + let has_prev = idx > 0; + let has_next = idx + 1 < len; + + let preview = LocalImage::new(info.path.clone(), 0, false).skeleton(true); + + let open_path = info.path.clone(); + let copy_path = info.path.clone(); + let copy_dispatch = dispatch.clone(); + let delete_path = info.path.clone(); + + OverlayPopup::new() + .on_close(move |_| scrim_close.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .on_global_key_down(move |e: Event| match &e.key { + Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), + Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), + _ => {} + }) + .child( + rect() + .vertical() + .width(Size::window_percent(88.)) + .height(Size::window_percent(90.)) + .spacing(12.) + .padding(Gaps::new_all(16.)) + .content(Content::Flex) + .child(header_row(&info, idx, len, move |_| close.call(()))) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(12.) + .overflow(Overflow::Clip) + .child(chevron_btn(IconType::ArrowLeft, has_prev, move |_| { + if idx > 0 { + index.set(idx - 1); + } + })) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(preview), + ) + .child(chevron_btn( + IconType::ArrowRight, + has_next, + move |_| { + if idx + 1 < len { + index.set(idx + 1); + } + }, + )), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + if let Some(dir) = open_path.parent() { + crate::platform::open_url( + &dir.to_string_lossy(), + ); + } + }) + .child(Icon::new(IconType::Folder).size(14.)) + .text("Open in folder"), + ) + .child( + Button::new() + .secondary() + .on_press(move |_| { + crate::platform::copy_image_to_clipboard( + copy_path.clone(), + ); + copy_dispatch + .notify("Copied to clipboard") + .body("Screenshot copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + }) + .child(Icon::new(IconType::Copy01).size(14.)) + .text("Copy"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| { + action.mutate(ScreenshotAction::Delete { + path: delete_path.clone(), + }); + delete_close.call(()); + }) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete"), + ), + ), + ), + ) + .into_element() + } +} + +fn chevron_btn( + icon: IconType, + enabled: bool, + on_press: impl Into>>, +) -> impl IntoElement { + let base = rect() + .width(Size::px(44.)) + .height(Size::px(44.)) + .center() + .corner_radius(CornerRadius::new_all(22.)); + + if !enabled { + return base + .background(Color::from_argb(70, 0, 0, 0)) + .on_press(|_| {}) + .child( + Icon::new(icon) + .size(26.) + .color(colors::fg_secondary().with_a(90)), + ) + .into_element(); + } + + base.background(Color::from_argb(140, 0, 0, 0)) + .cursor(CursorIcon::Pointer) + .on_press(on_press) + .child(Icon::new(icon).size(26.).color(colors::fg_primary())) + .into_element() +} + +fn header_row( + info: &ScreenshotInfo, + idx: usize, + len: usize, + on_close: impl Into>>, +) -> impl IntoElement { + 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); + } + + if len > 1 { + meta = format!("{} of {} • {meta}", idx + 1, len); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(info.name.clone()) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(meta) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .icon() + .on_press(on_close) + .child(Icon::new(IconType::XClose).size(18.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs new file mode 100644 index 00000000..6d6377cc --- /dev/null +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -0,0 +1,617 @@ +#![allow(dead_code)] +use freya::prelude::*; + +use crate::theme::colors; + +const LAZY_OVERSCAN: i64 = 3; + +pub(crate) fn normalize_wheel_delta(delta: f64, scale_factor: f64) -> f32 { + if delta == 0.0 { + return 0.0; + } + + const FREYA_LINE_SPEED: f64 = 53.0; + const FREYA_PIXEL_SPEED: f64 = 2.0; + const LINE_DELTA_TOLERANCE: f64 = 1e-6; + const LINE_SCROLL: f64 = 53.0; + const PIXEL_SCROLL: f64 = 1.0; + + let lines = (delta / FREYA_LINE_SPEED).round(); + if lines != 0.0 && (delta - lines * FREYA_LINE_SPEED).abs() <= LINE_DELTA_TOLERANCE { + return (lines * LINE_SCROLL) as f32; + } + + let scale_factor = if scale_factor > 0.0 { + scale_factor + } else { + 1.0 + }; + (delta / (FREYA_PIXEL_SPEED * scale_factor) * PIXEL_SCROLL) as f32 +} + +pub(crate) fn scroll_pos_from_wheel(wheel: f32, inner: f32, viewport: f32, current: f32) -> i32 { + if viewport >= inner { + return 0; + } + let new_pos = current + wheel; + if new_pos >= 0.0 && wheel > 0.0 { + return 0; + } + if new_pos <= -(inner - viewport) && wheel < 0.0 { + return -(inner - viewport) as i32; + } + new_pos as i32 +} + +pub(crate) fn corrected_scroll(inner: f32, viewport: f32, pos: f32) -> f32 { + if pos > 0.0 { + return 0.0; + } + if (-pos + viewport) > inner { + return if viewport < inner { + -(inner - viewport) + } else { + 0.0 + }; + } + pos +} + +pub(crate) fn scrollbar_pos_and_size(inner: f32, viewport: f32, scroll: f32) -> (f32, f32) { + if viewport >= inner { + return (0.0, inner); + } + let ratio = viewport / inner; + let thumb = (viewport * ratio).max(50.0); + let scroll_range = inner - viewport; + let thumb_range = viewport - thumb; + let normalized = -scroll / scroll_range; + + (normalized * thumb_range, thumb) +} + +#[derive(Clone, Copy)] +pub struct ScrollAreaCtx { + pub corrected_x: f32, + pub corrected_y: f32, + pub viewport_w: f32, + pub viewport_h: f32, + pub viewport_top: f32, + pub viewport_left: f32, +} + +pub struct ScrollArea { + width: Size, + height: Size, + padding: Gaps, + spacing: f32, + show_scrollbar: bool, + horizontal: bool, + content_width: f32, + stick_bottom: bool, + reset_key: Option, + controller: Option, + on_user_scroll: Option>, + on_ctx: Option>, + children: Vec, + builder: Option Element>>, +} + +impl Default for ScrollArea { + fn default() -> Self { + Self::new() + } +} + +impl ScrollArea { + pub fn new() -> Self { + Self { + width: Size::fill(), + height: Size::flex(1.0), + padding: Gaps::default(), + spacing: 0., + show_scrollbar: true, + horizontal: false, + content_width: 0., + stick_bottom: false, + reset_key: None, + controller: None, + on_user_scroll: None, + on_ctx: None, + children: Vec::new(), + builder: None, + } + } + + pub fn width(mut self, width: Size) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Size) -> Self { + self.height = height; + self + } + + pub fn padding(mut self, padding: Gaps) -> Self { + self.padding = padding; + self + } + + pub fn spacing(mut self, spacing: f32) -> Self { + self.spacing = spacing; + self + } + + pub fn show_scrollbar(mut self, show: bool) -> Self { + self.show_scrollbar = show; + self + } + + pub fn horizontal(mut self, content_width: f32) -> Self { + self.horizontal = true; + self.content_width = content_width; + self + } + + pub fn stick_bottom(mut self, stick: bool) -> Self { + self.stick_bottom = stick; + self + } + + pub fn reset_key(mut self, key: u64) -> Self { + self.reset_key = Some(key); + self + } + + pub fn scroll_controller(mut self, controller: ScrollController) -> Self { + self.controller = Some(controller); + self + } + + pub fn on_user_scroll(mut self, handler: impl FnMut(()) + 'static) -> Self { + self.on_user_scroll = Some(EventHandler::new(handler)); + self + } + + pub fn on_ctx(mut self, handler: impl FnMut(ScrollAreaCtx) + 'static) -> Self { + self.on_ctx = Some(EventHandler::new(handler)); + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.children = children.into_iter().collect(); + self + } + + pub fn child(mut self, child: impl IntoElement) -> Self { + self.children.push(child.into_element()); + self + } + + pub fn content(mut self, builder: impl Fn(ScrollAreaCtx) -> Element + 'static) -> Self { + self.builder = Some(Box::new(builder)); + self + } + + pub fn lazy( + mut self, + count: usize, + item_height: f32, + spacing: f32, + render: impl Fn(usize) -> Element + 'static, + ) -> Self { + let slot = (item_height + spacing).max(1.); + self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| { + let first = + (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize; + let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize; + let last = (first + span).min(count); + + let top_pad = first as f32 * slot; + let bottom_pad = count.saturating_sub(last) as f32 * slot; + + let mut container = rect().vertical().width(Size::fill()); + if top_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); + } + for i in first..last { + container = container.child( + rect() + .key(i) + .width(Size::fill()) + .height(Size::px(slot)) + .child( + rect() + .width(Size::fill()) + .height(Size::px(item_height)) + .child(render(i)), + ), + ); + } + if bottom_pad > 0. { + container = + container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); + } + container.into_element() + })); + self + } + + fn view(&self) -> impl IntoElement { + let horizontal = self.horizontal; + let content_w = self.content_width; + let spacing = self.spacing; + let padding = self.padding; + let show_scrollbar = self.show_scrollbar; + let stick_bottom = self.stick_bottom; + let on_user_scroll = self.on_user_scroll.clone(); + + let internal = use_scroll_controller(ScrollConfig::default); + let mut controller = self.controller.unwrap_or(internal); + + let scale_factor = Platform::get().scale_factor; + + let mut viewport_h = use_state(|| 0f32); + let mut viewport_w = use_state(|| 0f32); + let mut viewport_top = use_state(|| 0f32); + let mut viewport_left = use_state(|| 0f32); + let mut content_h = use_state(|| 0f32); + let mut scroll_x = use_state(|| 0f32); + let mut drag_start = use_state::>(|| None); + let mut drag_start_x = use_state::>(|| None); + let mut shift_held = use_state(|| false); + + let reset_key = self.reset_key; + let mut last_reset = use_state(|| reset_key); + if reset_key.is_some() && *last_reset.peek() != reset_key { + last_reset.set(reset_key); + controller.scroll_to_y(0); + scroll_x.set_if_modified(0.); + } + + let (_, scrolled_y) = controller.into(); + let vp_h = *viewport_h.read(); + let vp_w = *viewport_w.read(); + let vp_top = *viewport_top.read(); + let vp_left = *viewport_left.read(); + let ct_h = *content_h.read(); + + let corrected_y = corrected_scroll(ct_h, vp_h, scrolled_y as f32); + let corrected_x = if horizontal { + corrected_scroll(content_w, vp_w, *scroll_x.read()) + } else { + 0. + }; + + let (scrollbar_y, scrollbar_height) = scrollbar_pos_and_size(ct_h, vp_h, corrected_y); + let show_v = show_scrollbar && vp_h > 0. && vp_h < ct_h; + + let (scrollbar_x, scrollbar_width) = scrollbar_pos_and_size(content_w, vp_w, corrected_x); + let show_h = horizontal && vp_w > 0. && vp_w < content_w; + + let pressing_v = use_memo(move || drag_start.read().is_some()); + let pressing_h = use_memo(move || drag_start_x.read().is_some()); + + let ctx = ScrollAreaCtx { + corrected_x, + corrected_y, + viewport_w: vp_w, + viewport_h: vp_h, + viewport_top: vp_top, + viewport_left: vp_left, + }; + + if let Some(cb) = &self.on_ctx { + cb.call(ctx); + } + + let content_el = if let Some(builder) = &self.builder { + let inner = builder(ctx); + if padding != Gaps::default() { + rect() + .width(Size::fill()) + .padding(padding) + .child(inner) + .into_element() + } else { + inner + } + } else { + rect() + .vertical() + .width(Size::fill()) + .spacing(spacing) + .padding(padding) + .children(self.children.clone()) + .into_element() + }; + + let us_wheel = on_user_scroll.clone(); + let us_thumb = on_user_scroll.clone(); + let us_h_thumb = on_user_scroll; + + let can_scroll_v = vp_h > 0. && vp_h < ct_h; + let can_scroll_h = horizontal && vp_w > 0. && vp_w < content_w; + + let on_wheel = move |e: Event| { + let shift = *shift_held.read(); + let scale = *scale_factor.peek(); + + let delta_x = normalize_wheel_delta(e.delta_x, scale); + let delta_y = normalize_wheel_delta(e.delta_y, scale); + + let h_delta = if horizontal { + if shift && delta_x == 0.0 { + delta_y + } else { + delta_x + } + } else { + 0.0 + }; + let v_delta = if horizontal && shift { 0.0 } else { delta_y }; + + let do_h = h_delta != 0.0 && can_scroll_h; + let do_v = v_delta != 0.0 && can_scroll_v; + + if !do_h && !do_v { + return; + } + + e.stop_propagation(); + + if let Some(cb) = &us_wheel { + cb.call(()); + } + + if do_h { + let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read()); + let new_x = scroll_pos_from_wheel(h_delta, content_w, vp_w, cur_x); + scroll_x.set(new_x as f32); + } + + if do_v { + let (_, cur_y) = controller.into(); + let current = corrected_scroll(ct_h, vp_h, cur_y as f32); + let new_y = scroll_pos_from_wheel(v_delta, ct_h, vp_h, current); + if new_y != cur_y { + controller.scroll_to_y(new_y); + } + } + }; + + let on_thumb_down = move |e: Event| { + if let Some(cb) = &us_thumb { + cb.call(()); + } + let (_, y) = controller.into(); + drag_start.set(Some((e.global_location().y as f32, y))); + }; + + let on_h_thumb_down = move |e: Event| { + if let Some(cb) = &us_h_thumb { + cb.call(()); + } + drag_start_x.set(Some((e.global_location().x as f32, *scroll_x.read()))); + }; + + let on_global_move = move |e: Event| { + if let Some((grab_y, scroll_at_grab)) = *drag_start.read() { + e.stop_propagation(); + + let thumb_range = (vp_h - scrollbar_height).max(1.); + let scroll_range = (ct_h - vp_h).max(0.); + + let cursor_dy = e.global_location().y as f32 - grab_y; + let scroll_dy = -(cursor_dy / thumb_range) * scroll_range; + let target = scroll_at_grab + scroll_dy as i32; + + let clamped = corrected_scroll(ct_h, vp_h, target as f32) as i32; + + let (_, cur_y) = controller.into(); + + if clamped != cur_y { + controller.scroll_to_y(clamped); + } + + return; + } + if let Some((grab_x, scroll_at_grab)) = *drag_start_x.read() { + e.stop_propagation(); + let thumb_range = (vp_w - scrollbar_width).max(1.); + let scroll_range = (content_w - vp_w).max(0.); + let cursor_dx = e.global_location().x as f32 - grab_x; + let scroll_dx = -(cursor_dx / thumb_range) * scroll_range; + let target = scroll_at_grab + scroll_dx; + scroll_x.set_if_modified(corrected_scroll(content_w, vp_w, target)); + } + }; + + let on_global_release = move |_: Event| { + if drag_start.read().is_some() { + drag_start.set(None); + } + if drag_start_x.read().is_some() { + drag_start_x.set(None); + } + }; + + rect() + .width(self.width.clone()) + .height(self.height.clone()) + .scrollable(true) + .a11y_builder(move |node| { + node.set_scroll_x(corrected_x as f64); + node.set_scroll_y(corrected_y as f64); + }) + .on_wheel(on_wheel) + .on_capture_global_pointer_move(on_global_move) + .on_capture_global_pointer_press(on_global_release) + .on_global_key_down(move |e: Event| { + let held = e.modifiers.contains(Modifiers::SHIFT) + || matches!(e.code, Code::ShiftLeft | Code::ShiftRight); + shift_held.set_if_modified(held); + }) + .on_global_key_up(move |e: Event| { + let held = if matches!(e.code, Code::ShiftLeft | Code::ShiftRight) { + false + } else { + e.modifiers.contains(Modifiers::SHIFT) + }; + shift_held.set_if_modified(held); + }) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .on_sized(move |e: Event| { + let h = e.area.height(); + let w = e.area.width(); + let top = e.area.min_y(); + let left = e.area.min_x(); + if (*viewport_h.read() - h).abs() > 0.5 { + viewport_h.set(h); + } + if (*viewport_w.read() - w).abs() > 0.5 { + viewport_w.set(w); + } + if (*viewport_top.read() - top).abs() > 0.5 { + viewport_top.set(top); + } + if (*viewport_left.read() - left).abs() > 0.5 { + viewport_left.set(left); + } + }) + .child( + rect() + .width(Size::fill()) + .offset_x(corrected_x) + .offset_y(corrected_y) + .on_sized(move |e: Event| { + let h = e.area.height(); + if (*content_h.read() - h).abs() > 0.5 { + content_h.set(h); + if stick_bottom { + let vp = *viewport_h.read(); + let target = -((h - vp).max(0.)); + controller.scroll_to_y(target as i32); + } + } + }) + .child(content_el), + ) + .maybe_child(show_h.then(|| { + ScrollThumb { + vertical: false, + main_pos: scrollbar_x, + length: scrollbar_width, + pressing: pressing_h.into_readable(), + on_down: on_h_thumb_down.into(), + } + .into_element() + })) + .maybe_child(show_v.then(|| { + ScrollThumb { + vertical: true, + main_pos: scrollbar_y, + length: scrollbar_height, + pressing: pressing_v.into_readable(), + on_down: on_thumb_down.into(), + } + .into_element() + })), + ) + } +} + +impl PartialEq for ScrollArea { + fn eq(&self, other: &Self) -> bool { + if self.builder.is_some() || other.builder.is_some() { + return false; + } + self.width == other.width + && self.height == other.height + && self.padding == other.padding + && self.spacing == other.spacing + && self.show_scrollbar == other.show_scrollbar + && self.horizontal == other.horizontal + && self.content_width == other.content_width + && self.stick_bottom == other.stick_bottom + && self.reset_key == other.reset_key + && self.children == other.children + } +} + +impl Component for ScrollArea { + fn render(&self) -> impl IntoElement { + self.view() + } +} + +#[derive(PartialEq)] +struct ScrollThumb { + vertical: bool, + main_pos: f32, + length: f32, + pressing: Readable, + on_down: EventHandler>, +} + +impl Component for ScrollThumb { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let on_down = self.on_down.clone(); + let pressing = *self.pressing.read(); + let hovered = *hovering.read(); + + let color = if pressing { + colors::fg_secondary_pressed() + } else if hovered { + colors::fg_secondary_hover() + } else { + colors::fg_secondary() + }; + let thickness = if pressing || hovered { 8. } else { 6. }; + + let (position, width, height, layer) = if self.vertical { + ( + Position::new_absolute().right(0.).top(self.main_pos), + Size::px(14.), + Size::px(self.length), + Layer::Relative(999), + ) + } else { + ( + Position::new_absolute().left(self.main_pos).bottom(2.), + Size::px(self.length), + Size::px(8.), + Layer::Relative(2), + ) + }; + + let (bar_w, bar_h) = if self.vertical { + (Size::px(thickness), Size::fill()) + } else { + (Size::fill(), Size::px(thickness)) + }; + + rect() + .position(position) + .width(width) + .height(height) + .center() + .layer(layer) + .on_pointer_down(move |e| on_down.call(e)) + .on_pointer_over(move |_| hovering.set(true)) + .on_pointer_out(move |_| hovering.set(false)) + .child( + rect() + .width(bar_w) + .height(bar_h) + .corner_radius(CornerRadius::new_all(12.)) + .background(color.with_a(160)), + ) + } +} diff --git a/packages/oneclient_app/src/components/segmented_control.rs b/packages/oneclient_app/src/components/segmented_control.rs new file mode 100644 index 00000000..917beb31 --- /dev/null +++ b/packages/oneclient_app/src/components/segmented_control.rs @@ -0,0 +1,213 @@ +use freya::prelude::*; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[derive(PartialEq, Clone)] +pub struct Segment { + value: T, + icon: Option, + label: Option, +} + +impl Segment { + pub fn new(value: T) -> Self { + Self { + value, + icon: None, + label: None, + } + } + + pub fn icon(mut self, icon: IconType) -> Self { + self.icon = Some(icon); + self + } + + pub fn label(mut self, text: impl Into) -> Self { + self.label = Some(text.into()); + self + } +} + +#[derive(PartialEq)] +pub struct SegmentedControl { + selected: State, + segments: Vec>, + height: f32, + icon_size: Option, + tint_icons: bool, + equal_width: Option, + disabled: bool, +} + +impl SegmentedControl { + pub fn new(selected: State) -> Self { + Self { + selected, + segments: Vec::new(), + height: 36., + icon_size: None, + tint_icons: true, + equal_width: None, + disabled: false, + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn segment(mut self, segment: Segment) -> Self { + self.segments.push(segment); + self + } + + pub fn segments(mut self, segments: impl IntoIterator>) -> Self { + self.segments.extend(segments); + self + } + + #[allow(dead_code)] + pub fn height(mut self, height: f32) -> Self { + self.height = height; + self + } + + pub fn icon_size(mut self, size: f32) -> Self { + self.icon_size = Some(size); + self + } + + pub fn no_tint(mut self) -> Self { + self.tint_icons = false; + self + } + + pub fn equal_width(mut self, width: f32) -> Self { + self.equal_width = Some(width); + self + } +} + +impl Component for SegmentedControl { + fn render(&self) -> impl IntoElement { + let active = *self.selected.read(); + let selected = self.selected; + let icon_size = self.icon_size; + let tint = self.tint_icons; + let equal_width = self.equal_width; + let disabled = self.disabled; + + rect() + .horizontal() + .height(Size::px(self.height)) + .cross_align(Alignment::Center) + .spacing(2.) + .padding(Gaps::new_all(3.)) + .corner_radius(CornerRadius::new_all(9.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .children(self.segments.iter().map(move |seg| { + SegmentButton { + selected, + value: seg.value, + is_selected: seg.value == active, + icon: seg.icon, + label: seg.label.clone(), + icon_size, + tint, + equal_width, + disabled, + } + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct SegmentButton { + selected: State, + value: T, + is_selected: bool, + icon: Option, + label: Option, + icon_size: Option, + tint: bool, + equal_width: Option, + disabled: bool, +} + +impl Component for SegmentButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let mut selected = self.selected; + let value = self.value; + let is_selected = self.is_selected; + let disabled = self.disabled; + let equal_width = self.equal_width; + let focused = focus().is_focused(); + + let content_color = if disabled { + colors::fg_secondary().with_a(110) + } else if is_selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + rect() + .horizontal() + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(6.) + .corner_radius(CornerRadius::new_all(6.)) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::Button) + .map(equal_width, |el, w| { + el.width(Size::px(w)).main_align(Alignment::Center) + }) + .maybe(equal_width.is_none(), |el| { + el.padding(Gaps::new_symmetric(0., 12.)) + }) + .background(if is_selected { + colors::component_bg_pressed() + } else { + Color::TRANSPARENT + }) + .maybe(focused && !disabled, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .maybe(!disabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_all_press(move |_| *selected.write() = value) + }) + .maybe_child(self.icon.map(|icon| { + let mut ic = Icon::new(icon); + if let Some(size) = self.icon_size { + ic = ic.size(size); + } + if self.tint { + ic = ic.color(content_color); + } + ic.into_element() + })) + .maybe_child(self.label.clone().map(|text| { + label() + .text(text) + .font_size(13.) + .font_weight(if is_selected { + FontWeight::MEDIUM + } else { + FontWeight::NORMAL + }) + .color(content_color) + .into_element() + })) + } +} diff --git a/packages/oneclient_app/src/components/splash_curtain.rs b/packages/oneclient_app/src/components/splash_curtain.rs new file mode 100644 index 00000000..7f2ee599 --- /dev/null +++ b/packages/oneclient_app/src/components/splash_curtain.rs @@ -0,0 +1,80 @@ +use std::time::Duration; + +use freya::animation::{AnimNum, Ease, Function, use_animation}; +use freya::prelude::*; + +use crate::hooks::use_splash; +use crate::theme::colors; + +const FADE_MS: u64 = 460; +/// Safety net reveal the app even if the "ready" signal never arrives +const FALLBACK_MS: u64 = 8000; + +#[derive(PartialEq)] +pub struct SplashCurtain; + +impl Component for SplashCurtain { + fn render(&self) -> impl IntoElement { + let splash = use_splash(); + + if !*splash.active.read() { + return rect().into_element(); + } + + // Fresh mount so `CurtainFade`'s animation and timers restart each time + CurtainFade.into_element() + } +} + +#[derive(PartialEq)] +struct CurtainFade; + +impl Component for CurtainFade { + fn render(&self) -> impl IntoElement { + let splash = use_splash(); + let ready = *splash.home_ready.read(); + + let fade = use_animation(|_| { + AnimNum::new(1.0, 0.0) + .time(FADE_MS) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + // Force-reveal after a while if home never reports ready + use_hook(move || { + let mut home_ready = splash.home_ready; + spawn(async move { + tokio::time::sleep(Duration::from_millis(FALLBACK_MS)).await; + if !*home_ready.peek() { + home_ready.set(true); + } + }); + }); + + use_side_effect_with_deps(&ready, move |&ready| { + if !ready { + return; + } + let mut fade = fade; + fade.start(); + let mut active = splash.active; + spawn(async move { + tokio::time::sleep(Duration::from_millis(FADE_MS + 40)).await; + active.set(false); + }); + }); + + let opacity = fade.get().value(); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_global().top(0.).left(0.)) + .layer(Layer::Overlay) + .opacity(opacity) + .background(colors::page()) + .window_drag() + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs new file mode 100644 index 00000000..58dd86c4 --- /dev/null +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -0,0 +1,197 @@ +use std::collections::HashSet; + +use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; +use freya::prelude::*; +use oneclient_net::status::{self, ServiceStatus}; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; + +const BAR_HEIGHT: f32 = 34.; +const AMBER: Color = Color::from_rgb(191, 122, 26); + +/// `Layer::RelativeOverlay(n)` saturates at `n = 16` putting children on the same +/// (unordered) layer as the bar background +/// 14 leaves headroom above every popup +const BAR_LAYER: u8 = 14; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Issue { + NoInternet, + McAuthDown, + PolyfrostDown, +} + +impl Issue { + fn is_active(self, s: &ServiceStatus) -> bool { + match self { + Self::NoInternet => !s.online, + Self::McAuthDown => s.online && !s.mc_auth_up, + Self::PolyfrostDown => s.online && !s.polyfrost_up, + } + } + + fn message(self) -> &'static str { + match self { + Self::NoInternet => "No internet connection.", + Self::McAuthDown => { + "Minecraft authentication servers are unreachable. Logging in may fail." + } + Self::PolyfrostDown => "Polyfrost services are experiencing issues.", + } + } + + fn icon(self) -> IconType { + match self { + Self::NoInternet => IconType::Globe01, + Self::McAuthDown => IconType::AlertTriangle, + Self::PolyfrostDown => IconType::AlertCircle, + } + } + + fn background(self) -> Color { + match self { + Self::NoInternet => colors::danger(), + Self::McAuthDown | Self::PolyfrostDown => AMBER, + } + } + + fn closeable(self) -> bool { + !matches!(self, Self::NoInternet) + } +} + +fn active_issues(s: &ServiceStatus) -> Vec { + [Issue::NoInternet, Issue::McAuthDown, Issue::PolyfrostDown] + .into_iter() + .filter(|i| i.is_active(s)) + .collect() +} + +#[derive(PartialEq)] +pub struct StatusBar; + +impl Component for StatusBar { + fn render(&self) -> impl IntoElement { + let mut status = use_state(status::current); + let mut dismissed = use_state(HashSet::::new); + + use_hook(move || { + status::request_recheck(); + let mut rx = status::subscribe(); + spawn(async move { + while rx.changed().await.is_ok() { + status.set(*rx.borrow()); + } + }); + }); + + use_side_effect(move || { + let s = *status.read(); + let cur = dismissed.peek().clone(); + let next: HashSet = cur.iter().copied().filter(|i| i.is_active(&s)).collect(); + if next != cur { + dismissed.set(next); + } + }); + + let s = *status.read(); + let dset = dismissed.read(); + let visible = active_issues(&s).into_iter().find(|i| !dset.contains(i)); + + match visible { + Some(issue) => StatusBanner { issue, dismissed }.into_element(), + None => rect().into_element(), + } + } +} + +#[derive(PartialEq)] +struct StatusBanner { + issue: Issue, + dismissed: State>, +} + +impl Component for StatusBanner { + fn render(&self) -> impl IntoElement { + let issue = self.issue; + let mut dismissed = self.dismissed; + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(260) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let p = intro.get().value(); + + let mut close_hover = use_state(|| false); + + let close = issue.closeable().then(|| { + rect() + .center() + .width(Size::px(22.)) + .height(Size::px(22.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if *close_hover.read() { + Color::WHITE.with_a(46) + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| close_hover.set(true)) + .on_pointer_leave(move |_| close_hover.set(false)) + .on_press(move |_| { + dismissed.write().insert(issue); + }) + .child(Icon::new(IconType::XClose).size(14.).color(Color::WHITE)) + .into_element() + }); + + // Flex spacers centre the message absolute positioning pinned the close button + // to the top of the content box inside the bar's padding + let leading = rect().width(Size::flex(1.0)).height(Size::fill()); + + let message = rect() + .horizontal() + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .layer(Layer::OverlayLevel(u8::MAX - 20)) + .child(Icon::new(issue.icon()).size(15.).color(Color::WHITE)) + .child( + label() + .text(issue.message()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(Color::WHITE), + ); + + let trailing = rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .horizontal() + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .layer(Layer::Relative(1)) + .maybe_child(close); + + rect() + .width(Size::window_percent(100.)) + .height(Size::px(BAR_HEIGHT)) + .position(Position::new_global().bottom(0.).left(0.)) + .layer(Layer::OverlayLevel(BAR_LAYER)) + .background(issue.background()) + .opacity(p) + .horizontal() + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 10.)) + .child(leading) + .child(message) + .child(trailing) + } +} diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs new file mode 100644 index 00000000..636748fe --- /dev/null +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -0,0 +1,224 @@ +use freya::prelude::*; + +use crate::theme::colors; + +pub struct TabItem { + label: String, + active: bool, + count: Option, + on_press: Option>>, +} + +impl TabItem { + pub fn new(label: impl Into, active: bool) -> Self { + Self { + label: label.into(), + active, + count: None, + on_press: None, + } + } + + #[allow(dead_code)] + pub fn count_text(mut self, text: impl Into) -> Self { + self.count = Some(text.into()); + self + } + + pub fn on_press(mut self, handler: impl Into>>) -> Self { + self.on_press = Some(handler.into()); + self + } +} + +pub struct TabBar { + tabs: Vec, + width: Size, + height: Size, + spacing: f32, + font_size: f32, +} + +impl Default for TabBar { + fn default() -> Self { + Self::new() + } +} + +impl TabBar { + pub fn new() -> Self { + Self { + tabs: Vec::new(), + width: Size::fill(), + height: Size::fill(), + spacing: 24., + font_size: 12., + } + } + + pub fn tabs(mut self, tabs: impl IntoIterator) -> Self { + self.tabs = tabs.into_iter().collect(); + self + } + + pub fn width(mut self, width: Size) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Size) -> Self { + self.height = height; + self + } + + pub fn spacing(mut self, spacing: f32) -> Self { + self.spacing = spacing; + self + } + + pub fn font_size(mut self, font_size: f32) -> Self { + self.font_size = font_size; + self + } +} + +fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement { + const ACTIVE_WEIGHT: FontWeight = FontWeight::MEDIUM; + + rect() + .center() + .content(Content::Fit) + .child( + label() + .text(text.to_string()) + .font_size(font_size) + .font_weight(ACTIVE_WEIGHT) + .color(Color::TRANSPARENT) + .text_align(TextAlign::Center), + ) + .child( + rect() + .position( + Position::new_absolute() + .top(0.) + .left(0.) + .right(0.) + .bottom(0.), + ) + .center() + .child( + label() + .text(text.to_string()) + .font_size(font_size) + .font_weight(if active { + ACTIVE_WEIGHT + } else { + FontWeight::NORMAL + }) + .color(colors::fg_primary()) + .text_align(TextAlign::Center), + ), + ) +} + +fn count_pill(count: &str, font_size: f32) -> impl IntoElement { + rect() + .center() + .padding(Gaps::new_symmetric(1., 6.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .child( + label() + .text(count.to_string()) + .font_size((font_size - 3.).max(8.)) + .color(colors::fg_secondary()), + ) +} + +impl IntoElement for TabBar { + fn into_element(self) -> Element { + let font_size = self.font_size; + + rect() + .horizontal() + .width(self.width) + .height(self.height) + .spacing(self.spacing) + .cross_align(Alignment::Center) + .content(Content::Fit) + .children(self.tabs.into_iter().map(|tab| { + TabButton { + label: tab.label, + active: tab.active, + count: tab.count, + font_size, + on_press: tab.on_press, + } + .into_element() + })) + .into_element() + } +} + +#[derive(PartialEq)] +struct TabButton { + label: String, + active: bool, + count: Option, + font_size: f32, + on_press: Option>>, +} + +impl Component for TabButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let active = self.active; + let font_size = self.font_size; + let underline_on = active || focus().is_focused(); + + let mut el = rect() + .vertical() + .content(Content::Fit) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer); + + if let Some(handler) = self.on_press.clone() { + el = el.on_all_press(move |e| handler.call(e)); + } + + el.child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .content(Content::Fit) + .child( + rect() + .vertical() + .content(Content::Fit) + .child(tab_label(&self.label, font_size, active)) + .child( + rect() + .height(Size::px(1.5)) + .width(Size::fill_minimum()) + .margin(Gaps::new_symmetric(0., 4.0)) + .corner_radius(CornerRadius::new_all(2.)) + .background(if underline_on { + colors::fg_primary() + } else { + Color::TRANSPARENT + }), + ), + ) + .maybe_child( + self.count + .as_ref() + .map(|c| count_pill(c, font_size).into_element()), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/text_input.rs b/packages/oneclient_app/src/components/text_input.rs new file mode 100644 index 00000000..6fad7509 --- /dev/null +++ b/packages/oneclient_app/src/components/text_input.rs @@ -0,0 +1,178 @@ +use freya::prelude::*; + +use crate::theme::colors; + +const GAP_VERT: f32 = 8.0; +const GAP_HORI: f32 = 12.0; + +fn text_input(value: impl Into>) -> Input { + Input::new(value) + .inner_margin(Gaps::new_symmetric(GAP_VERT, GAP_HORI)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .focus_background(colors::component_bg_pressed()) + .border_fill(colors::component_border()) + .focus_border_fill(colors::component_border_pressed()) + .color(colors::fg_primary()) + .placeholder_color(colors::fg_secondary()) +} + +pub fn validate_number(validator: InputValidator) { + let text = validator.text(); + + let valid = text.is_empty() || text.parse::().is_ok(); + + validator.set_valid(valid); +} + +/// Validates the input if user has entered more MB of memory than his machine has +pub fn validate_memory(validator: InputValidator) { + let text = validator.text(); + + let valid = text.is_empty() + || text + .parse::() + .is_ok_and(|mb| mb <= crate::utils::total_ram_mb()); + + validator.set_valid(valid); +} + +#[derive(Clone, PartialEq)] +pub struct TextInput { + input: Input, + layout: LayoutData, + style: StyleState, + text_style: TextStyleData, + elements: Vec, + key: DiffKey, +} + +#[allow(dead_code)] +impl TextInput { + pub fn new(value: impl Into>) -> Self { + Self::from_input(text_input(value)) + } + + pub fn from_input(input: Input) -> Self { + Self { + input, + layout: LayoutData::default(), + style: StyleState::default(), + text_style: TextStyleData::default(), + elements: Vec::new(), + key: DiffKey::None, + } + .font_size(14.) + } + + pub fn enabled(mut self, enabled: impl Into) -> Self { + self.input = self.input.enabled(enabled); + self + } + + pub fn placeholder(mut self, placeholder: impl Into>) -> Self { + self.input = self.input.placeholder(placeholder); + self + } + + pub fn on_validate(mut self, on_validate: impl Into>) -> Self { + self.input = self.input.on_validate(on_validate); + self + } + + pub fn on_submit(mut self, on_submit: impl Into>) -> Self { + self.input = self.input.on_submit(on_submit); + self + } + + pub fn mode(mut self, mode: InputMode) -> Self { + self.input = self.input.mode(mode); + self + } + + pub fn auto_focus(mut self, auto_focus: impl Into) -> Self { + self.input = self.input.auto_focus(auto_focus); + self + } + + pub fn leading(mut self, leading: impl Into) -> Self { + self.input = self.input.leading(leading); + self + } + + pub fn trailing(mut self, trailing: impl Into) -> Self { + self.input = self.input.trailing(trailing); + self + } + + pub fn on_pre_key_down( + mut self, + on_pre_key_down: impl Into, bool>>, + ) -> Self { + self.input = self.input.on_pre_key_down(on_pre_key_down); + self + } +} + +impl ChildrenExt for TextInput { + fn get_children(&mut self) -> &mut Vec { + &mut self.elements + } +} + +impl KeyExt for TextInput { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for TextInput { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl TextStyleExt for TextInput { + fn get_text_style_data(&mut self) -> &mut TextStyleData { + &mut self.text_style + } +} + +impl ContainerExt for TextInput {} +impl ContainerWithContentExt for TextInput {} + +impl StyleExt for TextInput { + fn get_style(&mut self) -> &mut StyleState { + &mut self.style + } +} + +impl Component for TextInput { + fn render(&self) -> impl IntoElement { + let mut card = rect() + .layout(self.layout.clone()) + .text_style(self.text_style.clone()) + .children(self.elements.clone()); + + card.get_style().clone_from(&self.style); + + card.child( + self.input + .clone() + .map(self.text_style.text_align, |mut el, text_align| { + el = el.text_align(text_align); + + if text_align == TextAlign::Center { + el = el.inner_margin(Gaps::new_symmetric(GAP_VERT, 0.)); + } + + el + }) + .width(Size::fill()), + ) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs new file mode 100644 index 00000000..b7d98eeb --- /dev/null +++ b/packages/oneclient_app/src/components/toasts.rs @@ -0,0 +1,477 @@ +use std::{cell::Cell, rc::Rc, time::Instant}; + +use freya::{ + animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, use_animation, + use_animation_with_dependencies, + }, + prelude::*, +}; +use oneclient_events::Level; + +use crate::{ + components::{Button, ButtonSize, Icon, IconType, progress_track}, + hooks::{use_dispatch, use_notifications_snapshot}, + motion::use_animations_enabled, + notifications::{InboxEntry, NotificationActionKind}, + theme::colors, + ui::divider, +}; + +const TOAST_W: f32 = 300.; +const TRAVEL: f32 = 320.; +const TTL_MS: f32 = crate::notifications::MESSAGE_TOAST_TTL.as_millis() as f32; +const TTL_STEP: std::time::Duration = std::time::Duration::from_millis(100); +/// Below the navbar a smaller offset let the startup progress toast cover the nav links +const TOP_PX: f32 = crate::theme::NAVBAR_HEIGHT_PX + 12.; + +#[derive(Clone, Copy, PartialEq)] +enum Variant { + Progress, + Action, + Message, +} + +impl Variant { + fn of(entry: &InboxEntry) -> Self { + if entry.is_loading && entry.progress.is_some() { + Self::Progress + } else if !entry.actions.is_empty() { + Self::Action + } else { + Self::Message + } + } +} + +fn entry_icon(entry: &InboxEntry) -> (IconType, Color) { + if entry.is_loading && entry.progress.is_some() { + return (IconType::FolderDownload, colors::fg_primary()); + } + + match entry.level { + Level::Error => (IconType::AlertTriangle, colors::danger()), + Level::Info => (IconType::DownloadCloud02, colors::fg_primary()), + } +} + +#[derive(PartialEq)] +pub struct Toasts; + +impl Component for Toasts { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + // While any toast is hovered every toast's timer pauses + let hovering = use_state(|| 0usize); + + let entries: Vec = snapshot + .active_toast_entry_ids + .iter() + .filter_map(|id| snapshot.inbox.iter().find(|e| e.id == *id).cloned()) + .collect(); + + if entries.is_empty() { + return rect().into_element(); + } + + let mut stack = rect() + .vertical() + .spacing(12.) + .position(Position::new_global().top(TOP_PX).right(24.)) + .layer(Layer::OverlayLevel(3)); + + for entry in entries { + let id = entry.id; + stack = stack.child( + ToastCard { + entry, + hovering, + key: DiffKey::None, + } + .key(id), + ); + } + + stack.into_element() + } +} + +#[derive(PartialEq)] +struct ToastCard { + entry: InboxEntry, + hovering: State, + key: DiffKey, +} + +impl KeyExt for ToastCard { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for ToastCard { + fn render(&self) -> impl IntoElement { + let entry = self.entry.clone(); + let id = entry.id; + let variant = Variant::of(&entry); + let dispatch = use_dispatch(); + + let slide = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(TRAVEL, 0.) + .time(320) + .function(Function::Cubic) + .ease(Ease::Out) + }); + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(260).ease(Ease::Out) + }); + + let animations = use_animations_enabled(); + let paused = *self.hovering.read() > 0; + let mut was_paused = use_state(|| false); + let mut from_pct = use_state(|| 100.); + let mut run_start = use_state(Instant::now); + let mut wall_pct = use_state(|| 100.); + + if paused != *was_paused.peek() { + if paused { + let remaining = remaining_pct(*from_pct.peek(), *run_start.peek()); + from_pct.set(remaining); + wall_pct.set(remaining); + } else { + run_start.set(Instant::now()); + } + was_paused.set(paused); + } + + let mut ticker = use_state(|| None::); + use_side_effect_with_deps(&(animations, paused), move |&(animations, paused)| { + if animations || paused { + ticker.set(None); + return; + } + let handle = spawn(async move { + let platform = Platform::get(); + loop { + wall_pct.set(remaining_pct(*from_pct.peek(), *run_start.peek())); + platform.send(UserEvent::RequestRedraw); + tokio::time::sleep(TTL_STEP).await; + } + }); + ticker.set(Some(handle.owned())); + }); + + let ttl_bar_anim = + use_animation_with_dependencies(&(paused, *from_pct.peek()), |conf, deps| { + let &(paused, from) = deps; + conf.on_creation(if paused { + OnCreation::Nothing + } else { + OnCreation::Run + }); + conf.on_change(if paused { + OnChange::Reset + } else { + OnChange::Rerun + }); + AnimNum::new(from, 0.).time(((TTL_MS * from / 100.) as u64).max(1)) + }); + + let offset = slide.read().value(); + let opacity = fade.read().value(); + let ttl_pct = if animations { + ttl_bar_anim.read().value() + } else { + *wall_pct.read() + }; + + let mut hovering = self.hovering; + let hover_flag = use_hook(|| Rc::new(Cell::new(false))); + let (over_flag, out_flag, drop_flag) = + (hover_flag.clone(), hover_flag.clone(), hover_flag.clone()); + let over_dispatch = dispatch.clone(); + let out_dispatch = dispatch.clone(); + let drop_dispatch = dispatch.clone(); + + use_drop(move || { + if drop_flag.replace(false) { + let count = hovering.peek().saturating_sub(1); + hovering.set(count); + if count == 0 { + drop_dispatch.resume_toasts(); + } + } + }); + + let card = match variant { + Variant::Progress => progress_card(&entry), + Variant::Action => action_card(&entry, dispatch.clone(), id, ttl_pct), + Variant::Message => message_card(&entry, dispatch, id, ttl_pct), + }; + + rect() + .width(Size::px(TOAST_W)) + .offset_x(offset) + .opacity(opacity) + // `enter`/`leave` are exclusive to the deepest node so the close button would + // read as leaving the toast + .on_pointer_over(move |_| { + if over_flag.replace(true) { + return; + } + let count = *hovering.peek() + 1; + hovering.set(count); + if count == 1 { + over_dispatch.pause_toasts(); + } + }) + .on_pointer_out(move |_| { + if !out_flag.replace(false) { + return; + } + let count = hovering.peek().saturating_sub(1); + hovering.set(count); + if count == 0 { + out_dispatch.resume_toasts(); + } + }) + .child(card) + } +} + +fn remaining_pct(from_pct: f32, run_start: Instant) -> f32 { + let elapsed_pct = run_start.elapsed().as_secs_f32() * 1000. / TTL_MS * 100.; + (from_pct - elapsed_pct).clamp(0., 100.) +} + +fn toast_shell(entry: &InboxEntry) -> Rect { + let border_color = match entry.level { + Level::Error => colors::danger(), + Level::Info => colors::component_border(), + }; + rect() + .width(Size::px(TOAST_W)) + .background(colors::component_bg()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .border( + Border::new() + .fill(border_color) + .width(1.) + .alignment(BorderAlignment::Inner), + ) +} + +fn icon_badge(entry: &InboxEntry) -> impl IntoElement { + let (icon, color) = entry_icon(entry); + rect() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(6.)) + .center() + .child(Icon::new(entry.icon.unwrap_or(icon)).size(24.).color(color)) +} + +fn header(entry: &InboxEntry) -> impl IntoElement { + let title_color = match entry.level { + Level::Error => colors::danger(), + Level::Info => colors::fg_primary(), + }; + let body_color = colors::fg_secondary(); + + rect() + .vertical() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .spacing(2.) + .child( + label() + .text(entry.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(title_color), + ) + .child( + label() + .text(entry.body.clone()) + .font_size(12.) + .color(body_color), + ) +} + +fn ttl_bar(entry: &InboxEntry, pct: f32) -> impl IntoElement { + let color = match entry.level { + Level::Error => colors::danger(), + Level::Info => colors::brand(), + }; + rect() + .width(Size::fill()) + .height(Size::px(4.)) + .background(color.with_a(60)) + .child( + rect() + .width(Size::percent(pct.clamp(0.0, 100.0))) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(60.)) + .background(color), + ) +} + +fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement { + rect() + .position(Position::new_absolute().top(8.).right(8.)) + .child( + Button::new() + .ghost() + .size(ButtonSize::Icon) + .width(Size::px(28.)) + .height(Size::px(28.)) + .on_press(move |_| dispatch.dismiss_toast(id)) + .child( + Icon::new(IconType::XClose) + .size(16.) + .color(colors::fg_secondary()), + ), + ) +} + +fn message_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect { + toast_shell(entry) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(15., 40., 17., 15.)) + .spacing(16.) + .cross_align(Alignment::Center) + .child(icon_badge(entry)) + .child(header(entry)), + ) + .child(close_button(dispatch, id)) + .child(ttl_bar(entry, ttl_pct)) +} + +fn action_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect { + let action = entry.actions.first().cloned(); + + toast_shell(entry) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new(15., 15., 12., 15.)) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(16.) + .cross_align(Alignment::Center) + .child(icon_badge(entry)) + .child(header(entry)), + ) + .child(divider()) + .child(action_row(action, dispatch, id)), + ) + .child(ttl_bar(entry, ttl_pct)) +} + +fn action_row( + action: Option, + dispatch: crate::Actions, + id: u64, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + let review_label = action + .as_ref() + .map(|a| a.label.clone()) + .unwrap_or_else(|| "Review".to_string()); + let review_kind = action.map(|a| a.kind); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .small() + .secondary() + .width(Size::flex(1.0)) + .on_press(move |_| dismiss.dismiss_toast(id)) + .child(Icon::new(IconType::XClose).size(16.)) + .text("Dismiss"), + ) + .child( + Button::new() + .small() + .primary() + .width(Size::flex(1.0)) + .on_press(move |_| match &review_kind { + Some(kind) => run_action(&dispatch, kind), + None => dispatch.mark_notification_read(id), + }) + .child(Icon::new(IconType::Eye).size(16.)) + .text(review_label), + ) +} + +fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summaries) => { + dispatch.open_cluster_update(summaries.clone()); + } + } +} + +fn progress_card(entry: &InboxEntry) -> Rect { + let frac = entry + .progress + .map(|(cur, total)| cur as f32 / total.max(1) as f32) + .unwrap_or(0.0) + .clamp(0.0, 1.0); + + toast_shell(entry).child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(12., 14., 12., 14.)) + .spacing(13.) + .cross_align(Alignment::Start) + .child(icon_badge(entry)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(entry.title.clone()) + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(entry.body.clone()) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + progress_track( + frac * 100.0, + 6., + colors::brand(), + colors::brand().with_a(128), + ) + .margin(Gaps::new(8., 0., 0., 0.)), + ), + ), + ) +} diff --git a/packages/oneclient_app/src/components/toggle.rs b/packages/oneclient_app/src/components/toggle.rs new file mode 100644 index 00000000..30adb1d7 --- /dev/null +++ b/packages/oneclient_app/src/components/toggle.rs @@ -0,0 +1,147 @@ +use freya::{ + animation::{AnimColor, AnimNum, Ease, Function, use_animation_transition}, + prelude::*, +}; + +use crate::{theme::colors, ui}; + +const WIDTH: f32 = 40.; +const HANDLE_SIZE: f32 = 16.; +const TIME: u64 = 180; + +pub fn toggle(value: State) -> impl IntoElement { + let mut v = value; + Switch { + value, + on_press: (move |()| v.toggle()).into(), + disabled: false, + } +} + +#[allow(dead_code)] +pub fn toggle_disabled(value: State) -> impl IntoElement { + Switch { + value, + on_press: (|()| {}).into(), + disabled: true, + } +} + +pub fn toggle_controlled(on: bool, on_toggle: EventHandler<()>) -> ToggleControlled { + ToggleControlled { on, on_toggle } +} + +#[derive(PartialEq)] +pub struct ToggleControlled { + on: bool, + on_toggle: EventHandler<()>, +} + +impl Component for ToggleControlled { + fn render(&self) -> impl IntoElement { + let mut value = use_state(|| self.on); + value.set_if_modified(self.on); + Switch { + value, + on_press: self.on_toggle.clone(), + disabled: false, + } + } +} + +#[derive(PartialEq)] +struct Switch { + value: State, + on_press: EventHandler<()>, + disabled: bool, +} + +impl Component for Switch { + fn render(&self) -> impl IntoElement { + let value = self.value; + let on_press = self.on_press.clone(); + let on = *value.read(); + let disabled = self.disabled; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let knob_align = if on { Alignment::End } else { Alignment::Start }; + + let track_border = if focus().is_focused() && !disabled { + colors::fg_primary() + } else { + colors::component_border() + }; + + let background = use_animation_transition(value, |_, on| { + if on { + AnimColor::new(colors::component_bg(), colors::brand()) + } else { + AnimColor::new(colors::brand(), colors::component_bg()) + } + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out) + }); + + let right_offset = use_animation_transition(value, |_, on| { + if on { + AnimNum::new(WIDTH - HANDLE_SIZE - 6., 0.) + } else { + AnimNum::new(0., WIDTH - HANDLE_SIZE - 6.) + } + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out) + }); + + let text = if on { "On" } else { "Off" }; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::Button) + .maybe(!disabled, |el| { + el.cursor(CursorIcon::Pointer).on_all_press(move |_| { + on_press.call(()); + }) + }) + .child( + label() + .text(text) + .font_size(12.) + .color(Color::from_rgb(167, 176, 190)), + ) + .child( + rect() + .width(Size::px(WIDTH)) + .height(Size::px(22.)) + .corner_radius(CornerRadius::new_all(11.)) + .padding(Gaps::new_all(3.)) + .main_align(knob_align) + .cross_align(Alignment::Center) + .background(if disabled { + colors::component_bg_disabled() + } else { + background.read().value() + }) + .border(ui::border_all_color(1., track_border)) + .child( + rect() + .width(Size::px(HANDLE_SIZE)) + .height(Size::px(HANDLE_SIZE)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if disabled { + colors::fg_secondary() + } else { + Color::WHITE + }) + .position(Position::new_absolute().right(right_offset.read().value())), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/update_prompt.rs b/packages/oneclient_app/src/components/update_prompt.rs new file mode 100644 index 00000000..b07a42ae --- /dev/null +++ b/packages/oneclient_app/src/components/update_prompt.rs @@ -0,0 +1,114 @@ +use freya::prelude::*; +use oneclient_events::Answer; + +use crate::updater::UPDATE_CHOICE_INSTALL; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct UpdatePromptOverlay; + +impl Component for UpdatePromptOverlay { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + // Recognised by the choice it offers so the event layer needn't enumerate prompts + if !prompt.has_choice(UPDATE_CHOICE_INSTALL) { + return rect().into_element(); + } + + let dismiss_label = prompt + .dismiss + .clone() + .unwrap_or_else(|| "Not now".to_string()); + let install_label = prompt + .choice(UPDATE_CHOICE_INSTALL) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Download".to_string()); + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let accept = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.dismiss_prompt()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::DownloadCloud02) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.dismiss_prompt()) + .text(dismiss_label), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + accept.answer_prompt(Answer::new( + UPDATE_CHOICE_INSTALL, + )) + }) + .child(Icon::new(IconType::DownloadCloud02).size(14.)) + .text(install_label), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/upload_mclogs.rs b/packages/oneclient_app/src/components/upload_mclogs.rs new file mode 100644 index 00000000..ac5d2ffa --- /dev/null +++ b/packages/oneclient_app/src/components/upload_mclogs.rs @@ -0,0 +1,67 @@ +use freya::prelude::*; +use freya::query::MutationStateData; +use freya::text_edit::Clipboard; + +use crate::components::{Button, Icon, IconType}; +use crate::hooks::{UseUploadLog, use_dispatch}; +use crate::view::app::cluster::logs::Confirm; + +#[derive(PartialEq)] +pub struct UploadToMclogs { + has_log: bool, + confirm: State>, +} + +impl UploadToMclogs { + pub fn new(has_log: bool, confirm: State>) -> Self { + Self { has_log, confirm } + } +} + +impl Component for UploadToMclogs { + fn render(&self) -> impl IntoElement { + let mut confirm = self.confirm; + + Button::new() + .secondary() + .enabled(self.has_log) + .on_press(move |_| confirm.set(Some(Confirm::Upload))) + .child(Icon::new(IconType::LinkExternal01).size(15.)) + .text("Upload to mclo.gs") + } +} + +pub fn use_mclogs_feedback(upload: UseUploadLog) { + let dispatch = use_dispatch(); + let mut handled = use_state(|| None::); + + use_side_effect(move || match &*upload.read().state() { + MutationStateData::Settled { + res: Ok(result), .. + } => { + if handled.peek().as_deref() == Some(result.url.as_str()) { + return; + } + + handled.set(Some(result.url.clone())); + let _ = Clipboard::set(result.url.clone()); + + dispatch + .notify("Uploaded to mclo.gs") + .body(format!("{} (copied to clipboard)", result.url)) + .info() + .icon(IconType::LinkExternal01) + .send(); + } + MutationStateData::Settled { res: Err(err), .. } => { + let msg = err.to_string(); + if handled.peek().as_deref() == Some(msg.as_str()) { + return; + } + + handled.set(Some(msg.clone())); + dispatch.notify("Upload failed").body(msg).error().send(); + } + _ => {} + }); +} diff --git a/packages/oneclient_app/src/components/version_card.rs b/packages/oneclient_app/src/components/version_card.rs new file mode 100644 index 00000000..8695627a --- /dev/null +++ b/packages/oneclient_app/src/components/version_card.rs @@ -0,0 +1,158 @@ +use freya::prelude::*; +use oneclient_common::VersionKey; +use oneclient_core::clusters::Cluster; + +use crate::components::ClusterLandscapeArt; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{ReleaseLine, line_art_key, line_title, loader_tags}; + +const CARD_HEIGHT_PX: f32 = 240.; + +pub struct VersionCard { + pub line: ReleaseLine, + pub art_key: Option, + pub tags: Vec, + pub title: String, + pub selected: bool, + pub on_press: EventHandler>, +} + +impl VersionCard { + pub fn new( + line: ReleaseLine, + clusters: &[Cluster], + selected: bool, + on_press: impl Into>>, + ) -> Self { + Self { + line, + art_key: line_art_key(line, clusters), + tags: loader_tags(clusters), + title: line_title(line, clusters), + selected, + on_press: on_press.into(), + } + } +} + +impl PartialEq for VersionCard { + fn eq(&self, other: &Self) -> bool { + self.line == other.line + && self.art_key == other.art_key + && self.tags == other.tags + && self.title == other.title + && self.selected == other.selected + } +} + +impl Component for VersionCard { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let selected = self.selected; + let hovered = *hovering.read(); + let focused = focus().is_focused(); + let on_press = self.on_press.clone(); + + let opacity = if selected || hovered || focused { + if selected { 1.0 } else { 0.85 } + } else { + 0.6 + }; + + let border_color = if selected || focused { + colors::brand() + } else if hovered { + colors::component_border_hover() + } else { + colors::component_border() + }; + + rect() + .key(self.line) + .width(Size::flex(1.0)) + .height(Size::px(CARD_HEIGHT_PX)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_all_press(move |e| on_press.call(e)) + .on_pointer_enter(move |_| { + *hovering.write() = true; + }) + .on_pointer_leave(move |_| { + *hovering.write() = false; + }) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(12.)) + .opacity(opacity) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child(ClusterLandscapeArt::for_version( + self.line.major, + self.art_key, + None, + false, + )), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .padding(Gaps::new_symmetric(12., 16.)) + .main_align(Alignment::SpaceBetween) + .corner_radius(CornerRadius::new_all(12.)) + .cross_align(Alignment::Start) + .border( + border_all_color(1., border_color) + .alignment(BorderAlignment::Inner), + ) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() + .angle(0.) + .stop((Color::from_af32rgb(0.8, 0, 0, 0), 0.)) + .stop((Color::from_af32rgb(0.3, 0, 0, 0), 20.)) + .stop((Color::from_af32rgb(0.3, 0, 0, 0), 60.)) + .stop((Color::from_af32rgb(0.8, 0, 0, 0), 100.)), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .children(self.tags.iter().map(|tag| { + rect() + .padding(Gaps::new_symmetric(4., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(colors::fg_primary()) + .child( + label() + .text(tag.clone()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::brand()), + ) + .into_element() + })), + ) + .child( + label() + .text(format!("Version {}", self.title)) + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ), + ) + } +} diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs new file mode 100644 index 00000000..0bfe6067 --- /dev/null +++ b/packages/oneclient_app/src/constants.rs @@ -0,0 +1,7 @@ +pub const WINDOW_APP_ID: &str = "oneclient_app"; +pub const WINDOW_TITLE: &str = "OneClient"; + +pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK"; +pub const UPDATER_ENDPOINT: &str = + "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"; +pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest"; diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs new file mode 100644 index 00000000..fe924da8 --- /dev/null +++ b/packages/oneclient_app/src/events.rs @@ -0,0 +1,379 @@ +//! Runs under freya's `spawn` not `tokio::spawn` radio state is `!Send` +//! Events are batched and folded because a radio write wakes every subscriber + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use freya::prelude::spawn_forever; +use freya::radio::RadioStation; +use oneclient_events::{Event, EventReceiver, GameEvent, LaunchStage, ProgressEvent, Signal}; +use tokio::sync::mpsc; + +use crate::hooks::PumpSignal; +use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView}; +use crate::state::{AppChannel, AppState, LoginProgress}; + +/// Quiet period before log lines are written without it every line wakes the log view +const GAME_LOG_FLUSH: Duration = Duration::from_millis(120); + +const COALESCE_BUDGET: usize = 1024; + +pub struct EventPump { + pub events: EventReceiver, + pub signals: mpsc::UnboundedReceiver, + pub station: RadioStation, +} + +impl EventPump { + pub async fn run(mut self) { + let mut armed: HashMap = HashMap::new(); + // Hovering any toast pauses every toast including ones arriving while hovering + let mut paused = false; + let mut game_flush: Option = None; + + loop { + let next_deadline = armed + .values() + .filter_map(ToastTimer::deadline) + .min() + .into_iter() + .chain(game_flush) + .min(); + + let timer = async { + match next_deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending::<()>().await, + } + }; + + tokio::select! { + () = timer => { + let now = tokio::time::Instant::now(); + let due: Vec = armed + .iter() + .filter(|(_, t)| t.deadline().is_some_and(|d| d <= now)) + .map(|(id, _)| *id) + .collect(); + + if !due.is_empty() { + { + let mut guard = self.station.write_channel(AppChannel::Notifications); + let AppState { + notifications, inbox, .. + } = &mut **guard; + for id in &due { + notifications.expire_toast(inbox, *id); + } + } + for id in due { + armed.remove(&id); + } + reconcile(&self.station, &mut armed, paused); + } + + if game_flush.is_some_and(|d| d <= now) { + game_flush = None; + // Lines were already folded in on arrival this only ends the quiet period + self.station.write_channel(AppChannel::Game); + } + } + + event = self.events.recv() => { + let Some(first) = event else { break }; + let mut batch = vec![first]; + while batch.len() < COALESCE_BUDGET { + match self.events.try_recv() { + Ok(next) => batch.push(next), + Err(_) => break, + } + } + + let folded = self.apply(batch); + if folded.touched_engine { + reconcile(&self.station, &mut armed, paused); + } + if folded.saw_game_log && game_flush.is_none() { + game_flush = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); + } + } + + signal = self.signals.recv() => { + let Some(signal) = signal else { break }; + match signal { + PumpSignal::Reconcile => reconcile(&self.station, &mut armed, paused), + PumpSignal::PauseToasts => { + paused = true; + for timer in armed.values_mut() { + timer.pause(); + } + } + PumpSignal::ResumeToasts => { + paused = false; + for timer in armed.values_mut() { + timer.resume(); + } + } + } + } + } + } + } + + fn apply(&mut self, batch: Vec) -> Folded { + let mut folded = Folded::default(); + let mut engine_events = Vec::new(); + let mut stages: Vec<(i64, LaunchStage)> = Vec::new(); + let mut logs: Vec<(i64, String)> = Vec::new(); + let mut failed: Option<(i64, String)> = None; + let mut login: Option> = None; + let mut sync_complete = false; + + for event in batch { + match event { + Event::Signal(Signal::ClustersChanged) => folded.clusters = true, + Event::Signal(Signal::JavaChanged) => folded.java = true, + Event::Signal(Signal::SyncComplete) => { + sync_complete = true; + folded.clusters = true; + } + Event::Game(GameEvent::Stage { cluster_id, stage }) => { + stages.push((cluster_id, stage)); + } + Event::Game(GameEvent::Log { cluster_id, line }) => { + folded.saw_game_log = true; + logs.push((cluster_id, line)); + } + Event::Game(GameEvent::Failed { + cluster_id, + message, + }) => failed = Some((cluster_id, message)), + // Lifted out so it never reaches the engine the sign-in modal renders it inline + Event::Progress(ProgressEvent::Update { + id, + ref label, + current, + total, + }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => { + login = Some((current < total).then(|| LoginProgress { + label: label.clone(), + current, + total, + })); + } + other => engine_events.push(other), + } + } + + if !stages.is_empty() || !logs.is_empty() || failed.is_some() { + let mut guard = self.station.write_channel(AppChannel::Game); + for (cluster_id, stage) in stages { + guard.game.stages.insert(cluster_id, stage); + if stage == LaunchStage::Checking { + guard.game.error = None; + guard + .game + .logs + .insert(cluster_id, std::sync::Arc::new(Vec::new())); + } + } + for (cluster_id, line) in logs { + let buffer = guard.game.logs.entry(cluster_id).or_default(); + std::sync::Arc::make_mut(buffer).push(std::sync::Arc::from(line)); + } + if let Some((cluster_id, message)) = failed { + guard.game.stages.insert(cluster_id, LaunchStage::Exited); + guard.game.error = Some(message); + } + } + + if sync_complete { + self.station + .write_channel(AppChannel::Launcher) + .launcher + .fetching = false; + } + + if let Some(progress) = login { + self.station + .write_channel(AppChannel::MicrosoftLogin) + .microsoft_login = progress; + } + + if !engine_events.is_empty() { + folded.touched_engine = true; + let mut guard = self.station.write_channel(AppChannel::Notifications); + let AppState { + notifications, + inbox, + prompt, + .. + } = &mut **guard; + for event in engine_events { + if let (_timers, Some(pending)) = notifications.dispatch(inbox, event) { + *prompt = Some(pending); + } + } + } + + if folded.clusters { + spawn_forever(async move { crate::hooks::invalidate_cluster_queries().await }); + } + if folded.java { + spawn_forever(async move { crate::hooks::invalidate_java_queries().await }); + } + + folded + } +} + +#[derive(Default)] +struct Folded { + touched_engine: bool, + saw_game_log: bool, + clusters: bool, + java: bool, +} + +fn reconcile( + station: &RadioStation, + armed: &mut HashMap, + paused: bool, +) { + let state = station.peek(); + let want: HashSet = state + .notifications + .active_toast_entry_ids() + .into_iter() + .filter(|id| state.inbox.iter().any(|e| e.id == *id && !e.is_loading)) + .collect(); + + for id in &want { + armed + .entry(*id) + .or_insert_with(|| ToastTimer::armed(paused)); + } + armed.retain(|id, _| want.contains(id)); +} + +enum ToastTimer { + Running(tokio::time::Instant), + Paused(Duration), +} + +impl ToastTimer { + fn armed(paused: bool) -> Self { + if paused { + Self::Paused(MESSAGE_TOAST_TTL) + } else { + Self::Running(tokio::time::Instant::now() + MESSAGE_TOAST_TTL) + } + } + + /// `None` while paused + fn deadline(&self) -> Option { + match self { + Self::Running(deadline) => Some(*deadline), + Self::Paused(_) => None, + } + } + + fn pause(&mut self) { + if let Self::Running(deadline) = self { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + *self = Self::Paused(remaining); + } + } + + fn resume(&mut self) { + if let Self::Paused(remaining) = self { + let remaining = *remaining; + *self = Self::Running(tokio::time::Instant::now() + remaining); + } + } +} + +#[must_use] +pub fn prompt_view(state: &AppState) -> Option { + state.prompt.as_ref().map(|prompt| PendingPromptView { + title: prompt.title.clone(), + question: prompt.question.clone(), + choices: prompt.choices.clone(), + dismiss: prompt.dismiss.clone(), + }) +} + +pub async fn start_launcher( + station: RadioStation, + events: oneclient_events::EventBus, +) -> Result<(), anyhow::Error> { + let state = crate::launcher::install(oneclient_core::LauncherState::new(events).await?); + + oneclient_net::status::start(state.services.requester.clone()); + oneclient_polyplus::start(std::sync::Arc::clone(&state.auth)); + oneclient_core::run_startup_tasks(&state); + + let data_dir = oneclient_common::paths::launcher_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + + let settings = state.settings.read().clone(); + let auto_update = settings.auto_update; + + let mut station = station; + { + let mut guard = station.write_channel(AppChannel::Launcher); + guard.launcher = crate::state::LauncherInit { + ready: true, + fetching: true, + syncing_bundles: true, + error: None, + snapshots: 0, + data_dir, + }; + } + { + let mut guard = station.write_channel(AppChannel::Settings); + guard.settings.settings = settings; + guard.settings.status = crate::state::AsyncStatus::Ready; + guard.settings.error = None; + } + + crate::hooks::invalidate_profile_queries().await; + crate::updater::spawn_update_check(auto_update, state.services.events.clone()); + Ok(()) +} + +pub fn report_startup_failure(station: &RadioStation, err: &anyhow::Error) { + let message = err.to_string(); + tracing::error!("launcher init failed: {err:#}"); + + let data_dir = oneclient_common::paths::launcher_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + + let mut station = *station; + { + let mut guard = station.write_channel(AppChannel::Launcher); + guard.launcher.error = Some(message.clone()); + guard.launcher.data_dir = data_dir; + guard.launcher.snapshots = crate::recovery::snapshots().len(); + } + + let mut guard = station.write_channel(AppChannel::Notifications); + let AppState { + notifications, + inbox, + .. + } = &mut **guard; + notifications.dispatch( + inbox, + Event::Notification(oneclient_events::Notification::Message( + oneclient_events::Message { + title: "Launcher failed to start".into(), + body: message, + level: oneclient_events::Level::Error, + }, + )), + ); +} diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs new file mode 100644 index 00000000..2afc0127 --- /dev/null +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -0,0 +1,1438 @@ +//! Core operations go onto freya's UI-thread executor radio state is `!Send` +//! +//! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when +//! the calling component unmounts this work is app-scoped not component-scoped + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use freya::prelude::spawn_forever; +use freya::radio::RadioStation; +use oneclient_cluster::{ + ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, +}; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_core::settings::LauncherSettings; +use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply}; +use oneclient_db::models::ClusterId; +use oneclient_events::{Answer, Level}; +use tokio::sync::mpsc; + +use crate::components::IconType; +use crate::notifications::{ + ClusterUpdateSummary, NotificationAction, NotificationSpec, PackageUpdateGroup, PendingPrompt, +}; +use crate::state::{AppChannel, AppState, AsyncStatus}; +use crate::{invalidate_java_queries, launcher}; + +/// Over-disabling is the cheaper mistake a dead button beats a second game +const LAUNCH_HOLD: Duration = Duration::from_secs(2); + +/// Mirrors `oneclient_net`'s reachability probe not reqwest's per-request +/// timeouts which are minutes long This sits between Play and the game +const UPDATE_CHECK_BUDGET: Duration = Duration::from_secs(8); + +#[derive(Debug, Clone, PartialEq)] +enum LaunchUpdatePlan { + Nothing, + Apply(Vec), + Prompt(Vec), +} + +/// Kept pure and separate from the work it describes so the three-way mode is +/// testable without a launcher database or provider +/// +/// `pending` has already had declined versions filtered out by +/// `BrowserUpdateCheck::pending` +fn plan_launch_updates( + mode: PackageUpdateMode, + pending: Vec, +) -> LaunchUpdatePlan { + if pending.is_empty() { + return LaunchUpdatePlan::Nothing; + } + + match mode { + // The cache was already written skipping only affects what is shown + PackageUpdateMode::Skip => LaunchUpdatePlan::Nothing, + PackageUpdateMode::Automatic => LaunchUpdatePlan::Apply(pending), + PackageUpdateMode::Prompt => LaunchUpdatePlan::Prompt(pending), + } +} + +/// The pump alone owns the toast timers so adding or removing a toast has to +/// tell it to re-arm hover-pause is a timer property not component state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PumpSignal { + Reconcile, + PauseToasts, + ResumeToasts, +} + +#[derive(Clone)] +pub struct Actions { + station: RadioStation, + pump: mpsc::UnboundedSender, + events: oneclient_events::EventBus, +} + +impl Actions { + #[must_use] + pub fn station(&self) -> RadioStation { + self.station + } + + /// A channel handle, so a clone still feeds the pump set up at launch + #[must_use] + pub fn events(&self) -> oneclient_events::EventBus { + self.events.clone() + } + + #[must_use] + pub fn new( + station: RadioStation, + pump: mpsc::UnboundedSender, + events: oneclient_events::EventBus, + ) -> Self { + Self { + station, + pump, + events, + } + } + + fn nudge(&self, signal: PumpSignal) { + let _ = self.pump.send(signal); + } + + fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) { + { + let mut guard = self + .station + .clone() + .write_channel(AppChannel::Notifications); + mutate(&mut guard); + } + self.nudge(PumpSignal::Reconcile); + } + + fn set_settings_error(&self, error: Option) { + self.station + .clone() + .write_channel(AppChannel::Settings) + .settings + .error = error; + } + + pub fn reload_settings(&self) { + let station = self.station; + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + { + let mut guard = station.clone().write_channel(AppChannel::Settings); + guard.settings.status = AsyncStatus::Loading; + guard.settings.error = None; + } + + let loaded = + oneclient_core::settings::store::load_settings(Some(&state.services.events)).await; + let discord_enabled = loaded.discord_enabled; + *state.settings.write() = loaded.clone(); + state.discord.set_enabled(discord_enabled); + + let mut guard = station.clone().write_channel(AppChannel::Settings); + guard.settings.settings = loaded; + guard.settings.status = AsyncStatus::Ready; + guard.settings.error = None; + }); + } + + pub fn save_settings(&self) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + { + let mut guard = actions.station.clone().write_channel(AppChannel::Settings); + guard.settings.saving = true; + guard.settings.error = None; + } + + let settings = state.settings.read().clone(); + let result = save_settings_and_apply(&state.services, &settings).await; + + let mut guard = actions.station.clone().write_channel(AppChannel::Settings); + guard.settings.error = result.err().map(|err| err.to_string()); + guard.settings.saving = false; + guard.settings.status = AsyncStatus::Ready; + }); + } + + fn mutate_settings( + &self, + mutate: impl FnOnce(&mut LauncherSettings), + ) -> Option { + let state = launcher::state().ok()?; + let updated = { + let mut lock = state.settings.write(); + mutate(&mut lock); + lock.clone() + }; + + let mut guard = self.station.clone().write_channel(AppChannel::Settings); + guard.settings.settings = updated.clone(); + guard.settings.status = AsyncStatus::Ready; + Some(updated) + } + + fn persist(&self, settings: LauncherSettings) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = save_settings_and_apply(&state.services, &settings).await { + actions.set_settings_error(Some(err.to_string())); + } + }); + } + + pub fn set_settings(&self, settings: LauncherSettings) { + let Some(updated) = self.mutate_settings(|s| *s = settings) else { + return; + }; + if let Ok(state) = launcher::state() { + state.discord.set_enabled(updated.discord_enabled); + } + self.persist(updated); + } + + /// Not persisted the next real save carries the seen versions along + pub fn record_seen_version(&self, version: impl Into) { + let version = version.into(); + self.mutate_settings(|settings| { + if !settings.seen_versions.iter().any(|v| v == &version) { + settings.seen_versions.push(version); + } + }); + } + + pub fn set_seen_changelog_version(&self, version: impl Into) { + let version = version.into(); + let mut changed = false; + let updated = self.mutate_settings(|settings| { + if settings.seen_changelog_version.as_deref() != Some(version.as_str()) { + settings.seen_changelog_version = Some(version); + changed = true; + } + }); + + if let (true, Some(updated)) = (changed, updated) { + self.persist(updated); + } + } + + pub fn mark_onboarding_seen(&self) { + if let Some(updated) = self.mutate_settings(|settings| settings.seen_onboarding = true) { + self.persist(updated); + } + } + + pub fn reset_onboarding(&self) { + if let Some(updated) = self.mutate_settings(|settings| { + settings.seen_onboarding = false; + settings.accepted_tos_version = 0; + settings.accepted_privacy_version = 0; + settings.seen_versions.clear(); + }) { + self.persist(updated); + } + } + + pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { + if let Some(updated) = self.mutate_settings(|settings| { + settings.accepted_tos_version = terms_version; + settings.accepted_privacy_version = privacy_version; + }) { + self.persist(updated); + } + } + + pub fn save_global_profile(&self, profile: GameSettingsProfile) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = save_global_profile(&state.settings, profile).await { + tracing::error!("failed to save the global profile: {err:#}"); + return; + } + actions.mutate_settings(|_| {}); + super::invalidate_profile_queries().await; + }); + } + + pub fn update_global_profile(&self, update: ProfileUpdate) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let mut global = state.settings.read().global_game_settings.clone(); + update.apply(&mut global); + if let Err(err) = save_global_profile(&state.settings, global).await { + tracing::error!("failed to update the global profile: {err:#}"); + return; + } + actions.mutate_settings(|_| {}); + super::invalidate_profile_queries().await; + }); + } + + pub fn create_settings_profile(&self, name: impl Into) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match oneclient_cluster::profiles::create_settings_profile( + &state.services.db, + &global, + &name, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to create settings profile: {err:#}"), + } + }); + } + + pub fn create_profile_from_global( + &self, + name: impl Into, + mem_max: Option, + force_fullscreen: Option, + ) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match oneclient_cluster::profiles::create_profile_from_global( + &state.services.db, + &global, + &name, + mem_max, + force_fullscreen, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to create profile: {err:#}"), + } + }); + } + + pub fn upsert_named_profile(&self, profile: GameSettingsProfile) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::upsert_named_profile(&state.services.db, &profile) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to save profile: {err:#}"), + } + }); + } + + pub fn update_named_profile(&self, name: impl Into, update: ProfileUpdate) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::update_named_profile( + &state.services.db, + &name, + update, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to update profile: {err:#}"), + } + }); + } + + pub fn delete_named_profile(&self, name: impl Into) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::delete_named_profile(&state.services.db, &name).await + { + Ok(()) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to delete profile: {err:#}"), + } + }); + } + + pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match state.clusters.update_profile(cluster_id, update).await { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to update cluster profile: {err:#}"), + } + }); + } + + pub fn create_and_assign_cluster_profile( + &self, + cluster_id: ClusterId, + profile_name: impl Into, + ) { + let profile_name = profile_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match state + .clusters + .create_and_assign_profile(&global, cluster_id, &profile_name) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to assign cluster profile: {err:#}"), + } + }); + } + + pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into) { + let version = version.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = state + .clusters + .update(cluster_id, ClusterUpdate::default().loader_version(version)) + .await + { + tracing::error!("failed to set loader version: {err:#}"); + return; + } + // Best effort on failure the cluster just will not re-download + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::NotReady) + .await; + super::invalidate_cluster_queries().await; + }); + } + + pub fn import_launcher( + &self, + source: oneclient_core::MigrationSource, + folder_name: impl Into, + target: oneclient_core::ImportTarget, + ) { + let folder_name = folder_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_core::import_migration_game_dir(&state, source, &folder_name, target) + .await + { + Ok(()) => { + events + .notify("Import complete") + .body(format!("Copied your files from {}.", source.display_name())) + .send(); + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Import failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn install_java_runtime(&self, vendor: oneclient_java::JavaVendor, major: u32) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match state.java.install_runtime_from(&vendor, major).await { + Ok(_) => { + events.signal(oneclient_events::Signal::JavaChanged); + invalidate_java_queries().await + } + Err(err) => events + .notify("Java install failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn add_custom_java_runtime(&self, path: PathBuf) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match state.java.add_custom_runtime(path).await { + Ok(runtime) => { + events + .notify("Java added") + .body(format!("Java {} ({})", runtime.major, runtime.vendor)) + .send(); + events.signal(oneclient_events::Signal::JavaChanged); + } + Err(err) => events + .notify("Failed to add Java") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn remove_java_runtime(&self, path: impl Into) { + let path = path.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match state.java.remove_runtime(&path).await { + Ok(()) => super::invalidate_java_queries().await, + Err(err) => tracing::error!("failed to remove java runtime: {err:#}"), + } + }); + } + + pub fn toggle_notification_center(&self) { + self.with_engine(|state| { + state.center_open = state + .notifications + .toggle_center(&mut state.inbox, state.center_open); + }); + } + + pub fn toggle_account_switcher(&self) { + let open = self.station.peek().account_switcher_open; + self.station + .clone() + .write_channel(AppChannel::AccountSwitcher) + .account_switcher_open = !open; + } + + pub fn close_account_switcher(&self) { + self.station + .clone() + .write_channel(AppChannel::AccountSwitcher) + .account_switcher_open = false; + } + + pub fn close_notification_center(&self) { + self.with_engine(|state| state.center_open = false); + } + + pub fn clear_notification_inbox(&self) { + self.with_engine(|state| { + state.inbox.clear(); + state.notifications.clear_inbox(); + }); + } + + pub fn dismiss_toast(&self, entry_id: u64) { + self.with_engine(|state| { + state + .notifications + .dismiss_toast(&mut state.inbox, entry_id) + }); + } + + pub fn mark_notification_read(&self, entry_id: u64) { + self.with_engine(|state| state.notifications.mark_read(&mut state.inbox, entry_id)); + } + + pub fn dismiss_notification(&self, entry_id: u64) { + self.with_engine(|state| { + state + .notifications + .dismiss_notification(&mut state.inbox, entry_id); + }); + } + + pub fn open_cluster_update(&self, summaries: Vec) { + self.with_engine(|state| { + state.notifications.open_cluster_update(summaries); + state.center_open = false; + }); + } + + pub fn close_cluster_update(&self) { + self.with_engine(|state| state.notifications.close_cluster_update()); + } + + pub fn close_package_updates(&self) { + self.with_engine(|state| state.notifications.close_package_updates()); + } + + /// Hovering any toast pauses every toast including ones arriving while + /// hovering hence a pump signal rather than a state flag + pub fn pause_toasts(&self) { + self.nudge(PumpSignal::PauseToasts); + } + + pub fn resume_toasts(&self) { + self.nudge(PumpSignal::ResumeToasts); + } + + pub fn answer_prompt(&self, answer: Answer) { + self.reply_to_prompt(Some(answer)); + } + + pub fn dismiss_prompt(&self) { + self.reply_to_prompt(None); + } + + fn reply_to_prompt(&self, answer: Option) { + let taken = self + .station + .clone() + .write_channel(AppChannel::Notifications) + .prompt + .take(); + + if let Some(PendingPrompt { + reply_tx: Some(reply_tx), + .. + }) = taken + { + let _ = reply_tx.send(answer); + } + } + + pub fn notify(&self, title: impl Into) -> NotificationBuilder { + NotificationBuilder { + actions: self.clone(), + spec: NotificationSpec { + title: title.into(), + body: String::new(), + level: Level::Info, + icon: None, + progress: None, + actions: Vec::new(), + }, + } + } + + fn push_notification(&self, spec: NotificationSpec) { + self.with_engine(|state| { + state.notifications.push_custom(&mut state.inbox, spec); + }); + } + + pub fn send_test_progress(&self, current: u64, total: u64) { + let Ok(state) = launcher::state() else { return }; + let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0); + state + .services + .events + .progress(id, "Downloading assets", current, total); + } + + /// The pending flag is raised synchronously before the first `await` the + /// claim itself is the guard against a double-click spawning two games + pub fn launch_cluster(&self, cluster_id: ClusterId) { + let claimed = self + .station + .clone() + .write_channel(AppChannel::Game) + .game + .begin_launch(cluster_id); + if !claimed { + return; + } + + let station = self.station; + let actions = self.clone(); + spawn_forever(async move { + let started = Instant::now(); + launch(&actions, cluster_id).await; + + // An instant failure would hand the button back inside the same + // click burst so hold it for the floor either way + if let Some(remaining) = LAUNCH_HOLD.checked_sub(started.elapsed()) { + tokio::time::sleep(remaining).await; + } + station + .clone() + .write_channel(AppChannel::Game) + .game + .finish_launch(cluster_id); + }); + } + + pub fn kill_cluster(&self, cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { return }; + if !state.games.kill(cluster_id) { + tracing::debug!(cluster_id, "kill requested but no tracked process"); + } + } + + pub fn dismiss_game_error(&self) { + self.station + .clone() + .write_channel(AppChannel::Game) + .game + .error = None; + } + + pub fn import_local_file( + &self, + cluster_id: ClusterId, + content_type: ContentType, + path: PathBuf, + ) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_content::packages::PackageStore::import_local_file( + &path, + content_type, + cluster_id, + &state.services.content(), + ) + .await + { + Ok(row) => { + events + .notify("Imported") + .body(format!("Added {}", row.file_name)) + .send(); + super::invalidate_cluster_queries().await; + } + Err(err) => events + .notify("Import failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn install_package( + &self, + cluster_id: ClusterId, + provider: ProviderId, + project_id: impl Into, + version_id: impl Into, + ) { + let (project_id, version_id) = (project_id.into(), version_id.into()); + let actions = self.clone(); + + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .begin(cluster_id, provider, project_id.clone()); + + spawn_forever(async move { + let finish = |actions: &Actions| { + actions + .station + .clone() + .write_channel(AppChannel::Installs) + .installs + .finish(cluster_id, provider, &project_id); + }; + + let Ok(state) = launcher::state() else { + finish(&actions); + return; + }; + + let install = crate::install::install_package( + &state, + provider, + &project_id, + &version_id, + cluster_id, + ) + .await; + + // Replaces the download's progress notification in place rather + // than arriving as a second one + let spec = match &install.result { + Ok(name) => NotificationSpec { + title: "Installed".to_string(), + body: crate::install::install_body( + name, + &install.dependencies, + &install.missing_dependencies, + ), + level: Level::Info, + icon: Some(IconType::Download01), + progress: None, + actions: Vec::new(), + }, + Err(err) => NotificationSpec { + title: "Install failed".to_string(), + body: err.to_string(), + level: Level::Error, + icon: None, + progress: None, + actions: Vec::new(), + }, + }; + + let session_id = install.session_id.unwrap_or_else(uuid::Uuid::nil); + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); + }); + + if install.result.is_ok() { + // Before the refresh below so the version list never renders + // the moment where both copies read as active + if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity( + cluster_id, + &state.services.content(), + ) + .await + { + tracing::warn!(%err, "failed to resolve duplicate package versions"); + } + + super::invalidate_cluster_content_queries().await; + + state + .services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } + + finish(&actions); + }); + } + + /// Shares the installs channel with [`Self::install_package`] so every row's + /// button reads as busy while one is being acted on + /// + /// Recorded as a bundle override when a bundle owns the artifact otherwise + /// the next sync puts the version straight back + pub fn remove_package_version( + &self, + cluster_id: ClusterId, + provider: ProviderId, + project_id: impl Into, + hash: impl Into, + display_name: impl Into, + ) { + let (project_id, hash, display_name) = + (project_id.into(), hash.into(), display_name.into()); + let actions = self.clone(); + + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .begin(cluster_id, provider, project_id.clone()); + + spawn_forever(async move { + let finish = |actions: &Actions| { + actions + .station + .clone() + .write_channel(AppChannel::Installs) + .installs + .finish(cluster_id, provider, &project_id); + }; + + let Ok(state) = launcher::state() else { + finish(&actions); + return; + }; + + let events = state.services.events.clone(); + match oneclient_core::remove_artifact_from_cluster( + cluster_id, + &hash, + true, + &state.services.content(), + ) + .await + { + Ok(()) => { + events.notify("Removed").body(display_name).send(); + + // The row's next state is read off the cluster's content + // so refresh that before the busy flag drops + super::invalidate_cluster_content_queries().await; + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Remove failed") + .body(err.to_string()) + .error() + .send(), + } + + finish(&actions); + }); + } + + pub fn install_bundle( + &self, + cluster_id: ClusterId, + bundle_name: impl Into, + skip_compatibility: bool, + ) { + let bundle_name = bundle_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_core::install_bundle( + cluster_id, + &bundle_name, + skip_compatibility, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(_) => { + events + .notify("Installed") + .body(format!("Added {bundle_name}")) + .send(); + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Install failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn apply_bundle_updates(&self, cluster_id: ClusterId) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let result = match oneclient_core::apply_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(result) => result, + Err(err) => { + state + .services + .events + .notify("Update failed") + .body(err.to_string()) + .error() + .send(); + return; + } + }; + + super::invalidate_cluster_queries().await; + if let Some(spec) = + crate::install::cluster_update_notification(cluster_id, &result, &state.services) + .await + { + actions.push_notification(spec); + } + }); + } + + /// `syncing_bundles` gates every launch button so the readiness check must + /// happen before the flag is raised not inside the task + pub fn sync_bundles(&self) { + let state = match launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::error!("bundle sync skipped, launcher not ready: {err:#}"); + return; + } + }; + + let actions = self.clone(); + self.station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = true; + + spawn_forever(async move { + if let Err(err) = state.bundles.sync(&state.services.content()).await { + tracing::error!("bundle catalog sync failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { + tracing::error!("cluster migrations failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await { + tracing::error!("bundle cluster provisioning failed: {err:#}"); + } + + // One grouped session for the batch so all downloads surface as a + // single notification `detach` lets it be converted in place + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + "Updating mods", + ); + let changed = oneclient_content::bundles::sync_all_cluster_bundles( + state.bundles.as_ref(), + &state.services.content(), + Some(&session), + ) + .await; + let session_id = session.detach(); + + actions + .station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = false; + super::invalidate_cluster_queries().await; + + let spec = + crate::install::combined_cluster_update_spec(&changed, &state.services).await; + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, spec); + }); + }); + } + + /// The check runs and the cache is written whatever the mode says keeping + /// the package manager's "Update available" markers honest + /// + /// Returns once the launch may proceed nothing is still downloading then + /// Never fails the launch every error logs and lets the game start + async fn resolve_package_updates_before_launch( + &self, + state: &Arc, + cluster_id: ClusterId, + ) { + let content = state.services.content(); + + // Bounded a hanging network would otherwise hold the launch for + // reqwest's much longer timeouts + let check = match tokio::time::timeout( + UPDATE_CHECK_BUDGET, + oneclient_core::refresh_browser_package_updates(cluster_id, &content), + ) + .await + { + Ok(Ok(check)) => check, + Ok(Err(err)) => { + tracing::warn!( + cluster_id, + error = %err, + "browser package update check failed, launching anyway" + ); + return; + } + Err(_elapsed) => { + tracing::debug!( + cluster_id, + "browser package update check exceeded its launch budget" + ); + return; + } + }; + + let pending = check.pending(); + let mode = self.cluster_update_mode(state, cluster_id).await; + + match plan_launch_updates(mode, pending) { + LaunchUpdatePlan::Nothing => {} + LaunchUpdatePlan::Apply(updates) => { + self.apply_updates_for_launch(state, &updates).await; + } + LaunchUpdatePlan::Prompt(updates) => { + let Some(group) = + crate::install::package_update_group(cluster_id, &updates, &state.services) + .await + else { + return; + }; + self.prompt_package_updates(group).await; + } + } + } + + /// Anything unreadable falls back to the default rather than blocking the + /// launch on a settings lookup + async fn cluster_update_mode( + &self, + state: &Arc, + cluster_id: ClusterId, + ) -> PackageUpdateMode { + let global = state.settings.read().global_game_settings.clone(); + + let Ok(cluster) = state.clusters.get(cluster_id).await else { + return PackageUpdateMode::default(); + }; + + state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok() + .and_then(|profile| profile.browser_update_mode) + .unwrap_or_default() + } + + /// One grouped progress notification so the pre-launch pause shows something + async fn apply_updates_for_launch( + &self, + state: &Arc, + updates: &[oneclient_core::BrowserPackageUpdate], + ) { + let content = state.services.content(); + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + "Updating packages", + ); + session.expect( + oneclient_events::TaskCategory::Packages, + updates.len() as u64, + updates.len() as u64, + ); + + let mut applied = 0usize; + for update in updates { + let child = session.child( + update.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + match oneclient_core::apply_browser_package_update(update, Some(&child), &content).await + { + Ok(_) => applied += 1, + // Not a reason to hold the game back the cache row survives + // so the next launch offers it again + Err(err) => tracing::warn!( + package = %update.display_name, + error = %err, + "automatic package update failed" + ), + } + child.finish(); + } + + let session_id = session.detach(); + let spec = (applied > 0).then(|| NotificationSpec { + title: "Packages updated".to_string(), + body: format!( + "{applied} package{} updated before launch", + if applied == 1 { "" } else { "s" } + ), + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: Vec::new(), + }); + + self.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, spec); + }); + + if applied > 0 { + super::invalidate_cluster_queries().await; + } + } + + async fn prompt_package_updates(&self, group: PackageUpdateGroup) { + let (done, wait) = tokio::sync::oneshot::channel(); + + self.with_engine(move |state| { + state + .notifications + .open_package_updates(vec![group], Some(done)); + state.center_open = false; + }); + + // A replaced or torn-down modal is equally a reason to stop waiting + let _ = wait.await; + } + + pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + let session = oneclient_events::GroupedProgressSession::start( + &events, + format!("Updating {}", update.display_name), + ); + let child = session.child( + update.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + + let result = oneclient_core::apply_browser_package_update( + &update, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + let session_id = session.detach(); + + let spec = match &result { + Ok(_) => NotificationSpec { + title: "Updated".to_string(), + body: format!( + "{} is now on {}", + update.display_name, update.latest_version_name + ), + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: Vec::new(), + }, + Err(err) => NotificationSpec { + title: "Update failed".to_string(), + body: err.to_string(), + level: Level::Error, + icon: None, + progress: None, + actions: Vec::new(), + }, + }; + + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); + }); + + // A failed update stays in the list so the user can retry + if result.is_ok() { + actions.with_engine(|app| { + app.notifications + .resolve_package_update(update.cluster_id, &update.hash); + }); + super::invalidate_cluster_queries().await; + } + }); + } + + /// The package stays marked out of date only the modal stops asking and + /// only for this version + pub fn skip_package_update(&self, cluster_id: ClusterId, hash: impl Into) { + let hash = hash.into(); + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = oneclient_core::skip_browser_package_update( + cluster_id, + &hash, + &state.services.content(), + ) + .await + { + tracing::warn!(error = %err, "failed to record a skipped package update"); + } + + actions.with_engine(|app| { + app.notifications.resolve_package_update(cluster_id, &hash); + }); + super::invalidate_cluster_queries().await; + }); + } +} + +/// Failures are reported as game events so the caller only has to know the +/// attempt is over Everything before `launch_cluster` is pre-launch work +async fn launch(actions: &Actions, cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + events.game_stage(cluster_id, oneclient_events::LaunchStage::Checking); + + let account = match state.auth.default_account_for_launch().await { + Ok(account) => account, + Err(err) => { + events.game_failed(cluster_id, format!("{err:#}")); + return; + } + }; + + let Some(account) = account else { + events.game_failed( + cluster_id, + "Add a Minecraft account before launching.".to_string(), + ); + return; + }; + + // Before the game process never after Minecraft reads its mods once at + // startup + actions + .resolve_package_updates_before_launch(&state, cluster_id) + .await; + + if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await { + // A missing file is the one failure the launcher can fix itself and a + // path inside our metadata folder gives the user nothing to act on + if err.indicates_missing_files() { + repair_and_relaunch(&state, cluster_id, &account, err).await; + return; + } + + events.game_failed(cluster_id, format!("{err:#}")); + } +} + +/// Only ever one retry if the game still will not start after a full rehash and +/// repair the problem is not missing files +async fn repair_and_relaunch( + state: &std::sync::Arc, + cluster_id: ClusterId, + account: &oneclient_auth::MinecraftAccount, + original: oneclient_core::LauncherError, +) { + let events = state.services.events.clone(); + + tracing::warn!( + cluster_id, + "launch failed on a missing file; repairing: {original:#}" + ); + events + .notify("Repairing installation") + .body("Some game files are missing. Checking and re-downloading them.") + .send(); + + let report = match oneclient_core::verify_cluster_files(state, cluster_id).await { + Ok(report) => report, + Err(repair_err) => { + tracing::error!(cluster_id, "repair failed: {repair_err:#}"); + // Lead with the original failure the repair failure explains why + // it was not fixed + events.game_failed( + cluster_id, + format!("{original:#} (repair also failed: {repair_err:#})"), + ); + return; + } + }; + + events + .notify("Repair complete") + .body(report.summary()) + .send(); + + if let Err(err) = oneclient_core::launch_cluster(state, cluster_id, account, true).await { + tracing::error!(cluster_id, "launch failed again after repair: {err:#}"); + events.game_failed(cluster_id, format!("{err:#}")); + } +} + +#[must_use = "the notification is not raised until `.send()` is called"] +pub struct NotificationBuilder { + actions: Actions, + spec: NotificationSpec, +} + +impl NotificationBuilder { + pub fn body(mut self, body: impl Into) -> Self { + self.spec.body = body.into(); + self + } + + pub fn level(mut self, level: Level) -> Self { + self.spec.level = level; + self + } + + pub fn info(self) -> Self { + self.level(Level::Info) + } + + pub fn error(self) -> Self { + self.level(Level::Error) + } + + pub fn icon(mut self, icon: IconType) -> Self { + self.spec.icon = Some(icon); + self + } + + pub fn progress(mut self, current: u64, total: u64) -> Self { + self.spec.progress = Some((current, total)); + self + } + + pub fn action(mut self, action: NotificationAction) -> Self { + self.spec.actions.push(action); + self + } + + pub fn actions(mut self, actions: impl IntoIterator) -> Self { + self.spec.actions = actions.into_iter().collect(); + self + } + + pub fn send(self) { + self.actions.push_notification(self.spec); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ProviderId; + + fn update(hash: &str) -> oneclient_core::BrowserPackageUpdate { + oneclient_core::BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped: false, + } + } + + #[test] + fn skip_mode_launches_without_asking_or_installing() { + assert_eq!( + plan_launch_updates(PackageUpdateMode::Skip, vec![update("a")]), + LaunchUpdatePlan::Nothing, + ); + } + + #[test] + fn automatic_mode_installs_without_asking() { + assert_eq!( + plan_launch_updates(PackageUpdateMode::Automatic, vec![update("a")]), + LaunchUpdatePlan::Apply(vec![update("a")]), + ); + } + + #[test] + fn prompt_mode_is_the_default_and_asks() { + assert_eq!( + plan_launch_updates(PackageUpdateMode::default(), vec![update("a")]), + LaunchUpdatePlan::Prompt(vec![update("a")]), + ); + } + + #[test] + fn nothing_pending_never_opens_a_modal() { + for mode in PackageUpdateMode::ALL.iter().copied() { + assert_eq!( + plan_launch_updates(mode, Vec::new()), + LaunchUpdatePlan::Nothing, + "{mode:?} must not hold a launch up over an empty list", + ); + } + } +} diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs new file mode 100644 index 00000000..54627eb8 --- /dev/null +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -0,0 +1,109 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_content::packages::ProviderId; +use oneclient_db::models::ClusterId; + +#[derive(Clone)] +pub struct ActiveClusterState(pub State>); + +pub fn use_provide_active_cluster(active: ActiveClusterState) { + use_provide_root_context(move || active.clone()); +} + +pub fn use_active_cluster_id() -> State> { + consume_root_context::().0 +} + +/// Startup curtain stays up past the Startup route until home content settles +/// so the app never reveals a half-populated home +#[derive(Clone, Copy)] +pub struct SplashState { + pub active: State, + pub home_ready: State, +} + +pub fn use_provide_splash(state: SplashState) { + use_provide_root_context(move || state); +} + +pub fn use_splash() -> SplashState { + consume_root_context::() +} + +#[derive(Clone)] +pub struct BrowserCompatState(pub State); + +pub fn use_provide_browser_compat(state: BrowserCompatState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_browser_compat() -> State { + consume_root_context::().0 +} + +#[derive(Clone)] +pub struct LinkConfirmState(pub State>); + +pub fn use_provide_link_confirm(state: LinkConfirmState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_link_confirm() -> State> { + consume_root_context::().0 +} + +#[derive(Clone)] +pub struct BrowserUiState { + pub query: String, + pub provider: ProviderId, + pub categories: Vec, + pub page: usize, +} + +impl Default for BrowserUiState { + fn default() -> Self { + Self { + query: String::new(), + provider: ProviderId::Modrinth, + categories: Vec::new(), + page: 0, + } + } +} + +#[derive(Clone)] +pub struct BrowserStateStore(pub State>); + +pub fn use_provide_browser_state(store: BrowserStateStore) { + use_provide_root_context(move || store.clone()); +} + +pub fn use_browser_state_store() -> State> { + consume_root_context::().0 +} + +#[derive(Clone)] +pub struct OnboardingSelectionState { + pub selected: State>, + /// Until set the shell keeps re-deriving `selected` from the catalog so a + /// late-settling bundle list still gets the right defaults + pub user_touched: State, + /// `None` means "no migration decision yet" + pub migrated_categories: State>>, + pub language: State, + pub reduce_motion: State, + pub predownload: State, + pub setup_started: State, + /// `None` = don't import + pub import_folder: State>, + pub import_dedicated: State, +} + +pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_onboarding_selection() -> OnboardingSelectionState { + consume_root_context::() +} diff --git a/packages/oneclient_app/src/hooks/debounce.rs b/packages/oneclient_app/src/hooks/debounce.rs new file mode 100644 index 00000000..b0c71857 --- /dev/null +++ b/packages/oneclient_app/src/hooks/debounce.rs @@ -0,0 +1,28 @@ +use std::time::Duration; + +use freya::prelude::*; + +/// Trailing-edge debounce updates only after `delay` with no further change +pub fn use_debounced(value: T, delay: Duration) -> State +where + T: Clone + PartialEq + 'static, +{ + let debounced = use_state(|| value.clone()); + // Task commits only if its generation is still latest dropping superseded changes + let mut generation = use_state(|| 0u64); + + use_side_effect_with_deps(&value, move |value| { + let value = value.clone(); + let this_gen = *generation.peek() + 1; + generation.set(this_gen); + let mut debounced = debounced; + spawn(async move { + tokio::time::sleep(delay).await; + if *generation.peek() == this_gen { + debounced.set(value); + } + }); + }); + + debounced +} diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs new file mode 100644 index 00000000..efadde52 --- /dev/null +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -0,0 +1,116 @@ +mod actions; +mod active_cluster; +mod debounce; +mod queries; +mod view_state; + +pub use debounce::use_debounced; +pub use view_state::{PersistedView, use_view_state}; + +pub use active_cluster::{ + ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState, + OnboardingSelectionState, SplashState, use_active_cluster_id, use_browser_compat, + use_browser_state_store, use_link_confirm, use_onboarding_selection, + use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, + use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash, use_splash, +}; + +pub use actions::{Actions, NotificationBuilder, PumpSignal}; +pub use queries::{ + AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, + CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, + ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, + OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, + SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, + UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount, + UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, 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_content_queries, + invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries, + invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries, + java_runtimes, latest_changelog_version, loaded_image, loader_versions, + login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, + mutation_is_running, onboarding_bundles_items, package_meta_batch, package_updates, + pick_version_metadata, project_detail, provider_versions, query_error, query_is_busy, + query_is_loading, reset_login_code_dedup, search_items, search_pending, search_total, + settled_or_loading, stale_hashes, 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, + try_storage_report, 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, 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_updates, use_package_versions, use_package_versions_when, + use_player_profile, use_player_skin, use_provider_versions, use_refresh_account, + use_refresh_all_accounts, use_remove_account, use_screenshot_action, + use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report, + use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, + versions_total, +}; + +use crate::notifications::NotificationSnapshot; +use crate::state::{ + AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState, +}; +use freya::prelude::*; +use freya::radio::use_radio; + +pub fn use_provide_actions(actions: &Actions) { + let actions = actions.clone(); + use_provide_root_context(move || actions.clone()); +} + +pub fn use_dispatch() -> Actions { + consume_root_context::() +} + +/// Wakes its component only when that channel is written so a toast tick does +/// not re-render a component reading only `data_dir` +pub fn use_launcher() -> LauncherInit { + use_radio(AppChannel::Launcher).read().launcher.clone() +} + +pub fn use_settings_snapshot() -> SettingsState { + use_radio(AppChannel::Settings).read().settings.clone() +} + +/// Built on read not published on write cloning the inbox on every one of a +/// download's tens of thousands of engine changes was the channel's main cost +pub fn use_notifications_snapshot() -> NotificationSnapshot { + let radio = use_radio(AppChannel::Notifications); + let state = radio.read(); + state.notifications.snapshot( + &state.inbox, + state.center_open, + crate::events::prompt_view(&state), + ) +} + +pub fn use_account_switcher_open() -> bool { + use_radio(AppChannel::AccountSwitcher) + .read() + .account_switcher_open +} + +pub fn use_game_snapshot() -> GameState { + use_radio(AppChannel::Game).read().game.clone() +} + +pub fn use_installs_snapshot() -> InstallState { + use_radio(AppChannel::Installs).read().installs.clone() +} + +pub fn use_microsoft_login_status() -> Option { + use_radio(AppChannel::MicrosoftLogin) + .read() + .microsoft_login + .clone() +} + +pub(crate) use queries::invalidate_auth_queries; diff --git a/packages/oneclient_app/src/hooks/queries/analytics.rs b/packages/oneclient_app/src/hooks/queries/analytics.rs new file mode 100644 index 00000000..cd80fd77 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/analytics.rs @@ -0,0 +1,58 @@ +use freya::query::{Query, QueryCapability, 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; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlobalAnalyticsQuery; + +impl QueryCapability for GlobalAnalyticsQuery { + type Ok = Analytics; + type Err = LauncherError; + type Keys = GlobalAnalyticsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + global_analytics(&state.services.db).await + } +} + +pub fn use_global_analytics() -> UseQuery { + use_query(Query::new(GlobalAnalyticsKeys, GlobalAnalyticsQuery)) +} + +pub fn try_global_analytics(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterAnalyticsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterAnalyticsQuery; + +impl QueryCapability for ClusterAnalyticsQuery { + type Ok = Analytics; + type Err = LauncherError; + type Keys = ClusterAnalyticsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + cluster_analytics(&state.services.db, keys.cluster_id).await + } +} + +pub fn use_cluster_analytics(cluster_id: i64) -> UseQuery { + use_query(Query::new( + ClusterAnalyticsKeys { cluster_id }, + ClusterAnalyticsQuery, + )) +} + +pub fn try_cluster_analytics(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs new file mode 100644 index 00000000..4380b280 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -0,0 +1,409 @@ +use std::sync::Mutex; + +use freya::query::{ + Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, + UseMutation, UseQuery, use_mutation, use_query, +}; +use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount}; +use oneclient_core::LauncherError; +use uuid::Uuid; + +static HANDLED_LOGIN_CODE: Mutex> = Mutex::new(None); + +pub fn login_code_already_handled(user_code: &str) -> bool { + let mut handled = HANDLED_LOGIN_CODE.lock().unwrap(); + if handled.as_deref() == Some(user_code) { + return true; + } + *handled = Some(user_code.to_string()); + false +} + +pub fn reset_login_code_dedup() { + *HANDLED_LOGIN_CODE.lock().unwrap() = None; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListAccountsKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DefaultAccountKeys { + pub fallback: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AccountKeys { + pub id: Uuid, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListAccountsQuery; + +impl QueryCapability for ListAccountsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ListAccountsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()?.auth.list_accounts().await) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DefaultAccountQuery; + +impl QueryCapability for DefaultAccountQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = DefaultAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let account = state.auth.default_account().await?; + if account.is_some() || !keys.fallback { + return Ok(account); + } + + let accounts = state.auth.list_accounts().await; + Ok(accounts.into_iter().next()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AccountQuery; + +impl QueryCapability for AccountQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = AccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()?.auth.get_account(keys.id).await) + } +} + +pub fn use_accounts() -> UseQuery { + use_query(Query::new(ListAccountsKeys, ListAccountsQuery)) +} + +pub fn use_default_account(fallback: bool) -> UseQuery { + use_query(Query::new( + DefaultAccountKeys { fallback }, + DefaultAccountQuery, + )) +} + +pub fn use_current_account() -> UseQuery { + use_default_account(true) +} + +pub fn use_account(id: Uuid) -> UseQuery { + use_query(Query::new(AccountKeys { id }, AccountQuery)) +} + +pub fn try_accounts(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn try_default_account(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub fn try_account(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub(crate) async fn invalidate_auth_queries(account_id: Option) { + QueriesStorage::::invalidate_matching(ListAccountsKeys).await; + for fallback in [false, true] { + QueriesStorage::::invalidate_matching(DefaultAccountKeys { fallback }) + .await; + } + if let Some(id) = account_id { + QueriesStorage::::invalidate_matching(AccountKeys { id }).await; + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BeginMicrosoftLoginMutation; + +impl MutationCapability for BeginMicrosoftLoginMutation { + type Ok = MicrosoftLoginSession; + type Err = LauncherError; + type Keys = (); + + async fn run(&self, _keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .begin_microsoft_login() + .await?) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FinishMicrosoftLoginMutation; + +impl MutationCapability for FinishMicrosoftLoginMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = MicrosoftLoginSession; + + async fn run(&self, flow: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .finish_microsoft_login(flow.clone()) + .await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if let Ok(account) = result { + invalidate_auth_queries(Some(account.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CancelMicrosoftLoginMutation; + +/// Keyed by the session's CSRF state token +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CancelMicrosoftLoginKeys { + pub state_token: String, +} + +impl MutationCapability for CancelMicrosoftLoginMutation { + type Ok = (); + type Err = LauncherError; + type Keys = CancelMicrosoftLoginKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + crate::launcher::state()? + .auth + .cancel_microsoft_login(&keys.state_token) + .await; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AddMicrosoftAccountMutation; + +impl MutationCapability for AddMicrosoftAccountMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = (); + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let flow = state.auth.begin_microsoft_login().await?; + Ok(state.auth.finish_microsoft_login(flow).await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if let Ok(account) = result { + invalidate_auth_queries(Some(account.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AddOfflineAccountMutation; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct AddOfflineAccountKeys { + pub username: String, +} + +impl MutationCapability for AddOfflineAccountMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = AddOfflineAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .add_offline_account(keys.username.clone()) + .await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if let Ok(account) = result { + invalidate_auth_queries(Some(account.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RemoveAccountMutation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RemoveAccountKeys { + pub id: Uuid, +} + +impl MutationCapability for RemoveAccountMutation { + type Ok = (); + type Err = LauncherError; + type Keys = RemoveAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .remove_account(keys.id) + .await?) + } + + async fn on_settled(&self, keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(Some(keys.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SetDefaultAccountMutation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SetDefaultAccountKeys { + pub id: Option, +} + +impl MutationCapability for SetDefaultAccountMutation { + type Ok = (); + type Err = LauncherError; + type Keys = SetDefaultAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .set_default_account(keys.id) + .await?) + } + + async fn on_settled(&self, keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(keys.id).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RefreshAccountMutation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RefreshAccountKeys { + pub id: Uuid, +} + +impl MutationCapability for RefreshAccountMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = RefreshAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .refresh_account(keys.id) + .await?) + } + + async fn on_settled(&self, keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(Some(keys.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RefreshAllAccountsMutation; + +impl MutationCapability for RefreshAllAccountsMutation { + type Ok = Vec; + type Err = LauncherError; + type Keys = (); + + async fn run(&self, _keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .refresh_all_accounts() + .await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(None).await; + } + } +} + +pub type UseSetDefaultAccount = UseMutation; +pub type UseRemoveAccount = UseMutation; +pub type UseRefreshAccount = UseMutation; + +pub fn use_begin_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(BeginMicrosoftLoginMutation)) +} + +pub fn use_finish_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(FinishMicrosoftLoginMutation)) +} + +pub fn use_cancel_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(CancelMicrosoftLoginMutation)) +} + +pub fn use_add_microsoft_account() -> UseMutation { + use_mutation(Mutation::new(AddMicrosoftAccountMutation)) +} + +pub fn use_add_offline_account() -> UseMutation { + use_mutation(Mutation::new(AddOfflineAccountMutation)) +} + +pub fn use_remove_account() -> UseMutation { + use_mutation(Mutation::new(RemoveAccountMutation)) +} + +pub fn use_set_default_account() -> UseMutation { + use_mutation(Mutation::new(SetDefaultAccountMutation)) +} + +pub fn use_refresh_account() -> UseMutation { + use_mutation(Mutation::new(RefreshAccountMutation)) +} + +pub fn use_refresh_all_accounts() -> UseMutation { + use_mutation(Mutation::new(RefreshAllAccountsMutation)) +} + +/// True before it has ever been run as well as while running For button +/// disabled state use [`mutation_is_running`] instead +pub fn mutation_is_pending(mutation: &UseMutation) -> bool { + matches!( + &*mutation.read().state(), + MutationStateData::Pending | MutationStateData::Loading { .. } + ) +} + +pub fn mutation_is_running(mutation: &UseMutation) -> bool { + mutation.read().state().is_loading() +} + +pub fn mutation_error(mutation: &UseMutation) -> Option +where + M: MutationCapability, + M::Err: Clone, +{ + let reader = mutation.read(); + match &*reader.state() { + MutationStateData::Settled { res: Err(err), .. } => Some(err.clone()), + MutationStateData::Loading { + res: Some(Err(err)), + } => Some(err.clone()), + _ => None, + } +} + +pub fn accounts_have_microsoft(accounts: &[MinecraftAccount]) -> bool { + accounts.iter().any(|a| a.kind == AccountKind::Microsoft) +} diff --git a/packages/oneclient_app/src/hooks/queries/bundles.rs b/packages/oneclient_app/src/hooks/queries/bundles.rs new file mode 100644 index 00000000..7e22aa88 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/bundles.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::clusters::Cluster; +use oneclient_core::{ + BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError, + get_bundles_with_update_status, list_cluster_bundle_overrides, +}; +use oneclient_db::models::ClusterId; + +#[derive(Clone, Debug)] +pub struct ClusterBundles { + pub cluster: Cluster, + pub archives: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct OnboardingBundlesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct OnboardingBundlesKeys; + +impl QueryCapability for OnboardingBundlesQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = OnboardingBundlesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let clusters = state.clusters.list().await?; + + let mut out = Vec::with_capacity(clusters.len()); + for cluster in clusters { + let archives = state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await + .unwrap_or_default(); + out.push(ClusterBundles { cluster, archives }); + } + Ok(out) + } +} + +pub fn use_onboarding_bundles() -> UseQuery { + use_query(Query::new(OnboardingBundlesKeys, OnboardingBundlesQuery)) +} + +pub fn onboarding_bundles_items( + query: &UseQuery, +) -> Option> { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundlesWithStatusQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundlesWithStatusKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for BundlesWithStatusQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = BundlesWithStatusKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let _ = keys; + let state = crate::launcher::state()?; + Ok(get_bundles_with_update_status( + self.cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await?) + } +} + +pub fn bundles_with_status_items( + query: &UseQuery, +) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} + +pub fn use_bundles_with_status(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + BundlesWithStatusKeys { cluster_id }, + BundlesWithStatusQuery { cluster_id }, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleOverridesQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleOverridesKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for BundleOverridesQuery { + type Ok = HashMap<(String, String), String>; + type Err = LauncherError; + type Keys = BundleOverridesKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let _ = keys; + let state = crate::launcher::state()?; + let rows = + list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?; + Ok(rows + .into_iter() + .map(|(bundle, pkg, ty)| ((bundle, pkg), ty)) + .collect()) + } +} + +pub fn use_bundle_overrides(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + BundleOverridesKeys { cluster_id }, + BundleOverridesQuery { cluster_id }, + )) +} + +pub fn bundle_overrides_map( + query: &UseQuery, +) -> HashMap<(String, String), String> { + super::state::settled_or_loading(query).unwrap_or_default() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleUpdatesQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleUpdatesKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for BundleUpdatesQuery { + type Ok = BundleUpdateCheckResult; + type Err = LauncherError; + type Keys = BundleUpdatesKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let _ = keys; + let state = crate::launcher::state()?; + Ok(oneclient_core::check_bundle_updates( + self.cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await?) + } +} + +pub fn use_bundle_updates(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + BundleUpdatesKeys { cluster_id }, + BundleUpdatesQuery { cluster_id }, + )) +} diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs new file mode 100644 index 00000000..3f4d0eab --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -0,0 +1,44 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{ChangelogGroup, LauncherError, fetch_changelog, parse_changelog}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ChangelogKeys { + pub meta_url_base: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ChangelogQuery; + +impl QueryCapability for ChangelogQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ChangelogKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let markdown = fetch_changelog(&state.services.requester).await?; + Ok(parse_changelog(&markdown)) + } +} + +pub fn use_changelog() -> UseQuery { + let meta_url_base = super::use_meta_url_key(); + + use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) +} + +pub fn changelog_groups(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn latest_changelog_version(query: &UseQuery) -> Option { + changelog_groups(query).and_then(|groups| groups.first().map(|group| group.version.clone())) +} + +pub fn changelog_error(query: &UseQuery) -> Option { + super::state::query_error(query) +} + +pub fn changelog_is_loading(query: &UseQuery) -> bool { + super::state::query_is_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs new file mode 100644 index 00000000..196d57f5 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -0,0 +1,52 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_core::{LauncherError, LinkedArtifactInfo}; +use oneclient_db::models::ClusterId; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterContentQuery { + pub cluster_id: ClusterId, + pub content_type: ContentType, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterContentKeys { + pub cluster_id: ClusterId, + pub content_type: ContentType, +} + +impl QueryCapability for ClusterContentQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterContentKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let all = + PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?; + Ok(all + .into_iter() + .filter(|item| item.content_type == self.content_type) + .collect()) + } +} + +pub fn use_cluster_content( + cluster_id: ClusterId, + content_type: ContentType, +) -> UseQuery { + use_query(Query::new( + ClusterContentKeys { + cluster_id, + content_type, + }, + ClusterContentQuery { + cluster_id, + content_type, + }, + )) +} + +pub fn cluster_content_items(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} diff --git a/packages/oneclient_app/src/hooks/queries/clusters.rs b/packages/oneclient_app/src/hooks/queries/clusters.rs new file mode 100644 index 00000000..a8bc5bea --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/clusters.rs @@ -0,0 +1,31 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; +use oneclient_core::clusters::Cluster; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListClustersQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListClustersKeys; + +impl QueryCapability for ListClustersQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ListClustersKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(state.clusters.list().await?) + } +} + +pub fn use_clusters() -> UseQuery { + use_query(Query::new(ListClustersKeys, ListClustersQuery)) +} + +/// A lookup not a fetch there is no per-cluster query the list is always +/// loaded and small A hook because it subscribes to the list +pub fn use_cluster(cluster_id: i64) -> Option { + let clusters = super::state::settled_or_loading(&use_clusters()).unwrap_or_default(); + clusters.into_iter().find(|c| c.id == cluster_id) +} diff --git a/packages/oneclient_app/src/hooks/queries/image.rs b/packages/oneclient_app/src/hooks/queries/image.rs new file mode 100644 index 00000000..e35c94e2 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/image.rs @@ -0,0 +1,58 @@ +use std::time::Duration; + +use bytes::Bytes; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; + +const IMAGE_STALE: Duration = Duration::from_secs(60 * 60); +const IMAGE_CLEAN: Duration = Duration::from_secs(6 * 60 * 60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CachedImageQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CachedImageKeys { + pub url: String, + pub max_edge: u32, +} + +impl QueryCapability for CachedImageQuery { + type Ok = Bytes; + type Err = LauncherError; + type Keys = CachedImageKeys; + + #[tracing::instrument(name = "cached_image", level = "debug", skip(self, keys), fields(url = %keys.url, max_edge = keys.max_edge))] + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.url.is_empty() { + return Err(LauncherError::Minecraft("no image url".to_string())); + } + + let state = crate::launcher::state()?; + state + .images + .get(&state.services.requester, &keys.url, keys.max_edge) + .await + } +} + +/// Returns the url alongside the bytes callers key their image handle on it +/// and a stale url with fresh bytes renders the wrong picture for a frame +pub fn loaded_image( + url: Option<&str>, + query: &UseQuery, +) -> Option<(String, Bytes)> { + let url = url?; + let bytes = super::state::settled_or_loading(query)?; + Some((url.to_string(), bytes)) +} + +pub fn use_cached_image(url: Option, max_edge: u32) -> UseQuery { + let url = url.unwrap_or_default(); + let keys = (!url.is_empty()).then_some(CachedImageKeys { url, max_edge }); + + use_query( + Query::new(keys, CachedImageQuery) + .stale_time(IMAGE_STALE) + .clean_time(IMAGE_CLEAN), + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs new file mode 100644 index 00000000..b65ec305 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/java.rs @@ -0,0 +1,68 @@ +use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query}; +use oneclient_java::{AvailableJava, JavaRuntime, JavaVendor}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListJavaRuntimesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListJavaRuntimesKeys; + +impl QueryCapability for ListJavaRuntimesQuery { + type Ok = Vec; + type Err = String; + type Keys = ListJavaRuntimesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + state.java.list_runtimes().await.map_err(|e| e.to_string()) + } +} + +pub fn use_java_runtimes() -> UseQuery { + use_query(Query::new(ListJavaRuntimesKeys, ListJavaRuntimesQuery)) +} + +pub fn java_runtimes(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} + +pub async fn invalidate_java_queries() { + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ProviderVersionsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ProviderVersionsKeys { + pub vendor: JavaVendor, +} + +impl QueryCapability for ProviderVersionsQuery { + type Ok = Vec; + type Err = String; + type Keys = ProviderVersionsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + state + .java + .available_versions(&keys.vendor) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_provider_versions(vendor: JavaVendor) -> UseQuery { + use_query(Query::new( + ProviderVersionsKeys { vendor }, + ProviderVersionsQuery, + )) +} + +pub fn provider_versions(query: &UseQuery) -> (Vec, bool) { + ( + super::state::settled_or_loading(query).unwrap_or_default(), + super::state::query_is_busy(query), + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs new file mode 100644 index 00000000..c1718ffe --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/logs.rs @@ -0,0 +1,152 @@ +use std::path::PathBuf; + +use freya::query::{ + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, +}; +use oneclient_core::{ + LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterLogsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterLogsQuery; + +impl QueryCapability for ClusterLogsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterLogsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + Ok(oneclient_core::list_cluster_logs(&cluster)?) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LogContentKeys { + pub path: PathBuf, + pub level: Option, + pub search: Option, + pub max_lines: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LogContentQuery; + +impl QueryCapability for LogContentQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = LogContentKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.path.as_os_str().is_empty() { + return Ok(Vec::new()); + } + Ok(oneclient_core::read_log_at( + &keys.path, + &ReadOptions { + level_filter: keys.level, + search: keys.search.clone(), + max_lines: keys.max_lines, + }, + ) + .await?) + } +} + +pub fn use_cluster_logs(cluster_id: i64) -> UseQuery { + use_query(Query::new(ClusterLogsKeys { cluster_id }, ClusterLogsQuery)) +} + +pub fn use_log_content( + path: PathBuf, + level: Option, + search: Option, + max_lines: Option, +) -> UseQuery { + use_query(Query::new( + LogContentKeys { + path, + level, + search, + max_lines, + }, + LogContentQuery, + )) +} + +pub fn try_cluster_logs(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn try_log_content(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub async fn invalidate_logs_queries() { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct UploadLogKeys { + pub path: PathBuf, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct UploadLogMutation; + +impl MutationCapability for UploadLogMutation { + type Ok = MclogsUploadResponse; + type Err = LauncherError; + type Keys = UploadLogKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(oneclient_core::upload_log_at(&state.services.requester, &keys.path).await?) + } +} + +pub type UseUploadLog = UseMutation; + +pub fn use_upload_log() -> UseUploadLog { + use_mutation(Mutation::new(UploadLogMutation)) +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum LogAction { + Delete { path: PathBuf }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LogActionMutation; + +impl MutationCapability for LogActionMutation { + type Ok = (); + type Err = LauncherError; + type Keys = LogAction; + + async fn run(&self, keys: &Self::Keys) -> Result { + match keys { + LogAction::Delete { path } => Ok(oneclient_core::delete_log_at(path).await?), + } + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_logs_queries().await; + } + } +} + +pub type UseLogAction = UseMutation; + +pub fn use_log_action() -> UseLogAction { + use_mutation(Mutation::new(LogActionMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/migration.rs b/packages/oneclient_app/src/hooks/queries/migration.rs new file mode 100644 index 00000000..4a9d862c --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -0,0 +1,52 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{ + LauncherError, MigrationDetection, detect_migration, resolve_migration_chain, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigrationQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigrationKeys; + +impl QueryCapability for MigrationQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = MigrationKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let Some(mut detection) = detect_migration().await? else { + return Ok(None); + }; + + // Runs before launcher migrations walks the source instance up the + // migration chain so it can be imported into the correct cluster + let rules = crate::launcher::state()?.versions.migrations().await; + if !rules.is_empty() { + for instance in &mut detection.instances { + if instance.mc_version.is_empty() { + continue; + } + let resolved = + resolve_migration_chain(&instance.mc_version, instance.mc_loader, &rules); + if resolved != instance.mc_version { + instance.target_mc_version = Some(resolved); + } + } + } + + Ok(Some(detection)) + } +} + +pub fn use_migration() -> UseQuery { + use_query(Query::new(MigrationKeys, MigrationQuery)) +} + +pub fn migration_detection(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub fn has_migration_data(query: &UseQuery) -> bool { + migration_detection(query).is_some_and(|d| !d.instances.is_empty()) +} diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs new file mode 100644 index 00000000..85dd4317 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -0,0 +1,101 @@ +use crate::hooks::use_settings_snapshot; + +fn use_meta_url_key() -> String { + use_settings_snapshot() + .settings + .custom_meta_url_base + .unwrap_or_default() + .trim() + .to_string() +} + +mod analytics; +mod auth; +mod bundles; +mod changelog; +mod cluster_content; +mod clusters; +mod image; +mod java; +mod logs; +mod migration; +mod mutations; +mod package_updates; +mod packages; +mod player_profile; +mod screenshots; +mod settings_profiles; +mod skin; +mod state; +mod storage; +mod tos; +mod version_metadata; +mod versions; + +pub use analytics::{ + try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics, +}; +pub use auth::{ + AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys, + CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, + RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, + UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, + mutation_is_pending, mutation_is_running, reset_login_code_dedup, try_account, try_accounts, + try_default_account, use_account, use_accounts, use_add_microsoft_account, + use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login, + use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account, + use_refresh_all_accounts, use_remove_account, use_set_default_account, +}; +pub use bundles::{ + ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items, + onboarding_bundles_items, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, + use_onboarding_bundles, +}; +pub use changelog::{ + changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, + use_changelog, +}; +pub use cluster_content::{cluster_content_items, use_cluster_content}; +pub use clusters::{use_cluster, use_clusters}; +pub use image::{CachedImageQuery, loaded_image, use_cached_image}; +pub use java::{ + invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes, + use_provider_versions, +}; +pub use logs::{ + ClusterLogsQuery, LogAction, LogContentQuery, UploadLogKeys, UploadLogMutation, UseLogAction, + UseUploadLog, invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, + use_log_action, use_log_content, use_upload_log, +}; +pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; +pub use mutations::{ + ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries, + invalidate_profile_queries, use_cluster_mutation, +}; +pub use package_updates::{package_updates, stale_hashes, use_package_updates}; +pub use packages::{ + BROWSE_PAGE_SIZE, VERSIONS_PAGE_SIZE, category_list, content_type_for_slug, package_meta_batch, + project_detail, search_items, search_pending, search_total, use_package_categories, + use_package_meta_batch, use_package_project, use_package_search, use_package_versions, + use_package_versions_when, version_list, versions_total, +}; +pub use player_profile::use_player_profile; +pub use screenshots::{ + ScreenshotAction, UseScreenshotAction, invalidate_screenshots_queries, try_cluster_screenshots, + use_cluster_screenshots, use_local_image, use_screenshot_action, use_screenshot_folder_watch, +}; +pub use settings_profiles::{ + try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile, + use_named_profiles, +}; +pub use skin::use_player_skin; +pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; +pub use storage::{ + StorageAction, StorageActionMutation, StorageReportQuery, UseStorageAction, + invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report, +}; +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, versions_metadata}; + +pub(crate) use auth::invalidate_auth_queries; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs new file mode 100644 index 00000000..89798f29 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -0,0 +1,231 @@ +use std::path::PathBuf; + +use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_db::models::ClusterId; + +use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; +use super::cluster_content::ClusterContentQuery; +use super::clusters::ListClustersQuery; +use super::package_updates::PackageUpdatesQuery; +use super::settings_profiles::{ + ClusterProfileQuery, ClusterSettingsQuery, GameProfileQuery, ListNamedProfilesQuery, +}; + +async fn timed(step: &'static str, fut: impl std::future::Future) { + let started = std::time::Instant::now(); + fut.await; + tracing::debug!( + target: "oneclient_app::perf", + step, + ms = started.elapsed().as_millis() as u64, + "invalidate step" + ); +} + +pub async fn invalidate_cluster_queries() { + let started = std::time::Instant::now(); + timed( + "cluster_content", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_overrides", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundles_with_status", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "clusters", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_updates", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "package_updates", + QueriesStorage::::invalidate_all(), + ) + .await; + tracing::debug!( + target: "oneclient_app::perf", + ms = started.elapsed().as_millis() as u64, + "cluster queries invalidated" + ); +} + +/// Split out of [`invalidate_cluster_queries`] so an install can wait for just +/// this before dropping its busy flag +pub async fn invalidate_cluster_content_queries() { + QueriesStorage::::invalidate_all().await; +} + +pub async fn invalidate_profile_queries() { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, PartialEq, Eq, Hash)] +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, + }, + SetBundlePackageEnabled { + cluster_id: ClusterId, + bundle_name: String, + package_id: String, + enabled: bool, + /// Manifest default matching it clears the override contradicting it + /// writes `Enabled` / `Disabled` + manifest_default: bool, + }, + ImportLocalFile { + cluster_id: ClusterId, + content_type: ContentType, + path: PathBuf, + }, + SetDedicatedDir { + cluster_id: ClusterId, + dedicated: bool, + }, + VerifyFiles { + cluster_id: ClusterId, + }, +} + +impl MutationCapability for ClusterMutation { + type Ok = (); + type Err = String; + type Keys = ClusterAction; + + async fn run(&self, keys: &ClusterAction) -> Result<(), String> { + let started = std::time::Instant::now(); + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let services = &state.services; + let content = &state.services.content(); + let result = match keys { + ClusterAction::ToggleArtifact { cluster_id, hash } => { + // Applied to the game folder at next launch never mid-session + // Minecraft reads its mods once at startup + oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content) + .await + .map(|_| ()) + } + ClusterAction::RemoveArtifact { cluster_id, hash } => { + oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await + } + ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => { + oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content) + .await + } + ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name, + package_id, + enabled, + manifest_default, + } => { + oneclient_core::set_bundle_package_enabled( + *cluster_id, + bundle_name, + package_id, + *enabled, + *manifest_default, + content, + ) + .await + } + ClusterAction::ImportLocalFile { + cluster_id, + content_type, + path, + } => PackageStore::import_local_file(path, *content_type, *cluster_id, content) + .await + .map(|row| { + services + .events + .notify("Imported") + .body(format!("Added {}", row.file_name)) + .send(); + }), + ClusterAction::SetDedicatedDir { + cluster_id, + dedicated, + } => state + .clusters + .set_dedicated_dir(*cluster_id, *dedicated, state.games.is_active(*cluster_id)) + .await + .map_err(|err| oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }), + ClusterAction::VerifyFiles { cluster_id } => { + // Reports its own outcome not the generic failure toast a + // verify that finds nothing wrong is still a useful result + match oneclient_core::verify_cluster_files(&state, *cluster_id).await { + Ok(report) => { + let notify = services.events.notify("Verification complete"); + let notify = notify.body(report.summary()); + if report.unrepairable.is_empty() { + notify.send(); + } else { + notify.error().send(); + } + Ok(()) + } + Err(err) => Err(oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }), + } + } + }; + tracing::debug!( + target: "oneclient_app::perf", + ms = started.elapsed().as_millis() as u64, + ok = result.is_ok(), + "cluster action ran" + ); + result.map_err(|e| e.to_string()) + } + + async fn on_settled(&self, _keys: &ClusterAction, result: &Result<(), String>) { + if let Err(err) = result + && let Ok(state) = crate::launcher::state() + { + state + .services + .events + .notify("Action failed") + .body(err) + .error() + .send(); + } + invalidate_cluster_queries().await; + } +} + +pub fn use_cluster_mutation() -> UseMutation { + use_mutation(Mutation::new(ClusterMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/package_updates.rs b/packages/oneclient_app/src/hooks/queries/package_updates.rs new file mode 100644 index 00000000..8d88fd52 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/package_updates.rs @@ -0,0 +1,50 @@ +use std::collections::HashSet; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{BrowserPackageUpdate, LauncherError}; +use oneclient_db::models::ClusterId; + +/// Reads the cache table only rendering a package list must never talk to a +/// provider The check itself runs on launch +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageUpdatesQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageUpdatesKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for PackageUpdatesQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = PackageUpdatesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(oneclient_core::cached_browser_package_updates( + self.cluster_id, + &state.services.content(), + ) + .await?) + } +} + +pub fn use_package_updates(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + PackageUpdatesKeys { cluster_id }, + PackageUpdatesQuery { cluster_id }, + )) +} + +pub fn package_updates(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} + +pub fn stale_hashes(query: &UseQuery) -> HashSet { + package_updates(query) + .into_iter() + .map(|update| update.hash) + .collect() +} diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs new file mode 100644 index 00000000..548276b9 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -0,0 +1,313 @@ +use std::time::Duration; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_common::domain::GameLoader; +use oneclient_common::search::normalize_query; +use oneclient_content::packages::types::{ + DEFAULT_PAGE_SIZE, Page, ProjectDetail, ProjectSummary, SearchFilters, SearchSort, + VersionSummary, +}; +use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; +use oneclient_core::LauncherError; + +pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE; +pub const VERSIONS_PAGE_SIZE: usize = 20; + +const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60); + +pub fn content_type_for_slug(slug: &str) -> ContentType { + match slug { + "shader" => ContentType::Shader, + "texture" => ContentType::ResourcePack, + _ => ContentType::Mod, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageSearchQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageSearchKeys { + pub provider: ProviderId, + pub content_type: ContentType, + pub query: String, + pub game_versions: Vec, + pub loaders: Vec, + pub categories: Vec, + pub sort: SearchSort, + pub page: usize, +} + +impl QueryCapability for PackageSearchQuery { + type Ok = Page; + type Err = LauncherError; + type Keys = PackageSearchKeys; + + #[tracing::instrument(name = "package_search", level = "debug", skip(self, keys), fields(provider = ?keys.provider, page = keys.page))] + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .search( + &SearchFilters { + query: (!keys.query.is_empty()).then(|| keys.query.clone()), + content_type: Some(keys.content_type), + 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), + offset: Some(keys.page * BROWSE_PAGE_SIZE), + limit: Some(BROWSE_PAGE_SIZE), + }, + &state.services.content(), + ) + .await?) + } +} + +#[allow(clippy::too_many_arguments)] +pub fn use_package_search( + provider: ProviderId, + content_type: ContentType, + query: String, + game_versions: Vec, + loaders: Vec, + categories: Vec, + sort: SearchSort, + page: usize, +) -> UseQuery { + use_query(Query::new( + PackageSearchKeys { + provider, + content_type, + // The key is also the cache key without normalizing "sodium" and + // "sodium " are two entries and a trailing space refetches for nothing + query: normalize_query(&query), + game_versions, + loaders, + categories, + sort, + page, + }, + PackageSearchQuery, + )) +} + +pub fn search_items(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query) + .map(|page| page.items) + .unwrap_or_default() +} + +pub fn search_total(query: &UseQuery) -> usize { + super::state::settled_or_loading(query) + .map(|page| page.total) + .unwrap_or(0) +} + +pub fn search_pending(query: &UseQuery) -> bool { + super::state::query_is_busy(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageProjectQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageProjectKeys { + pub provider: ProviderId, + pub project_id: String, +} + +impl QueryCapability for PackageProjectQuery { + type Ok = ProjectDetail; + type Err = LauncherError; + type Keys = PackageProjectKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .get_project_with_body(&keys.project_id, &state.services.content()) + .await?) + } +} + +pub fn use_package_project( + provider: ProviderId, + project_id: String, +) -> UseQuery { + use_query(Query::new( + PackageProjectKeys { + provider, + project_id, + }, + PackageProjectQuery, + )) +} + +pub fn project_detail(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageMetaBatchQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageMetaBatchKeys { + pub provider: ProviderId, + pub project_ids: Vec, +} + +impl QueryCapability for PackageMetaBatchQuery { + type Ok = std::collections::HashMap; + type Err = LauncherError; + type Keys = PackageMetaBatchKeys; + + #[tracing::instrument(name = "package_meta_batch", level = "debug", skip(self, keys), fields(provider = ?keys.provider, ids = keys.project_ids.len()))] + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(oneclient_content::packages::fetch_package_meta( + &state.services.content(), + keys.provider, + &keys.project_ids, + ) + .await?) + } +} + +pub fn use_package_meta_batch( + provider: ProviderId, + mut project_ids: Vec, +) -> UseQuery { + project_ids.sort(); + project_ids.dedup(); + use_query(Query::new( + PackageMetaBatchKeys { + provider, + project_ids, + }, + PackageMetaBatchQuery, + )) +} + +pub fn package_meta_batch( + query: &UseQuery, +) -> std::collections::HashMap { + super::state::settled_or_loading(query).unwrap_or_default() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageVersionsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageVersionsKeys { + pub provider: ProviderId, + pub project_id: String, + pub game_version: Option, + pub loader: Option, + pub page: usize, +} + +impl QueryCapability for PackageVersionsQuery { + type Ok = Page; + type Err = LauncherError; + type Keys = PackageVersionsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .list_versions( + &keys.project_id, + keys.game_version.as_deref(), + keys.loader, + keys.page * VERSIONS_PAGE_SIZE, + VERSIONS_PAGE_SIZE, + &state.services.content(), + ) + .await?) + } +} + +pub fn use_package_versions( + provider: ProviderId, + project_id: String, + game_version: Option, + loader: Option, + page: usize, +) -> UseQuery { + use_package_versions_when(true, provider, project_id, game_version, loader, page) +} + +pub fn use_package_versions_when( + enabled: bool, + provider: ProviderId, + project_id: String, + game_version: Option, + loader: Option, + page: usize, +) -> UseQuery { + let keys = enabled.then_some(PackageVersionsKeys { + provider, + project_id, + game_version, + loader, + page, + }); + + use_query(Query::new(keys, PackageVersionsQuery).stale_time(VERSIONS_STALE)) +} + +pub fn version_list(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query) + .map(|page| page.items) + .unwrap_or_default() +} + +pub fn versions_total(query: &UseQuery) -> usize { + super::state::settled_or_loading(query) + .map(|page| page.total) + .unwrap_or(0) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageCategoriesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageCategoriesKeys { + pub provider: ProviderId, + pub content_type: ContentType, +} + +impl QueryCapability for PackageCategoriesQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = PackageCategoriesKeys; + + #[tracing::instrument(name = "package_categories", level = "debug", skip(self, keys), fields(provider = ?keys.provider))] + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .list_categories(keys.content_type, &state.services.content()) + .await?) + } +} + +pub fn use_package_categories( + provider: ProviderId, + content_type: ContentType, +) -> UseQuery { + use_query(Query::new( + PackageCategoriesKeys { + provider, + content_type, + }, + PackageCategoriesQuery, + )) +} + +pub fn category_list(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs new file mode 100644 index 00000000..ec19930f --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -0,0 +1,57 @@ +use std::time::Duration; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; +use oneclient_mc::{self as minecraft, PlayerProfileView}; + +const PROFILE_STALE: Duration = Duration::from_secs(30 * 60); +const PROFILE_CLEAN: Duration = Duration::from_secs(2 * 60 * 60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FetchPlayerProfileQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PlayerProfileQueryKeys { + pub uuid: String, + pub access_token: Option, +} + +impl QueryCapability for FetchPlayerProfileQuery { + type Ok = PlayerProfileView; + type Err = LauncherError; + type Keys = PlayerProfileQueryKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let access_token = keys.access_token.clone(); + + let state = crate::launcher::state()?; + let client = &state.services.requester; + + Ok( + minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()) + .await?, + ) + } + + // TODO Cache + // fn matches(&self, keys: &Self::Keys) -> bool { + // keys.uuid.as_deref().is_some_and(|id| !id.is_empty()) + // } +} + +pub fn use_player_profile( + uuid: impl Into, + access_token: Option>, +) -> UseQuery { + use_query( + Query::new( + PlayerProfileQueryKeys { + uuid: uuid.into(), + access_token: access_token.map(|s| s.into()), + }, + FetchPlayerProfileQuery, + ) + .stale_time(PROFILE_STALE) + .clean_time(PROFILE_CLEAN), + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs new file mode 100644 index 00000000..29625df6 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -0,0 +1,215 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use bytes::Bytes; +use freya::prelude::{spawn, use_hook}; +use freya::query::{ + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, +}; +use notify::{EventKind, RecursiveMode, Watcher}; +use oneclient_core::{LauncherError, ScreenshotInfo}; +use tokio::sync::{Semaphore, mpsc}; + +static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterScreenshotsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterScreenshotsQuery; + +impl QueryCapability for ClusterScreenshotsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterScreenshotsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + Ok(oneclient_core::list_cluster_screenshots(&cluster)?) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LocalImageKeys { + pub path: PathBuf, + pub max_edge: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LocalImageQuery; + +impl QueryCapability for LocalImageQuery { + type Ok = Bytes; + type Err = LauncherError; + type Keys = LocalImageKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let path = keys.path.clone(); + let max_edge = (keys.max_edge != 0).then_some(keys.max_edge); + + let _permit = if max_edge.is_some() { + let sem = LOCAL_IMAGE_SEMAPHORE + .get_or_init(|| Arc::new(Semaphore::new(1))) + .clone(); + Some( + sem.acquire_owned() + .await + .map_err(|_| LauncherError::Minecraft("local image semaphore closed".into()))?, + ) + } else { + None + }; + + Ok( + tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge)) + .await + .map_err(|e| LauncherError::Minecraft(e.to_string()))??, + ) + } +} + +pub fn use_cluster_screenshots(cluster_id: i64) -> UseQuery { + use_query(Query::new( + ClusterScreenshotsKeys { cluster_id }, + ClusterScreenshotsQuery, + )) +} + +pub fn use_local_image(path: PathBuf, max_edge: u32) -> UseQuery { + use_query(Query::new( + LocalImageKeys { path, max_edge }, + LocalImageQuery, + )) +} + +const WATCH_QUIET: Duration = Duration::from_millis(400); + +pub fn use_screenshot_folder_watch( + folder: Option, + query: UseQuery, +) { + use_hook(move || { + let Some(folder) = folder else { + return; + }; + + spawn(async move { + if let Err(err) = watch_folder(&folder, query).await { + tracing::warn!( + folder = %folder.display(), + error = %err, + "not watching the screenshot folder; the list will refresh on re-entry only" + ); + } + }); + }); +} + +async fn watch_folder( + folder: &Path, + query: UseQuery, +) -> notify::Result<()> { + // If there is no screenshots folder it creates it + std::fs::create_dir_all(folder)?; + + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| match event { + Ok(event) if touches_image(&event) => { + let _ = tx.send(()); + } + Ok(_) => {} + Err(err) => tracing::debug!(error = %err, "screenshot watcher reported an error"), + })?; + watcher.watch(folder, RecursiveMode::NonRecursive)?; + + let mut pending = false; + loop { + tokio::select! { + biased; + + event = rx.recv() => { + if event.is_none() { + return Ok(()); + } + pending = true; + } + + () = quiet_period(pending) => { + pending = false; + query.invalidate(); + } + } + } +} + +async fn quiet_period(pending: bool) { + if pending { + tokio::time::sleep(WATCH_QUIET).await; + } else { + std::future::pending::<()>().await; + } +} + +fn touches_image(event: ¬ify::Event) -> bool { + if matches!(event.kind, EventKind::Access(_)) { + return false; + } + + event.paths.iter().any(|path| { + path.extension().and_then(OsStr::to_str).is_some_and(|ext| { + ["png", "jpg", "jpeg"] + .iter() + .any(|known| ext.eq_ignore_ascii_case(known)) + }) + }) +} + +pub fn try_cluster_screenshots( + query: &UseQuery, +) -> Option> { + super::state::settled_or_loading(query) +} + +pub async fn invalidate_screenshots_queries() { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum ScreenshotAction { + Delete { path: PathBuf }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ScreenshotActionMutation; + +impl MutationCapability for ScreenshotActionMutation { + type Ok = (); + type Err = LauncherError; + type Keys = ScreenshotAction; + + async fn run(&self, keys: &Self::Keys) -> Result { + match keys { + ScreenshotAction::Delete { path } => Ok(oneclient_core::delete_screenshot(path)?), + } + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_screenshots_queries().await; + } + } +} + +pub type UseScreenshotAction = UseMutation; + +pub fn use_screenshot_action() -> UseScreenshotAction { + use_mutation(Mutation::new(ScreenshotActionMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs new file mode 100644 index 00000000..6977b829 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs @@ -0,0 +1,136 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_cluster::profiles::{ + get_profile_or_default, list_named_profiles, resolve_cluster_profile, +}; +use oneclient_core::settings::GameSettingsProfile; +use oneclient_db::models::ClusterId; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListNamedProfilesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListNamedProfilesKeys; + +impl QueryCapability for ListNamedProfilesQuery { + type Ok = Vec; + type Err = String; + type Keys = ListNamedProfilesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + list_named_profiles(&state.services.db) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_named_profiles() -> UseQuery { + use_query(Query::new(ListNamedProfilesKeys, ListNamedProfilesQuery)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GameProfileQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct GameProfileKeys { + pub name: Option, +} + +impl QueryCapability for GameProfileQuery { + type Ok = GameSettingsProfile; + type Err = String; + type Keys = GameProfileKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let name = keys.name.clone(); + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + get_profile_or_default(&state.services.db, &global, name.as_deref()) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_game_profile(name: Option>) -> UseQuery { + use_query(Query::new( + GameProfileKeys { + name: name.map(Into::into), + }, + GameProfileQuery, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterProfileQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ClusterProfileKeys { + pub profile_name: Option, +} + +impl QueryCapability for ClusterProfileQuery { + type Ok = GameSettingsProfile; + type Err = String; + type Keys = ClusterProfileKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let profile_name = keys.profile_name.clone(); + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + resolve_cluster_profile(&state.services.db, &global, profile_name.as_deref()) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_cluster_profile( + profile_name: Option>, +) -> UseQuery { + use_query(Query::new( + ClusterProfileKeys { + profile_name: profile_name.map(Into::into), + }, + ClusterProfileQuery, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterSettingsQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterSettingsKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for ClusterSettingsQuery { + type Ok = GameSettingsProfile; + type Err = String; + type Keys = ClusterSettingsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let cluster_id = keys.cluster_id; + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let cluster = state + .clusters + .get(cluster_id) + .await + .map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + state + .clusters + .resolve_settings(&global, &cluster) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_cluster_settings(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + ClusterSettingsKeys { cluster_id }, + ClusterSettingsQuery, + )) +} + +pub fn try_game_profile(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs new file mode 100644 index 00000000..10ab6609 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -0,0 +1,32 @@ +use bytes::Bytes; +use freya::prelude::*; + +use crate::AppAssets; + +pub fn use_player_skin(uuid: String) -> (Bytes, bool) { + let profile = super::use_player_profile(uuid.clone(), None::); + + let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile) + .map_or((None, false), |profile| (profile.skin_url, profile.is_slim)); + + let skin_query = super::use_cached_image(skin_url.clone(), 256); + + let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default()); + let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default()); + + let default_slim = (java_string_hash(&uuid) & 1) == 1; + + match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) { + Some((_, bytes)) => (bytes, is_slim), + None if default_slim => (alex.read().clone(), true), + None => (steve.read().clone(), false), + } +} + +fn java_string_hash(s: &str) -> i32 { + let mut h: i32 = 0; + for c in s.encode_utf16() { + h = h.wrapping_mul(31).wrapping_add(c as i32); + } + h +} diff --git a/packages/oneclient_app/src/hooks/queries/state.rs b/packages/oneclient_app/src/hooks/queries/state.rs new file mode 100644 index 00000000..63e5f3ac --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/state.rs @@ -0,0 +1,53 @@ +//! Query state readers The useful cases straddle two `QueryStateData` +//! variants a refetch still holds the previous value the UI wants to show + +use std::fmt::Display; + +use freya::query::{QueryCapability, QueryStateData, UseQuery}; + +/// Keeps the previous value visible across a refetch `None` means there has +/// never been a successful result (not finished yet or only errors so far) +pub fn settled_or_loading(query: &UseQuery) -> Option +where + Q: QueryCapability, + Q::Ok: Clone, +{ + let reader = query.read(); + let state = reader.state(); + state.ok().cloned() +} + +/// Not reported while loading a refetch in flight has not failed yet even if +/// the previous attempt did +pub fn query_error(query: &UseQuery) -> Option +where + Q: QueryCapability, + Q::Err: Display, +{ + let reader = query.read(); + let state = reader.state(); + match &*state { + QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), + _ => None, + } +} + +/// A refetch still holding a previous value is *not* loading here Use +/// [`query_is_busy`] where the distinction matters +pub fn query_is_loading(query: &UseQuery) -> bool { + let reader = query.read(); + let state = reader.state(); + matches!( + &*state, + QueryStateData::Pending | QueryStateData::Loading { res: None } + ) +} + +pub fn query_is_busy(query: &UseQuery) -> bool { + let reader = query.read(); + let state = reader.state(); + matches!( + &*state, + QueryStateData::Pending | QueryStateData::Loading { .. } + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/storage.rs b/packages/oneclient_app/src/hooks/queries/storage.rs new file mode 100644 index 00000000..4db9e814 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/storage.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use freya::query::{ + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, +}; +use oneclient_core::LauncherError; +use oneclient_core::storage::{StorageReport, storage_report}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageReportKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageReportQuery; + +impl QueryCapability for StorageReportQuery { + type Ok = StorageReport; + type Err = LauncherError; + type Keys = StorageReportKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + storage_report(&state).await + } +} + +const STORAGE_STALE_TIME: Duration = Duration::from_secs(120); + +pub fn use_storage_report() -> UseQuery { + use_query(Query::new(StorageReportKeys, StorageReportQuery).stale_time(STORAGE_STALE_TIME)) +} + +pub fn try_storage_report(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +pub async fn invalidate_storage_queries() { + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StorageAction { + CleanUnreferencedCache, + CleanLegacyClusterContent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageActionMutation; + +impl MutationCapability for StorageActionMutation { + type Ok = (); + type Err = String; + type Keys = StorageAction; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + + match keys { + StorageAction::CleanUnreferencedCache => { + oneclient_core::storage::clean_unreferenced_cache(&state) + .await + .map(|_| ()) + } + StorageAction::CleanLegacyClusterContent => { + oneclient_core::storage::clean_legacy_cluster_content(&state) + .await + .map(|_| ()) + } + } + .map_err(|e| e.to_string()) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_storage_queries().await; + } + } +} + +pub type UseStorageAction = UseMutation; + +pub fn use_storage_action() -> UseStorageAction { + use_mutation(Mutation::new(StorageActionMutation)) +} 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..7add719c --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/tos.rs @@ -0,0 +1,39 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{LauncherError, TermsDocument, fetch_terms}; + +#[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 = crate::launcher::state()?; + fetch_terms(&state.services.requester).await + } +} + +pub fn use_terms() -> UseQuery { + let meta_url_base = super::use_meta_url_key(); + + use_query(Query::new(TermsKeys { meta_url_base }, TermsQuery)) +} + +pub fn terms_document(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +pub fn terms_error(query: &UseQuery) -> Option { + super::state::query_error(query) +} + +pub fn terms_is_loading(query: &UseQuery) -> bool { + super::state::query_is_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs new file mode 100644 index 00000000..e2f5646c --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -0,0 +1,113 @@ +use oneclient_common::VersionKey; +use oneclient_common::domain::GameLoader; +use oneclient_core::VersionMetadata; + +use super::use_versions; + +pub fn pick_version_metadata( + list: &[VersionMetadata], + major: u32, + key: Option, + loader: Option, +) -> Option { + let loader_matches = |m: &VersionMetadata| match (&m.loader, loader) { + (Some(s), Some(l)) => l.to_string().eq_ignore_ascii_case(s), + _ => false, + }; + + if let Some(key) = key { + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.key() == Some(key) && loader_matches(m)) + { + return Some(hit.clone()); + } + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.key() == Some(key)) + { + return Some(hit.clone()); + } + let (minor, _) = key; + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.minor_version == Some(minor)) + { + return Some(hit.clone()); + } + } + + list.iter() + .find(|m| m.major_version == major && m.minor_version.is_none()) + .cloned() +} + +pub fn use_version_metadata( + major: Option, + key: Option, + loader: Option, +) -> Option { + let versions_query = use_versions(); + + let major = major?; + let reader = versions_query.read(); + let state = reader.state(); + let list = state.ok()?; + + pick_version_metadata(list, major, key, loader) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry( + major: u32, + minor: Option, + patch: Option, + loader: Option<&str>, + name: &str, + ) -> VersionMetadata { + VersionMetadata { + major_version: major, + minor_version: minor, + patch_version: patch, + loader: loader.map(str::to_string), + name: name.to_string(), + art_url: None, + long_description: None, + tags: Vec::new(), + predownload: false, + } + } + + fn list() -> Vec { + vec![ + entry(26, None, None, None, "major"), + entry(26, Some(1), None, Some("fabric"), "bare minor"), + entry(26, Some(1), Some(2), Some("fabric"), "patched"), + ] + } + + #[test] + fn patch_and_bare_minor_are_distinct() { + let bare = pick_version_metadata(&list(), 26, Some((1, None)), Some(GameLoader::Fabric)); + assert_eq!(bare.unwrap().name, "bare minor"); + + let patched = + pick_version_metadata(&list(), 26, Some((1, Some(2))), Some(GameLoader::Fabric)); + assert_eq!(patched.unwrap().name, "patched"); + } + + #[test] + fn unknown_patch_falls_back_to_its_minor_line() { + let hit = pick_version_metadata(&list(), 26, Some((1, Some(9))), Some(GameLoader::Fabric)); + assert_eq!(hit.unwrap().name, "bare minor"); + } + + #[test] + fn unknown_minor_falls_back_to_major() { + let hit = pick_version_metadata(&list(), 26, Some((7, None)), Some(GameLoader::Fabric)); + assert_eq!(hit.unwrap().name, "major"); + } +} diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs new file mode 100644 index 00000000..d4ff81cf --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -0,0 +1,87 @@ +use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use oneclient_common::domain::GameLoader; +use oneclient_core::{LauncherError, VersionMetadata}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionsMetadataQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionsMetadataKeys; + +impl QueryCapability for VersionsMetadataQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = VersionsMetadataKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; + if !metadata.is_empty() { + return Ok(metadata); + } + state.versions.sync(&state.services).await?; + Ok(state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await) + } +} + +pub fn use_versions() -> UseQuery { + use_query(Query::new(VersionsMetadataKeys, VersionsMetadataQuery)) +} + +/// A failed fetch reads as an empty list rather than `None` so callers that +/// block on this don't wait forever when the network is down +pub fn versions_metadata(query: &UseQuery) -> Option> { + let reader = query.read(); + let state = reader.state(); + match &*state { + QueryStateData::Settled { res: Err(_), .. } => Some(Vec::new()), + other => other.ok().cloned(), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LoaderVersionsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LoaderVersionsKeys { + pub mc_version: String, + pub loader: GameLoader, +} + +impl QueryCapability for LoaderVersionsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = LoaderVersionsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let mut metadata = state.metadata.lock().await; + Ok(oneclient_core::get_loader_versions( + &mut metadata, + &state.services.mc(), + &keys.mc_version, + keys.loader, + ) + .await?) + } +} + +pub fn use_loader_versions( + mc_version: String, + loader: GameLoader, +) -> UseQuery { + use_query(Query::new( + LoaderVersionsKeys { mc_version, loader }, + LoaderVersionsQuery, + )) +} + +pub fn loader_versions(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} diff --git a/packages/oneclient_app/src/hooks/view_state.rs b/packages/oneclient_app/src/hooks/view_state.rs new file mode 100644 index 00000000..64696a34 --- /dev/null +++ b/packages/oneclient_app/src/hooks/view_state.rs @@ -0,0 +1,42 @@ +use freya::prelude::*; +use oneclient_core::settings::{ViewLayout, ViewState}; + +use super::{use_dispatch, use_settings_snapshot}; + +#[derive(Clone, Copy)] +pub struct PersistedView { + 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 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); + }); + + PersistedView { layout, sort } +} diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs new file mode 100644 index 00000000..62961989 --- /dev/null +++ b/packages/oneclient_app/src/install.rs @@ -0,0 +1,342 @@ +use std::sync::Arc; + +use oneclient_core::LauncherState; + +use oneclient_content::packages::PackageStore; +use oneclient_events::Level; + +use crate::components::IconType; +use crate::notifications::{ + ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, + NotificationSpec, PackageUpdateGroup, +}; + +/// Falls back to a placeholder a missing row must not lose the whole message +pub async fn cluster_display_name( + cluster_id: i64, + services: &oneclient_core::LauncherServices, +) -> String { + PackageStore::get_cluster(cluster_id, &services.content()) + .await + .map(|cluster| cluster.name) + .unwrap_or_else(|_| "Cluster".to_string()) +} + +pub async fn package_update_group( + cluster_id: i64, + updates: &[oneclient_core::BrowserPackageUpdate], + services: &oneclient_core::LauncherServices, +) -> Option { + if updates.is_empty() { + return None; + } + + Some(PackageUpdateGroup { + cluster_id, + cluster_name: cluster_display_name(cluster_id, services).await, + packages: updates.to_vec(), + }) +} + +pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem { + match &file.kind { + oneclient_core::BundleFileKind::Managed { + provider, + project_id, + .. + } => ClusterUpdateItem { + provider: *provider, + project_id: Some(project_id.clone()), + fallback: file.display_name(), + }, + oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { + provider: oneclient_content::packages::ProviderId::Local, + project_id: None, + fallback: file.display_name(), + }, + } +} + +async fn cluster_update_summary( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let updated: Vec = result + .updates_applied + .iter() + .map(|u| item_from_bundle_file(&u.new_file)) + .collect(); + let added: Vec = result + .additions_applied + .iter() + .map(|a| item_from_bundle_file(&a.new_file)) + .collect(); + let removed: Vec = result + .removals_applied + .iter() + .map(|r| ClusterUpdateItem { + provider: r + .provider + .unwrap_or(oneclient_content::packages::ProviderId::Local), + project_id: r.project_id.clone(), + fallback: r + .display_name + .clone() + .unwrap_or_else(|| r.package_id.clone()), + }) + .collect(); + + if updated.is_empty() && added.is_empty() && removed.is_empty() { + return None; + } + + let cluster_name = + oneclient_content::packages::PackageStore::get_cluster(cluster_id, &services.content()) + .await + .map(|c| c.name) + .unwrap_or_else(|_| "Cluster".to_string()); + + Some(ClusterUpdateSummary { + cluster_id, + cluster_name, + updated, + added, + removed, + }) +} + +pub async fn cluster_update_notification( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let summary = cluster_update_summary(cluster_id, result, services).await?; + let total = summary.total(); + let body = format!( + "{total} package{} changed in {}", + if total == 1 { "" } else { "s" }, + summary.cluster_name + ); + + Some(NotificationSpec { + title: "Cluster updated".to_string(), + body, + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), + }], + }) +} + +/// All clusters share one "View changes" action so the notification keeps exactly two buttons +pub async fn combined_cluster_update_spec( + changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)], + services: &oneclient_core::LauncherServices, +) -> Option { + let mut summaries = Vec::new(); + let mut total_changes = 0usize; + + for (cluster_id, result) in changed { + if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await { + total_changes += summary.total(); + summaries.push(summary); + } + } + + if summaries.is_empty() { + return None; + } + + let cluster_count = summaries.len(); + let body = format!( + "{total_changes} package{} updated across {cluster_count} cluster{}", + if total_changes == 1 { "" } else { "s" }, + if cluster_count == 1 { "" } else { "s" } + ); + + Some(NotificationSpec { + title: "Mods updated".to_string(), + body, + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summaries), + }], + }) +} + +/// The session is detached not finished so the caller can reuse its notification +/// as the "Installed" / "Install failed" result +/// `session_id` is `None` if no download ran +pub struct PackageInstall { + pub session_id: Option, + pub result: anyhow::Result, + pub dependencies: Vec, + /// Unresolved or failed dependencies the package still installs + pub missing_dependencies: Vec, +} + +pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> String { + let mut body = format!("Added {name}"); + + if !dependencies.is_empty() { + body.push_str(&format!( + " with {} dependenc{}", + dependencies.len(), + if dependencies.len() == 1 { "y" } else { "ies" } + )); + } + body.push('.'); + + if !missing.is_empty() { + body.push_str(&format!(" Could not add: {}.", missing.join(", "))); + } + + body +} + +impl PackageInstall { + fn failed(err: anyhow::Error) -> Self { + Self { + session_id: None, + result: Err(err), + dependencies: Vec::new(), + missing_dependencies: Vec::new(), + } + } +} + +pub async fn install_package( + state: &Arc, + provider: oneclient_content::packages::ProviderId, + project_id: &str, + version_id: &str, + cluster_id: i64, +) -> PackageInstall { + let lookup = async { + let provider_impl = state.services.packages.get(provider)?; + let project = provider_impl + .get_project(project_id, &state.services.content()) + .await?; + let version = provider_impl + .get_version(project_id, version_id, &state.services.content()) + .await?; + anyhow::Ok((project, version)) + } + .await; + + let (project, version) = match lookup { + Ok(found) => found, + Err(err) => return PackageInstall::failed(err), + }; + + // Resolved before the session starts so its children can be announced up front + let mut resolution = oneclient_content::packages::DependencyResolution::default(); + if oneclient_content::packages::resolves_dependencies(project.content_type) { + match oneclient_content::packages::resolve_required( + provider, + &version, + cluster_id, + &state.services.content(), + ) + .await + { + Ok(resolved) => resolution = resolved, + Err(err) => { + tracing::warn!(%err, "dependency resolution failed, installing package alone"); + } + } + } + + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + format!("Installing {}", project.name), + ); + + let size = version.primary_file().map(|f| f.size).unwrap_or(0); + let dependency_bytes: u64 = resolution + .install + .iter() + .map(|dep| dep.version.primary_file().map(|f| f.size).unwrap_or(0)) + .sum(); + session.expect( + oneclient_events::TaskCategory::Packages, + 1 + resolution.install.len() as u64, + size + dependency_bytes, + ); + + let mut installed_dependencies = Vec::new(); + let mut missing_dependencies = resolution.unresolved; + + // Dependencies first so the package is never in a cluster without them however the run ends + for dependency in &resolution.install { + let child = session.child( + dependency.project.name.clone(), + dependency + .version + .primary_file() + .map(|f| f.size) + .unwrap_or(0), + oneclient_events::TaskCategory::Packages, + ); + + let result = PackageStore::install_to_cluster( + provider, + &dependency.project, + &dependency.version, + cluster_id, + false, + false, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + + match result { + Ok(_) => installed_dependencies.push(dependency.project.name.clone()), + Err(err) => { + tracing::warn!( + dependency = %dependency.project.name, + %err, + "failed to install dependency" + ); + missing_dependencies.push(dependency.project.name.clone()); + } + } + } + + let child = session.child( + project.name.clone(), + size, + oneclient_events::TaskCategory::Packages, + ); + + let result = PackageStore::install_to_cluster( + provider, + &project, + &version, + cluster_id, + false, + false, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + + PackageInstall { + session_id: Some(session.detach()), + result: result.map(|_| project.name).map_err(anyhow::Error::from), + dependencies: installed_dependencies, + missing_dependencies, + } +} diff --git a/packages/oneclient_app/src/launcher.rs b/packages/oneclient_app/src/launcher.rs new file mode 100644 index 00000000..8b7bcb4e --- /dev/null +++ b/packages/oneclient_app/src/launcher.rs @@ -0,0 +1,22 @@ +//! The global lives here not in the core so the library stays constructible in tests +//! It is needed because `freya::query`'s `run` methods take only keys with no context + +use std::sync::{Arc, OnceLock}; + +use oneclient_core::{LauncherError, LauncherResult, LauncherState}; + +static LAUNCHER: OnceLock> = OnceLock::new(); + +/// Returns the existing handle on a second call rather than panicking +pub fn install(state: Arc) -> Arc { + if let Err(existing) = LAUNCHER.set(Arc::clone(&state)) { + tracing::warn!("launcher state was already installed; keeping the first"); + return existing; + } + state +} + +/// Errors with [`LauncherError::NotInitialized`] before startup installs the handle +pub fn state() -> LauncherResult> { + LAUNCHER.get().cloned().ok_or(LauncherError::NotInitialized) +} diff --git a/packages/oneclient_app/src/layout/animated_outlet.rs b/packages/oneclient_app/src/layout/animated_outlet.rs new file mode 100644 index 00000000..4ea329e3 --- /dev/null +++ b/packages/oneclient_app/src/layout/animated_outlet.rs @@ -0,0 +1,176 @@ +use freya::animation::*; +use freya::prelude::*; +use freya::router::*; + +use super::app_shell::{appshell_overlay, back_button, hides_overlay}; +use crate::Route; +use crate::theme; +use crate::ui::entrance_motion_layer; + +#[derive(Clone, Copy, PartialEq)] +enum Enter { + None, + Up, + Fade, +} + +/// This outlet must stay still for these the shell runs its own content-only +/// transition else the sidebar animates along with the page +fn is_sidebar_route(route: &Route) -> bool { + matches!( + route, + Route::SettingsAppearance {} + | Route::SettingsMinecraft {} + | Route::SettingsAccounts {} + | Route::SettingsLauncher {} + | Route::SettingsJava {} + | Route::SettingsStorage {} + | Route::SettingsApis {} + | Route::SettingsLanguage {} + | Route::SettingsDeveloper {} + | Route::SettingsChangelog {} + ) +} + +fn is_cluster_route(route: &Route) -> bool { + matches!( + route, + Route::ClusterOverview { .. } + | Route::ClusterLogs { .. } + | Route::ProcessLogs { .. } + | Route::ClusterScreenshots { .. } + | Route::ClusterMods { .. } + | Route::ClusterShaders { .. } + | Route::ClusterTextures { .. } + | Route::ClusterSettings { .. } + ) +} + +fn enter_kind(from: &Route, to: &Route) -> Enter { + if (is_sidebar_route(from) && is_sidebar_route(to)) + || (is_cluster_route(from) && is_cluster_route(to)) + { + Enter::None + } else if hides_overlay(from) { + Enter::Up + } else { + Enter::Fade + } +} + +#[derive(PartialEq)] +pub struct AnimatedAppOutlet; + +impl Component for AnimatedAppOutlet { + fn render(&self) -> impl IntoElement { + let mut router = use_animated_router::(); + + let route = use_route::(); + let mut origin_route = use_state(|| route.clone()); + let mut last_route = use_state(|| route.clone()); + + use_side_effect_with_deps(&route, move |current| { + let prev = last_route.peek().clone(); + if !(is_sidebar_route(&prev) && is_sidebar_route(current)) { + origin_route.set(prev); + } + last_route.set(current.clone()); + }); + + let back_title = origin_route.read().title(); + + let anim = use_animation(|_conf| { + AnimNum::new(0., 1.) + .time(430) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let mut last_to = use_state(|| None::); + + let (_, to, is_transition) = match &*router.read() { + AnimatedRouterContext::FromTo(from, to) => (enter_kind(from, to), to.clone(), true), + AnimatedRouterContext::In(to) => (Enter::None, to.clone(), false), + }; + + if last_to.peek().as_ref() != Some(&to) { + last_to.set(Some(to.clone())); + if is_transition { + anim.run(AnimDirection::Forward); + } + } + + let anim_finished = *anim.has_run_yet().read() && !*anim.is_running().read(); + + use_side_effect_with_deps(&anim_finished, move |&finished| { + if finished { + Platform::get().send(UserEvent::RequestRedraw); + } + }); + + if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } + + let kind = match &*router.read() { + AnimatedRouterContext::FromTo(from, to) => enter_kind(from, to), + AnimatedRouterContext::In(_) => Enter::None, + }; + + let is_home = matches!(to, Route::Home {}); + let show_overlay = !hides_overlay(&to); + + let p = if anim_finished { + 1.0 + } else { + anim.get().value() + }; + + let chrome_opacity = if kind == Enter::Up { p } else { 1.0 }; + + let (content_dy, content_opacity) = if anim_finished || matches!(kind, Enter::None) { + (0., 1.) + } else { + match kind { + Enter::None => (0., 1.), + Enter::Up => ((1. - p) * 48., p), + Enter::Fade => ((1. - p) * 22., p), + } + }; + + let overlay = show_overlay.then(|| appshell_overlay().opacity(chrome_opacity)); + + let back = (!is_home).then(|| { + rect() + .opacity(chrome_opacity) + .child(back_button(&back_title)) + }); + + let column = rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .margin(Gaps::new(theme::NAVBAR_HEIGHT_PX, 0., 0., 0.)) + .overflow(Overflow::Clip) + .layer(Layer::Relative(3)) + .maybe_child(back) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .child(entrance_motion_layer( + 0., + content_dy, + content_opacity, + Outlet::::new(), + )), + ); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .maybe_child(overlay) + .child(column) + } +} diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs new file mode 100644 index 00000000..91d1e0a0 --- /dev/null +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -0,0 +1,613 @@ +use bytes::Bytes; +use freya::animation::*; +use freya::engine::prelude::{Paint, SamplingOptions, SkData, SkImage, SkRect}; +use freya::prelude::*; +use freya::query::QueryStateData; +use freya::router::*; +use skia_safe::canvas::SrcRectConstraint; +use skia_safe::sampling_options::CubicResampler; + +use crate::Route; +use crate::components::Button; +use crate::components::{ + ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, + ScrollArea, +}; +use crate::layout::AnimatedAppOutlet; +use crate::theme; +use crate::use_settings_snapshot; +use oneclient_core::clusters::Cluster; +use oneclient_db::models::ClusterId; + +use crate::hooks::{ + ActiveClusterState, BrowserCompatState, BrowserStateStore, use_active_cluster_id, use_clusters, + use_game_snapshot, use_launcher, use_provide_active_cluster, use_provide_browser_compat, + use_provide_browser_state, use_splash, +}; +use crate::theme::colors; +use oneclient_events::LaunchStage; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(PartialEq)] +pub struct AppShell; + +impl Component for AppShell { + fn render(&self) -> impl IntoElement { + let active_cluster = use_state(|| None::); + use_provide_active_cluster(ActiveClusterState(active_cluster)); + + let browser_compat = use_state(|| true); + use_provide_browser_compat(BrowserCompatState(browser_compat)); + + let browser_state = use_state(HashMap::new); + use_provide_browser_state(BrowserStateStore(browser_state)); + + // `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here + let mut drop_hovering = use_state(|| false); + let mut drop_pending = use_state(Vec::::new); + + let game = use_game_snapshot(); + + let running_cluster = game + .stages + .iter() + .find(|(_, s)| **s == LaunchStage::Running) + .map(|(id, _)| *id); + + use_side_effect_with_deps(&running_cluster, move |running| { + if let Some(cluster_id) = *running { + spawn(async move { + let _ = RouterContext::get().push(Route::ProcessLogs { cluster_id }); + }); + } + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .color(colors::fg_primary()) + .overflow(Overflow::Clip) + .on_global_file_hover(move |_| drop_hovering.set(true)) + .on_global_file_hover_cancelled(move |_| drop_hovering.set(false)) + .on_file_drop(move |e: Event| { + drop_hovering.set(false); + drop_pending.write().extend_from_slice(&e.file_paths); + }) + .child(AppNavbar) + .child(AppHomeBackground) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(AnimatedRouter::::new(AnimatedAppOutlet)), + ) + .child(FileDropOverlay { + hovering: drop_hovering, + pending: drop_pending, + }) + .maybe_child( + game.error + .clone() + .map(|message| LaunchErrorDialog { message }.into_element()), + ) + } +} + +pub fn hides_overlay(route: &Route) -> bool { + matches!(route, Route::Home {}) +} + +const DIALOG_BG: Color = Color::from_rgb(21, 28, 34); +const CODE_BG: Color = Color::from_rgb(13, 18, 22); + +#[derive(PartialEq)] +struct LaunchErrorDialog { + message: String, +} + +impl Component for LaunchErrorDialog { + fn render(&self) -> impl IntoElement { + let dispatch = crate::hooks::use_dispatch(); + let lines: Vec = self + .message + .lines() + .map(|line| { + label() + .text(line.to_string()) + .font_family(theme::MONO_FONT) + .font_size(12.) + .width(Size::fill()) + .color(colors::fg_primary()) + .into_element() + }) + .collect(); + + let close = dispatch.clone(); + let outside_close = dispatch.clone(); + let copy_dispatch = dispatch.clone(); + OverlayPopup::new() + .on_close(move |_| close.dismiss_game_error()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .on_press(move |_| outside_close.dismiss_game_error()) + .child( + rect() + .vertical() + .width(Size::px(560.)) + .background(DIALOG_BG) + .corner_radius(CornerRadius::new_all(16.)) + .padding(Gaps::new_all(24.)) + .spacing(16.) + .border(crate::ui::border_all_color(1., colors::component_border())) + .on_press(|e: Event| e.stop_propagation()) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::danger()), + ) + .child( + label() + .text("Game failed to launch") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text("Minecraft could not be launched. Error details:") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::px(260.)) + .background(CODE_BG) + .corner_radius(CornerRadius::new_all(8.)) + .padding(Gaps::new_all(12.)) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .children(lines), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(10.) + .child(copy_error_button(&self.message, copy_dispatch)) + .child( + Button::new() + .primary() + .on_press(move |_| dispatch.dismiss_game_error()) + .text("Close"), + ), + ), + ), + ) + } +} + +fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElement { + let message = message.to_string(); + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = freya::text_edit::Clipboard::set(message.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + dispatch + .notify("Copy failed") + .body("Could not copy the error to the clipboard.") + .error() + .send(); + } else { + dispatch + .notify("Copied to clipboard") + .body("Error message copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + } + }) + .child(Icon::new(IconType::Copy01).size(14.)) + .text("Copy") + .into_element() +} + +pub(crate) fn appshell_overlay() -> Rect { + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + .layer(Layer::Relative(2)) + .child( + rect() + .height(Size::px(200.)) + .width(Size::fill()) + .background( + LinearGradient::new() + .stop((Color::BLACK.with_a(100), 0.)) + .stop((colors::page().with_a(255), 95.0)), + ), + ) + .child( + rect() + .height(Size::fill()) + .width(Size::fill()) + .background(colors::page()), + ) +} + +pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg"; + +fn home_cluster(clusters: &[Cluster], active: Option) -> Option<&Cluster> { + active + .and_then(|id| clusters.iter().find(|c| c.id == id)) + .or_else(|| clusters.first()) +} + +fn home_art(cluster: Option<&Cluster>) -> DynamicArt { + cluster + .map_or_else(DynamicArt::fallback, DynamicArt::for_cluster) + .preview_edge(ART_PREVIEW_EDGE) +} + +/// Warms the home art at the root so its three async hops overlap startup behind the +/// splash left to Home the fallback lingers on screen after the curtain lifts +#[derive(PartialEq, Clone, Copy)] +pub struct HomeArtPrefetch; + +impl Component for HomeArtPrefetch { + fn render(&self) -> impl IntoElement { + // Before the launcher is ready every query errors and a settled error is not + // retried until a new subscriber mounts + if !use_launcher().ready { + return rect().into_element(); + } + HomeArtWarm.into_element() + } +} + +#[derive(PartialEq, Clone, Copy)] +struct HomeArtWarm; + +impl Component for HomeArtWarm { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let art = { + let reader = clusters_query.read(); + let state = reader.state(); + // No active cluster this early so this picks the same one `AppHomeBackground` will + let clusters = state.ok().map_or(&[][..], Vec::as_slice); + home_art(home_cluster(clusters, None)) + }; + let _ = art.use_bytes(); + + rect().into_element() + } +} + +#[derive(PartialEq, Clone, Copy)] +pub struct AppHomeBackground; + +impl Component for AppHomeBackground { + fn render(&self) -> impl IntoElement { + let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled; + let clusters_query = use_clusters(); + let active_id = use_active_cluster_id(); + let launcher = use_launcher(); + let splash = use_splash(); + + // The home view is populated once these settle which releases the splash curtain + let clusters_settled = matches!( + &*clusters_query.read().state(), + QueryStateData::Settled { .. } + ); + let home_ready = clusters_settled && !launcher.fetching; + use_side_effect_with_deps(&home_ready, move |&ready| { + if ready { + let mut flag = splash.home_ready; + if !*flag.peek() { + flag.set(true); + } + } + }); + + let (art, art_dep) = { + let reader = clusters_query.read(); + let state = reader.state(); + let clusters = state.ok().map_or(&[][..], Vec::as_slice); + let chosen = home_cluster(clusters, *active_id.read()); + let dep = chosen.map(|c| c.id); + (home_art(chosen), dep) + }; + + let (_, art_bytes) = art.use_bytes(); + + let fade = use_animation_with_dependencies(&art_dep, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(520) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let fade_opacity = fade.get().value(); + + ParallaxArt { + art_bytes, + fade_opacity, + parallax_enabled, + } + .into_element() + } +} + +#[derive(PartialEq, Clone)] +pub struct ParallaxArt { + pub art_bytes: Bytes, + pub fade_opacity: f32, + pub parallax_enabled: bool, +} + +impl Component for ParallaxArt { + fn render(&self) -> impl IntoElement { + let mut size = use_state(|| (0f32, 0f32)); + let mut target = use_state(|| (0f32, 0f32)); + let mut current = use_state(|| (0f32, 0f32)); + + let mut anim_task = use_state(|| None::); + use_side_effect_with_deps(&self.parallax_enabled, move |enabled| { + if *enabled { + let handle = spawn(async move { + let platform = Platform::get(); + + const FRAME: std::time::Duration = std::time::Duration::from_millis(33); + const STEP: f32 = 0.12; + const SETTLE: f32 = 0.002; + + loop { + let (tx, ty) = *target.peek(); + let (cx, cy) = *current.peek(); + + if (tx - cx).abs() > SETTLE || (ty - cy).abs() > SETTLE { + current.set((cx + (tx - cx) * STEP, cy + (ty - cy) * STEP)); + platform.send(UserEvent::RequestRedraw); + } + + tokio::time::sleep(FRAME).await; + } + }); + anim_task.set(Some(handle.owned())); + } else { + anim_task.set(None); + target.set((0.0, 0.0)); + current.set((0.0, 0.0)); + } + }); + + let mut img_cache = use_state(|| None::<(usize, SkImage)>); + let src_ptr = self.art_bytes.as_ptr() as usize; + let cached = img_cache.peek().clone(); + let image = match cached { + Some((ptr, img)) if ptr == src_ptr => Some(img), + _ => { + let decoded = decode_background_image(&self.art_bytes); + if let Some(img) = &decoded { + img_cache.set(Some((src_ptr, img.clone()))); + } + decoded + } + }; + + let parallax_enabled = self.parallax_enabled; + let fade_opacity = self.fade_opacity; + + let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let Some(image) = &image else { + return; + }; + let (w, h) = (ctx.size.width, ctx.size.height); + if w <= 0.0 || h <= 0.0 { + return; + } + let iw = image.width() as f32; + let ih = image.height() as f32; + if iw <= 0.0 || ih <= 0.0 { + return; + } + + let (cx, cy) = if parallax_enabled { + current() + } else { + (0.0, 0.0) + }; + + let canvas_aspect = w / h; + let img_aspect = iw / ih; + let (mut crop_w, mut crop_h) = if img_aspect > canvas_aspect { + (ih * canvas_aspect, ih) + } else { + (iw, iw / canvas_aspect) + }; + + const SCALE: f32 = 1.10; + crop_w /= SCALE; + crop_h /= SCALE; + + const STRENGTH: f32 = 0.01; + let pan_x = cx * w * 0.5 * STRENGTH * (crop_w / w); + let pan_y = cy * h * 0.5 * STRENGTH * (crop_h / h); + let ccx = iw * 0.5 - pan_x; + let ccy = ih * 0.5 - pan_y; + + let src = SkRect::new( + ccx - crop_w * 0.5, + ccy - crop_h * 0.5, + ccx + crop_w * 0.5, + ccy + crop_h * 0.5, + ); + let dst = SkRect::new(0.0, 0.0, w, h); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + let sampling = SamplingOptions::from(CubicResampler::mitchell()); + ctx.canvas.draw_image_rect_with_sampling_options( + image, + Some((&src, SrcRectConstraint::Strict)), + dst, + sampling, + &paint, + ); + }); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .position(Position::new_absolute()) + .interactive(false) + .on_sized(move |e: Event| { + let (sw, sh) = (e.area.width(), e.area.height()); + let (pw, ph) = *size.peek(); + if (pw - sw).abs() > 0.5 || (ph - sh).abs() > 0.5 { + size.set((sw, sh)); + } + }) + .maybe(parallax_enabled, |el| { + el.on_capture_global_pointer_move(move |e: Event| { + let (sw, sh) = *size.peek(); + if sw > 0.0 && sh > 0.0 { + let loc = e.global_location(); + target.set(( + (loc.x as f32 / sw) * 2.0 - 1.0, + (loc.y as f32 / sh) * 2.0 - 1.0, + )); + } + }) + }) + .child( + rect() + .expanded() + .position(Position::new_absolute()) + .layer(Layer::Relative(1)) + .child(gradient_overlay_radial()), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(0)) + .opacity(fade_opacity) + .child( + canvas(render_cb) + .key(src_ptr as u64) + .width(Size::fill()) + .height(Size::fill()), + ), + ) + } +} + +fn decode_background_image(bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(bytes) }; + let img = SkImage::from_encoded(data)?; + Some(img.make_raster_image(None, None).unwrap_or(img)) +} + +const VIGNETTE_SPOTLIGHT_SHADER: &str = r#" +uniform float2 iResolution; + +half4 main(float2 fragCoord) { + float2 uv = fragCoord / iResolution; + + float2 center = float2(0.80, 0.45); + float2 distVec = uv - center; + + float2 dir = normalize(distVec + float2(0.00001)); + + float leftScale = 1.8; + float rightScale = 4.5; + float scaleX = mix(leftScale, rightScale, smoothstep(-1.0, 1.0, dir.x)); + + float topScale = 2.2; + float bottomScale = 2.2; + float scaleY = mix(topScale, bottomScale, smoothstep(-1.0, 1.0, dir.y)); + + distVec.x *= scaleX; + distVec.y *= scaleY; + + float dist = length(distVec); + + float vignette = smoothstep(0.0, 1.2, dist); + vignette = pow(vignette, 1.3); + + float maxDarkness = 0.8; + float alpha = vignette * maxDarkness; + + return half4(0.0, 0.0, 0.0, alpha); +} +"#; + +pub(crate) fn gradient_overlay_radial() -> impl IntoElement { + let effect = use_hook(|| { + freya::engine::prelude::RuntimeEffect::make_for_shader(VIGNETTE_SPOTLIGHT_SHADER, None) + .expect("Failed to compile vignette shader") + }); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .background(ShaderFill::new( + VIGNETTE_SPOTLIGHT_SHADER, + effect, + move |effect, bounds| { + let w = bounds.width(); + let h = bounds.height(); + + #[rustfmt::skip] + let uniforms: [f32; 2] = [ + w, h, + ]; + + let bytes = unsafe { + std::slice::from_raw_parts( + uniforms.as_ptr() as *const u8, + std::mem::size_of_val(&uniforms), + ) + }; + + effect.make_shader(freya::engine::prelude::Data::new_copy(bytes), &[], None) + }, + )) +} + +pub(crate) fn back_button(destination: &str) -> impl IntoElement { + Button::new() + .ghost() + .small() + .on_press(|_| { + RouterContext::get().go_back(); + }) + .margin(Gaps::new(0., 0., 0., 32.)) + .child(Icon::new(IconType::ArrowLeft).size(12.)) + .text(format!("Back to {destination}")) +} diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs new file mode 100644 index 00000000..062d6ae8 --- /dev/null +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -0,0 +1,343 @@ +use freya::animation::*; +use freya::prelude::*; +use freya::router::*; +use oneclient_common::parse_mc_version; + +use crate::components::{Button, Icon, IconType, TabBar, TabItem}; +use crate::hooks::{ + use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::entrance_motion_layer; +use crate::view::app::launch_button_state; + +const HEADER_HEIGHT: f32 = 64.; +const TABS_HEIGHT: f32 = 44.; +const BAR_SPACING: f32 = 16.; + +#[derive(PartialEq, Clone, Copy)] +pub enum ClusterViewShellTab { + Overview, + Logs, + GameLog, + Screenshots, + Mods, + Shaders, + Textures, + Settings, +} + +impl ClusterViewShellTab { + fn label(&self) -> &'static str { + match self { + Self::Overview => "Overview", + Self::Logs => "Logs", + Self::GameLog => "Live Log", + Self::Screenshots => "Screenshots", + Self::Mods => "Mods", + Self::Shaders => "Shaders", + Self::Textures => "Textures", + Self::Settings => "Settings", + } + } + + fn route(&self, cluster_id: i64) -> Option { + match self { + Self::Overview => Some(Route::ClusterOverview { cluster_id }), + Self::Logs => Some(Route::ClusterLogs { cluster_id }), + Self::GameLog => Some(Route::ProcessLogs { cluster_id }), + Self::Screenshots => Some(Route::ClusterScreenshots { cluster_id }), + Self::Mods => Some(Route::ClusterMods { cluster_id }), + Self::Shaders => Some(Route::ClusterShaders { cluster_id }), + Self::Textures => Some(Route::ClusterTextures { cluster_id }), + Self::Settings => Some(Route::ClusterSettings { cluster_id }), + } + } + + fn index(self) -> i32 { + match self { + Self::Overview => 0, + Self::Logs => 1, + Self::Screenshots => 2, + Self::Mods => 3, + Self::Shaders => 4, + Self::Textures => 5, + Self::Settings => 6, + Self::GameLog => 7, + } + } +} + +const SIDE_PADDING: f32 = 40.; + +pub fn cluster_content() -> Rect { + rect().vertical().width(Size::fill()).height(Size::fill()) +} + +fn route_cluster(route: &Route) -> Option<(i64, ClusterViewShellTab)> { + Some(match route { + Route::ClusterOverview { cluster_id } => (*cluster_id, ClusterViewShellTab::Overview), + Route::ClusterLogs { cluster_id } => (*cluster_id, ClusterViewShellTab::Logs), + Route::ProcessLogs { cluster_id } => (*cluster_id, ClusterViewShellTab::GameLog), + Route::ClusterScreenshots { cluster_id } => (*cluster_id, ClusterViewShellTab::Screenshots), + Route::ClusterMods { cluster_id } => (*cluster_id, ClusterViewShellTab::Mods), + Route::ClusterShaders { cluster_id } => (*cluster_id, ClusterViewShellTab::Shaders), + Route::ClusterTextures { cluster_id } => (*cluster_id, ClusterViewShellTab::Textures), + Route::ClusterSettings { cluster_id } => (*cluster_id, ClusterViewShellTab::Settings), + _ => return None, + }) +} + +#[derive(PartialEq)] +pub struct ClusterShell; + +impl Component for ClusterShell { + fn render(&self) -> impl IntoElement { + let route = use_route::(); + let (cluster_id, active_tab) = + route_cluster(&route).unwrap_or((0, ClusterViewShellTab::Overview)); + + let dispatch = use_dispatch(); + let game = use_game_snapshot(); + let launcher = use_launcher(); + let syncing = launcher.fetching || launcher.syncing_bundles; + let cluster = use_cluster(cluster_id); + + let show_game_log = game.is_active(cluster_id); + let launch_state = launch_button_state(&game, cluster_id, syncing); + + // Queried unconditionally the shell can mount before the cluster list settles + // and a conditional hook would change this component's hook count mid-life + let parsed = cluster + .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.key()), + cluster.as_ref().map(|c| c.mc_loader), + ); + + let header = cluster.as_ref().map(|cluster| { + let title = format!("{} {}", cluster.mc_loader, cluster.mc_version); + let subtitle = metadata + .and_then(|m| m.long_description) + .unwrap_or_else(|| cluster.name.clone()); + cluster_header( + title, + subtitle, + cluster_id, + dispatch, + launch_state, + game.is_running(cluster_id), + cluster.game_dir().ok(), + ) + .into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new( + BAR_SPACING, + SIDE_PADDING, + BAR_SPACING, + SIDE_PADDING, + )) + .spacing(BAR_SPACING) + .maybe_child(header) + .child(cluster_tabs(active_tab, cluster_id, show_game_log)), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., SIDE_PADDING, SIDE_PADDING, SIDE_PADDING)) + .child(AnimatedRouter::::new(ClusterContentOutlet)), + ) + } +} + +fn tab_dir(from: &Route, to: &Route) -> f32 { + match (route_cluster(from), route_cluster(to)) { + (Some((_, a)), Some((_, b))) => (b.index() - a.index()).signum() as f32, + _ => 0., + } +} + +#[derive(PartialEq)] +struct ClusterContentOutlet; + +impl Component for ClusterContentOutlet { + fn render(&self) -> impl IntoElement { + let mut router = use_animated_router::(); + + let anim = use_animation(|_conf| { + AnimNum::new(0., 1.) + .time(300) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let mut last_to = use_state(|| None::); + + let (dir, to, is_transition) = match &*router.read() { + AnimatedRouterContext::FromTo(from, to) => (tab_dir(from, to), to.clone(), true), + AnimatedRouterContext::In(to) => (0., to.clone(), false), + }; + + if last_to.peek().as_ref() != Some(&to) { + last_to.set(Some(to.clone())); + if is_transition { + anim.run(AnimDirection::Forward); + } + } + + let anim_finished = *anim.has_run_yet().read() && !*anim.is_running().read(); + + use_side_effect_with_deps(&anim_finished, move |&finished| { + if finished { + Platform::get().send(UserEvent::RequestRedraw); + } + }); + + if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } + + const CONTENT_DX: f32 = 44.; + let p = if anim_finished { + 1.0 + } else { + anim.get().value() + }; + let slide_x = if anim_finished || dir == 0. { + 0. + } else { + dir * (1.0 - p) * CONTENT_DX + }; + let opacity = if anim_finished || dir == 0. { 1. } else { p }; + + entrance_motion_layer(slide_x, 0., opacity, Outlet::::new()) + } +} + +fn cluster_header( + title: String, + subtitle: String, + cluster_id: i64, + dispatch: crate::Actions, + launch_state: (&'static str, bool), + running: bool, + folder: Option, +) -> impl IntoElement { + let (launch_label, launch_enabled) = launch_state; + let kill_dispatch = dispatch.clone(); + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .min_height(Size::px(HEADER_HEIGHT)) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(title) + .font_size(28.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .maybe_child(folder.map(crate::components::open_folder_button)) + .maybe(running, |el| { + el.child( + Button::new() + .danger() + .icon() + .on_press(move |_| kill_dispatch.kill_cluster(cluster_id)) + .child(Icon::new(IconType::Square).size(16.)), + ) + }) + .child( + Button::new() + .primary() + .large() + .enabled(launch_enabled) + .on_press(move |_| { + if launch_enabled { + dispatch.launch_cluster(cluster_id); + } + }) + .text(launch_label), + ), + ) +} + +fn cluster_tabs( + active: ClusterViewShellTab, + cluster_id: i64, + show_game_log: bool, +) -> impl IntoElement { + let tabs = [ + Some(ClusterViewShellTab::Overview), + Some(ClusterViewShellTab::Logs), + Some(ClusterViewShellTab::Screenshots), + Some(ClusterViewShellTab::Mods), + Some(ClusterViewShellTab::Shaders), + Some(ClusterViewShellTab::Textures), + Some(ClusterViewShellTab::Settings), + show_game_log.then_some(ClusterViewShellTab::GameLog), + ]; + + let tab_items = tabs.into_iter().flatten().map(|tab| { + let route = tab.route(cluster_id); + let mut item = TabItem::new(tab.label(), tab == active); + if let Some(route) = route { + item = item.on_press(move |_| { + let _ = RouterContext::get().push(route.clone()); + }); + } + item + }); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(TABS_HEIGHT)) + .cross_align(Alignment::Center) + .padding(Gaps::new(0., 24., 0., 24.)) + .background(colors::page_elevated()) + .corner_radius(CornerRadius::new_all(12.)) + .child( + TabBar::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(24.) + .font_size(12.) + .tabs(tab_items), + ) +} diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs new file mode 100644 index 00000000..ac882c8b --- /dev/null +++ b/packages/oneclient_app/src/layout/mod.rs @@ -0,0 +1,14 @@ +mod animated_outlet; +mod app_shell; +mod cluster_shell; +mod onboarding_shell; +mod root_layout; +mod settings_shell; + +pub use animated_outlet::AnimatedAppOutlet; +pub(crate) use app_shell::gradient_overlay_radial; +pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch}; +pub use cluster_shell::{ClusterShell, cluster_content}; +pub use onboarding_shell::OnboardingShell; +pub use root_layout::RootLayout; +pub use settings_shell::SettingsShell; diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs new file mode 100644 index 00000000..0f6c489a --- /dev/null +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -0,0 +1,130 @@ +use std::collections::HashSet; + +use freya::prelude::*; +use freya::router::{Outlet, use_route}; + +use crate::Route; +use crate::components::OnboardingNavbar; +use crate::hooks::{ + OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_migration, + use_onboarding_bundles, use_provide_onboarding_selection, +}; +use crate::theme::colors; +use crate::view::onboarding::{ + LoadingBackdrop, default_selection, onboarding_step_index, onboarding_total, +}; + +#[derive(PartialEq)] +pub struct OnboardingShell; + +impl Component for OnboardingShell { + fn render(&self) -> impl IntoElement { + let mut selected = use_state(HashSet::new); + let user_touched = use_state(|| false); + let migrated_categories = use_state(|| None::>); + let language = use_state(|| "English".to_string()); + let reduce_motion = use_state(|| false); + let predownload = use_state(|| true); + let setup_started = use_state(|| false); + let import_folder = use_state(|| None::); + let import_dedicated = use_state(|| false); + use_provide_onboarding_selection(OnboardingSelectionState { + selected, + user_touched, + migrated_categories, + language, + reduce_motion, + predownload, + setup_started, + import_folder, + import_dedicated, + }); + + let migration_query = use_migration(); + let detected_migration = has_migration_data(&migration_query); + + let route = use_route::(); + let step_index = onboarding_step_index(&route, detected_migration); + + let bundles = use_onboarding_bundles(); + + use_side_effect(move || { + if *user_touched.peek() { + return; + } + let Some(items) = onboarding_bundles_items(&bundles) else { + return; + }; + let categories = migrated_categories.read().clone(); + let defaults = default_selection(&items, categories.as_deref()); + if *selected.peek() != defaults { + selected.set(defaults); + } + }); + let show_backdrop = + matches!(&route, Route::OnboardingDownloading {}) && *setup_started.read(); + let backdrop = show_backdrop.then(|| { + let clusters = onboarding_bundles_items(&bundles) + .map(|items| items.into_iter().map(|cb| cb.cluster).collect()) + .unwrap_or_default(); + LoadingBackdrop { clusters }.into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::page()) + .color(colors::fg_primary()) + .overflow(Overflow::Clip) + .maybe_child(backdrop) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .layer(Layer::Relative(30)) + .child(progress_bar( + step_index, + onboarding_total(detected_migration), + )) + .child(OnboardingNavbar) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .child(Outlet::::new()) + .child(copyright()), + ), + ) + } +} + +fn progress_bar(step_index: usize, total: usize) -> impl IntoElement { + let filled = ((step_index + 1) as f32 / total as f32) * 100.; + + rect() + .width(Size::fill()) + .height(Size::px(3.)) + .background(Color::WHITE.with_a(28)) + .child( + rect() + .width(Size::percent(filled)) + .height(Size::fill()) + .background(colors::brand()), + ) +} + +fn copyright() -> impl IntoElement { + rect() + .position(Position::new_absolute().bottom(16.).left(24.)) + .interactive(false) + .child( + label() + .text("Design © 2026 Polyfrost. All rights reserved.") + .font_size(11.) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs new file mode 100644 index 00000000..e88433ec --- /dev/null +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -0,0 +1,81 @@ +use freya::prelude::*; +use freya::router::*; + +use crate::components::{ + AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, + NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, UpdatePromptOverlay, +}; +use crate::hooks::{SplashState, use_provide_splash}; +use crate::layout::HomeArtPrefetch; +use crate::motion::AnimationClockDriver; +use crate::routes::Route; +use crate::theme; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct RootLayout; + +impl Component for RootLayout { + fn render(&self) -> impl IntoElement { + let active = use_state(|| false); + let home_ready = use_state(|| false); + use_provide_splash(SplashState { active, home_ready }); + + // macOS rounds the window natively so Freya must not round on top of it + #[cfg(target_os = "macos")] + let corner = 0.; + + // Elsewhere the window is borderless and squared when maximized + // No reactive maximized signal exists so root_size changes stand in as the trigger + #[cfg(not(target_os = "macos"))] + let corner = { + let root_size = Platform::get().root_size; + let mut maximized = use_state(|| false); + let size = *root_size.read(); + let dep = (size.width as i32, size.height as i32); + use_side_effect_with_deps(&dep, move |_| { + spawn(async move { + let is_max = Platform::get() + .post_callback(|id, ctx| { + ctx.windows.get(&id).map(|w| w.window().is_maximized()) + }) + .await; + if let Ok(Some(is_max)) = is_max { + if *maximized.peek() != is_max { + maximized.set(is_max); + } + } + }); + }); + if *maximized.read() { 0. } else { 12. } + }; + + rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::page()) + .color(colors::fg_primary()) + .font_family(theme::DEFAULT_FONT) + .corner_radius(CornerRadius::new_all(corner)) + .overflow(Overflow::Clip) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(Outlet::::new()), + ) + .child(NotificationCenter) + .child(AccountSwitcher) + .child(Toasts) + .child(UpdatePromptOverlay) + .child(JavaPromptOverlay) + // Must stay last it renders whatever the overlays above did not claim + .child(GenericPromptOverlay) + .child(ClusterUpdatePopup) + .child(PackageUpdatePopup) + .child(StatusBar) + .child(SplashCurtain) + .child(AnimationClockDriver) + .child(HomeArtPrefetch) + } +} diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs new file mode 100644 index 00000000..0700c104 --- /dev/null +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -0,0 +1,835 @@ +use std::borrow::Cow; +use std::cell::RefCell; + +use freya::animation::*; +use freya::prelude::*; +use freya::router::*; +use freya::text_edit::Clipboard; +use oneclient_common::search::{MatchScore, SearchQuery}; +use sysinfo::CpuRefreshKind; +use sysinfo::MemoryRefreshKind; +use sysinfo::RefreshKind; +use sysinfo::System; + +use crate::components::{Icon, IconType, ScrollArea, ScrollAreaCtx, TextInput}; +use crate::hooks::{latest_changelog_version, use_changelog, use_settings_snapshot}; +use crate::routes::Route; +use crate::theme::colors; +use crate::use_dispatch; + +const SIDEBAR_WIDTH_PX: f32 = 225.; +const ITEM_HEIGHT_PX: f32 = 33.; +const SEARCH_WIDTH_PX: f32 = 256.; +const SEARCH_RESULTS_MAX: usize = 12; + +thread_local! { + static SETTINGS_SCROLL_CTX: RefCell> = const { RefCell::new(None) }; + static SETTINGS_SCROLL_CONTROLLER: RefCell> = const { RefCell::new(None) }; + static PENDING_SETTING_FOCUS: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn set_pending_setting_focus(id: &'static str) { + PENDING_SETTING_FOCUS.with(|cell| *cell.borrow_mut() = Some(id)); + + spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + PENDING_SETTING_FOCUS.with(|cell| { + if *cell.borrow() == Some(id) { + *cell.borrow_mut() = None; + } + }); + }); +} + +#[derive(Clone)] +struct SearchItem { + id: &'static str, + icon: IconType, + title: &'static str, + description: &'static str, + keywords: &'static [&'static str], + route: Route, +} + +// TODO Replace this with inventory crate or something +const SEARCH_INDEX: &[SearchItem] = &[ + SearchItem { + id: "launcher.discord_rpc", + icon: IconType::Link03, + title: "Discord RPC", + description: "Enable Discord Rich Presence.", + keywords: &["discord", "rpc", "rich presence"], + route: Route::SettingsLauncher {}, + }, + SearchItem { + id: "launcher.folder", + icon: IconType::Folder, + title: "Launcher Folder", + description: "Open the launcher data directory.", + keywords: &["data dir", "directory", "folder", "open"], + route: Route::SettingsLauncher {}, + }, + SearchItem { + id: "appearance.accent_color", + icon: IconType::PaintPour, + title: "Accent color", + description: "The main color used across the launcher.", + keywords: &["theme", "accent", "color", "brand"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "appearance.custom_theme", + icon: IconType::Colors, + title: "Custom theme", + description: "Create, edit, and import launcher themes.", + keywords: &["theme", "custom", "import", "export"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "appearance.parallax_background", + icon: IconType::Eye, + title: "Parallax background", + description: "Make the home screen background drift with your cursor.", + keywords: &["background", "parallax", "motion"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "appearance.animations", + icon: IconType::Play, + title: "Animations", + description: "Toggle all animations in the launcher.", + keywords: &["reduce motion", "animations", "effects"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "apis.modrinth_key", + icon: IconType::Key01, + title: "Modrinth API key", + description: "Personal access token used for Modrinth requests.", + keywords: &["modrinth", "token", "api key", "pat"], + route: Route::SettingsApis {}, + }, + SearchItem { + id: "apis.curseforge_key", + icon: IconType::Key01, + title: "CurseForge API key", + description: "API key used for CurseForge requests.", + keywords: &["curseforge", "api key"], + route: Route::SettingsApis {}, + }, + SearchItem { + id: "apis.custom_endpoint", + icon: IconType::Globe01, + title: "Custom API Endpoint", + description: "Override the default OneClient backend endpoint.", + keywords: &["endpoint", "api", "backend", "url"], + route: Route::SettingsApis {}, + }, + SearchItem { + id: "minecraft.force_fullscreen", + icon: IconType::Maximize01, + title: "Force Fullscreen", + description: "Force Minecraft to start in fullscreen mode.", + keywords: &["fullscreen", "window"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.resolution", + icon: IconType::LayoutTop, + title: "Resolution", + description: "The game window resolution in pixels.", + keywords: &["width", "height", "window size", "resolution"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.memory", + icon: IconType::Database01, + title: "Memory", + description: "The amount of memory allocated for the game.", + keywords: &["ram", "memory", "heap", "xmx"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.pre_launch", + icon: IconType::FilePlus02, + title: "Pre-Launch Command", + description: "Command to run before launching the game.", + keywords: &["hook", "command", "pre launch", "prelaunch"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.wrapper", + icon: IconType::ParagraphWrap, + title: "Wrapper Command", + description: "Command to run when launching the game.", + keywords: &["hook", "command", "wrapper", "gamescope"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.post_exit", + icon: IconType::FileX02, + title: "Post-Exit Command", + description: "Command to run after exiting the game.", + keywords: &["hook", "command", "post exit", "postexit"], + route: Route::SettingsMinecraft {}, + }, + // Java routing only the page is not built from settings_row_focus yet + SearchItem { + id: "java.install", + icon: IconType::Download01, + title: "Install Java", + description: "Install a Java runtime from a provider.", + keywords: &["java", "runtime", "temurin", "zulu", "install manager"], + route: Route::SettingsJava {}, + }, + SearchItem { + id: "java.add_folder", + icon: IconType::Folder, + title: "Add Java from folder", + description: "Add an existing Java installation.", + keywords: &["java", "runtime", "custom", "folder", "path"], + route: Route::SettingsJava {}, + }, + SearchItem { + id: "storage.usage", + icon: IconType::Database01, + title: "Disk usage", + description: "See what the launcher is storing and where.", + keywords: &["storage", "disk", "space", "size", "usage", "cache"], + route: Route::SettingsStorage {}, + }, + SearchItem { + id: "storage.cleanup", + icon: IconType::Trash01, + title: "Free up space", + description: "Remove cached packages and leftovers nothing is using.", + keywords: &[ + "storage", "clean", "cleanup", "free", "space", "cache", "unused", "orphan", "leftover", + ], + route: Route::SettingsStorage {}, + }, + SearchItem { + id: "language.select", + icon: IconType::Globe01, + title: "Language", + description: "Choose the launcher language.", + keywords: &[ + "language", "locale", "english", "spanish", "french", "german", "russian", "japanese", + "chinese", + ], + route: Route::SettingsLanguage {}, + }, + SearchItem { + id: "developer.compat_only", + icon: IconType::SearchMd, + title: "Compatible content only", + description: "Filter the content browser to the active cluster.", + keywords: &["browser", "compat", "filter"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "developer.log_debug", + icon: IconType::Terminal, + title: "Log Debug Info", + description: "Enable extra debug logging (requires restart).", + keywords: &["debug", "logs", "logging"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "developer.show_dev", + icon: IconType::CodeSnippet02, + title: "Show Dev stuff", + description: "Enable dev tools and show the debug page.", + keywords: &["devtools", "developer", "debug"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "developer.debug_page", + icon: IconType::CodeSnippet02, + title: "Debug Page", + description: "Open the debug page.", + keywords: &["debug", "page"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "nav.accounts", + icon: IconType::Users01, + title: "Accounts", + description: "Manage Minecraft / Microsoft accounts.", + keywords: &["account", "login", "microsoft", "msa"], + route: Route::SettingsAccounts {}, + }, + SearchItem { + id: "nav.changelog", + icon: IconType::RefreshCcw02, + title: "Changelog", + description: "View what's new in OneClient.", + keywords: &["changelog", "release notes", "updates"], + route: Route::SettingsChangelog {}, + }, +]; + +#[derive(PartialEq, Clone, Copy)] +pub enum SettingsTab { + MinecraftSettings, + Accounts, + LauncherSettings, + Java, + Storage, + Appearance, + Apis, + Language, + DeveloperOptions, + Changelog, +} + +impl SettingsTab { + fn label(self) -> &'static str { + match self { + Self::MinecraftSettings => "Minecraft settings", + Self::Accounts => "Accounts", + Self::LauncherSettings => "Launcher settings", + Self::Java => "Java", + Self::Storage => "Storage", + Self::Appearance => "Appearance", + Self::Apis => "APIs", + Self::Language => "Language", + Self::DeveloperOptions => "Developer options", + Self::Changelog => "Changelog", + } + } + + fn icon(self) -> IconType { + match self { + Self::MinecraftSettings => IconType::Sliders04, + Self::Accounts => IconType::Users01, + Self::LauncherSettings => IconType::Rocket02, + Self::Java => IconType::CodeSnippet02, + Self::Storage => IconType::Database01, + Self::Appearance => IconType::Brush01, + Self::Apis => IconType::Key01, + Self::Language => IconType::Globe01, + Self::DeveloperOptions => IconType::CodeSnippet02, + Self::Changelog => IconType::RefreshCcw02, + } + } + + fn route(self) -> Option { + match self { + Self::MinecraftSettings => Some(Route::SettingsMinecraft {}), + Self::Accounts => Some(Route::SettingsAccounts {}), + Self::LauncherSettings => Some(Route::SettingsLauncher {}), + Self::Java => Some(Route::SettingsJava {}), + Self::Storage => Some(Route::SettingsStorage {}), + Self::Appearance => Some(Route::SettingsAppearance {}), + Self::Apis => Some(Route::SettingsApis {}), + Self::Language => Some(Route::SettingsLanguage {}), + Self::DeveloperOptions => Some(Route::SettingsDeveloper {}), + Self::Changelog => Some(Route::SettingsChangelog {}), + } + } +} + +struct SettingsGroup { + label: &'static str, + tabs: &'static [SettingsTab], +} + +const GROUPS: &[SettingsGroup] = &[ + SettingsGroup { + label: "LAUNCHER SETTINGS", + tabs: &[ + SettingsTab::LauncherSettings, + SettingsTab::Java, + SettingsTab::Storage, + SettingsTab::Appearance, + SettingsTab::Apis, + SettingsTab::Language, + ], + }, + SettingsGroup { + label: "GAME SETTINGS", + tabs: &[SettingsTab::MinecraftSettings, SettingsTab::Accounts], + }, + SettingsGroup { + label: "ABOUT", + tabs: &[SettingsTab::DeveloperOptions, SettingsTab::Changelog], + }, +]; + +#[derive(PartialEq)] +pub struct SettingsShell; + +fn route_tab(route: &Route) -> SettingsTab { + match route { + Route::SettingsMinecraft {} => SettingsTab::MinecraftSettings, + Route::SettingsAccounts {} => SettingsTab::Accounts, + Route::SettingsLauncher {} => SettingsTab::LauncherSettings, + Route::SettingsJava {} => SettingsTab::Java, + Route::SettingsStorage {} => SettingsTab::Storage, + Route::SettingsApis {} => SettingsTab::Apis, + Route::SettingsLanguage {} => SettingsTab::Language, + Route::SettingsDeveloper {} => SettingsTab::DeveloperOptions, + Route::SettingsChangelog {} => SettingsTab::Changelog, + _ => SettingsTab::Appearance, + } +} + +impl Component for SettingsShell { + fn render(&self) -> impl IntoElement { + let route = use_route::(); + let active = route_tab(&route); + + let changelog_unread = has_unread_changelog(); + + let search = use_state(String::new); + let query = SearchQuery::new(&search.read()); + let scroll = use_scroll_controller(ScrollConfig::default); + SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll)); + + let anim = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(340) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let mut last_route = use_state(|| route.clone()); + if *last_route.peek() != route { + last_route.set(route.clone()); + anim.run(AnimDirection::Forward); + } + let p = anim.get().value(); + + let children: Vec = if query.is_empty() { + const SHIFT_PX: f32 = 46.; + vec![ + rect() + .width(Size::fill()) + .offset_x(-(1.0 - p) * SHIFT_PX) + .opacity(p) + .child(Outlet::::new()) + .into_element(), + ] + } else { + search_results(&query, search) + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 32.)) + .spacing(40.) + .child(sidebar(active, changelog_unread)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .spacing(24.) + .child(content_header(active.label().to_string(), search)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .show_scrollbar(true) + .spacing(4.) + .scroll_controller(scroll) + .on_ctx(|ctx| { + SETTINGS_SCROLL_CTX.with(|cell| *cell.borrow_mut() = Some(ctx)); + }) + .children(children), + ), + ) + } +} + +fn content_header(title: String, search: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(title) + .font_size(30.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child(search_box(search)) +} + +fn search_box(mut search: State) -> impl IntoElement { + let has_query = !search.read().trim().is_empty(); + let base = TextInput::new(search) + .placeholder("Search settings...") + .width(Size::px(SEARCH_WIDTH_PX)) + .leading( + Icon::new(IconType::SearchMd) + .size(16.) + .color(colors::fg_secondary()) + .into_element(), + ); + + if !has_query { + return base; + } + + base.trailing( + rect() + .padding(Gaps::new_symmetric(2., 2.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(Color::TRANSPARENT) + .cursor(CursorIcon::Pointer) + .on_press(move |_| search.set(String::new())) + .child( + Icon::new(IconType::XClose) + .size(16.) + .color(colors::fg_secondary()), + ) + .into_element(), + ) +} + +/// A title hit outranks a keyword or description hit however well those scored +fn settings_score(item: &SearchItem, query: &SearchQuery) -> Option<(u8, MatchScore)> { + if let Some(score) = query.score(item.title) { + return Some((2, score)); + } + if let Some(score) = query.best_score(item.keywords.iter().copied()) { + return Some((1, score)); + } + query.score(item.description).map(|score| (0, score)) +} + +fn search_results(query: &SearchQuery, mut search: State) -> Vec { + let mut scored: Vec<(u8, MatchScore, SearchItem)> = SEARCH_INDEX + .iter() + .filter_map(|item| settings_score(item, query).map(|(f, s)| (f, s, item.clone()))) + .collect(); + + // Shortest title last it is the entry closest to being just the query + scored.sort_by_key(|(field, score, item)| { + ( + std::cmp::Reverse(*field), + std::cmp::Reverse(*score), + item.title.len(), + ) + }); + let mut matches: Vec = scored.into_iter().map(|(_, _, item)| item).collect(); + 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(), + ]; + } + + let mut out: Vec = Vec::with_capacity(matches.len() + 1); + out.push( + rect() + .padding(Gaps::new(16., 0., 8., 2.)) + .child( + label() + .text("SEARCH RESULTS") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element(), + ); + + for item in matches { + let route = item.route; + let id = item.id; + let title = item.title; + let desc = item.description; + out.push( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + set_pending_setting_focus(id); + search.set(String::new()); + let _ = RouterContext::get().replace(route.clone()); + }) + .child(Icon::new(item.icon)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(desc) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child( + Icon::new(IconType::ChevronRight) + .size(18.) + .color(colors::fg_secondary()), + ) + .into_element(), + ); + } + out +} + +/// False while the changelog is loading or failed rather than guessing +fn has_unread_changelog() -> bool { + let settings = use_settings_snapshot().settings; + let query = use_changelog(); + + match latest_changelog_version(&query) { + Some(latest) => settings.seen_changelog_version.as_deref() != Some(latest.as_str()), + None => false, + } +} + +fn sidebar(active: SettingsTab, changelog_unread: bool) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(SIDEBAR_WIDTH_PX)) + .min_width(Size::px(SIDEBAR_WIDTH_PX)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(16., 0., 0., 0.)) + .content(Content::Flex) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(16.) + .children(GROUPS.iter().map(|group| { + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + rect().padding(Gaps::new(0., 0., 2., 6.)).child( + label() + .text(group.label) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), + ) + .children(group.tabs.iter().map(|tab| { + SidebarItem { + tab: *tab, + active: *tab == active, + has_dot: matches!(tab, SettingsTab::Changelog) && changelog_unread, + } + .into_element() + })) + .into_element() + })), + ) + .child(SidebarInfo::new()) +} + +#[derive(PartialEq)] +struct SidebarInfo { + kernel_version: String, + ram_mb: u64, + cpu_kind: String, +} + +impl SidebarInfo { + pub fn new() -> Self { + let system = System::new_with_specifics( + RefreshKind::nothing() + .with_memory(MemoryRefreshKind::nothing().with_ram()) + .with_cpu(CpuRefreshKind::nothing()), + ); + + let kernel_version = System::kernel_long_version(); + let ram_mb = system.total_memory() / 1024 / 1024; + let cpu_kind = system + .cpus() + .first() + .map(|cpu| cpu.brand()) + .unwrap_or_default(); + + Self { + kernel_version, + ram_mb, + cpu_kind: cpu_kind.to_string(), + } + } +} + +impl Component for SidebarInfo { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + + let items: [Cow<'static, str>; 4] = [ + Cow::Borrowed(concat!( + "OneClient v", + env!("CARGO_PKG_VERSION"), + cfg_select! { + debug_assertions => " (debug)", + _ => " (release)" + } + )), + Cow::Owned(self.kernel_version.clone()), + Cow::Owned(self.cpu_kind.clone()), + Cow::Owned(format!("RAM Total of {} MB", self.ram_mb)), + ]; + + let text = items + .iter() + .map(|s| s.as_ref()) + .collect::>() + .join("\n"); + + let copy_to_clipboard = move |_| { + if let Err(err) = Clipboard::set(text.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + dispatch + .notify("Copy failed") + .body("Could not copy system information to the clipboard.") + .error() + .send(); + } else { + dispatch + .notify("Copied to clipboard") + .body("System information copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + } + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .font_size(12.) + .color(colors::fg_secondary()) + .children( + items + .into_iter() + .map(|item| label().text(item).into_element()), + ) + .cursor(CursorIcon::Pointer) + .on_press(copy_to_clipboard) + } +} + +#[derive(PartialEq)] +struct SidebarItem { + tab: SettingsTab, + active: bool, + has_dot: bool, +} + +impl Component for SidebarItem { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let tab = self.tab; + let active = self.active; + let route = tab.route(); + + let background = if active { + colors::page_elevated() + } else if *hovering.read() { + colors::ghost_overlay() + } else { + Color::TRANSPARENT + }; + + let has_dot = self.has_dot; + + let mut el = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(ITEM_HEIGHT_PX)) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(background) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| *hovering.write() = true) + .on_pointer_leave(move |_| *hovering.write() = false) + .map(route, |el, route| { + el.on_all_press(move |_| { + let _ = RouterContext::get().replace(route.clone()); + }) + }); + + if focus().is_focused() { + el = el.border( + Border::new() + .fill(colors::brand()) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + el.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary())) + .child( + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(tab.label()) + .font_size(14.) + .color(colors::fg_primary()), + ) + .maybe_child(has_dot.then(notification_dot)), + ) + } +} + +fn notification_dot() -> impl IntoElement { + rect() + .width(Size::px(4.)) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::brand()) +} diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs new file mode 100644 index 00000000..f5b016d1 --- /dev/null +++ b/packages/oneclient_app/src/lib.rs @@ -0,0 +1,33 @@ +// TODO Remove this once freya has some workaround for this +#![allow(float_literal_f32_fallback)] +// Instrumented oneclient_core async call chains exceed the default limit +#![recursion_limit = "256"] + +mod assets; +mod components; +pub mod events; +pub mod hooks; +mod install; +mod launcher; +mod layout; +mod motion; +mod notifications; +pub mod platform; +pub mod recovery; +mod routes; +pub mod state; +pub mod theme; +mod transfer; +mod ui; +pub mod updater; +pub(crate) mod utils; +mod view; + +pub mod constants; + +pub use assets::AppAssets; +pub use components::ConfirmLinkOverlay; +pub use events::EventPump; +pub use hooks::*; +pub use routes::{Route, router}; +pub use state::{AppChannel, AppState}; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs new file mode 100644 index 00000000..d555468d --- /dev/null +++ b/packages/oneclient_app/src/main.rs @@ -0,0 +1,135 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] +#![recursion_limit = "256"] + +use freya::prelude::*; +use freya::radio::use_init_radio_station; +use oneclient_app::state::{AppChannel, AppState}; +use oneclient_app::{ + Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme, + use_provide_actions, use_provide_link_confirm, +}; +use tokio::runtime::Builder; + +struct OneClientApp; + +impl App for OneClientApp { + fn render(&self) -> impl IntoElement { + // Radio state is `!Send` so every writer runs on the UI thread `spawn_forever` + let station = use_init_radio_station::(AppState::default); + + let actions = use_hook(move || { + let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel(); + let (events_bus, events_rx) = oneclient_events::EventBus::channel(); + let actions = Actions::new(station, signals_tx, events_bus.clone()); + + // Started first so nothing emitted during startup waits on a consumer + spawn_forever( + EventPump { + events: events_rx, + signals: signals_rx, + station, + } + .run(), + ); + + let startup = actions.clone(); + let rescue_bus = events_bus.clone(); + spawn_forever(async move { + match events::start_launcher(station, events_bus).await { + // Must follow startup `sync_bundles` needs the launcher handle and firing + // it early leaves `syncing_bundles` stuck disabling every launch button + Ok(()) => startup.sync_bundles(), + Err(err) => { + events::report_startup_failure(&station, &err); + oneclient_app::updater::spawn_update_check(false, rescue_bus); + } + } + }); + + actions + }); + + use_provide_actions(&actions); + + let link_confirm = use_state(|| None::); + use_provide_link_confirm(LinkConfirmState(link_confirm)); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(ConfirmLinkOverlay) + .child(router()) + } +} + +fn main() { + let mut builder = Builder::new_multi_thread(); + builder.enable_all().max_blocking_threads(64); + + // Debug builds emit unoptimized async code that can overflow the default 2MB stack + #[cfg(debug_assertions)] + builder.thread_stack_size(3 * 1024 * 1024); + + let rt = builder.build().unwrap(); + 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"); + + let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); + + #[cfg(target_os = "macos")] + oneclient_app::platform::macos::loop_memory_collector(); + + let window_config = WindowConfig::new_app(OneClientApp) + .with_title(constants::WINDOW_TITLE) + .with_app_id(constants::WINDOW_APP_ID) + .with_icon(LaunchConfig::window_icon(include_bytes!( + "../icons/128x128.png" + ))) + .with_size(1200., 800.) + .with_min_size(800., 600.) + .with_transparency(true) + .with_background(Color::TRANSPARENT); + + #[cfg(target_os = "macos")] + let window_config = window_config + .with_decorations(true) + .with_window_attributes(|attrs, _| { + use freya::winit::platform::macos::WindowAttributesExtMacOS; + attrs + .with_title_hidden(true) + .with_titlebar_transparent(true) + .with_titlebar_buttons_hidden(true) + .with_fullsize_content_view(true) + .with_has_shadow(true) + }); + + #[cfg(not(target_os = "macos"))] + let window_config = window_config.with_decorations(false); + + let mut launch_config = LaunchConfig::new() + .with_window(window_config) + .with_gpu_resource_cache_limit( + std::env::var("ONECLIENT_GPU_CACHE_BYTES") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(512 * 1024 * 1024), + ) + .with_default_font(theme::DEFAULT_FONT); + + for (font, bytes) in theme::load_fonts() { + launch_config = launch_config.with_font(font, bytes); + } + + launch(launch_config); +} diff --git a/packages/oneclient_app/src/motion.rs b/packages/oneclient_app/src/motion.rs new file mode 100644 index 00000000..85b76547 --- /dev/null +++ b/packages/oneclient_app/src/motion.rs @@ -0,0 +1,29 @@ +use freya::prelude::*; + +use crate::hooks::use_settings_snapshot; + +#[derive(PartialEq)] +pub struct AnimationClockDriver; + +impl Component for AnimationClockDriver { + fn render(&self) -> impl IntoElement { + let enabled = use_settings_snapshot().settings.animations_enabled; + let clock = AnimationClock::get(); + + use_side_effect_with_deps(&enabled, move |&enabled| { + if enabled { + clock.enable(); + } else { + clock.disable(); + } + }); + + rect().into_element() + } +} + +/// Read from the setting rather than the clock so a component re-renders when the preference is +/// toggled, and so it never reads the one frame of stale speed before the driver above applies it +pub fn use_animations_enabled() -> bool { + use_settings_snapshot().settings.animations_enabled +} diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs new file mode 100644 index 00000000..386d927f --- /dev/null +++ b/packages/oneclient_app/src/notifications.rs @@ -0,0 +1,1009 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use oneclient_content::packages::ProviderId; +use oneclient_core::BrowserPackageUpdate; +use oneclient_db::models::ClusterId; +use oneclient_events::{ + Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, +}; +use tokio::sync::oneshot; +use uuid::Uuid; + +use crate::components::IconType; +use crate::transfer::{TransferMeter, TransferStats}; + +pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug, PartialEq)] +pub struct NotificationAction { + pub label: String, + pub kind: NotificationActionKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum NotificationActionKind { + /// A list so a batch sync stays a single "View changes" action not one button per cluster + OpenClusterUpdate(Vec), +} + +/// Carries core updates whole not a display projection the modal's Update button +/// hands one straight back to the apply path +#[derive(Clone, Debug, PartialEq)] +pub struct PackageUpdateGroup { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub packages: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClusterUpdateItem { + pub provider: ProviderId, + pub project_id: Option, + /// Used when the meta cache has no entry (file name / package id) + pub fallback: String, +} + +impl ClusterUpdateItem { + pub fn from_name(name: impl Into) -> Self { + Self { + provider: ProviderId::Local, + project_id: None, + fallback: name.into(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClusterUpdateSummary { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub updated: Vec, + pub added: Vec, + pub removed: Vec, +} + +impl ClusterUpdateSummary { + pub fn total(&self) -> usize { + self.updated.len() + self.added.len() + self.removed.len() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct InboxEntry { + pub level: Level, + pub id: u64, + pub title: String, + pub body: String, + pub icon: Option, + pub progress: Option<(u64, u64)>, + pub is_loading: bool, + pub read: bool, + pub created_at: Instant, + pub actions: Vec, + pub tasks: Vec, + /// Bytes/sec and seconds remaining for grouped downloads + pub transfer: Option, +} + +/// One aggregate row per [`TaskCategory`] (all libraries collapse into one "Libraries" row) +#[derive(Clone, Debug, PartialEq)] +pub struct TaskView { + pub label: String, + pub phase: &'static str, + pub current: u64, + pub total: u64, + pub done_count: u64, + pub total_count: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NotificationSpec { + pub title: String, + pub body: String, + pub level: Level, + pub icon: Option, + pub progress: Option<(u64, u64)>, + pub actions: Vec, +} + +impl InboxEntry { + pub fn dismissable(&self) -> bool { + !self.is_loading + } + + pub fn click_dismissable(&self) -> bool { + self.dismissable() && self.actions.is_empty() + } +} + +#[derive(Clone, Debug)] +pub struct ActiveToast { + #[allow(dead_code)] + pub toast_id: u64, + pub entry_id: u64, +} + +#[derive(Debug)] +pub struct PendingPrompt { + pub title: String, + pub question: String, + pub choices: Vec, + pub dismiss: Option, + /// Taken when answered so a second answer cannot fire the oneshot twice + pub reply_tx: Option>>, +} + +#[derive(Clone, Debug)] +pub struct PendingPromptView { + pub title: String, + pub question: String, + pub choices: Vec, + pub dismiss: Option, +} + +impl PendingPromptView { + /// Lets a specialised overlay recognise a prompt without the event layer naming it + pub fn has_choice(&self, id: &str) -> bool { + self.choices.iter().any(|choice| choice.id == id) + } + + pub fn choice(&self, id: &str) -> Option<&Choice> { + self.choices.iter().find(|choice| choice.id == id) + } +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub struct ToastDismissTimer { + pub toast_id: u64, + pub entry_id: u64, + pub after: Duration, +} + +#[derive(Clone, Debug, Default)] +pub struct NotificationSnapshot { + pub inbox: Vec, + pub center_open: bool, + pub pending_prompt: Option, + pub cluster_update: Option>, + pub package_updates: Option>, + pub active_toast_entry_ids: Vec, +} + +impl NotificationSnapshot { + pub fn unread_count(&self) -> usize { + NotificationState::unread_count(&self.inbox) + } +} + +#[derive(Default)] +pub struct NotificationState { + next_id: u64, + active_toasts: Vec, + progress_entries: HashMap, + grouped_entries: HashMap, + grouped_tasks: HashMap, + pending_timers: Vec, + cluster_update: Option>, + package_updates: Option>, + /// Resumes the launch that opened the update modal + /// Held here not by the launch task + /// because every way the modal can end goes through this state + package_updates_done: Option>, +} + +const CATEGORY_ORDER: [TaskCategory; 7] = [ + TaskCategory::Java, + TaskCategory::Client, + TaskCategory::Libraries, + TaskCategory::Natives, + TaskCategory::Assets, + TaskCategory::Metadata, + TaskCategory::Packages, +]; + +struct ChildRec { + category: TaskCategory, + phase: &'static str, + current: u64, + total: u64, +} + +#[derive(Default)] +struct GroupedTasks { + title: String, + children: HashMap, + done_units: HashMap, + done_count: HashMap, + seen_count: HashMap, + /// Expected counts/bytes announced up-front so totals don't climb mid-download + reserved_count: HashMap, + reserved_units: HashMap, + meter: TransferMeter, +} + +impl GroupedTasks { + fn task_list(&self) -> Vec { + CATEGORY_ORDER + .iter() + .filter_map(|&cat| { + let seen_count = self.seen_count.get(&cat).copied().unwrap_or(0); + let reserved_count = self.reserved_count.get(&cat).copied().unwrap_or(0); + let total_count = seen_count.max(reserved_count); + if total_count == 0 { + return None; + } + let done_units = self.done_units.get(&cat).copied().unwrap_or(0); + let done_count = self.done_count.get(&cat).copied().unwrap_or(0); + let reserved_units = self.reserved_units.get(&cat).copied().unwrap_or(0); + + let live: Vec<&ChildRec> = self + .children + .values() + .filter(|c| c.category == cat) + .collect(); + let live_current: u64 = live.iter().map(|c| c.current.min(c.total)).sum(); + let live_total: u64 = live.iter().map(|c| c.total).sum(); + + let phase = live + .iter() + .map(|c| c.phase) + .find(|p| *p != "Downloading") + .unwrap_or("Downloading"); + + Some(TaskView { + label: cat.label().to_string(), + phase, + current: done_units + live_current, + // Reserved total keeps the denominator stable while children are still added + total: reserved_units.max(done_units + live_total), + done_count, + total_count, + }) + }) + .collect() + } + + fn active_body(&self) -> Option<&'static str> { + let mut minecraft = false; + let mut packages = false; + for child in self.children.values() { + if child.category.is_minecraft() { + minecraft = true; + } else { + packages = true; + } + } + if minecraft { + Some("Downloading Minecraft") + } else if packages { + Some("Downloading Packages") + } else { + None + } + } +} + +impl NotificationState { + pub fn snapshot( + &self, + inbox: &[InboxEntry], + center_open: bool, + pending_prompt: Option, + ) -> NotificationSnapshot { + NotificationSnapshot { + inbox: inbox.to_vec(), + center_open, + pending_prompt, + cluster_update: self.cluster_update.clone(), + package_updates: self.package_updates.clone(), + active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), + } + } + + #[must_use] + pub fn active_toast_entry_ids(&self) -> Vec { + self.active_toasts.iter().map(|t| t.entry_id).collect() + } + + pub fn open_cluster_update(&mut self, summaries: Vec) { + if summaries.is_empty() { + return; + } + self.cluster_update = Some(summaries); + } + + pub fn close_cluster_update(&mut self) { + self.cluster_update = None; + } + + /// `done` is the launch's continuation it fires immediately when there is nothing + /// to show so a launch never waits on a modal that was never drawn + pub fn open_package_updates( + &mut self, + groups: Vec, + done: Option>, + ) { + let groups: Vec = groups + .into_iter() + .filter(|group| !group.packages.is_empty()) + .collect(); + + if groups.is_empty() { + if let Some(done) = done { + let _ = done.send(()); + } + return; + } + + // A second modal would strand the first launch so release the old continuation first + self.finish_package_updates(); + self.package_updates = Some(groups); + self.package_updates_done = done; + } + + pub fn close_package_updates(&mut self) { + self.finish_package_updates(); + } + + /// Applying and skipping are both answers either drops the row and can close the modal + pub fn resolve_package_update(&mut self, cluster_id: ClusterId, hash: &str) { + let Some(groups) = self.package_updates.as_mut() else { + return; + }; + + for group in groups.iter_mut() { + if group.cluster_id == cluster_id { + group.packages.retain(|p| p.hash != hash); + } + } + groups.retain(|group| !group.packages.is_empty()); + + if groups.is_empty() { + self.finish_package_updates(); + } + } + + /// Safe to call when nothing is open so every exit path can call it unconditionally + fn finish_package_updates(&mut self) { + self.package_updates = None; + if let Some(done) = self.package_updates_done.take() { + // A send failure means the launch task is gone nothing left to resume + let _ = done.send(()); + } + } + + pub fn unread_count(inbox: &[InboxEntry]) -> usize { + inbox.iter().filter(|entry| !entry.read).count() + } + + /// Deliberately does not snapshot this runs tens of thousands of times per download + /// and each snapshot deep-clones the inbox + /// Callers snapshot once after draining + pub fn dispatch( + &mut self, + inbox: &mut Vec, + event: Event, + ) -> (Vec, Option) { + self.pending_timers.clear(); + + match event { + Event::Notification(Notification::Message(message)) => { + let entry_id = self.push_inbox( + inbox, + message.title, + message.body, + message.level, + None, + false, + ); + self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); + } + // The sign-in modal renders this progress itself it must not also become a toast + Event::Progress(ProgressEvent::Update { id, .. }) + if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} + Event::Progress(ProgressEvent::Update { + id, + label, + current, + total, + }) => { + self.handle_progress(inbox, id, label, current, total); + } + Event::Progress(ProgressEvent::Complete { id, title, body }) => { + self.handle_progress_complete(inbox, id, title, body); + } + Event::Progress(ProgressEvent::Grouped(event)) => { + self.handle_grouped_progress(inbox, event); + } + // Handled directly by the runtime loop they never become inbox entries + Event::Signal(_) | Event::Game(_) => {} + Event::Notification(Notification::Prompt(request)) => { + let pending_prompt = Some(PendingPrompt { + title: request.title, + question: request.body, + choices: request.choices, + dismiss: request.dismiss, + reply_tx: Some(request.reply), + }); + let timers = std::mem::take(&mut self.pending_timers); + return (timers, pending_prompt); + } + } + + let timers = std::mem::take(&mut self.pending_timers); + (timers, None) + } + + pub fn toggle_center(&mut self, _inbox: &mut [InboxEntry], center_open: bool) -> bool { + let next = !center_open; + if next { + self.active_toasts.clear(); + self.pending_timers.clear(); + } + next + } + + pub fn clear_inbox(&mut self) { + self.progress_entries.clear(); + self.grouped_entries.clear(); + self.grouped_tasks.clear(); + self.active_toasts.clear(); + self.pending_timers.clear(); + } + + pub fn dismiss_toast(&mut self, inbox: &mut Vec, entry_id: u64) { + let Some(pos) = self + .active_toasts + .iter() + .position(|t| t.entry_id == entry_id) + else { + return; + }; + let entry = inbox.iter().find(|e| e.id == entry_id); + if entry.is_some_and(|e| !e.dismissable()) { + return; + } + let has_progress = entry.is_some_and(|e| e.progress.is_some()); + self.active_toasts.remove(pos); + if !has_progress { + self.forget_entry(inbox, entry_id); + } + } + + #[allow(dead_code)] + pub fn dismiss_toast_timer(&mut self, _inbox: &[InboxEntry], toast_id: u64, _entry_id: u64) { + self.active_toasts + .retain(|toast| toast.toast_id != toast_id); + } + + pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) { + self.active_toasts + .retain(|toast| toast.entry_id != entry_id); + } + + pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) { + if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { + entry.read = true; + } + } + + pub fn dismiss_notification(&mut self, inbox: &mut Vec, entry_id: u64) { + if inbox + .iter() + .find(|e| e.id == entry_id) + .is_some_and(|e| e.dismissable()) + { + self.forget_entry(inbox, entry_id); + } + } + + fn forget_entry(&mut self, inbox: &mut Vec, entry_id: u64) { + inbox.retain(|e| e.id != entry_id); + self.active_toasts.retain(|t| t.entry_id != entry_id); + self.progress_entries.retain(|_, &mut v| v != entry_id); + self.grouped_entries.retain(|_, &mut v| v != entry_id); + } + + fn push_inbox( + &mut self, + inbox: &mut Vec, + title: String, + body: String, + level: Level, + progress: Option<(u64, u64)>, + is_loading: bool, + ) -> u64 { + let id = self.next_id; + self.next_id += 1; + inbox.insert( + 0, + InboxEntry { + id, + title, + body, + level, + icon: None, + progress, + is_loading, + read: false, + created_at: Instant::now(), + actions: Vec::new(), + tasks: Vec::new(), + transfer: None, + }, + ); + id + } + + pub fn push_custom(&mut self, inbox: &mut Vec, spec: NotificationSpec) -> u64 { + let NotificationSpec { + title, + body, + level, + icon, + progress, + actions, + } = spec; + + let is_loading = progress.is_some_and(|(current, total)| total == 0 || current < total); + + let id = self.next_id; + self.next_id += 1; + inbox.insert( + 0, + InboxEntry { + id, + title, + body, + level, + icon, + progress: progress.map(|(current, total)| (current, total.max(1))), + is_loading, + read: false, + created_at: Instant::now(), + actions, + tasks: Vec::new(), + transfer: None, + }, + ); + + if progress.is_some() { + self.ensure_progress_toast(id); + } else { + self.push_ephemeral_toast(id, MESSAGE_TOAST_TTL); + } + + id + } + + fn update_inbox_entry( + &self, + inbox: &mut [InboxEntry], + entry_id: u64, + title: impl Into, + body: impl Into, + progress: Option<(u64, u64)>, + is_loading: bool, + ) { + if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { + entry.title = title.into(); + entry.body = body.into(); + entry.progress = progress; + entry.is_loading = is_loading; + entry.read = false; + } + } + + fn push_ephemeral_toast(&mut self, entry_id: u64, ttl: Duration) { + let toast_id = self.next_id; + self.next_id += 1; + self.active_toasts.push(ActiveToast { toast_id, entry_id }); + self.pending_timers.push(ToastDismissTimer { + toast_id, + entry_id, + after: ttl, + }); + } + + fn ensure_progress_toast(&mut self, entry_id: u64) { + if self + .active_toasts + .iter() + .any(|toast| toast.entry_id == entry_id) + { + return; + } + + let toast_id = self.next_id; + self.next_id += 1; + self.active_toasts.push(ActiveToast { toast_id, entry_id }); + } + + fn handle_progress( + &mut self, + inbox: &mut Vec, + id: Uuid, + label: String, + current: u64, + total: u64, + ) { + let done = total > 0 && current >= total; + let body = progress_body(&label, current, total); + let progress = Some((current, total.max(1))); + + let entry_id = if let Some(&entry_id) = self.progress_entries.get(&id) { + entry_id + } else { + let entry_id = self.push_inbox( + inbox, + label.clone(), + body.clone(), + Level::Info, + progress, + !done, + ); + self.progress_entries.insert(id, entry_id); + entry_id + }; + + self.update_inbox_entry(inbox, entry_id, label, body, progress, !done); + self.ensure_progress_toast(entry_id); + + // The id->entry mapping is kept even once `done` so a later `ProgressComplete` + // can convert this same card into its finished state + } + + /// Converts the in-flight card in place so an operation and its "done" notice stay one + /// notification falls back to a fresh message if the entry was dismissed mid-flight + fn handle_progress_complete( + &mut self, + inbox: &mut Vec, + id: Uuid, + title: String, + body: String, + ) { + if let Some(entry_id) = self.progress_entries.remove(&id) { + self.update_inbox_entry(inbox, entry_id, title, body, None, false); + self.ensure_progress_toast(entry_id); + } else { + let entry_id = self.push_inbox(inbox, title, body, Level::Info, None, false); + self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); + } + } + + fn handle_grouped_progress( + &mut self, + inbox: &mut Vec, + event: GroupedProgressEvent, + ) { + match event { + GroupedProgressEvent::Start { session_id, title } => { + let entry_id = self.push_inbox( + inbox, + title.clone(), + "Preparing...".to_string(), + Level::Info, + None, + true, + ); + self.grouped_entries.insert(session_id, entry_id); + self.grouped_tasks.insert( + session_id, + GroupedTasks { + title, + ..Default::default() + }, + ); + } + GroupedProgressEvent::Expect { + session_id, + category, + count, + total, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) { + *group.reserved_count.entry(category).or_default() += count; + *group.reserved_units.entry(category).or_default() += total; + } + self.refresh_grouped_entry(inbox, session_id); + if let Some(&entry_id) = self.grouped_entries.get(&session_id) { + self.ensure_progress_toast(entry_id); + } + } + GroupedProgressEvent::AddChild { + session_id, + child_id, + label: _, + total, + category, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) { + group.children.insert( + child_id, + ChildRec { + category, + phase: "Downloading", + current: 0, + total: total.max(1), + }, + ); + *group.seen_count.entry(category).or_default() += 1; + } + self.refresh_grouped_entry(inbox, session_id); + if let Some(&entry_id) = self.grouped_entries.get(&session_id) { + self.ensure_progress_toast(entry_id); + } + } + GroupedProgressEvent::UpdateChild { + session_id, + child_id, + current, + total, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) + && let Some(task) = group.children.get_mut(&child_id) + { + task.current = current; + task.total = total.max(1); + } + self.refresh_grouped_entry(inbox, session_id); + } + GroupedProgressEvent::SetChildPhase { + session_id, + child_id, + phase, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) + && let Some(task) = group.children.get_mut(&child_id) + { + task.phase = phase.label(); + } + self.refresh_grouped_entry(inbox, session_id); + } + GroupedProgressEvent::FinishChild { + session_id, + child_id, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) + && let Some(task) = group.children.remove(&child_id) + { + *group.done_units.entry(task.category).or_default() += task.total; + *group.done_count.entry(task.category).or_default() += 1; + } + self.refresh_grouped_entry(inbox, session_id); + } + GroupedProgressEvent::End { session_id } => { + self.grouped_tasks.remove(&session_id); + if let Some(entry_id) = self.grouped_entries.remove(&session_id) { + let had_progress = inbox + .iter() + .find(|e| e.id == entry_id) + .is_some_and(|e| e.progress.is_some()); + if !had_progress { + self.forget_entry(inbox, entry_id); + } else if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { + entry.body = "Complete".to_string(); + entry.progress = Some((1, 1)); + entry.is_loading = false; + entry.tasks = Vec::new(); + entry.transfer = None; + } + } + } + } + } + + /// Reuses the same inbox id so a progress card morphs into its result instead of + /// becoming a second entry + /// `spec: None` forgets the entry entirely + pub fn finish_grouped_as_actions( + &mut self, + inbox: &mut Vec, + session_id: Uuid, + spec: Option, + ) { + self.grouped_tasks.remove(&session_id); + let entry_id = self.grouped_entries.remove(&session_id); + + let Some(spec) = spec else { + if let Some(entry_id) = entry_id { + self.forget_entry(inbox, entry_id); + } + return; + }; + + let NotificationSpec { + title, + body, + level, + icon, + progress: _, + actions, + } = spec; + + match entry_id.and_then(|id| inbox.iter_mut().find(|e| e.id == id)) { + Some(entry) => { + entry.title = title; + entry.body = body; + entry.level = level; + entry.icon = icon; + entry.progress = None; + entry.is_loading = false; + entry.read = false; + entry.created_at = Instant::now(); + entry.actions = actions; + entry.tasks = Vec::new(); + entry.transfer = None; + // Keeping it in `active_toasts` lets the loop arm a dismiss timer + // now that the entry is no longer loading + self.ensure_progress_toast(entry.id); + } + None => { + self.push_custom( + inbox, + NotificationSpec { + title, + body, + level, + icon, + progress: None, + actions, + }, + ); + } + } + } + + fn refresh_grouped_entry(&mut self, inbox: &mut [InboxEntry], session_id: Uuid) { + let Some(&entry_id) = self.grouped_entries.get(&session_id) else { + return; + }; + let Some(group) = self.grouped_tasks.get_mut(&session_id) else { + return; + }; + + let tasks = group.task_list(); + let body = group + .active_body() + .map(str::to_string) + .unwrap_or_else(|| "Preparing...".to_string()); + let title = group.title.clone(); + + let completed: u64 = tasks.iter().map(|t| t.current.min(t.total)).sum(); + let total: u64 = tasks.iter().map(|t| t.total).sum::().max(1); + + // Below 1 B/s the estimate is noise + let transfer = group + .meter + .sample(completed, total) + .filter(|stats| stats.speed_bps >= 1.0); + + let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { + return; + }; + entry.title = title; + entry.body = body; + entry.progress = Some((completed, total)); + entry.is_loading = true; + entry.read = false; + entry.tasks = tasks; + entry.transfer = transfer; + } +} + +fn progress_body(label: &str, current: u64, total: u64) -> String { + if total == 0 { + return label.to_string(); + } + + let percent = ((current as f64 / total as f64) * 100.0).round() as u64; + format!("{label} - {percent}%") +} + +#[cfg(test)] +mod package_update_tests { + use super::*; + use oneclient_common::domain::ProviderId; + + fn update(hash: &str) -> BrowserPackageUpdate { + BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped: false, + } + } + + fn group(hashes: &[&str]) -> PackageUpdateGroup { + PackageUpdateGroup { + cluster_id: 1, + cluster_name: "Test".into(), + packages: hashes.iter().map(|hash| update(hash)).collect(), + } + } + + #[test] + fn the_launch_resumes_once_every_row_is_answered() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + state.open_package_updates(vec![group(&["a", "b"])], Some(done)); + + state.resolve_package_update(1, "a"); + assert!( + wait.try_recv().is_err(), + "a launch must wait while rows are still unanswered" + ); + + state.resolve_package_update(1, "b"); + assert!( + wait.try_recv().is_ok(), + "the last answer releases the launch" + ); + assert!(state.package_updates.is_none()); + } + + #[test] + fn dismissing_the_modal_still_releases_the_launch() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + state.open_package_updates(vec![group(&["a"])], Some(done)); + + state.close_package_updates(); + + assert!( + wait.try_recv().is_ok(), + "\"Not now\" declines the updates, not the launch" + ); + } + + #[test] + fn nothing_to_show_releases_the_launch_immediately() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + + state.open_package_updates(vec![group(&[])], Some(done)); + + assert!( + wait.try_recv().is_ok(), + "a modal that never opens cannot be answered" + ); + assert!(state.package_updates.is_none()); + } + + #[test] + fn a_second_modal_does_not_strand_the_first_launch() { + let mut state = NotificationState::default(); + let (first, mut first_wait) = oneshot::channel(); + let (second, mut second_wait) = oneshot::channel(); + + state.open_package_updates(vec![group(&["a"])], Some(first)); + state.open_package_updates(vec![group(&["b"])], Some(second)); + + assert!( + first_wait.try_recv().is_ok(), + "the replaced launch is let go" + ); + assert!(second_wait.try_recv().is_err(), "the new one still waits"); + } + + #[test] + fn resolving_an_unknown_package_is_a_no_op() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + state.open_package_updates(vec![group(&["a"])], Some(done)); + + state.resolve_package_update(1, "not-here"); + state.resolve_package_update(99, "a"); + + assert!(wait.try_recv().is_err()); + assert!(state.package_updates.is_some()); + } +} diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs new file mode 100644 index 00000000..d6cc40a1 --- /dev/null +++ b/packages/oneclient_app/src/platform.rs @@ -0,0 +1,56 @@ +pub fn open_url(url: &str) { + if let Err(err) = open::that_detached(url) { + tracing::warn!("failed to open url {url}: {err}"); + } +} + +pub fn copy_image_to_clipboard(path: std::path::PathBuf) { + std::thread::spawn(move || { + let img = match image::open(&path) { + Ok(img) => img.into_rgba8(), + Err(err) => { + tracing::warn!("failed to decode {} for clipboard: {err}", path.display()); + return; + } + }; + let (width, height) = (img.width() as usize, img.height() as usize); + let data = arboard::ImageData { + width, + height, + bytes: std::borrow::Cow::Owned(img.into_raw()), + }; + match arboard::Clipboard::new() { + Ok(mut clip) => { + if let Err(err) = clip.set_image(data) { + tracing::warn!("failed to copy image to clipboard: {err}"); + } + } + Err(err) => tracing::warn!("failed to open clipboard: {err}"), + } + }); +} + +#[cfg(target_os = "macos")] +pub mod macos { + use std::time::Duration; + + pub fn loop_memory_collector() { + tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(8)).await; + + loop { + release_unused_memory(); + tokio::time::sleep(Duration::from_secs(60)).await; + } + }); + } + + fn release_unused_memory() { + unsafe { + unsafe extern "C" { + fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize; + } + malloc_zone_pressure_relief(core::ptr::null_mut(), 0); + } + } +} diff --git a/packages/oneclient_app/src/recovery.rs b/packages/oneclient_app/src/recovery.rs new file mode 100644 index 00000000..9478d4e9 --- /dev/null +++ b/packages/oneclient_app/src/recovery.rs @@ -0,0 +1,72 @@ +use std::path::PathBuf; + +use freya::prelude::spawn_forever; + +use crate::hooks::Actions; + +pub fn database_path() -> Option { + oneclient_common::paths::database_file().ok() +} + +pub fn snapshots() -> Vec { + database_path() + .map(|path| oneclient_db::backup::list(&path)) + .unwrap_or_default() +} + +pub fn open_data_folder() { + let Ok(dir) = oneclient_common::paths::launcher_dir() else { + return; + }; + + crate::platform::open_url(&dir.to_string_lossy()); +} + +pub fn restore_latest(actions: &Actions) { + let Some(path) = database_path() else { return }; + let Some(snapshot) = snapshots().into_iter().next() else { + set_error(actions, "There are no snapshots to restore.".to_owned()); + return; + }; + + match oneclient_db::backup::restore(&path, &snapshot) { + Ok(_) => retry(actions), + Err(err) => set_error(actions, format!("Couldn't restore the snapshot: {err}")), + } +} + +pub fn reset(actions: &Actions) { + let Some(path) = database_path() else { return }; + + match oneclient_db::backup::reset(&path) { + Ok(_) => retry(actions), + Err(err) => set_error(actions, format!("Couldn't reset the database: {err}")), + } +} + +fn retry(actions: &Actions) { + let station = actions.station(); + let events = actions.events(); + + { + let mut guard = station + .clone() + .write_channel(crate::state::AppChannel::Launcher); + guard.launcher.error = None; + guard.launcher.snapshots = snapshots().len(); + } + + spawn_forever(async move { + if let Err(err) = crate::events::start_launcher(station, events).await { + crate::events::report_startup_failure(&station, &err); + } + }); +} + +fn set_error(actions: &Actions, message: String) { + let mut guard = actions + .station() + .write_channel(crate::state::AppChannel::Launcher); + guard.launcher.error = Some(message); + guard.launcher.snapshots = snapshots().len(); +} diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs new file mode 100644 index 00000000..de586dc8 --- /dev/null +++ b/packages/oneclient_app/src/routes.rs @@ -0,0 +1,168 @@ +use freya::prelude::*; +use freya::router::*; + +use crate::layout::AppShell; +use crate::layout::ClusterShell; +use crate::layout::OnboardingShell; +use crate::layout::RootLayout; +use crate::layout::SettingsShell; + +use crate::view::{ + NotFound, Startup, + app::{ + AccountSkins, Clusters, Debug, Home, Stats, + browser::{Browser, BrowserPackage}, + cluster::{ + ClusterLogs, ClusterMods, ClusterOverview, ClusterScreenshots, ClusterSettings, + ClusterShaders, ClusterTextures, ProcessLogs, + }, + settings::{ + SettingsAccounts, SettingsApis, SettingsAppearance, SettingsChangelog, + SettingsDeveloper, SettingsJava, SettingsLanguage, SettingsLauncher, SettingsMinecraft, + SettingsStorage, + }, + }, + onboarding::{ + OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, + OnboardingMigration, OnboardingPreferences, OnboardingTerms, OnboardingWelcome, + }, +}; + +#[derive(Routable, Debug, Clone, PartialEq)] +#[rustfmt::skip] +pub enum Route { + #[layout(RootLayout)] + #[route("/")] + Startup {}, + + #[layout(OnboardingShell)] + #[route("/onboarding")] + OnboardingWelcome {}, + #[route("/onboarding/terms")] + OnboardingTerms {}, + #[route("/onboarding/migration")] + OnboardingMigration {}, + #[route("/onboarding/language")] + OnboardingLanguage {}, + #[route("/onboarding/account")] + OnboardingAccount {}, + #[route("/onboarding/preferences")] + OnboardingPreferences {}, + #[route("/onboarding/bundles")] + OnboardingBundles {}, + #[route("/onboarding/downloading")] + OnboardingDownloading {}, + #[end_layout] + + #[layout(AppShell)] + #[route("/app")] + Home {}, + #[route("/app/clusters")] + Clusters {}, + + #[layout(ClusterShell)] + #[route("/app/clusters/:cluster_id")] + ClusterOverview { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/logs")] + ClusterLogs { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/process")] + ProcessLogs { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/screenshots")] + ClusterScreenshots { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/mods")] + ClusterMods { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/shaders")] + ClusterShaders { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/textures")] + ClusterTextures { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/settings")] + ClusterSettings { cluster_id: i64 }, + #[end_layout] + + #[route("/app/browser/:cluster_id/:package_type?:pick_cluster")] + Browser { cluster_id: i64, package_type: String, pick_cluster: bool }, + #[route("/app/browser/:cluster_id/:package_type/package/:package_id")] + BrowserPackage { cluster_id: i64, package_type: String, package_id: String }, + #[route("/app/account/skins")] + AccountSkins {}, + #[route("/app/stats")] + Stats {}, + #[route("/app/debug")] + Debug {}, + + #[layout(SettingsShell)] + #[route("/app/settings/appearance")] + SettingsAppearance {}, + #[route("/app/settings/minecraft")] + SettingsMinecraft {}, + #[route("/app/settings/accounts")] + SettingsAccounts {}, + #[route("/app/settings/launcher")] + SettingsLauncher {}, + #[route("/app/settings/java")] + SettingsJava {}, + #[route("/app/settings/storage")] + SettingsStorage {}, + #[route("/app/settings/apis")] + SettingsApis {}, + #[route("/app/settings/language")] + SettingsLanguage {}, + #[route("/app/settings/developer")] + SettingsDeveloper {}, + #[route("/app/settings/changelog")] + SettingsChangelog {}, + // #[route("/app/settings/feedback")] + // SettingsFeedback {}, + #[end_layout] + #[end_layout] + + #[route("/:..path")] + NotFound { path: Vec }, +} + +impl Route { + pub fn title(&self) -> String { + 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(), + Route::OnboardingPreferences { .. } => "Accessibility".to_string(), + Route::OnboardingBundles { .. } => "Mods".to_string(), + Route::OnboardingDownloading { .. } => "Finishing".to_string(), + Route::Home { .. } => "Home".to_string(), + Route::Clusters { .. } => "Versions".to_string(), + Route::ClusterOverview { .. } => "Cluster".to_string(), + Route::ClusterLogs { .. } => "Cluster Logs".to_string(), + Route::ProcessLogs { .. } => "Process".to_string(), + Route::ClusterScreenshots { .. } => "Cluster Screenshots".to_string(), + Route::ClusterMods { .. } => "Cluster Mods".to_string(), + Route::ClusterShaders { .. } => "Cluster Shaders".to_string(), + Route::ClusterTextures { .. } => "Cluster Textures".to_string(), + Route::ClusterSettings { .. } => "Cluster Settings".to_string(), + Route::Browser { .. } => "Browser".to_string(), + Route::BrowserPackage { .. } => "Browser".to_string(), + Route::AccountSkins { .. } => "Skins".to_string(), + Route::Stats { .. } => "Statistics".to_string(), + Route::Debug { .. } => "Debug".to_string(), + Route::SettingsAppearance { .. } => "Settings".to_string(), + Route::SettingsMinecraft { .. } => "Minecraft Settings".to_string(), + Route::SettingsAccounts { .. } => "Accounts".to_string(), + Route::SettingsLauncher { .. } => "Launcher Settings".to_string(), + Route::SettingsJava { .. } => "Java".to_string(), + Route::SettingsStorage { .. } => "Storage".to_string(), + Route::SettingsApis { .. } => "APIs".to_string(), + Route::SettingsLanguage { .. } => "Language".to_string(), + Route::SettingsDeveloper { .. } => "Developer Options".to_string(), + Route::SettingsChangelog { .. } => "Changelog".to_string(), + // Route::SettingsFeedback { .. } => "Feedback".to_string(), + Route::NotFound { .. } => "Not Found".to_string(), + } + } +} + +pub fn router() -> impl IntoElement { + Router::::new(|| RouterConfig::default().with_initial_path(Route::Startup {})) +} diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs new file mode 100644 index 00000000..eae4ac29 --- /dev/null +++ b/packages/oneclient_app/src/state.rs @@ -0,0 +1,207 @@ +//! [`AppChannel`] gives per-concern subscription a single `watch` channel woke every +//! consumer and deep-cloned the inbox on every event + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use freya::radio::RadioChannel; +use oneclient_common::domain::ProviderId; +use oneclient_core::settings::LauncherSettings; +use oneclient_events::LaunchStage; + +use crate::notifications::{InboxEntry, NotificationState, PendingPrompt}; + +/// Writing through a channel wakes only the components that subscribed to it +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum AppChannel { + Launcher, + Settings, + Notifications, + Game, + AccountSwitcher, + MicrosoftLogin, + Installs, +} + +impl RadioChannel for AppChannel {} + +#[derive(Default)] +pub struct AppState { + pub launcher: LauncherInit, + pub settings: SettingsState, + /// The engine itself not a snapshot both the pump and UI actions fold into it + pub notifications: NotificationState, + /// Held beside the engine which folds events into it by `&mut` so the engine + /// stays a plain state machine with no channels of its own + pub inbox: Vec, + pub prompt: Option, + pub center_open: bool, + pub game: GameState, + pub account_switcher_open: bool, + pub microsoft_login: Option, + pub installs: InstallState, +} + +/// In-flight installs so the button that started one stays disabled until it lands +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InstallState { + pending: HashSet<(i64, ProviderId, String)>, +} + +impl InstallState { + pub fn begin(&mut self, cluster_id: i64, provider: ProviderId, project_id: String) { + self.pending.insert((cluster_id, provider, project_id)); + } + + pub fn finish(&mut self, cluster_id: i64, provider: ProviderId, project_id: &str) { + self.pending + .remove(&(cluster_id, provider, project_id.to_string())); + } + + #[must_use] + pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool { + self.pending + .contains(&(cluster_id, provider, project_id.to_string())) + } +} + +#[derive(Clone, Debug, Default)] +pub struct LauncherInit { + pub ready: bool, + pub fetching: bool, + /// Launch is disabled until this clears + pub syncing_bundles: bool, + pub error: Option, + pub data_dir: String, + /// Restorable database snapshots, for the startup failure screen + pub snapshots: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AsyncStatus { + #[default] + Idle, + Loading, + Ready, + Error, +} + +#[derive(Clone, Debug, Default)] +pub struct SettingsState { + pub settings: LauncherSettings, + pub status: AsyncStatus, + pub saving: bool, + pub error: Option, +} + +/// Rendered inside the sign-in modal rather than as a toast the core reports it +/// as ordinary progress and has no opinion about where it is shown +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoginProgress { + pub label: String, + pub current: u64, + pub total: u64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GameState { + pub stages: HashMap, + pub error: Option, + pub logs: HashMap>>>, + /// Launches started from the UI but not yet answered by core which takes a few + /// hundred ms every click in that window otherwise spawns its own game + pending: HashSet, +} + +impl GameState { + #[must_use] + pub fn stage(&self, cluster_id: i64) -> Option { + self.stages.get(&cluster_id).copied() + } + + /// Returns false if a launch is already in flight the re-entrancy guard + pub fn begin_launch(&mut self, cluster_id: i64) -> bool { + if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { + return false; + } + self.pending.insert(cluster_id); + true + } + + pub fn finish_launch(&mut self, cluster_id: i64) { + self.pending.remove(&cluster_id); + } + + #[must_use] + pub fn is_launch_pending(&self, cluster_id: i64) -> bool { + self.pending.contains(&cluster_id) + } + + #[must_use] + pub fn is_busy(&self, cluster_id: i64) -> bool { + self.stage(cluster_id).is_some_and(LaunchStage::is_busy) + } + + #[must_use] + pub fn is_running(&self, cluster_id: i64) -> bool { + self.stage(cluster_id) == Some(LaunchStage::Running) + } + + #[must_use] + pub fn is_active(&self, cluster_id: i64) -> bool { + matches!(self.stage(cluster_id), Some(s) if s != LaunchStage::Exited) + } + + #[must_use] + pub fn logs_for(&self, cluster_id: i64) -> Arc>> { + self.logs.get(&cluster_id).cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::app::launch_button_state; + + #[test] + fn a_second_click_is_refused_before_core_answers() { + let mut game = GameState::default(); + + assert!(game.begin_launch(1)); + assert!(!game.begin_launch(1)); + assert!(game.begin_launch(2)); + } + + #[test] + fn a_running_cluster_cannot_be_launched_again() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Running); + + assert!(!game.begin_launch(1)); + } + + #[test] + fn the_claim_is_released_when_the_launch_settles() { + let mut game = GameState::default(); + + assert!(game.begin_launch(1)); + game.finish_launch(1); + assert!(game.begin_launch(1)); + } + + #[test] + fn the_button_disables_on_the_claim_alone() { + let mut game = GameState::default(); + assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + + game.begin_launch(1); + assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + + // Held past a failure which parks the stage at `Exited` + game.stages.insert(1, LaunchStage::Exited); + assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + + game.finish_launch(1); + assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + } +} diff --git a/packages/oneclient_app/src/theme/colors.rs b/packages/oneclient_app/src/theme/colors.rs new file mode 100644 index 00000000..0db8798d --- /dev/null +++ b/packages/oneclient_app/src/theme/colors.rs @@ -0,0 +1,152 @@ +use freya::prelude::Color; + +pub const MODRINTH_COLOR: Color = Color::from_rgb(27, 217, 106); +pub const CURSEFORGE_COLOR: Color = Color::from_rgb(241, 100, 54); + +pub fn page() -> Color { + Color::from_rgb(17, 23, 28) +} + +pub fn page_elevated() -> Color { + Color::from_rgb(21, 28, 34) +} + +pub fn page_overlay() -> Color { + Color::from_argb(140, 17, 23, 28) +} + +pub fn fg_primary() -> Color { + Color::from_rgb(213, 219, 255) +} + +pub fn fg_primary_hover() -> Color { + Color::from_rgb(218, 224, 255) +} + +pub fn fg_primary_pressed() -> Color { + Color::from_rgb(225, 229, 255) +} + +pub fn fg_primary_disabled() -> Color { + Color::from_rgb(205, 209, 235) +} + +pub fn fg_secondary() -> Color { + Color::from_rgb(120, 129, 141) +} + +pub fn fg_secondary_hover() -> Color { + Color::from_rgb(95, 104, 116) +} + +pub fn fg_secondary_pressed() -> Color { + Color::from_rgb(75, 84, 96) +} + +pub fn brand() -> Color { + Color::from_rgb(43, 75, 255) +} + +pub fn brand_hover() -> Color { + Color::from_rgb(40, 67, 221) +} + +pub fn brand_pressed() -> Color { + Color::from_rgb(57, 87, 255) +} + +pub fn brand_disabled() -> Color { + Color::from_rgb(31, 47, 129) +} + +pub fn ghost_overlay() -> Color { + Color::from_argb(12, 255, 255, 255) +} + +pub fn ghost_overlay_hover() -> Color { + Color::from_argb(26, 255, 255, 255) +} + +pub fn ghost_overlay_pressed() -> Color { + Color::from_argb(38, 255, 255, 255) +} + +pub fn component_bg() -> Color { + Color::from_rgb(26, 34, 40) +} + +pub fn component_bg_hover() -> Color { + Color::from_rgb(29, 36, 43) +} + +pub fn component_bg_pressed() -> Color { + Color::from_rgb(34, 44, 53) +} + +pub fn component_bg_disabled() -> Color { + Color::from_rgb(16, 24, 31) +} + +pub fn component_border() -> Color { + Color::from_argb(12, 255, 255, 255) +} + +pub fn component_border_hover() -> Color { + Color::from_argb(25, 255, 255, 255) +} + +pub fn component_border_pressed() -> Color { + Color::from_argb(38, 255, 255, 255) +} + +pub fn danger() -> Color { + Color::from_rgb(255, 68, 68) +} + +pub fn danger_hover() -> Color { + Color::from_rgb(214, 52, 52) +} + +pub fn danger_pressed() -> Color { + Color::from_rgb(255, 86, 86) +} + +pub fn danger_disabled() -> Color { + Color::from_rgb(235, 48, 48) +} + +pub fn success() -> Color { + Color::from_rgb(35, 154, 96) +} + +pub fn code_info() -> Color { + Color::from_rgb(97, 175, 239) +} + +pub fn code_warn() -> Color { + Color::from_rgb(229, 192, 123) +} + +pub fn code_error() -> Color { + Color::from_rgb(224, 108, 117) +} + +pub fn code_debug() -> Color { + Color::from_rgb(152, 195, 121) +} + +pub fn code_chat() -> Color { + Color::from_rgb(198, 120, 221) +} + +pub fn code_muted() -> Color { + Color::from_rgb(120, 128, 140) +} + +pub fn selection_bg() -> Color { + Color::from_rgb(97, 175, 239).with_a(60) +} + +pub fn toast_action() -> Color { + Color::from_rgb(155, 161, 166) +} diff --git a/packages/oneclient_app/src/theme/mod.rs b/packages/oneclient_app/src/theme/mod.rs new file mode 100644 index 00000000..771d4be5 --- /dev/null +++ b/packages/oneclient_app/src/theme/mod.rs @@ -0,0 +1,38 @@ +use bytes::Bytes; + +use crate::AppAssets; + +pub mod colors; + +pub const NAVBAR_HEIGHT_PX: f32 = 80.; +pub const HOME_PADDING_PX: f32 = 48.; +pub const DEFAULT_FONT: &str = "Poppins"; +pub const MONO_FONT: &str = "JetBrains Mono"; + +pub fn load_fonts() -> Vec<(&'static str, Bytes)> { + const FONTS: [(&str, &str); 11] = [ + (DEFAULT_FONT, "fonts/Poppins/Poppins-Bold.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-BoldItalic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-SemiBold.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-SemiBoldItalic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Medium.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-MediumItalic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Regular.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Italic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Light.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-LightItalic.ttf"), + (MONO_FONT, "fonts/JetBrainsMono/JetBrainsMonoVariable.ttf"), + ]; + + FONTS + .iter() + .filter_map(|&(weight, file)| { + let bytes = AppAssets::get_bytes(file).or_else(|| { + tracing::error!("Failed to load embedded font '{file}'"); + None + })?; + + Some((weight, bytes)) + }) + .collect() +} diff --git a/packages/oneclient_app/src/transfer.rs b/packages/oneclient_app/src/transfer.rs new file mode 100644 index 00000000..85b52480 --- /dev/null +++ b/packages/oneclient_app/src/transfer.rs @@ -0,0 +1,114 @@ +use std::time::Instant; + +/// Weight of the existing average per sample kept sluggish because downloads are bursty +const SMOOTHING: f64 = 0.7; + +#[derive(Debug, Clone, Copy, Default)] +pub struct TransferMeter { + last_sample: Option<(Instant, u64)>, + speed_bps: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TransferStats { + pub speed_bps: f64, + /// `None` until there is enough signal to estimate or when the total is unknown + pub eta_secs: Option, +} + +impl TransferMeter { + /// `completed` and `total` are cumulative bytes + /// Samples under 250ms apart are not folded in + /// the tiny elapsed divisor would swamp the average with noise + pub fn sample(&mut self, completed: u64, total: u64) -> Option { + let now = Instant::now(); + + let Some((last_at, last_completed)) = self.last_sample else { + self.last_sample = Some((now, completed)); + return None; + }; + + let elapsed = now.duration_since(last_at).as_secs_f64(); + if elapsed < 0.25 { + return self.stats(completed, total); + } + + let delta = completed.saturating_sub(last_completed) as f64; + let instant = delta / elapsed; + + self.speed_bps = if self.speed_bps <= 0.0 { + instant + } else { + self.speed_bps * SMOOTHING + instant * (1.0 - SMOOTHING) + }; + self.last_sample = Some((now, completed)); + + self.stats(completed, total) + } + + fn stats(&self, completed: u64, total: u64) -> Option { + if self.speed_bps <= 0.0 { + return None; + } + + let remaining = total.saturating_sub(completed); + Some(TransferStats { + speed_bps: self.speed_bps, + // No total means no estimate the denominator may still be climbing + eta_secs: (total > 0).then(|| (remaining as f64 / self.speed_bps) as u64), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_sample_only_establishes_a_baseline() { + let mut meter = TransferMeter::default(); + assert!(meter.sample(0, 1000).is_none()); + } + + #[test] + fn a_total_of_zero_yields_speed_without_an_eta() { + let mut meter = TransferMeter::default(); + meter.sample(0, 0); + std::thread::sleep(std::time::Duration::from_millis(300)); + + let stats = meter.sample(1_000, 0).expect("should have a rate by now"); + assert!(stats.speed_bps > 0.0); + assert_eq!(stats.eta_secs, None); + } + + #[test] + fn eta_falls_as_the_download_completes() { + let mut meter = TransferMeter::default(); + meter.sample(0, 10_000); + std::thread::sleep(std::time::Duration::from_millis(300)); + let early = meter.sample(1_000, 10_000).unwrap().eta_secs.unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(300)); + let late = meter.sample(9_000, 10_000).unwrap().eta_secs.unwrap(); + + assert!(late < early, "eta should shrink: {early} -> {late}"); + } + + #[test] + fn samples_taken_too_close_together_do_not_distort_the_average() { + let mut meter = TransferMeter::default(); + meter.sample(0, 10_000); + std::thread::sleep(std::time::Duration::from_millis(300)); + let settled = meter.sample(1_000, 10_000).unwrap().speed_bps; + + for i in 1..50 { + meter.sample(1_000 + i, 10_000); + } + + let after = meter.speed_bps; + assert!( + (after - settled).abs() < f64::EPSILON, + "burst of close samples changed the rate: {settled} -> {after}" + ); + } +} diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs new file mode 100644 index 00000000..0e075fe3 --- /dev/null +++ b/packages/oneclient_app/src/ui.rs @@ -0,0 +1,131 @@ +use std::time::Instant; + +use freya::prelude::*; + +use crate::theme; + +pub fn border_all(width: f32) -> Border { + Border::new() + .fill(theme::colors::component_border()) + .width(BorderWidth { + top: width, + right: width, + bottom: width, + left: width, + }) +} + +pub fn border_all_color(width: f32, color: Color) -> Border { + Border::new().fill(color).width(BorderWidth { + top: width, + right: width, + bottom: width, + left: width, + }) +} + +pub fn fmt_date(ts: chrono::DateTime) -> String { + ts.format("%Y-%m-%d %H:%M").to_string() +} + +/// Shortest band is a minute nothing re-renders on a timer so a seconds counter +/// would freeze at whatever it read when the row was drawn +pub fn relative_time(created_at: Instant) -> String { + let secs = created_at.elapsed().as_secs(); + match secs { + 0..=59 => "Just now".to_string(), + 60..=3599 => format!("{}m ago", secs / 60), + 3600..=86_399 => format!("{}h ago", secs / 3600), + _ => format!("{}d ago", secs / 86_400), + } +} + +/// Returns the `Rect` not an `Element` so callers can inset or round it +pub fn divider() -> Rect { + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(theme::colors::component_border()) +} + +pub fn centered_note(text: &str) -> Element { + rect() + .width(Size::fill()) + .height(Size::px(240.)) + .center() + .child( + label() + .text(text.to_string()) + .font_size(14.) + .color(theme::colors::fg_secondary()), + ) + .into_element() +} + +/// Returns 1 until the grid has measured itself so the first frame still renders +pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize { + if width <= 0. { + return 1; + } + (((width + gap) / (max_col + gap)).ceil() as usize).max(1) +} + +/// Reports its measured size back through `width` for [`grid_columns_for_width`] +/// Short final rows are padded with empty flex cells so tiles keep the column width +pub fn flow_grid(items: Vec, cols: usize, mut width: State, gap: f32) -> Element { + let cols = cols.max(1); + + let mut root = rect().vertical().width(Size::fill()).spacing(gap); + let mut iter = items.into_iter(); + let mut remaining = true; + while remaining { + let mut row = rect() + .horizontal() + .width(Size::fill()) + .spacing(gap) + .content(Content::Flex); + let mut filled = 0; + for _ in 0..cols { + if let Some(item) = iter.next() { + row = row.child(item); + filled += 1; + } else { + row = row.child(rect().width(Size::flex(1.0))); + } + } + if filled == 0 { + break; + } + remaining = filled == cols; + root = root.child(row.into_element()); + } + + root.on_sized(move |event: Event| { + let w = event.data().area.width(); + if (w - *width.peek()).abs() > 0.5 { + *width.write() = w; + } + }) + .into_element() +} + +pub fn entrance_motion_layer( + slide_x: f32, + slide_y: f32, + opacity: f32, + child: impl IntoElement, +) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute().top(slide_y).left(slide_x)) + .opacity(opacity) + .child(child), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs new file mode 100644 index 00000000..20691f2f --- /dev/null +++ b/packages/oneclient_app/src/updater.rs @@ -0,0 +1,185 @@ +use std::cell::Cell; + +use cargo_packager_updater::{Config, Update, check_update}; +use oneclient_events::{Choice, EventBus, Prompt}; +use uuid::Uuid; + +use crate::constants::{RELEASES_URL, UPDATER_ENDPOINT, UPDATER_PUBKEY}; + +pub const UPDATE_CHOICE_INSTALL: &str = "update.install"; + +enum UpdateAnswer { + Install, +} + +fn update_prompt(version: &str) -> Prompt { + Prompt::new( + "Update available", + format!("OneClient {version} is ready to install. Download and install it now?"), + ) + .option( + Choice::primary(UPDATE_CHOICE_INSTALL, "Install"), + UpdateAnswer::Install, + ) + .dismiss("Not now") +} + +const PROGRESS_STEP: u64 = 256 * 1024; + +pub fn spawn_update_check(auto_install: bool, events: EventBus) { + tokio::spawn(async move { + if let Err(err) = run_check(auto_install, events).await { + tracing::warn!("update check failed: {err:#}"); + } + }); +} + +/// Debug-only drives the full auto-update UX +pub fn spawn_simulated_update() { + tokio::spawn(async move { + if let Err(err) = run_simulated_update().await { + tracing::warn!("simulated update failed: {err:#}"); + } + }); +} + +async fn run_simulated_update() -> anyhow::Result<()> { + const FAKE_VERSION: &str = "9999.9999.9999"; + const FAKE_TOTAL: u64 = 48 * 1024 * 1024; + + let events = crate::launcher::state()?.services.events.clone(); + + if events.ask(update_prompt(FAKE_VERSION)).await?.is_none() { + tracing::info!("user declined simulated update"); + return Ok(()); + } + + let progress_id = Uuid::new_v4(); + let label = format!("Downloading OneClient {FAKE_VERSION}"); + + let mut downloaded = 0u64; + events.progress(progress_id, &label, downloaded, FAKE_TOTAL); + while downloaded < FAKE_TOTAL { + downloaded = (downloaded + PROGRESS_STEP * 8).min(FAKE_TOTAL); + events.progress(progress_id, &label, downloaded, FAKE_TOTAL); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + events.finish_progress( + progress_id, + "Finished Downloading", + format!("OneClient {FAKE_VERSION} is ready. Restart to apply."), + ); + + Ok(()) +} + +async fn run_check(auto_install: bool, events: EventBus) -> anyhow::Result<()> { + // `check_update` performs a blocking HTTP request so offload it to a thread pool + let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else { + tracing::info!("no update available"); + return Ok(()); + }; + + tracing::info!("update available: {}", update.version); + + // cargo-packager-updater can only replace an AppImage in place deb/rpm installs live + // under a package-managed path that an install would fail on or clobber + if !can_self_update() { + tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only"); + events + .notify("Update available") + .body(format!( + "OneClient {} is available. Download the latest package from {} to update.", + update.version, RELEASES_URL + )) + .send(); + return Ok(()); + } + + if !auto_install && events.ask(update_prompt(&update.version)).await?.is_none() { + tracing::info!("user declined update {}", update.version); + return Ok(()); + } + + download_and_install(update, events).await +} + +fn can_self_update() -> bool { + if cfg!(debug_assertions) { + return false; + } + + if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE") + .is_some_and(|val| val.eq_ignore_ascii_case("1")) + { + return false; + } + + #[cfg(target_os = "linux")] + { + std::env::var_os("APPIMAGE").is_some() + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + +fn check_for_update() -> anyhow::Result> { + let current = env!("CARGO_PKG_VERSION").parse()?; + let config = Config { + endpoints: vec![UPDATER_ENDPOINT.parse()?], + pubkey: UPDATER_PUBKEY.into(), + ..Default::default() + }; + + Ok(check_update(current, config)?) +} + +async fn download_and_install(update: Update, events: EventBus) -> anyhow::Result<()> { + let progress_id = Uuid::new_v4(); + let version = update.version.clone(); + let label = format!("Downloading OneClient {version}"); + + events.progress(progress_id, &label, 0, 0); + + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let downloaded = Cell::new(0u64); + let last_sent = Cell::new(0u64); + + let bytes = update.download_extended( + |chunk, total| { + let now = downloaded.get() + chunk as u64; + downloaded.set(now); + let total = total.unwrap_or(0); + + if now == chunk as u64 + || (total > 0 && now >= total) + || now - last_sent.get() >= PROGRESS_STEP + { + last_sent.set(now); + events.progress(progress_id, &label, now, total); + } + }, + || {}, + )?; + + let total = downloaded.get().max(1); + events.progress(progress_id, &label, total, total); + + update.install(bytes)?; + + // Converts the same download card into its finished state rather than adding a second + events.finish_progress( + progress_id, + "Finished Downloading", + format!("OneClient {version} is ready. Restart to apply."), + ); + Ok(()) + }) + .await??; + + tracing::info!("update installed; restart to apply"); + Ok(()) +} diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs new file mode 100644 index 00000000..ecea19aa --- /dev/null +++ b/packages/oneclient_app/src/utils.rs @@ -0,0 +1,485 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; + +use chrono::{Datelike, NaiveDate}; +use oneclient_common::domain::GameLoader; +use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; +use oneclient_core::clusters::Cluster; +use sysinfo::{MemoryRefreshKind, RefreshKind, System}; + +pub type ClusterGroups = BTreeMap>; + +/// The modern scheme puts a full release in the first two components (`26.1`) so each +/// minor is its own line legacy `1.x` versions keep the whole major (`1.21`) as one line +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ReleaseLine { + pub major: u32, + /// `Some` only for the modern scheme where the minor is part of the line + pub minor: Option, +} + +impl ReleaseLine { + fn from_parsed(parsed: &ParsedMcVersion) -> Option { + let minor = if parsed.major >= 26 { + Some(parsed.minor?) + } else { + None + }; + Some(Self { + major: parsed.major, + minor, + }) + } + + pub fn from_version(version: &str) -> Option { + Self::from_parsed(&parse_mc_version(version)?) + } + + pub fn for_cluster(cluster: &Cluster) -> Option { + Self::from_version(&cluster.mc_version) + } + + /// The generic name of the line `26.1` `1.21` + pub fn pretty_name(&self) -> String { + match self.minor { + Some(minor) => format!("{}.{minor}", self.major), + None => format!("1.{}", self.major), + } + } + + fn art_key(&self) -> Option { + self.minor.map(|minor| (minor, None)) + } +} + +fn cluster_key(cluster: &Cluster) -> Option { + parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.key()) +} + +fn sole_version_key(clusters: &[Cluster]) -> Option { + match version_keys(clusters).as_slice() { + [only] => Some(*only), + _ => None, + } +} + +/// A line with a single version is shown as that full version (`26.1.2`) else `1.21` +pub fn line_title(line: ReleaseLine, clusters: &[Cluster]) -> String { + match sole_version_key(clusters) { + Some(key) => version_label(line.major, key), + None => line.pretty_name(), + } +} + +pub fn line_art_key(line: ReleaseLine, clusters: &[Cluster]) -> Option { + sole_version_key(clusters).or_else(|| line.art_key()) +} + +pub fn group_clusters_by_release(clusters: &[Cluster]) -> ClusterGroups { + let mut groups: ClusterGroups = BTreeMap::new(); + + for cluster in clusters { + let Some(line) = ReleaseLine::for_cluster(cluster) else { + continue; + }; + groups.entry(line).or_default().push(cluster.clone()); + } + + for list in groups.values_mut() { + list.sort_by_key(|b| std::cmp::Reverse(cluster_key(b))); + } + + groups +} + +pub fn loader_tags(clusters: &[Cluster]) -> Vec { + let mut tags = Vec::new(); + for cluster in clusters { + if cluster.mc_loader.is_modded() { + let label = cluster.mc_loader.to_string(); + if !tags.iter().any(|t| t == &label) { + tags.push(label); + } + } + } + tags.sort(); + tags +} + +pub fn version_keys(clusters: &[Cluster]) -> Vec { + let mut keys = Vec::new(); + for cluster in clusters { + if let Some(key) = cluster_key(cluster) + && !keys.contains(&key) + { + keys.push(key); + } + } + keys.sort(); + keys +} + +pub fn loaders_for_line(clusters: &[Cluster]) -> Vec { + let mut loaders = Vec::new(); + for cluster in clusters { + if cluster.mc_loader.is_modded() && !loaders.contains(&cluster.mc_loader) { + loaders.push(cluster.mc_loader); + } + } + loaders.sort_by_key(|loader| loader.to_string()); + loaders +} + +pub fn resolve_cluster( + clusters: &[Cluster], + key: Option, + loader: Option, +) -> Option { + clusters + .iter() + .find(|cluster| { + let version_ok = key.is_none_or(|key| cluster_key(cluster) == Some(key)); + let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader); + version_ok && loader_ok + }) + .cloned() +} + +/// The line the page opens on the active cluster's line else the newest +pub fn default_line(groups: &ClusterGroups, active: Option) -> Option { + if let Some(cluster) = active + && let Some(line) = ReleaseLine::for_cluster(&cluster) + { + return Some(line); + } + groups.keys().next_back().copied() +} + +pub fn default_version_key( + clusters: &[Cluster], + preferred: Option, +) -> Option { + let keys = version_keys(clusters); + if keys.is_empty() { + return None; + } + if let Some(key) = preferred + && keys.contains(&key) + { + return Some(key); + } + keys.last().copied() +} + +pub fn default_loader(clusters: &[Cluster], preferred: Option) -> Option { + let loaders = loaders_for_line(clusters); + if loaders.is_empty() { + return clusters.first().map(|c| c.mc_loader); + } + if let Some(loader) = preferred + && loaders.contains(&loader) + { + return Some(loader); + } + Some(loaders[0]) +} + +pub fn version_label(major: u32, (minor, patch): VersionKey) -> String { + format_mc_version(major, minor, patch) +} + +pub fn format_size(bytes: u64) -> String { + const KB: f64 = 1024.; + const MB: f64 = KB * 1024.; + let b = bytes as f64; + if b >= MB { + format!("{:.1} MB", b / MB) + } else if b >= KB { + format!("{:.0} KB", b / KB) + } else { + format!("{bytes} B") + } +} + +pub fn format_res((w, h): (u32, u32)) -> String { + format!("{w}×{h}") +} + +/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM, +/// so the max preset is 16GB - 2GB = 14GB) +const MEMORY_HEADROOM_GB: u32 = 2; +const MEMORY_PRESETS_GB: [u32; 10] = [2, 4, 6, 8, 12, 16, 24, 32, 48, 64]; + +pub fn total_ram_mb() -> u32 { + static TOTAL_RAM_MB: std::sync::OnceLock = std::sync::OnceLock::new(); + + *TOTAL_RAM_MB.get_or_init(|| { + let system = System::new_with_specifics( + RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram()), + ); + (system.total_memory() / 1024 / 1024).min(u32::MAX as u64) as u32 + }) +} + +pub fn memory_presets_mb() -> Vec { + presets_for_total_gb((total_ram_mb() as f32 / 1024.).round() as u32) +} + +fn presets_for_total_gb(total_gb: u32) -> Vec { + let usable_gb = total_gb.saturating_sub(MEMORY_HEADROOM_GB); + if usable_gb == 0 { + return vec![1024]; + } + + let mut presets: Vec = MEMORY_PRESETS_GB + .iter() + .copied() + .filter(|gb| *gb <= usable_gb) + .map(|gb| gb * 1024) + .collect(); + + let usable_mb = usable_gb * 1024; + if presets.last() != Some(&usable_mb) { + presets.push(usable_mb); + } + + presets +} + +/// `8192` -> `8 GB` `1536` -> `1.5 GB` +pub fn format_memory_gb(mb: u32) -> String { + if mb.is_multiple_of(1024) { + format!("{} GB", mb / 1024) + } else { + format!("{:.1} GB", mb as f32 / 1024.) + } +} + +/// `7384` -> `2h 3m` `540` -> `9m` `0` -> `0m` +pub fn format_duration_hm(secs: i64) -> String { + if secs <= 0 { + return "0m".to_string(); + } + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + if hours > 0 { + format!("{hours}h {minutes}m") + } else { + format!("{minutes}m") + } +} + +/// `3723` -> `1h 2m` `83` -> `1m 23s` `45` -> `45s` +pub fn format_duration_hms(secs: i64) -> String { + if secs <= 0 { + return "0s".to_string(); + } + if secs >= 3600 { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } else if secs >= 60 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + } +} + +/// `1500` -> `1.5K` `2_400_000` -> `2.4M` +pub fn abbreviate_number(n: u64) -> String { + let f = n as f64; + if f >= 1_000_000.0 { + format!("{:.1}M", f / 1_000_000.0) + } else if f >= 1_000.0 { + format!("{:.1}K", f / 1_000.0) + } else { + n.to_string() + } +} + +pub fn plural(n: i64) -> &'static str { + if n == 1 { "" } else { "s" } +} + +pub fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// 24-hour clock label `9` -> `09:00` +pub fn format_hour(hour: usize) -> String { + format!("{hour:02}:00") +} + +pub fn parse_day(date: &str) -> Option { + NaiveDate::parse_from_str(date, "%Y-%m-%d").ok() +} + +/// `2026-07-05` -> `Jul 5` +pub fn format_day(date: NaiveDate) -> String { + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + format!("{} {}", MONTHS[date.month0() as usize], date.day()) +} + +pub fn sort_clusters_for_home(mut clusters: Vec) -> Vec { + clusters.sort_by(compare_last_played); + clusters +} + +fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering { + match (a.last_played, b.last_played) { + // Most recently played first + (Some(a), Some(b)) => b.cmp(&a), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + // Never played latest version first (major then minor) + (None, None) => version_sort_key(b).cmp(&version_sort_key(a)), + } +} + +fn version_sort_key(cluster: &Cluster) -> (u32, u32, u32) { + parse_mc_version(&cluster.mc_version) + .map(|v| (v.major, v.minor.unwrap_or(0), v.patch.unwrap_or(0))) + .unwrap_or((0, 0, 0)) +} + +#[cfg(not(target_os = "linux"))] +pub fn is_wayland() -> bool { + false +} + +#[cfg(target_os = "linux")] +pub fn is_wayland() -> bool { + static IS_WAYLAND: std::sync::OnceLock = std::sync::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 + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::cluster; + + fn versioned(id: i64, mc_version: &str) -> Cluster { + Cluster { + mc_version: mc_version.to_string(), + ..cluster(id) + } + } + + fn line(major: u32, minor: Option) -> ReleaseLine { + ReleaseLine { major, minor } + } + + fn lines_of(clusters: &[Cluster]) -> Vec { + group_clusters_by_release(clusters) + .keys() + .copied() + .collect() + } + + #[test] + fn modern_minors_are_separate_lines() { + let clusters = [ + versioned(1, "26.1"), + versioned(2, "26.2"), + versioned(3, "26.2.1"), + ]; + + assert_eq!( + lines_of(&clusters), + vec![line(26, Some(1)), line(26, Some(2))] + ); + } + + #[test] + fn legacy_minors_share_one_line() { + let clusters = [versioned(1, "1.21.5"), versioned(2, "1.21.11")]; + + assert_eq!(lines_of(&clusters), vec![line(21, None)]); + } + + #[test] + fn a_lone_version_is_shown_in_full() { + let clusters = [versioned(1, "26.1.2")]; + let line = line(26, Some(1)); + + assert_eq!(line_title(line, &clusters), "26.1.2"); + assert_eq!(line_art_key(line, &clusters), Some((1, Some(2)))); + } + + #[test] + fn several_loaders_of_one_version_still_count_as_one() { + let clusters = [ + versioned(1, "26.1.2"), + Cluster { + mc_loader: GameLoader::Vanilla, + ..versioned(2, "26.1.2") + }, + ]; + + assert_eq!(line_title(line(26, Some(1)), &clusters), "26.1.2"); + } + + #[test] + fn several_versions_fall_back_to_the_generic_name() { + let legacy = [ + versioned(1, "1.21.1"), + versioned(2, "1.21.10"), + versioned(3, "1.21.11"), + ]; + assert_eq!(line_title(line(21, None), &legacy), "1.21"); + assert_eq!(line_art_key(line(21, None), &legacy), None); + + let modern = [versioned(4, "26.1"), versioned(5, "26.1.2")]; + assert_eq!(line_title(line(26, Some(1)), &modern), "26.1"); + assert_eq!(line_art_key(line(26, Some(1)), &modern), Some((1, None))); + } + + #[test] + fn presets_stop_two_gigabytes_short_of_the_machine() { + assert_eq!( + presets_for_total_gb(16), + vec![2048, 4096, 6144, 8192, 12288, 14336] + ); + assert_eq!( + presets_for_total_gb(32), + vec![2048, 4096, 6144, 8192, 12288, 16384, 24576, 30720] + ); + } + + #[test] + fn a_ceiling_landing_on_a_round_step_is_not_repeated() { + assert_eq!(presets_for_total_gb(8), vec![2048, 4096, 6144]); + } + + #[test] + fn a_tiny_machine_still_gets_one_preset() { + assert_eq!(presets_for_total_gb(4), vec![2048]); + assert_eq!(presets_for_total_gb(2), vec![1024]); + } + + #[test] + fn memory_labels_drop_the_decimal_when_whole() { + assert_eq!(format_memory_gb(8192), "8 GB"); + assert_eq!(format_memory_gb(1536), "1.5 GB"); + } + + #[test] + fn a_bare_legacy_version_keeps_its_line_name() { + let clusters = [versioned(1, "1.21")]; + + assert_eq!(line_title(line(21, None), &clusters), "1.21"); + } +} diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs new file mode 100644 index 00000000..7eb925a3 --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; + +use chrono::{Local, NaiveDate}; +use freya::prelude::*; + +use oneclient_core::game::{DayPlaytime, PlaytimeStats, WEEKDAY_LABELS}; + +use crate::components::{BarChart, IconType, Segment, SegmentedControl, ValueUnit}; +use crate::utils::{format_day, format_duration_hm, format_hour, parse_day}; + +use super::{chart_card, nav_button}; + +#[derive(Clone, Copy, PartialEq)] +enum WhenMode { + Weekday, + Hour, +} + +#[derive(PartialEq)] +pub(super) struct WhenChart { + per_weekday: Vec, + per_hour: Vec, + peak_weekday: Option, + peak_hour: Option, +} + +impl WhenChart { + pub(super) fn from_stats(stats: &PlaytimeStats) -> Self { + Self { + per_weekday: stats.per_weekday.to_vec(), + per_hour: stats.per_hour.to_vec(), + peak_weekday: stats.peak_weekday, + peak_hour: stats.peak_hour, + } + } +} + +impl Component for WhenChart { + fn render(&self) -> impl IntoElement { + let mode = use_state(|| WhenMode::Weekday); + let m = *mode.read(); + + let control = SegmentedControl::new(mode) + .height(30.) + .segment(Segment::new(WhenMode::Weekday).label("Day")) + .segment(Segment::new(WhenMode::Hour).label("Hour")) + .into_element(); + + let (subtitle, chart) = match m { + WhenMode::Weekday => { + let day = self + .peak_weekday + .map(|i| WEEKDAY_LABELS[i].to_string()) + .unwrap_or_else(|| "—".to_string()); + ( + format!("Most active on {day}"), + BarChart::new(self.per_weekday.clone(), weekday_labels()) + .highlight(self.peak_weekday) + .unit(ValueUnit::Duration) + .gap(6.) + .into_element(), + ) + } + WhenMode::Hour => { + let hour = self + .peak_hour + .map(format_hour) + .unwrap_or_else(|| "—".to_string()); + ( + format!("Peak around {hour}"), + BarChart::new(self.per_hour.clone(), (0..24).map(format_hour).collect()) + .highlight(self.peak_hour) + .unit(ValueUnit::Duration) + .gap(3.) + .into_element(), + ) + } + }; + + chart_card("When you play", subtitle, Some(control), chart) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Range { + TwoWeeks, + Month, + Quarter, +} + +impl Range { + fn days(self) -> usize { + match self { + Range::TwoWeeks => 14, + Range::Month => 30, + Range::Quarter => 90, + } + } +} + +#[derive(PartialEq)] +pub(super) struct DailyChart { + daily: Vec, +} + +impl DailyChart { + pub(super) fn new(daily: Vec) -> Self { + Self { daily } + } +} + +impl Component for DailyChart { + fn render(&self) -> impl IntoElement { + let range = use_state(|| Range::TwoWeeks); + let mut offset = use_state(|| 0usize); + + let series = continuous_series(&self.daily); + if series.is_empty() { + return chart_card( + "Daily playtime", + "No sessions recorded yet".to_string(), + None, + rect().height(Size::px(120.)).into_element(), + ); + } + + let window = range.read().days(); + let total = series.len(); + let max_offset = total.saturating_sub(1) / window; + let off = (*offset.read()).min(max_offset); + + let end = total.saturating_sub(off * window); + let start = end.saturating_sub(window); + let slice = &series[start..end]; + + let values: Vec = slice.iter().map(|(_, s)| *s).collect(); + let labels: Vec = slice.iter().map(|(d, _)| format_day(*d)).collect(); + + 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_duration_hm(win_total), + }; + + let can_older = off < max_offset; + let can_newer = off > 0; + let nav = rect() + .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( + SegmentedControl::new(range) + .height(30.) + .segment(Segment::new(Range::TwoWeeks).label("2W")) + .segment(Segment::new(Range::Month).label("1M")) + .segment(Segment::new(Range::Quarter).label("3M")) + .into_element(), + ) + .into_element(); + + chart_card( + "Daily playtime", + subtitle, + Some(nav), + BarChart::new(values, labels) + .unit(ValueUnit::Duration) + .gap(3.) + .into_element(), + ) + } +} + +fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { + let map: HashMap = daily + .iter() + .filter_map(|d| parse_day(&d.date).map(|date| (date, d.secs))) + .collect(); + + let Some(first) = map.keys().min().copied() else { + return Vec::new(); + }; + let today = Local::now().date_naive(); + let last = map.keys().max().copied().unwrap_or(first).max(today); + + let mut out = Vec::new(); + let mut cur = first; + while cur <= last { + out.push((cur, map.get(&cur).copied().unwrap_or(0))); + cur = match cur.succ_opt() { + Some(next) => next, + None => break, + }; + } + out +} + +pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option { + if session_secs.len() < 2 && !force { + return None; + } + + const EDGES: [(i64, &str); 6] = [ + (15 * 60, "<15m"), + (30 * 60, "15m-30m"), + (60 * 60, "30m-60m"), + (2 * 3600, "1h-2h"), + (4 * 3600, "2h-4h"), + (i64::MAX, "4h+"), + ]; + + let mut counts = [0i64; 6]; + for &s in session_secs { + let idx = EDGES.iter().position(|(edge, _)| s < *edge).unwrap_or(5); + counts[idx] += 1; + } + + let labels: Vec = EDGES.iter().map(|(_, l)| (*l).to_string()).collect(); + let peak = counts + .iter() + .enumerate() + .max_by_key(|(_, c)| **c) + .filter(|(_, c)| **c > 0) + .map(|(i, _)| i); + + let subtitle = format!("{} sessions grouped by length", session_secs.len()); + + Some(chart_card( + "Session length", + subtitle, + None, + BarChart::new(counts.to_vec(), labels) + .highlight(peak) + .unit(ValueUnit::Count) + .gap(6.) + .into_element(), + )) +} + +fn weekday_labels() -> Vec { + WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect() +} diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs new file mode 100644 index 00000000..52ee6097 --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/mod.rs @@ -0,0 +1,245 @@ +use chrono::{Duration, Local}; +use freya::prelude::*; + +use oneclient_core::game::{Analytics, DayPlaytime, PlaytimeStats}; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +mod charts; +mod servers; +mod tiles; + +use charts::{DailyChart, WhenChart, distribution_card}; +use servers::servers_section; +use tiles::{personas_row, tiles_row}; + +pub fn analytics_body(analytics: &Analytics) -> Element { + analytics_body_inner(analytics, false) +} + +/// `force_all` keeps the normally-hidden sections (session length servers) on screen +fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element { + let stats = &analytics.playtime; + + let mut root = rect().vertical().width(Size::fill()).spacing(24.); + + root = root.child(tiles_row(analytics, force_all)); + + if !stats.personas.is_empty() { + root = root.child(personas_row(&stats.personas)); + } + + let mut charts: Vec = vec![ + WhenChart::from_stats(stats).into_element(), + DailyChart::new(stats.daily.clone()).into_element(), + ]; + if let Some(dist) = distribution_card(&stats.session_secs, force_all) { + charts.push(dist); + } + root = root.child(charts_grid(charts)); + + if force_all || !analytics.servers.is_empty() { + root = root.child(servers_section(&analytics.servers)); + } + + root.into_element() +} + +pub fn analytics_placeholder(note: &str) -> Element { + AnalyticsPlaceholder { + note: note.to_string(), + } + .into_element() +} + +#[derive(PartialEq)] +struct AnalyticsPlaceholder { + note: String, +} + +impl Component for AnalyticsPlaceholder { + fn render(&self) -> impl IntoElement { + let mut body_height = use_state(|| 0f32); + let scrim_height = *body_height.read(); + + rect() + .width(Size::fill()) + .child( + rect() + .width(Size::fill()) + .on_sized(move |e: Event| { + let height = e.area.height(); + if (*body_height.read() - height).abs() > 0.5 { + body_height.set(height); + } + }) + .child(analytics_body_inner(&empty_analytics(), true)), + ) + .child( + rect() + .position(Position::new_absolute()) + .width(Size::fill()) + .height(Size::px(scrim_height)) + .layer(Layer::Relative(1)) + .center() + .padding(40.) + .background(colors::page_overlay()) + .on_press(|_| {}) + .child( + rect() + .max_width(Size::px(360.)) + .padding(Gaps::new_symmetric(16., 24.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .center() + .child( + label() + .text(self.note.clone()) + .font_size(14.) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ), + ) + } +} + +/// A run of zero-second days ending today so the timeline still renders columns for each range +fn empty_analytics() -> Analytics { + const EMPTY_DAYS: i64 = 90; + + let today = Local::now().date_naive(); + let daily: Vec = (0..EMPTY_DAYS) + .rev() + .map(|i| DayPlaytime { + date: (today - Duration::days(i)).format("%Y-%m-%d").to_string(), + secs: 0, + }) + .collect(); + + let playtime = PlaytimeStats { + total_secs: 0, + session_count: 0, + per_weekday: [0; 7], + per_hour: [0; 24], + daily, + session_secs: Vec::new(), + active_days: 0, + avg_secs_per_active_day: 0.0, + peak_hour: None, + peak_weekday: None, + night_share: 0.0, + personas: Vec::new(), + }; + + Analytics { + playtime, + servers: Vec::new(), + } +} + +fn charts_grid(cards: Vec) -> Element { + let mut grid = rect().vertical().width(Size::fill()).spacing(24.); + for pair in cards.chunks(2) { + if pair.len() == 1 { + grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); + continue; + } + + let mut row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Start) + .spacing(16.); + for card in pair { + row = row.child(rect().width(Size::flex(1.0)).child(card.clone())); + } + grid = grid.child(row); + } + grid.into_element() +} + +pub(super) fn chart_card( + title: &str, + subtitle: String, + trailing: Option, + chart: Element, +) -> Element { + card() + .width(Size::fill()) + .spacing(16.) + .child(card_header(title, subtitle, trailing)) + .child(chart) + .into_element() +} + +pub(super) fn card_header(title: &str, subtitle: String, trailing: Option) -> Element { + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(trailing) + .into_element() +} + +pub(super) fn nav_button( + icon: IconType, + enabled: bool, + on_press: impl FnMut(Event) + 'static, +) -> Element { + let color = if enabled { + colors::fg_secondary() + } else { + colors::fg_secondary().with_a(70) + }; + rect() + .width(Size::px(30.)) + .height(Size::px(30.)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .maybe(enabled, |el| { + el.on_press(on_press).cursor(CursorIcon::Pointer) + }) + .child(Icon::new(icon).size(14.).color(color)) + .into_element() +} + +pub(super) fn card() -> Rect { + rect() + .vertical() + .padding(20.) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) +} diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs new file mode 100644 index 00000000..263343dc --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -0,0 +1,414 @@ +use freya::prelude::*; + +use oneclient_core::game::ServerStat; + +use crate::components::{ + Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color, +}; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{format_duration_hm, plural}; + +use super::{card, card_header}; + +/// Number of servers charted the rest are grouped into an "Other" slice +const TOP_SERVERS: usize = 10; + +pub(super) fn servers_section(servers: &[ServerStat]) -> Element { + ServersSection { + servers: servers.to_vec(), + } + .into_element() +} + +#[derive(PartialEq)] +struct ServersSection { + servers: Vec, +} + +impl Component for ServersSection { + fn render(&self) -> impl IntoElement { + let hovered = use_state(|| Option::::None); + let mut show_all = use_state(|| false); + + let total_joins: i64 = self.servers.iter().map(|s| s.joins).sum(); + + let mut sorted = self.servers.clone(); + sorted.sort_by_key(|s| std::cmp::Reverse(s.total_secs)); + + let (top, rest) = sorted.split_at(sorted.len().min(TOP_SERVERS)); + let mut values: Vec = top.iter().map(|s| s.total_secs).collect(); + let mut labels: Vec = top.iter().map(server_label).collect(); + if !rest.is_empty() { + values.push(rest.iter().map(|s| s.total_secs).sum()); + labels.push(format!("{} more", rest.len())); + } + + let details = (!self.servers.is_empty()).then(|| { + Button::new() + .ghost() + .small() + .on_press(move |_| show_all.set(true)) + .child(Icon::new(IconType::Eye).size(12.)) + .text("Details") + .into_element() + }); + + card() + .width(Size::fill()) + .spacing(14.) + .child(card_header( + "Servers", + format!( + "{} server{} · {} join{}", + self.servers.len(), + plural(self.servers.len() as i64), + total_joins, + plural(total_joins), + ), + details, + )) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(20.) + .child( + PieChart::new(values.clone(), labels.clone()) + .unit(ValueUnit::Duration) + .hovered(hovered), + ) + .child(rect().width(Size::flex(1.0)).child(legend( + &values, + &labels, + *hovered.read(), + ))), + ) + .maybe_child(show_all.read().then(|| { + OverlayPopup::new() + .on_close(move |_| show_all.set(false)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(ServerDetailsPopup { + servers: sorted.clone(), + on_close: EventHandler::new(move |()| show_all.set(false)), + }), + ) + .into_element() + })) + } +} + +fn legend(values: &[i64], labels: &[String], active: Option) -> Element { + let total: i64 = values.iter().sum(); + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + + for (i, (value, name)) in values.iter().zip(labels).enumerate() { + let is_active = Some(i) == active; + let pct = if total > 0 { + (*value as f32 / total as f32) * 100. + } else { + 0. + }; + let dim = active.is_some() && !is_active; + + list = list.child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(3., 6.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if is_active { + colors::component_bg() + } else { + Color::TRANSPARENT + }) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(slice_color(i).with_a(if dim { 120 } else { 255 })), + ) + .child( + label() + .text(name.clone()) + .font_size(11.) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("{pct:.0}%")) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(format_duration_hm(*value)) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ); + } + + list.into_element() +} + +/// A Component not a builder mounting its `ScrollArea` conditionally would change the caller's hook count and panic Freya +#[derive(PartialEq)] +struct ServerDetailsPopup { + servers: Vec, + on_close: EventHandler<()>, +} + +impl Component for ServerDetailsPopup { + fn render(&self) -> impl IntoElement { + let servers = &self.servers; + let on_close = self.on_close.clone(); + 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 { + list = list.child(ServerRow { + server: server.clone(), + max_secs, + }); + } + + rect() + .vertical() + .width(Size::px(520.)) + .max_height(Size::window_percent(70.)) + .padding(20.) + .spacing(14.) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text("All servers") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} server{} by playtime", + servers.len(), + plural(servers.len() as i64) + )) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .small() + .on_press(move |_| on_close.call(())) + .child(Icon::new(IconType::XClose).size(12.)), + ), + ) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::px(380.)) + .child(list), + ) + } +} + +/// IPs stay masked in the always-visible legend the details popup reveals them on hover +fn server_label(s: &ServerStat) -> String { + if s.is_ip { + return "Direct IP".to_string(); + } + display_address(s) +} + +#[derive(PartialEq)] +struct ServerRow { + server: ServerStat, + max_secs: i64, +} + +impl Component for ServerRow { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let is_hovered = *hovered.read(); + + let s = &self.server; + let reveal = is_hovered || !s.is_ip; + let address = if reveal { + display_address(s) + } else { + mask(&display_address(s)) + }; + + let frac = (s.total_secs as f32 / self.max_secs as f32).clamp(0., 1.); + + let bar = rect() + .width(Size::fill()) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::component_bg()) + .child( + rect() + .width(Size::percent(frac * 100.)) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::brand().with_a(if reveal { 255 } else { 150 })), + ); + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if is_hovered { + colors::component_bg_hover() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| *hovered.write() = true) + .on_pointer_leave(move |_| *hovered.write() = false) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .child(ServerIcon { + url: server_icon_url(s), + is_ip: s.is_ip, + size: 28., + }) + .child( + label() + .text(address) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()) + .width(Size::flex(1.0)), + ) + .child( + label() + .text(format!("{}×", s.joins)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(format_duration_hm(s.total_secs)) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child(bar) + } +} + +const SERVER_ICON_EDGE: u32 = 64; + +fn server_icon_url(s: &ServerStat) -> Option { + if s.is_ip { + return None; + } + + Some(format!("https://api.mcsrvstat.us/icon/{}", s.address)) +} + +#[derive(PartialEq)] +struct ServerIcon { + url: Option, + is_ip: bool, + size: f32, +} + +impl Component for ServerIcon { + fn render(&self) -> impl IntoElement { + let size = self.size; + let query = use_cached_image(self.url.clone(), SERVER_ICON_EDGE); + let loaded = loaded_image(self.url.as_deref(), &query); + + match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(size)) + .height(Size::px(size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(6.)) + .into_element(), + None => server_icon_placeholder(self.is_ip, size), + } + } +} + +fn server_icon_placeholder(is_ip: bool, size: f32) -> Element { + let icon = if is_ip { + IconType::Eye + } else { + IconType::Globe01 + }; + rect() + .center() + .width(Size::px(size)) + .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()), + ) + .into_element() +} + +fn display_address(s: &ServerStat) -> String { + match s.port { + Some(port) if port != 25565 => format!("{}:{}", s.address, port), + _ => s.address.clone(), + } +} + +fn mask(address: &str) -> String { + let (host, tail) = match address.split_once(':') { + Some((h, p)) => (h, format!(":{p}")), + None => (address, String::new()), + }; + let dots = "•".repeat(host.chars().count().clamp(6, 18)); + format!("{dots}{tail}") +} diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs new file mode 100644 index 00000000..bdfa60a5 --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -0,0 +1,137 @@ +use freya::prelude::*; + +use oneclient_core::game::{Analytics, Persona}; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_duration_hm; + +use super::card; + +pub(super) fn tiles_row(analytics: &Analytics, force_all: bool) -> Element { + let stats = &analytics.playtime; + let avg_session = if stats.session_count > 0 { + stats.total_secs / stats.session_count as i64 + } else { + 0 + }; + 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::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(), + ), + ]; + if force_all || !analytics.servers.is_empty() { + tiles.push(stat_tile( + IconType::Globe01, + "Server joins", + format!("{total_joins}"), + )); + } + + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .spacing(16.) + .children(tiles) + .into_element() +} + +fn stat_tile(icon: IconType, caption: &str, value: String) -> Element { + card() + .width(Size::flex(1.0)) + .spacing(10.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(Icon::new(icon).size(16.).color(colors::fg_secondary())) + .child( + label() + .text(caption.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + label() + .text(value) + .font_size(26.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .into_element() +} + +pub(super) fn personas_row(personas: &[Persona]) -> Element { + let mut row = rect().horizontal().width(Size::fill()).spacing(16.); + for persona in personas { + row = row.child(persona_card(*persona)); + } + row.into_element() +} + +fn persona_icon(persona: Persona) -> IconType { + match persona { + Persona::NightOwl => IconType::ClockRewind, + Persona::Gamer => IconType::Rocket02, + } +} + +fn persona_card(persona: Persona) -> Element { + card() + .width(Size::flex(1.0)) + .horizontal() + .cross_align(Alignment::Center) + .spacing(14.) + .border(border_all_color(1., colors::brand())) + .child( + rect() + .width(Size::px(44.)) + .height(Size::px(44.)) + .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( + rect() + .vertical() + .spacing(2.) + .child( + label() + .text(persona.title().to_string()) + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(persona.description().to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs new file mode 100644 index 00000000..b05f09bd --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -0,0 +1,443 @@ +use super::*; + +use std::collections::HashMap; + +use freya::router::RouterContext; +use oneclient_content::packages::types::ProjectSummary; +use oneclient_content::packages::{ContentType, ProviderId}; + +use crate::components::{Button, Icon, IconType}; +use crate::hooks::{ + content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, use_installs_snapshot, + use_package_versions_when, version_list, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +type InstalledMap = HashMap<(ProviderId, String), Installed>; + +/// Diameter of the round install button +const INSTALL_BUTTON: f32 = 30.; + +fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { + installed + .get(&(item.provider, item.id.clone())) + .map(|installed| installed.source) +} + +pub(super) fn grid_row( + row: Vec, + cluster_id: i64, + package_type: &str, + installed: &InstalledMap, +) -> impl IntoElement { + let package_type = package_type.to_string(); + let fill = GRID_COLUMNS - row.len(); + let installed: Vec> = row + .iter() + .map(|item| installed_for(installed, item)) + .collect(); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .spacing(GRID_SPACING) + .content(Content::Flex) + .children( + row.into_iter() + .zip(installed) + .map(move |(item, installed)| { + PackageCard::new(item, cluster_id, package_type.clone(), installed) + .into_element() + }), + ) + .maybe(fill > 0, move |mut el| { + for _ in 0..fill { + el = el.child(rect().width(Size::flex(1.0))); + } + el + }) + .into_element() +} + +fn open_package(cluster_id: i64, package_type: &str, provider: ProviderId, id: &str) { + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type.to_string(), + package_id: encode_package_id(provider, id), + }); +} + +#[derive(PartialEq)] +struct PackageCard { + item: ProjectSummary, + cluster_id: i64, + package_type: String, + installed: Option, +} + +impl PackageCard { + fn new( + item: ProjectSummary, + cluster_id: i64, + package_type: String, + installed: Option, + ) -> Self { + Self { + item, + cluster_id, + package_type, + installed, + } + } +} + +impl Component for PackageCard { + fn render(&self) -> impl IntoElement { + let id = self.item.id.clone(); + let provider = self.item.provider; + let package_type = self.package_type.clone(); + let icon_url = self.item.icon_url.clone(); + let cluster_id = self.cluster_id; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::px(CARD_H)) + .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) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .child( + rect() + .margin(Gaps::new_all(1.)) + .corner_radius(CornerRadius::new(10., 10., 0., 0.)) + .overflow(Overflow::Clip) + .child(PackageBanner::new(icon_url, BANNER_H)) + // Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's + .maybe_child(self.installed.map(|installed| { + rect() + .position(Position::new_absolute().top(8.).left(8.)) + .layer(Layer::Relative(7)) + .child(installed_badge_overlay(installed)) + .into_element() + })) + .maybe_child(self.installed.is_none().then(|| { + rect() + .position(Position::new_absolute().top(8.).right(8.)) + .layer(Layer::Relative(7)) + .child(InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + false, + )) + .into_element() + })), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new_all(12.)) + .main_align(Alignment::SpaceBetween) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(self.item.name.clone()) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(CARD_NAME), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child( + label() + .text(format!("by {}", self.item.author)) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child(Icon::new(self.item.provider).size(12.)), + ) + .child( + label() + .text(self.item.summary.clone()) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .child(downloads_row(self.item.downloads)), + ) + } +} + +pub(super) fn list_row( + item: ProjectSummary, + cluster_id: i64, + package_type: &str, + installed: &InstalledMap, +) -> impl IntoElement { + ListRow { + installed: installed_for(installed, &item), + item, + cluster_id, + package_type: package_type.to_string(), + } +} + +#[derive(PartialEq)] +struct ListRow { + item: ProjectSummary, + cluster_id: i64, + package_type: String, + installed: Option, +} + +impl Component for ListRow { + fn render(&self) -> impl IntoElement { + let item = self.item.clone(); + let cluster_id = self.cluster_id; + let id = item.id.clone(); + let provider = item.provider; + let package_type = self.package_type.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + 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) + .cursor(CursorIcon::Pointer) + .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.)) + // Beside the attribution rather than far right where it drifted from the package and crowded the install button + .maybe_child( + self.installed.map(|installed| { + installed_badge(installed, 10.).into_element() + }), + ), + ) + .child( + label() + .text(item.summary.clone()) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .child(downloads_row(item.downloads)) + // Kept once installed rather than dropped a row that loses its button leaves its neighbours misaligned + .child(InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + self.installed.is_some(), + )) + } +} + +/// Same version pick and dispatch as the install button on the package page +#[derive(PartialEq)] +struct InstallButton { + provider: ProviderId, + project_id: String, + cluster_id: i64, + content_type: ContentType, + /// Cluster already has this one callers that hide the button entirely pass `false` + installed: bool, +} + +impl InstallButton { + fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str, installed: bool) -> Self { + Self { + provider: item.provider, + project_id: item.id.clone(), + cluster_id, + content_type: content_type_for_slug(package_type), + installed, + } + } +} + +impl Component for InstallButton { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let provider = self.provider; + let project_id = self.project_id.clone(); + + let dispatch = use_dispatch(); + let compat = *use_browser_compat().read(); + let cluster = use_cluster(cluster_id); + + // The same narrowing the package page does so both agree on what "latest" installs + let (game_version, loader) = match (compat, &cluster) { + (true, Some(c)) => ( + Some(c.mc_version.clone()), + (self.content_type == ContentType::Mod).then_some(c.mc_loader), + ), + _ => (None, None), + }; + + let installed = self.installed; + + let versions = version_list(&use_package_versions_when( + !installed, + provider, + project_id.clone(), + game_version, + loader, + 0, + )); + let latest = versions.first().map(|v| v.version_id.clone()); + + // Nothing to start twice while an install is running or before versions arrive + let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + + rect() + // The card behind is one big press target stop here so the package page doesn't open on top of the install + .on_all_press(|e: Event| e.stop_propagation()) + .child( + Button::new() + .primary() + .width(Size::px(INSTALL_BUTTON)) + .height(Size::px(INSTALL_BUTTON)) + .padding(Gaps::new_all(0.)) + .corner_radius(CornerRadius::new_all(INSTALL_BUTTON / 2.)) + .alt(if installed { + "Already installed" + } else if installing { + "Installing" + } else { + "Install" + }) + .enabled(!installed && latest.is_some() && !installing) + .on_press(move |_| { + if let Some(version_id) = latest.clone() { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + ); + } + }) + .child( + Icon::new(if installing { + IconType::Loading02 + } else { + IconType::Download01 + }) + .size(15.) + .color(colors::fg_primary()), + ), + ) + } +} + +fn downloads_row(downloads: u64) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child( + Icon::new(IconType::Download01) + .size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(abbreviate_number(downloads)) + .font_size(11.) + .color(colors::fg_secondary()), + ) +} + +pub(super) fn empty_state() -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("No results. Try a different search, category or provider.") + .font_size(14.) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs new file mode 100644 index 00000000..a5e38650 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -0,0 +1,418 @@ +use std::time::Duration; + +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, +}; +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_common::parse_mc_version; +use oneclient_common::search::normalize_query; +use oneclient_content::packages::types::ProjectSummary; +use oneclient_content::packages::types::SearchSort; +use oneclient_content::packages::{ContentType, ProviderId}; +use oneclient_core::VersionMetadata; +use oneclient_core::clusters::Cluster; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, +}; +use crate::hooks::use_cluster; +use crate::hooks::{ + BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, + cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, + search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store, + use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, + use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; + +use super::{ + InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay, + installed_map, +}; +use crate::utils::{abbreviate_number, sort_clusters_for_home}; + +mod cards; +mod sidebar; +mod skeletons; +use cards::{empty_state, grid_row, list_row}; +use sidebar::CategorySidebar; +use skeletons::{SkeletonListRow, skeleton_grid_row}; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const CARD_NAME: Color = Color::from_rgb(213, 219, 255); +const GRID_COLUMNS: usize = 3; +const SCROLLBAR_GUTTER: f32 = 18.; +const CARD_H: f32 = 240.; +const BANNER_H: f32 = 100.; +const LIST_ROW_H: f32 = 78.; +const GRID_SPACING: f32 = 12.; +const LIST_SPACING: f32 = 8.; +const SEARCH_DEBOUNCE_MS: u64 = 250; + +fn type_title(package_type: &str) -> &'static str { + match package_type { + "shader" => "Shaders", + "texture" => "Textures", + _ => "Mods", + } +} + +fn encode_package_id(provider: ProviderId, id: &str) -> String { + format!("{}:{}", provider as u8, id) +} + +#[derive(PartialEq)] +pub struct Browser { + pub cluster_id: i64, + pub package_type: String, + /// Entered from the navbar so the cluster to install into is picked here + pub pick_cluster: bool, +} + +impl Component for Browser { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let package_type = self.package_type.clone(); + let content_type = content_type_for_slug(&package_type); + + let store = use_browser_state_store(); + let state_key = format!("{cluster_id}:{package_type}"); + let saved = store.peek().get(&state_key).cloned().unwrap_or_default(); + + let query = use_state(|| saved.query.clone()); + let provider = use_state(|| saved.provider); + let view_mode = use_view_state(&format!("browser.{package_type}")).layout; + let compatible_only = use_browser_compat(); + let selected_categories = use_state(|| saved.categories.clone()); + let page = use_state(|| saved.page); + + { + let mut store = store; + let key = state_key.clone(); + use_side_effect(move || { + let snapshot = BrowserUiState { + query: query.read().clone(), + provider: *provider.read(), + categories: selected_categories.read().clone(), + page: *page.read(), + }; + store.write().insert(key.clone(), snapshot); + }); + } + + // Debounce the search query so typing doesn't fire a request per keystroke + let debounced_query = use_debounced( + query.read().clone(), + Duration::from_millis(SEARCH_DEBOUNCE_MS), + ); + + let cluster = use_cluster(cluster_id); + let provider_id = *provider.read(); + let compat = *compatible_only.read(); + let cats = selected_categories.read().clone(); + + let (game_versions, loaders) = match (compat, &cluster) { + (true, Some(c)) => { + let loaders = if content_type == ContentType::Mod { + vec![c.mc_loader] + } else { + Vec::new() + }; + (vec![c.mc_version.clone()], loaders) + } + _ => (Vec::new(), Vec::new()), + }; + + let mut page_state = page; + // Normalised like the search key so a query differing only by a space does not reset the page + let signature = format!( + "{provider_id:?}|{}|{compat}|{}", + normalize_query(&debounced_query.read()).to_lowercase(), + cats.join(",") + ); + let mut last_signature = use_state(|| signature.clone()); + if *last_signature.peek() != signature { + last_signature.set(signature); + if *page_state.peek() != 0 { + page_state.set(0); + } + } + + let search = use_package_search( + provider_id, + content_type, + debounced_query.read().clone(), + game_versions, + loaders, + cats.clone(), + SearchSort::Relevance, + *page.read(), + ); + let categories_query = use_package_categories(provider_id, content_type); + let all_categories = category_list(&categories_query); + + let installed = installed_map( + cluster_content_items(&use_cluster_content(cluster_id, content_type)), + &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + ); + + let packages = search_items(&search); + let total = search_total(&search); + let pending = search_pending(&search); + let current_page = *page.read(); + + // A page switch reports no total until it settles holding the last count keeps the pager in place + let live_pages = (total > 0).then(|| total.div_ceil(BROWSE_PAGE_SIZE).max(1)); + let mut settled_pages = use_state(|| live_pages); + if !pending { + settled_pages.set_if_modified(live_pages); + } + let pages = if pending { + *settled_pages.read() + } else { + live_pages + }; + + let mode = *view_mode.read(); + + let fade_dep = (current_page, packages.is_empty(), pending, mode); + let fade = use_animation_with_dependencies(&fade_dep, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(240) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let fade_opacity = fade.read().value(); + + let sa = ScrollArea::new() + .reset_key(current_page as u64) + .padding(Gaps::new(0., SCROLLBAR_GUTTER, 0., 0.)); + + let pkg = package_type.clone(); + let results = if !packages.is_empty() { + match mode { + ViewLayout::Grid => { + let rows: Vec> = + packages.chunks(GRID_COLUMNS).map(|c| c.to_vec()).collect(); + + sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| { + grid_row(rows[i].clone(), cluster_id, &pkg, &installed).into_element() + }) + } + ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| { + list_row(packages[i].clone(), cluster_id, &pkg, &installed).into_element() + }), + } + } else if pending { + 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() + }) + } + ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { + SkeletonListRow.into_element() + }), + } + } else { + sa.children([empty_state().into_element()]) + } + .into_element(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 40.)) + .spacing(16.) + .child(page_title( + &package_type, + cluster.as_ref().map(|c| c.name.clone()), + self.pick_cluster.then(|| ClusterPicker { + cluster_id, + package_type: package_type.clone(), + }), + )) + .child(controls(provider, query, view_mode)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(24.) + .maybe(!all_categories.is_empty(), |el| { + el.child(CategorySidebar { + categories: all_categories, + selected: selected_categories, + }) + }) + .child( + rect() + .vertical() + .content(Content::Flex) + .width(Size::flex(1.0)) + .height(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(results) + .opacity(fade_opacity), + ) + .maybe_child(pages.map(|pages| { + Pagination::new(page, pages) + .enabled(!pending) + .into_element() + })), + ), + ) + } +} + +fn page_title( + package_type: &str, + cluster_name: Option, + picker: Option, +) -> impl IntoElement { + rect() + .vertical() + .spacing(2.) + .child( + label() + .text(format!("Browse {}", type_title(package_type))) + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .maybe_child(match picker { + Some(picker) => Some(picker.into_element()), + // Without the picker the cluster is fixed so it's just a subtitle + None => cluster_name.map(|name| { + label() + .text(format!("for {name}")) + .font_size(14.) + .color(colors::fg_secondary()) + .into_element() + }), + }) +} + +/// Falls back to the bare version while the manifest hasn't arrived or doesn't cover it +fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String { + parse_mc_version(&cluster.mc_version) + .and_then(|parsed| { + pick_version_metadata( + metadata, + parsed.major, + parsed.key(), + Some(cluster.mc_loader), + ) + }) + .map(|m| m.name) + .unwrap_or_else(|| cluster.mc_version.clone()) +} + +/// The choice lives in the route so back and forward keep the cluster being browsed +#[derive(PartialEq)] +struct ClusterPicker { + cluster_id: i64, + package_type: String, +} + +impl Component for ClusterPicker { + fn render(&self) -> impl IntoElement { + let clusters = + sort_clusters_for_home(settled_or_loading(&use_clusters()).unwrap_or_default()); + let metadata = versions_metadata(&use_versions()).unwrap_or_default(); + + let labels: Vec = clusters + .iter() + .map(|c| format!("{} · {}", c.name, version_name(&metadata, c))) + .collect(); + let ids: Vec = clusters.iter().map(|c| c.id).collect(); + + let selected = ids + .iter() + .position(|id| *id == self.cluster_id) + .and_then(|idx| labels.get(idx).cloned()) + .unwrap_or_else(|| "Select a version".to_string()); + + let package_type = self.package_type.clone(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .margin(Gaps::new(4., 0., 0., 0.)) + .child( + label() + .text("for") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(selected, labels) + .width(Size::px(240.)) + .height(Size::px(28.)) + .on_select(move |idx: usize| { + if let Some(cluster_id) = ids.get(idx).copied() { + let _ = RouterContext::get().replace(Route::Browser { + cluster_id, + package_type: package_type.clone(), + pick_cluster: true, + }); + } + }), + ) + } +} + +fn controls( + provider: State, + query: State, + view_mode: State, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .child(rect().width(Size::flex(1.0))) + .child( + SegmentedControl::new(view_mode) + .equal_width(30.) + .icon_size(16.) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .segment(Segment::new(ViewLayout::List).icon(IconType::LayoutTop)), + ) + .child( + SegmentedControl::new(provider) + .no_tint() + .segments(ProviderId::REMOTE_PROVIDERS.iter().map(|provider| { + Segment::new(*provider) + .icon(IconType::from(*provider)) + .label(provider.to_string()) + })) + .into_element(), + ) + .child( + TextInput::new(query) + .width(Size::px(260.)) + .placeholder("Search for content") + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ), + ) +} diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs new file mode 100644 index 00000000..66b7dfdb --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -0,0 +1,117 @@ +use super::*; + +use crate::components::ScrollArea; +use crate::theme::colors; +use crate::utils::capitalize; + +#[derive(PartialEq)] +pub(super) struct CategorySidebar { + pub(super) categories: Vec, + pub(super) selected: State>, +} + +impl Component for CategorySidebar { + fn render(&self) -> impl IntoElement { + let selected = self.selected; + let list = rect().vertical().width(Size::fill()).spacing(2.).children( + self.categories.clone().into_iter().map(move |cat| { + let is_selected = selected.read().contains(&cat); + let mut selected = selected; + let cat_for_press = cat.clone(); + let on_toggle: EventHandler<()> = (move |()| { + let mut current = selected.read().clone(); + if let Some(i) = current.iter().position(|c| c == &cat_for_press) { + current.remove(i); + } else { + current.push(cat_for_press.clone()); + } + selected.set(current); + }) + .into(); + CategoryRow { + name: cat.clone(), + selected: is_selected, + on_toggle, + key: DiffKey::None, + } + .key(cat) + .into_element() + }), + ); + + rect() + .vertical() + .width(Size::px(190.)) + .height(Size::fill()) + .spacing(8.) + .child( + label() + .text("CATEGORIES") + .font_size(11.) + .font_weight(FontWeight::LIGHT) + .color(colors::fg_secondary()), + ) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .children([list.into_element()]), + ) + } +} + +#[derive(PartialEq)] +struct CategoryRow { + name: String, + selected: bool, + on_toggle: EventHandler<()>, + key: DiffKey, +} + +impl KeyExt for CategoryRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for CategoryRow { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let selected = self.selected; + let on_toggle = self.on_toggle.clone(); + let hovered = *hovering.read(); + let focused = focus().is_focused(); + let color = if selected || hovered || focused { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(3., 0.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_all_press(move |_| on_toggle.call(())) + .child( + label() + .text(capitalize(&self.name)) + .font_size(13.) + .max_lines(1) + .font_weight(if selected { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(color), + ) + } +} diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs new file mode 100644 index 00000000..30f6d2aa --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs @@ -0,0 +1,100 @@ +use super::*; + +use freya::animation::{AnimNum, Ease, Function, OnCreation, OnFinish, use_animation}; + +use crate::theme::colors; +use crate::ui::border_all_color; + +fn skeleton_block(width: Size, height: f32) -> impl IntoElement { + rect() + .width(width) + .height(Size::px(height)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .into_element() +} + +fn use_skeleton_pulse() -> f32 { + let pulse = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + AnimNum::new(0.4, 0.9) + .time(800) + .ease(Ease::InOut) + .function(Function::Sine) + }); + pulse.read().value() +} + +#[derive(PartialEq)] +struct SkeletonCard; + +impl Component for SkeletonCard { + fn render(&self) -> impl IntoElement { + let pulse = use_skeleton_pulse(); + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::px(CARD_H)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .overflow(Overflow::Clip) + .opacity(pulse) + .child(skeleton_block(Size::fill(), BANNER_H)) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .child(skeleton_block(Size::percent(70.), 16.)) + .child(skeleton_block(Size::percent(40.), 10.)) + .child(skeleton_block(Size::fill(), 10.)) + .child(skeleton_block(Size::percent(85.), 10.)) + .child(rect().width(Size::fill()).height(Size::flex(1.0))) + .child(skeleton_block(Size::percent(30.), 10.)), + ) + } +} + +#[derive(PartialEq)] +pub(super) struct SkeletonListRow; + +impl Component for SkeletonListRow { + fn render(&self) -> impl IntoElement { + let pulse = use_skeleton_pulse(); + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(LIST_ROW_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .opacity(pulse) + .child(skeleton_block(Size::px(48.), 48.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(6.) + .child(skeleton_block(Size::percent(45.), 14.)) + .child(skeleton_block(Size::percent(80.), 10.)), + ) + } +} + +pub(super) fn skeleton_grid_row() -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .spacing(GRID_SPACING) + .content(Content::Flex) + .children((0..GRID_COLUMNS).map(|_| SkeletonCard.into_element())) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs new file mode 100644 index 00000000..2bf654f7 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -0,0 +1,552 @@ +mod index; +mod package; + +pub use index::Browser; +pub use package::BrowserPackage; + +use std::collections::HashMap; + +use freya::prelude::*; +use oneclient_content::packages::ProviderId; +use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; + +use crate::components::{Icon, IconType}; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const BANNER_BG: Color = Color::from_rgb(21, 28, 34); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum InstallSource { + Manual, + Bundled, +} + +impl InstallSource { + fn label(self) -> &'static str { + match self { + Self::Manual => "Installed", + Self::Bundled => "Bundled", + } + } + + /// Bundled reads blue rather than green the cluster's bundle put it there not the user + fn color(self) -> Color { + match self { + Self::Manual => colors::success(), + Self::Bundled => colors::code_info(), + } + } +} + +#[derive(Clone, PartialEq)] +pub(crate) struct InstalledVersion { + pub version_id: String, + /// The artifact to remove + /// `None` when a bundle names this version but nothing is linked + pub hash: Option, + /// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off + pub enabled: bool, + pub source: InstallSource, +} + +/// Holds every version found rather than an arbitrary winner a cluster can end up with more than one +#[derive(Clone, PartialEq)] +pub(crate) struct Installed { + /// A bundle claiming the project wins over a hand-installed copy + pub source: InstallSource, + pub versions: Vec, +} + +impl Installed { + pub fn is_version(&self, version_id: &str) -> bool { + self.find_version(version_id).is_some() + } + + pub fn find_version(&self, version_id: &str) -> Option<&InstalledVersion> { + self.versions.iter().find(|v| v.version_id == version_id) + } + + /// Counts linked artifacts only a bundle pin the cluster never downloaded is not a second copy + pub fn is_duplicated(&self) -> bool { + self.versions.iter().filter(|v| v.hash.is_some()).count() > 1 + } +} + +/// Local files and a bundle's external files are left out they have no project id to match against +pub(crate) fn installed_map( + content: Vec, + bundles: &[BundleWithUpdateStatus], +) -> HashMap<(ProviderId, String), Installed> { + let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new(); + + for item in content { + let (Some(provider), Some(project_id)) = (item.provider, item.project_id) else { + continue; + }; + + // Created even without a recorded version the package is in the cluster it just cannot be tied to a version row + let installed = map.entry((provider, project_id)).or_insert(Installed { + source: InstallSource::Manual, + versions: Vec::new(), + }); + + if let Some(version_id) = item.version_id { + installed.versions.push(InstalledVersion { + version_id, + hash: Some(item.hash), + enabled: item.enabled, + source: InstallSource::Manual, + }); + } + } + + // Bundle membership wins a bundle's files would otherwise read as hand-installed + // The manifest pin is only a fallback for a missing linked version + for bundle in bundles { + for (file, _status) in &bundle.files { + if let BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } = &file.kind + { + match map.get_mut(&(*provider, project_id.clone())) { + Some(installed) => { + installed.source = InstallSource::Bundled; + if let Some(version) = installed + .versions + .iter_mut() + .find(|v| &v.version_id == version_id) + { + version.source = InstallSource::Bundled; + } + } + None => { + map.insert( + (*provider, project_id.clone()), + Installed { + source: InstallSource::Bundled, + versions: vec![InstalledVersion { + version_id: version_id.clone(), + hash: None, + enabled: false, + source: InstallSource::Bundled, + }], + }, + ); + } + } + } + } + } + + map +} + +pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { + badge(installed, font_size, installed.color().with_a(38), None) +} + +/// Brings its own backdrop and outline so it stays legible over card artwork +pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElement { + badge( + installed, + 10., + BANNER_BG.with_a(225), + Some(installed.color().with_a(110)), + ) +} + +/// Which of several installed versions the game actually loads +pub(crate) fn activity_badge(active: bool) -> impl IntoElement { + let (text, color) = if active { + ("Active", colors::brand()) + } else { + ("Inactive", colors::fg_secondary()) + }; + + // No icon three pills in a row all wearing a tick reads as decoration + rect() + .horizontal() + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(color.with_a(38)) + .child(label().text(text).font_size(11.).max_lines(1).color(color)) +} + +fn badge( + installed: InstallSource, + font_size: f32, + background: Color, + border: Option, +) -> impl IntoElement { + let color = installed.color(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(background) + .map(border, |el, border| el.border(border_all_color(1., border))) + .child( + Icon::new(IconType::CheckCircle) + .size(font_size) + .color(color), + ) + .child( + label() + .text(installed.label()) + .font_size(font_size) + .max_lines(1) + .color(color), + ) +} + +#[derive(PartialEq)] +pub(crate) struct Thumbnail { + icon_url: Option, + size: f32, + radius: f32, + key: DiffKey, +} + +impl Thumbnail { + pub fn new(icon_url: Option, size: f32) -> Self { + Self { + icon_url, + size, + radius: 10., + key: DiffKey::None, + } + } + + pub fn radius(mut self, radius: f32) -> Self { + self.radius = radius; + self + } +} + +impl KeyExt for Thumbnail { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for Thumbnail { + fn render(&self) -> impl IntoElement { + let size = self.size; + let radius = self.radius; + let query = use_cached_image(self.icon_url.clone(), 256); + let loaded = loaded_image(self.icon_url.as_deref(), &query); + + match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(size)) + .height(Size::px(size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(radius)) + .into_element(), + None => rect() + .center() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(radius)) + .background(colors::component_bg()) + .child( + Icon::new(IconType::DotsGrid) + .size(size * 0.4) + .color(colors::fg_secondary()), + ) + .into_element(), + } + } +} + +#[derive(PartialEq)] +pub(crate) struct PackageBanner { + icon_url: Option, + height: f32, + key: DiffKey, +} + +impl PackageBanner { + pub fn new(icon_url: Option, height: f32) -> Self { + Self { + icon_url, + height, + key: DiffKey::None, + } + } +} + +impl KeyExt for PackageBanner { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for PackageBanner { + fn render(&self) -> impl IntoElement { + let h = self.height; + let icon = h * 0.62; + let query = use_cached_image(self.icon_url.clone(), 512); + let loaded = loaded_image(self.icon_url.as_deref(), &query); + + let banner = rect() + .width(Size::fill()) + .height(Size::px(h)) + .center() + .overflow(Overflow::Clip) + .background(BANNER_BG); + + match loaded { + Some((url, bytes)) => banner + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url.clone(), bytes.clone())) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center), + ) + .layer(Layer::Relative(1)), + ) + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .blur(12.) + .background(BANNER_BG.with_a(120)) + .overflow(Overflow::Clip) + .layer(Layer::Relative(3)), + ) + .child( + rect() + .width(Size::px(icon)) + .height(Size::px(icon)) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(icon)) + .height(Size::px(icon)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(10.)), + ) + .layer(Layer::Relative(5)), + ), + None => banner.child( + rect() + .center() + .width(Size::px(icon)) + .height(Size::px(icon)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .child( + Icon::new(IconType::DotsGrid) + .size(icon * 0.45) + .color(colors::fg_secondary()), + ), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::archive; + use oneclient_content::packages::ContentType; + use oneclient_core::{BundleFile, FileUpdateStatus}; + + fn linked(project_id: &str, version_id: Option<&str>, hash: &str) -> LinkedArtifactInfo { + disabled_linked(project_id, version_id, hash, true) + } + + fn disabled_linked( + project_id: &str, + version_id: Option<&str>, + hash: &str, + enabled: bool, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.to_string(), + cluster_file_name: format!("{hash}.jar"), + enabled, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: Some(project_id.to_string()), + version_id: version_id.map(Into::into), + display_name: None, + display_version: None, + provider: Some(ProviderId::Modrinth), + published_at: None, + } + } + + fn managed(project_id: &str, version_id: &str) -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + path: format!("mods/{project_id}.jar"), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: project_id.to_string(), + version_id: version_id.to_string(), + sha1: format!("sha-{version_id}"), + }, + } + } + + fn bundles(files: Vec) -> Vec { + vec![BundleWithUpdateStatus { + files: files + .iter() + .cloned() + .map(|f| (f, FileUpdateStatus::UpToDate)) + .collect(), + archive: archive("performance", true, files), + has_updates: false, + }] + } + + fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed { + map.get(&(ProviderId::Modrinth, project.to_string())) + .expect("project missing from the map") + .clone() + } + + #[test] + fn every_installed_version_of_one_project_is_listed() { + let map = installed_map( + vec![ + linked("sodium", Some("v1"), "hash-1"), + linked("sodium", Some("v2"), "hash-2"), + ], + &[], + ); + + let sodium = entry(&map, "sodium"); + assert!( + sodium.is_version("v1"), + "the older version is still in there" + ); + assert!(sodium.is_version("v2")); + assert_eq!( + sodium.find_version("v2").and_then(|v| v.hash.clone()), + Some("hash-2".to_string()), + "each version carries the artifact the remove button needs" + ); + } + + #[test] + fn a_bundle_marks_only_the_version_it_ships() { + let map = installed_map( + vec![ + linked("sodium", Some("v1"), "hash-1"), + linked("sodium", Some("v2"), "hash-2"), + ], + &bundles(vec![managed("sodium", "v1")]), + ); + + let sodium = entry(&map, "sodium"); + assert_eq!( + sodium.source, + InstallSource::Bundled, + "the bundle owns the project" + ); + assert_eq!( + sodium.find_version("v1").map(|v| v.source), + Some(InstallSource::Bundled) + ); + assert_eq!( + sodium.find_version("v2").map(|v| v.source), + Some(InstallSource::Manual), + "a version the user added themselves does not become the bundle's" + ); + } + + #[test] + fn an_unlinked_bundle_pin_is_the_fallback() { + let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")])); + + let sodium = entry(&map, "sodium"); + assert_eq!(sodium.source, InstallSource::Bundled); + assert_eq!( + sodium.find_version("v1").map(|v| v.hash.clone()), + Some(None), + "nothing is linked, so there is no artifact to remove" + ); + } + + #[test] + fn a_bundle_pin_is_not_listed_beside_a_linked_version() { + let map = installed_map( + vec![linked("sodium", Some("v2"), "hash-2")], + &bundles(vec![managed("sodium", "v1")]), + ); + + let sodium = entry(&map, "sodium"); + assert!( + !sodium.is_version("v1"), + "a pin the cluster never downloaded is not installed" + ); + assert_eq!(sodium.versions.len(), 1); + } + + #[test] + fn a_single_copy_is_not_a_duplicate() { + let map = installed_map(vec![linked("sodium", Some("v1"), "hash-1")], &[]); + + assert!(!entry(&map, "sodium").is_duplicated()); + } + + #[test] + fn an_unlinked_bundle_pin_is_not_a_second_copy() { + let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")])); + + assert!( + !entry(&map, "sodium").is_duplicated(), + "one pin and nothing linked is not two of anything" + ); + } + + #[test] + fn each_copy_reports_whether_the_game_loads_it() { + let map = installed_map( + vec![ + disabled_linked("sodium", Some("v1"), "hash-1", false), + linked("sodium", Some("v2"), "hash-2"), + ], + &[], + ); + + let sodium = entry(&map, "sodium"); + assert!(sodium.is_duplicated()); + assert_eq!(sodium.find_version("v1").map(|v| v.enabled), Some(false)); + assert_eq!(sodium.find_version("v2").map(|v| v.enabled), Some(true)); + } + + #[test] + fn an_artifact_with_no_recorded_version_still_marks_the_project() { + let map = installed_map(vec![linked("sodium", None, "hash-1")], &[]); + + let sodium = entry(&map, "sodium"); + assert!( + sodium.versions.is_empty(), + "there is no version to tie to a row in the list" + ); + assert_eq!(sodium.source, InstallSource::Manual); + } +} diff --git a/packages/oneclient_app/src/view/app/browser/package/gallery.rs b/packages/oneclient_app/src/view/app/browser/package/gallery.rs new file mode 100644 index 00000000..a8b66d45 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/gallery.rs @@ -0,0 +1,325 @@ +//! Remote images behind the shared image cache a tile and the opened view ask for the same url at two different sizes + +use super::*; + +use oneclient_content::packages::types::GalleryImage; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{loaded_image, query_is_busy, use_cached_image}; +use crate::theme::colors; +use crate::ui::{border_all_color, flow_grid, grid_columns_for_width}; + +/// How wide a tile is allowed to get before the grid takes another column +const MAX_COL_W: f32 = 400.; +const GRID_GAP: f32 = 16.; +const TILE_PREVIEW_H: f32 = 168.; + +/// Downscale ceilings for the image cache full-size tiles made the grid slow to fill +const TILE_EDGE: u32 = 640; +const FULL_EDGE: u32 = 2048; + +pub(super) fn gallery_panel(images: Vec) -> impl IntoElement { + Gallery { images } +} + +#[derive(PartialEq)] +struct Gallery { + images: Vec, +} + +impl Component for Gallery { + fn render(&self) -> impl IntoElement { + let grid_width = use_state(|| 0f32); + let mut viewing = use_state(|| None::); + + let tiles: Vec = self + .images + .iter() + .enumerate() + .map(|(idx, image)| { + let key = image.url.clone(); + GalleryTile { + image: image.clone(), + on_open: (move |()| viewing.set(Some(idx))).into(), + key: DiffKey::None, + } + .key(key) + .into_element() + }) + .collect(); + + let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); + let images = self.images.clone(); + + rect() + .width(Size::fill()) + .child(flow_grid(tiles, cols, grid_width, GRID_GAP)) + .maybe_child((*viewing.read()).map(|start| { + GalleryViewer { + images: images.clone(), + start, + on_close: (move |()| viewing.set(None)).into(), + } + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct GalleryTile { + image: GalleryImage, + on_open: EventHandler<()>, + key: DiffKey, +} + +impl KeyExt for GalleryTile { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for GalleryTile { + fn render(&self) -> impl IntoElement { + let query = use_cached_image(Some(self.image.url.clone()), TILE_EDGE); + let loaded = loaded_image(Some(&self.image.url), &query); + + let mut hovered = use_state(|| false); + let on_open = self.on_open.clone(); + + let preview = rect() + .width(Size::fill()) + .height(Size::px(TILE_PREVIEW_H)) + .center() + .overflow(Overflow::Clip) + .background(colors::component_bg()) + .maybe_child(loaded.map(|(url, bytes)| { + ImageViewer::new((url, bytes)) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center) + .into_element() + })); + + rect() + .vertical() + .width(Size::flex(1.0)) + .max_width(Size::px(MAX_COL_W)) + .corner_radius(CornerRadius::new_all(10.)) + .overflow(Overflow::Clip) + .background(PANEL_BG) + .border( + border_all_color( + 1., + if *hovered.read() { + colors::component_border_hover() + } else { + colors::component_border() + }, + ) + .alignment(BorderAlignment::Inner), + ) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| on_open.call(())) + .child(preview) + .maybe(self.image.title.is_some(), |el| { + el.child( + rect() + .width(Size::fill()) + .padding(Gaps::new_all(10.)) + .child( + label() + .text(self.image.title.clone().unwrap_or_default()) + .font_size(12.) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + }) + } +} + +#[derive(PartialEq)] +struct GalleryViewer { + images: Vec, + start: usize, + on_close: EventHandler<()>, +} + +impl Component for GalleryViewer { + fn render(&self) -> impl IntoElement { + let len = self.images.len(); + + let mut index = use_state({ + let start = self.start.min(len.saturating_sub(1)); + move || start + }); + + if len == 0 { + return rect().into_element(); + } + let idx = (*index.read()).min(len - 1); + let image = self.images[idx].clone(); + + let query = use_cached_image(Some(image.url.clone()), FULL_EDGE); + let loaded = loaded_image(Some(&image.url), &query); + + let close = self.on_close.clone(); + let scrim_close = self.on_close.clone(); + + let has_prev = idx > 0; + let has_next = idx + 1 < len; + + let preview = rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .overflow(Overflow::Clip) + .maybe_child(loaded.map(|(url, bytes)| { + ImageViewer::new((url, bytes)) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Min) + .into_element() + })) + // A full-size fetch separate from the tile's cached copy a failed fetch is not loading + .maybe_child(query_is_busy(&query).then(|| { + label() + .text("Loading image...") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element() + })); + + OverlayPopup::new() + .on_close(move |_| scrim_close.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .on_global_key_down(move |e: Event| match &e.key { + Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), + Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), + _ => {} + }) + .child( + rect() + .vertical() + .width(Size::window_percent(88.)) + .height(Size::window_percent(90.)) + .spacing(12.) + .padding(Gaps::new_all(16.)) + .content(Content::Flex) + .child(header_row(&image, idx, len, move |_| close.call(()))) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(12.) + .overflow(Overflow::Clip) + .child(chevron_btn(IconType::ArrowLeft, has_prev, move |_| { + if idx > 0 { + index.set(idx - 1); + } + })) + .child(preview) + .child(chevron_btn( + IconType::ArrowRight, + has_next, + move |_| { + if idx + 1 < len { + index.set(idx + 1); + } + }, + )), + ), + ), + ) + .into_element() + } +} + +fn chevron_btn( + icon: IconType, + enabled: bool, + on_press: impl Into>>, +) -> impl IntoElement { + let base = rect() + .width(Size::px(44.)) + .height(Size::px(44.)) + .center() + .corner_radius(CornerRadius::new_all(22.)); + + if !enabled { + return base + .background(Color::from_argb(70, 0, 0, 0)) + .on_press(|_| {}) + .child( + Icon::new(icon) + .size(26.) + .color(colors::fg_secondary().with_a(90)), + ) + .into_element(); + } + + base.background(Color::from_argb(140, 0, 0, 0)) + .cursor(CursorIcon::Pointer) + .on_press(on_press) + .child(Icon::new(icon).size(26.).color(colors::fg_primary())) + .into_element() +} + +fn header_row( + image: &GalleryImage, + idx: usize, + len: usize, + on_close: impl Into>>, +) -> impl IntoElement { + let title = image + .title + .clone() + .unwrap_or_else(|| format!("Image {}", idx + 1)); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(len > 1, |el| { + el.child( + label() + .text(format!("{} of {len}", idx + 1)) + .font_size(11.) + .color(colors::fg_secondary()), + ) + }), + ) + .child( + Button::new() + .ghost() + .icon() + .on_press(on_close) + .child(Icon::new(IconType::XClose).size(18.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs new file mode 100644 index 00000000..31770866 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -0,0 +1,226 @@ +use freya::prelude::*; +use oneclient_content::packages::{ContentType, ProviderId}; + +use crate::components::ScrollArea; +use crate::hooks::use_cluster; +use crate::hooks::{ + bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail, + use_browser_compat, use_bundles_with_status, use_cluster_content, use_dispatch, + use_installs_snapshot, use_link_confirm, use_package_project, use_package_versions, + version_list, versions_total, +}; +use crate::theme::colors; +use crate::ui::border_all_color; + +use super::{ + Installed, InstalledVersion, PackageBanner, Thumbnail, activity_badge, installed_badge, + installed_badge_overlay, installed_map, +}; +use crate::utils::abbreviate_number; + +mod gallery; +mod panels; +mod sidebar; +use gallery::gallery_panel; +use panels::{about_panel, loading_body, tabs, versions_panel}; +use sidebar::sidebar; + +const PANEL_BG: Color = Color::from_rgb(21, 28, 34); +const SIDEBAR_W: f32 = 280.; +const SCROLLBAR_GUTTER: f32 = 18.; + +fn decode_package_id(package_id: &str) -> (ProviderId, String) { + match package_id.split_once(':') { + Some((repr, id)) => { + let provider = repr + .parse::() + .ok() + .and_then(ProviderId::from_repr) + .unwrap_or(ProviderId::Modrinth); + (provider, id.to_string()) + } + None => (ProviderId::Modrinth, package_id.to_string()), + } +} + +fn pill(text: String) -> impl IntoElement { + rect() + .center() + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(text) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn pill_flow(items: &[String], per_row: usize, max: usize) -> impl IntoElement { + let shown: Vec = items.iter().take(max).cloned().collect(); + let overflow = items.len().saturating_sub(shown.len()); + let mut all = shown; + if overflow > 0 { + all.push(format!("+{overflow}")); + } + + rect() + .vertical() + .spacing(4.) + .children(all.chunks(per_row.max(1)).map(|chunk| { + rect() + .horizontal() + .spacing(4.) + .children(chunk.iter().map(|t| pill(t.clone()).into_element())) + .into_element() + })) + .into_element() +} + +fn pill_detail(key: &str, items: &[String]) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(key.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(pill_flow(items, 4, 18)) + .into_element() +} + +#[derive(PartialEq)] +pub struct BrowserPackage { + pub cluster_id: i64, + pub package_type: String, + pub package_id: String, +} + +impl Component for BrowserPackage { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let (provider, project_id) = decode_package_id(&self.package_id); + let content_type = content_type_for_slug(&self.package_type); + + let active_tab = use_state(|| 0usize); + let versions_page = use_state(|| 0usize); + let compatible_only = use_browser_compat(); + let dispatch = use_dispatch(); + let confirm = use_link_confirm(); + + let cluster = use_cluster(cluster_id); + let compat = *compatible_only.read(); + let (game_version, loader) = match (compat, &cluster) { + (true, Some(c)) => ( + Some(c.mc_version.clone()), + (content_type == ContentType::Mod).then_some(c.mc_loader), + ), + _ => (None, None), + }; + + let mut versions_page_state = versions_page; + let mut last_compat = use_state(|| compat); + if *last_compat.peek() != compat { + last_compat.set(compat); + if *versions_page_state.peek() != 0 { + versions_page_state.set(0); + } + } + + let project_query = use_package_project(provider, project_id.clone()); + let versions_query = use_package_versions( + provider, + project_id.clone(), + game_version, + loader, + *versions_page.read(), + ); + + let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + + let installed = installed_map( + cluster_content_items(&use_cluster_content(cluster_id, content_type)), + &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + ) + .remove(&(provider, project_id.clone())); + + let project = project_detail(&project_query); + let versions = version_list(&versions_query); + let total_versions = versions_total(&versions_query); + let latest_version = versions.first().map(|v| v.version_id.clone()); + + let gallery = project + .as_ref() + .map(|p| p.gallery.clone()) + .unwrap_or_default(); + let has_gallery = !gallery.is_empty(); + let max_tab = if has_gallery { 2 } else { 1 }; + let current = (*active_tab.read()).min(max_tab); + + let body = match (&project, current) { + (None, _) => loading_body().into_element(), + (Some(project), 0) => about_panel(project).into_element(), + (Some(project), 1) => versions_panel( + versions, + total_versions, + versions_page, + provider, + project.id.clone(), + cluster_id, + dispatch.clone(), + installed.clone(), + installing, + ) + .into_element(), + (Some(_), _) => gallery_panel(gallery).into_element(), + }; + + let row = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Start) + .spacing(24.) + .content(Content::Flex) + .child(sidebar( + project, + latest_version, + provider, + cluster_id, + dispatch, + confirm, + installed, + installing, + )) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(12.) + .child(tabs(active_tab, has_gallery)) + .child(body), + ) + .into_element(); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 40.)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .reset_key(current as u64) + .padding(Gaps::new(0., SCROLLBAR_GUTTER, 0., 0.)) + .children([row]), + ) + .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 new file mode 100644 index 00000000..829aca06 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -0,0 +1,339 @@ +use super::*; + +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary}; + +use crate::Actions; +use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; +use crate::hooks::VERSIONS_PAGE_SIZE; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +pub(super) fn loading_body() -> impl IntoElement { + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_all(32.)) + .child( + label() + .text("Loading package...") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +pub(super) fn tabs(active_tab: State, has_gallery: bool) -> impl IntoElement { + let mut control = SegmentedControl::new(active_tab) + .height(36.) + .segment(Segment::new(0usize).label("About")) + .segment(Segment::new(1usize).label("Versions")); + if has_gallery { + control = control.segment(Segment::new(2usize).label("Gallery")); + } + control +} + +pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement { + let body = match &project.body { + PackageBody::Raw(md) => md.clone(), + PackageBody::Url(url) => format!("{}\n\n[View online]({url})", project.summary), + }; + MarkdownPanel { body }.into_element() +} + +#[derive(PartialEq)] +struct MarkdownPanel { + body: String, +} + +impl Component for MarkdownPanel { + fn render(&self) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child( + MarkdownViewer::new(self.body.clone()) + .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(26.) + .heading_h2(22.) + .heading_h3(18.) + .heading_h4(16.) + .heading_h5(14.) + .heading_h6(13.) + .paragraph_size(13.) + .code_font_size(12.), + ) + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn versions_panel( + versions: Vec, + total_versions: usize, + versions_page: State, + provider: ProviderId, + project_id: String, + cluster_id: i64, + dispatch: Actions, + installed: Option, + installing: bool, +) -> impl IntoElement { + let current = *versions_page.read(); + let total_pages = total_versions.div_ceil(VERSIONS_PAGE_SIZE).max(1); + + let rows: Element = if versions.is_empty() { + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_all(32.)) + .child( + label() + .text("No matching versions.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() + } else { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(versions.into_iter().map(move |v| { + let tag = installed + .as_ref() + .and_then(|installed| installed.find_version(&v.version_id)) + .cloned(); + let duplicated = installed + .as_ref() + .is_some_and(|installed| installed.is_duplicated()); + version_row( + v, + provider, + project_id.clone(), + cluster_id, + dispatch.clone(), + tag, + duplicated, + installing, + ) + .into_element() + })) + .into_element() + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(12.) + .child(rows) + .maybe(total_versions > 0, |el| { + el.child(version_pager(current, total_pages, versions_page)) + }) + .into_element() +} + +fn version_pager(current: usize, total_pages: usize, page: State) -> impl IntoElement { + let nav = move |target: usize, enabled: bool, icon: IconType| { + let mut page = page; + rect() + .center() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_press(move |_| page.set(target)) + }) + .child(Icon::new(icon).size(14.).color(if enabled { + colors::fg_primary() + } else { + colors::fg_secondary().with_a(90) + })) + .into_element() + }; + + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(12.) + .child(nav( + current.saturating_sub(1), + current > 0, + IconType::ArrowLeft, + )) + .child( + label() + .text(format!("Page {} / {}", current + 1, total_pages)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(nav( + current + 1, + current + 1 < total_pages, + IconType::ArrowRight, + )) + .into_element() +} + +#[allow(clippy::too_many_arguments)] +fn version_row( + v: VersionSummary, + provider: ProviderId, + project_id: String, + cluster_id: i64, + dispatch: Actions, + installed: Option, + // Saying which version is live only tells the user anything when there are several + duplicated: bool, + installing: bool, +) -> impl IntoElement { + let version_id = v.version_id.clone(); + let mut chips: Vec = v.loaders.iter().map(|l| l.to_string()).collect(); + chips.extend(v.game_versions.iter().cloned()); + let stats = { + let mut parts = vec![format!("{} downloads", abbreviate_number(v.downloads))]; + if v.file_size > 0 { + parts.push(format_size(v.file_size)); + } + parts.push(v.published.format("%Y-%m-%d").to_string()); + parts.join(" · ") + }; + let has_chips = !chips.is_empty(); + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child(release_badge(v.release_type)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(6.) + .child( + label() + .text(v.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(has_chips, |el| el.child(pill_flow(&chips, 8, 10))) + .child( + label() + .text(stats) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .maybe_child( + installed + .as_ref() + .map(|installed| installed_badge(installed.source, 11.).into_element()), + ) + .maybe_child( + installed + .as_ref() + .filter(|_| duplicated) + .map(|installed| activity_badge(installed.enabled).into_element()), + ) + .child(version_button( + installed, v.name, provider, project_id, version_id, cluster_id, dispatch, installing, + )) +} + +/// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it +#[allow(clippy::too_many_arguments)] +fn version_button( + installed: Option, + version_name: String, + provider: ProviderId, + project_id: String, + version_id: String, + cluster_id: i64, + dispatch: Actions, + busy: bool, +) -> impl IntoElement { + let Some(installed) = installed else { + return Button::new() + .secondary() + .small() + .enabled(!busy) + .on_press(move |_| { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id.clone(), + ); + }) + .text("Install"); + }; + + let Some(hash) = installed.hash else { + return Button::new() + .secondary() + .small() + .enabled(false) + .text("Install"); + }; + + Button::new() + .danger() + .small() + .enabled(!busy) + .on_press(move |_| { + dispatch.remove_package_version( + cluster_id, + provider, + project_id.clone(), + hash.clone(), + version_name.clone(), + ); + }) + .text("Remove") +} + +fn release_badge(release_type: ReleaseType) -> impl IntoElement { + let (text, color) = match release_type { + ReleaseType::Release => ("R", colors::code_info()), + ReleaseType::Beta => ("B", colors::code_warn()), + ReleaseType::Alpha => ("A", colors::code_error()), + }; + rect() + .center() + .width(Size::px(28.)) + .height(Size::px(28.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(color.with_a(40)) + .child( + label() + .text(text) + .font_size(13.) + .font_weight(FontWeight::BOLD) + .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 new file mode 100644 index 00000000..04939b5d --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -0,0 +1,455 @@ +use super::*; + +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{ProjectDetail, ProjectMember}; + +use crate::Actions; +use crate::components::{Button, Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[allow(clippy::too_many_arguments)] +pub(super) fn sidebar( + project: Option, + latest_version: Option, + provider: ProviderId, + cluster_id: i64, + dispatch: Actions, + confirm: State>, + installed: Option, + installing: bool, +) -> impl IntoElement { + let Some(project) = project else { + return rect() + .width(Size::px(SIDEBAR_W)) + .min_width(Size::px(SIDEBAR_W)) + .into_element(); + }; + + let project_id = project.id.clone(); + // Nothing to do when this version is already there or while an install is running + let have_latest = match (&installed, &latest_version) { + (Some(installed), Some(latest)) => installed.is_version(latest), + _ => false, + }; + let can_install = latest_version.is_some() && !have_latest && !installing; + + rect() + .vertical() + .width(Size::px(SIDEBAR_W)) + .min_width(Size::px(SIDEBAR_W)) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .width(Size::fill()) + .overflow(Overflow::Clip) + .child(PackageBanner::new(project.icon_url.clone(), 110.)) + .maybe_child(installed.as_ref().map(|installed| { + rect() + .position(Position::new_absolute().top(8.).left(8.)) + .layer(Layer::Relative(7)) + .child(installed_badge_overlay(installed.source)) + .into_element() + })), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .child( + label() + .text(project.name.clone()) + .font_size(18.) + .font_weight(FontWeight::BOLD) + .max_lines(2) + .color(colors::fg_primary()), + ) + .child(ProviderTag { + text: format!("{} on {}", project.content_type, project.provider), + provider: project.provider, + url: provider_project_url(&project), + confirm, + }) + .maybe(!project.summary.is_empty(), |el| { + el.child( + label() + .text(project.summary.clone()) + .font_size(12.) + .max_lines(4) + .color(colors::fg_secondary()), + ) + }) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child( + Icon::new(IconType::Download01) + .size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(format!( + "{} downloads", + abbreviate_number(project.downloads) + )) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ), + ), + ) + .child( + Button::new() + .primary() + .width(Size::fill()) + .enabled(can_install) + .on_press(move |_| { + if let Some(version_id) = latest_version.clone() { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + ); + } + }) + .child(Icon::new(IconType::Download01).size(14.)) + .text(if installing { + "Installing..." + } else if have_latest { + "Latest installed" + } else { + "Install latest" + }), + ) + .maybe( + !project.members.is_empty() || !project.author.is_empty(), + |el| el.child(authors_card(&project, confirm)), + ) + .child(details_card(&project)) + .maybe(!project.links.is_empty(), |el| { + el.child(links_card(&project.links, confirm)) + }) + .into_element() +} + +/// Modrinth doesn't hand a url out so it's rebuilt from the slug CurseForge lists it as "Website" +fn provider_project_url(project: &ProjectDetail) -> Option { + match project.provider { + ProviderId::Modrinth => Some(format!( + "{}{}/{}", + ProviderId::Modrinth.website(), + project.content_type.modrinth_type(), + project.slug + )), + ProviderId::CurseForge => project + .links + .iter() + .find(|(label, _)| label == "Website") + .map(|(_, url)| url.clone()), + ProviderId::Local => None, + } +} + +/// Plain text when the provider has no page so a local package doesn't look clickable +#[derive(PartialEq)] +struct ProviderTag { + text: String, + provider: ProviderId, + url: Option, + confirm: State>, +} + +impl Component for ProviderTag { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let interactive = url.is_some(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child(Icon::new(self.provider).size(12.)) + .child( + label() + .text(self.text.clone()) + .font_size(11.) + .color(if interactive { + colors::code_info() + } else { + colors::fg_secondary() + }), + ) + .maybe(interactive, |el| { + let url = url.clone().unwrap_or_default(); + el.a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Link) + .corner_radius(CornerRadius::new_all(6.)) + .maybe(focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + }) + } +} + +fn card(title: &str, rows: Vec) -> impl IntoElement { + card_spaced(title, rows, 10.) +} + +/// `card` with a caller-chosen row gap one-line link lists read better tight +fn card_spaced(title: &str, rows: Vec, spacing: f32) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(spacing) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .children(rows) + .into_element() +} + +fn authors_card(project: &ProjectDetail, confirm: State>) -> impl IntoElement { + let rows: Vec = if project.members.is_empty() { + vec![author_row( + &ProjectMember { + name: project.author.clone(), + role: "Author".to_string(), + url: None, + avatar_url: None, + }, + confirm, + )] + } else { + project + .members + .iter() + .map(|m| author_row(m, confirm)) + .collect() + }; + card("Authors", rows) +} + +fn author_row(member: &ProjectMember, confirm: State>) -> Element { + AuthorRow { + name: member.name.clone(), + role: member.role.clone(), + url: member.url.clone(), + avatar_url: member.avatar_url.clone(), + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct AuthorRow { + name: String, + role: String, + url: Option, + avatar_url: Option, + confirm: State>, +} + +impl Component for AuthorRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let interactive = url.is_some(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_all(4.)) + .maybe(interactive, |el| { + let url = url.clone().unwrap_or_default(); + el.a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + }) + .maybe(interactive && focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .child(Thumbnail::new(self.avatar_url.clone(), 32.).radius(6.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( + label() + .text(self.name.clone()) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(!self.role.is_empty(), |el| { + el.child( + label() + .text(self.role.clone()) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + }), + ) + } +} + +fn details_card(project: &ProjectDetail) -> impl IntoElement { + let mut rows: Vec = Vec::new(); + if !project.game_versions.is_empty() { + rows.push(pill_detail("Versions", &project.game_versions)); + } + if !project.loaders.is_empty() { + let loaders: Vec = project.loaders.iter().map(|l| l.to_string()).collect(); + rows.push(pill_detail("Loaders", &loaders)); + } + if let Some(license) = &project.license { + rows.push(detail_row(IconType::Key01, "License", license)); + } + rows.push(detail_row( + IconType::Calendar, + "Created", + &project.created.format("%Y-%m-%d").to_string(), + )); + rows.push(detail_row( + IconType::ClockRewind, + "Updated", + &project.updated.format("%Y-%m-%d").to_string(), + )); + card("Details", rows) +} + +fn detail_row(icon: IconType, key: &str, value: &str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(Icon::new(icon).size(14.).color(colors::fg_secondary())) + .child( + label() + .text(key.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .width(Size::flex(1.0)) + .main_align(Alignment::End) + .child( + label() + .text(value.to_string()) + .font_size(12.) + .max_lines(3) + .width(Size::fill()) + .color(colors::fg_primary()), + ), + ) + .into_element() +} + +fn links_card(links: &[(String, String)], confirm: State>) -> impl IntoElement { + let rows: Vec = links + .iter() + .map(|(lbl, url)| link_row(lbl.clone(), url.clone(), confirm)) + .collect(); + card_spaced("Links", rows, 2.) +} + +fn link_row(label_text: String, url: String, confirm: State>) -> Element { + LinkRow { + label_text, + url, + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct LinkRow { + label_text: String, + url: String, + confirm: State>, +} + +impl Component for LinkRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_symmetric(2., 4.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Link) + .maybe(focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + .child( + Icon::new(IconType::Link03) + .size(14.) + .color(colors::code_info()), + ) + .child( + label() + .text(self.label_text.clone()) + .font_size(12.) + .color(colors::code_info()), + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs new file mode 100644 index 00000000..0f27bfb7 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -0,0 +1,772 @@ +use freya::prelude::*; +use oneclient_common::Patch; +use oneclient_common::domain::GameLoader; +use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution}; +use oneclient_java::JavaRuntime; + +use crate::components::{ + Button, Dropdown, Icon, IconType, ScrollArea, TextInput, memory_field, toggle, + toggle_controlled, validate_number, +}; +use crate::hooks::{ + ClusterAction, java_runtimes, loader_versions, mutation_is_running, query_error, + try_game_profile, use_cluster_mutation, use_dispatch, use_game_profile, use_java_runtimes, + use_loader_versions, use_settings_snapshot, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui::centered_note; +use crate::view::app::settings::{section_header, settings_row}; + +use super::cluster_not_found; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterSettings { + pub cluster_id: i64, +} + +impl Component for ClusterSettings { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let global = use_settings_snapshot().settings.global_game_settings; + + let cluster = use_cluster(cluster_id); + let profile_name = cluster + .as_ref() + .and_then(|c| c.setting_profile_name.clone()); + let profile_query = use_game_profile(profile_name); + let mc_version = cluster + .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 versions_query = use_loader_versions(mc_version, loader); + let runtimes_query = use_java_runtimes(); + + let Some(cluster) = cluster else { + return cluster_not_found(); + }; + + let Some(profile) = try_game_profile(&profile_query) else { + let note = match query_error(&profile_query) { + Some(err) => format!("Could not load these settings: {err}"), + None => "Loading settings...".to_string(), + }; + return cluster_content().child(centered_note(¬e)).into_element(); + }; + + let versions = loader_versions(&versions_query); + let runtimes = java_runtimes(&runtimes_query); + + cluster_content() + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(4.) + .child(section_header("LOADER")) + .child( + LoaderRow { + cluster_id, + loader, + selected: cluster.mc_loader_version.clone(), + versions, + } + .into_element(), + ) + .child(section_header("JAVA")) + .child( + JavaRow { + cluster_id, + value: profile.java_path.clone(), + global: global.java_path.clone(), + runtimes, + } + .into_element(), + ) + .child(text_row(cluster_id, TextField::JvmArgs, &profile, &global)) + .child(section_header("GAME")) + .child( + ToggleRow { + cluster_id, + value: profile.force_fullscreen, + global: global.force_fullscreen.unwrap_or(false), + } + .into_element(), + ) + .child( + ResolutionRow { + cluster_id, + value: profile.resolution, + global: global.resolution.unwrap_or_default(), + } + .into_element(), + ) + .child( + MemoryRow { + cluster_id, + value: profile.mem_max, + global: global.mem_max.unwrap_or(4096), + } + .into_element(), + ) + .child(section_header("CONTENT")) + .child( + BrowserUpdateModeRow { + cluster_id, + value: profile.browser_update_mode, + global: global.browser_update_mode.unwrap_or_default(), + } + .into_element(), + ) + .child(section_header("DIRECTORY")) + .child( + DedicatedDirRow { + cluster_id, + dedicated: cluster.uses_dedicated_dir(), + } + .into_element(), + ) + .child(section_header("PROCESS")) + .child(text_row(cluster_id, TextField::Pre, &profile, &global)) + .child(text_row(cluster_id, TextField::Wrapper, &profile, &global)) + .child(text_row(cluster_id, TextField::Post, &profile, &global)) + .child(section_header("REPAIR")) + .child(VerifyFilesRow { cluster_id }.into_element()), + ) + .into_element() + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Field { + ForceFullscreen, + Resolution, + MemMax, + JavaPath, + BrowserUpdateMode, +} + +fn clear_update(field: Field) -> ProfileUpdate { + let mut u = ProfileUpdate::default(); + match field { + Field::ForceFullscreen => u.force_fullscreen = Patch::Clear, + Field::Resolution => u.resolution = Patch::Clear, + Field::MemMax => u.mem_max = Patch::Clear, + Field::JavaPath => u.java_path = Patch::Clear, + Field::BrowserUpdateMode => u.browser_update_mode = Patch::Clear, + } + u +} + +fn runtime_label(runtime: &JavaRuntime) -> String { + format!("{} {} ({})", runtime.vendor, runtime.major, runtime.version) +} + +fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElement { + let color = if overridden { + colors::fg_secondary() + } else { + colors::fg_secondary().with_a(60) + }; + + Button::new() + .small() + .ghost() + .icon() + .corner_radius(CornerRadius::new_all(7.)) + .maybe(overridden, |el| el.on_press(move |_| on_reset.call(()))) + .enabled(overridden) + .child(Icon::new(IconType::RefreshCcw02).size(15.).color(color)) + .into_element() +} + +fn override_cell( + control: impl IntoElement, + overridden: bool, + on_reset: EventHandler<()>, +) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child(control) + .child(reset_button(overridden, on_reset)) + .into_element() +} + +#[derive(PartialEq)] +struct ToggleRow { + cluster_id: i64, + value: Option, + global: bool, +} + +impl Component for ToggleRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let global = self.global; + let dispatch = use_dispatch(); + + let initial = self.value.unwrap_or(self.global); + let mut state = use_state(move || initial); + let mut last = use_state(move || initial); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let v = *state.read(); + if v == *last.peek() { + return; + } + last.set(v); + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + force_fullscreen: Patch::Set(v), + ..Default::default() + }, + ); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + last.set(global); + state.set(global); + dispatch.update_cluster_profile(cluster_id, clear_update(Field::ForceFullscreen)); + }) + .into(); + + settings_row( + IconType::Maximize01, + "Force Fullscreen", + "Force Minecraft to start in fullscreen mode.", + override_cell(toggle(state), overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct VerifyFilesRow { + cluster_id: i64, +} + +impl Component for VerifyFilesRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let mutation = use_cluster_mutation(); + let running = mutation_is_running(&mutation); + + let on_press = move |_| { + mutation.mutate(ClusterAction::VerifyFiles { cluster_id }); + }; + + // The pass reads every installed file so say it takes a while otherwise the button reads as broken + let button = Button::new() + .small() + .secondary() + .enabled(!running) + .maybe(!running, |el| el.on_press(on_press)) + .text(if running { + "Verifying..." + } else { + "Verify Files" + }); + + settings_row( + IconType::ClipboardCheck, + "Verify Files", + "Check every installed file against its official checksum and \ + re-download anything corrupt or missing. Takes a few minutes.", + button, + ) + } +} + +#[derive(PartialEq)] +struct DedicatedDirRow { + cluster_id: i64, + dedicated: bool, +} + +impl Component for DedicatedDirRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dedicated = self.dedicated; + let mutation = use_cluster_mutation(); + + let on_toggle: EventHandler<()> = (move |()| { + mutation.mutate(ClusterAction::SetDedicatedDir { + cluster_id, + dedicated: !dedicated, + }); + }) + .into(); + + settings_row( + IconType::Folder, + "Dedicated Directory", + "Run this cluster in its own .minecraft instead of the shared one.", + toggle_controlled(dedicated, on_toggle), + ) + } +} + +#[derive(PartialEq)] +struct MemoryRow { + cluster_id: i64, + value: Option, + global: u32, +} + +impl Component for MemoryRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let dispatch = use_dispatch(); + + let global = self.global.to_string(); + let initial = self.value.unwrap_or(self.global).to_string(); + let mut memory = use_state({ + let v = initial.clone(); + move || v + }); + let mut last = use_state(move || initial); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let raw = memory.read().clone(); + if raw == *last.peek() { + return; + } + last.set(raw.clone()); + let mem_max = match raw.trim() { + "" => Patch::Clear, + m => m.parse::().map(Patch::Set).unwrap_or(Patch::Unchanged), + }; + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + mem_max, + ..Default::default() + }, + ); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + last.set(global.clone()); + memory.set(global.clone()); + dispatch.update_cluster_profile(cluster_id, clear_update(Field::MemMax)); + }) + .into(); + + let control = memory_field(memory); + + settings_row( + IconType::Database01, + "Memory", + "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct ResolutionRow { + cluster_id: i64, + value: Option, + global: Resolution, +} + +impl Component for ResolutionRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let dispatch = use_dispatch(); + let global = self.global; + let resolved = self.value.unwrap_or(self.global); + + let mut width = use_state({ + let v = resolved.width.to_string(); + move || v + }); + let mut height = use_state({ + let v = resolved.height.to_string(); + move || v + }); + let mut last = use_state(move || (resolved.width.to_string(), resolved.height.to_string())); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let w = width.read().clone(); + let h = height.read().clone(); + if (w.clone(), h.clone()) == *last.peek() { + return; + } + last.set((w.clone(), h.clone())); + let resolution = match (w.trim(), h.trim()) { + ("", "") => Patch::Clear, + (w, h) => match (w.parse::(), h.parse::()) { + (Ok(w), Ok(h)) => Patch::Set(Resolution::new(w, h)), + _ => Patch::Unchanged, + }, + }; + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + resolution, + ..Default::default() + }, + ); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + let gw = global.width.to_string(); + let gh = global.height.to_string(); + last.set((gw.clone(), gh.clone())); + width.set(gw); + height.set(gh); + dispatch.update_cluster_profile(cluster_id, clear_update(Field::Resolution)); + }) + .into(); + + let control = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + TextInput::new(width) + .placeholder("854") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)), + ) + .child( + Icon::new(IconType::X) + .size(14.) + .color(colors::fg_secondary()), + ) + .child( + TextInput::new(height) + .placeholder("480") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)), + ); + + settings_row( + IconType::LayoutTop, + "Resolution", + "The game window resolution in pixels.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum TextField { + JvmArgs, + Pre, + Wrapper, + Post, +} + +impl TextField { + fn meta(self) -> (IconType, &'static str, &'static str, &'static str) { + match self { + Self::JvmArgs => ( + IconType::Terminal, + "JVM Arguments", + "Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.", + "-XX:+UseG1GC", + ), + Self::Pre => ( + IconType::FilePlus02, + "Pre-Launch Command", + "Command to run before launching the game.", + "echo 'Game started'", + ), + Self::Wrapper => ( + IconType::ParagraphWrap, + "Wrapper Command", + "Command to run when launching the game.", + "gamescope", + ), + Self::Post => ( + IconType::FileX02, + "Post-Exit Command", + "Command to run after exiting the game.", + "echo 'Game exited'", + ), + } + } + + fn value(self, profile: &GameSettingsProfile) -> Option { + match self { + Self::JvmArgs => profile.launch_args.clone(), + Self::Pre => profile.hook_pre.clone(), + Self::Wrapper => profile.hook_wrapper.clone(), + Self::Post => profile.hook_post.clone(), + } + } + + fn patch(self, value: Patch) -> ProfileUpdate { + let mut u = ProfileUpdate::default(); + match self { + Self::JvmArgs => u.launch_args = value, + Self::Pre => u.hook_pre = value, + Self::Wrapper => u.hook_wrapper = value, + Self::Post => u.hook_post = value, + } + u + } +} + +fn text_row( + cluster_id: i64, + field: TextField, + profile: &GameSettingsProfile, + global: &GameSettingsProfile, +) -> impl IntoElement { + TextRow { + cluster_id, + field, + value: field.value(profile), + global: field.value(global).unwrap_or_default(), + } + .into_element() +} + +#[derive(PartialEq)] +struct TextRow { + cluster_id: i64, + field: TextField, + value: Option, + global: String, +} + +impl Component for TextRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let field = self.field; + let overridden = self.value.is_some(); + let (icon, title, description, placeholder) = field.meta(); + let dispatch = use_dispatch(); + + let global = self.global.clone(); + let initial = self.value.clone().unwrap_or_else(|| self.global.clone()); + let mut text = use_state({ + let v = initial.clone(); + move || v + }); + let mut last = use_state(move || initial); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let raw = text.read().clone(); + if raw == *last.peek() { + return; + } + last.set(raw.clone()); + let patch = match raw.trim() { + "" => Patch::Clear, + v => Patch::Set(v.to_string()), + }; + dispatch.update_cluster_profile(cluster_id, field.patch(patch)); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + last.set(global.clone()); + text.set(global.clone()); + dispatch.update_cluster_profile(cluster_id, field.patch(Patch::Clear)); + }) + .into(); + + let control = TextInput::new(text) + .placeholder(placeholder) + .width(Size::px(220.)); + + settings_row( + icon, + title, + description, + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct JavaRow { + cluster_id: i64, + value: Option, + global: Option, + runtimes: Vec, +} + +impl Component for JavaRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let dispatch = use_dispatch(); + let runtimes = self.runtimes.clone(); + + let resolved = self.value.clone().or_else(|| self.global.clone()); + + let control: Element = if runtimes.is_empty() { + label() + .text("No runtimes installed") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } else { + let mut options: Vec = vec!["Automatic".into()]; + options.extend(runtimes.iter().map(runtime_label)); + + let selected = resolved + .as_ref() + .and_then(|path| runtimes.iter().find(|r| &r.absolute_path == path)) + .map(runtime_label) + .unwrap_or_else(|| "Automatic".into()); + + let paths: Vec = runtimes.iter().map(|r| r.absolute_path.clone()).collect(); + + let dispatch = dispatch.clone(); + Dropdown::new(selected, options) + .width(Size::px(260.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if idx == 0 { + dispatch.update_cluster_profile(cluster_id, clear_update(Field::JavaPath)); + } else if let Some(path) = paths.get(idx - 1) { + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + java_path: Patch::Set(path.clone()), + ..Default::default() + }, + ); + } + }) + .into_element() + }; + + let on_reset: EventHandler<()> = (move |()| { + dispatch.update_cluster_profile(cluster_id, clear_update(Field::JavaPath)); + }) + .into(); + + settings_row( + IconType::CodeSnippet02, + "Java Runtime", + "The Java runtime used to launch this cluster.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct BrowserUpdateModeRow { + cluster_id: i64, + value: Option, + global: PackageUpdateMode, +} + +impl Component for BrowserUpdateModeRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let resolved = self.value.unwrap_or(self.global); + let dispatch = use_dispatch(); + + let options: Vec = PackageUpdateMode::ALL + .iter() + .map(|mode| mode.label().to_string()) + .collect(); + + let control = { + let dispatch = dispatch.clone(); + Dropdown::new(resolved.label(), options) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(mode) = PackageUpdateMode::ALL.get(idx).copied() { + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + browser_update_mode: Patch::Set(mode), + ..Default::default() + }, + ); + } + }) + }; + + let on_reset: EventHandler<()> = (move |()| { + dispatch.update_cluster_profile(cluster_id, clear_update(Field::BrowserUpdateMode)); + }) + .into(); + + settings_row( + IconType::RefreshCw01, + "Browser Package Updates", + "What to do when content installed from the browser has a newer version. Bundle content is not affected.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct LoaderRow { + cluster_id: i64, + loader: GameLoader, + selected: Option, + versions: Vec, +} + +impl Component for LoaderRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + let versions = self.versions.clone(); + let selected = self + .selected + .clone() + .unwrap_or_else(|| versions.first().cloned().unwrap_or_else(|| "Latest".into())); + + let control: Element = if versions.is_empty() { + label() + .text("No versions available") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } else { + let options = versions.clone(); + Dropdown::new(selected, options.clone()) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(version) = options.get(idx) { + dispatch.set_cluster_loader_version(cluster_id, version.clone()); + } + }) + .into_element() + }; + + settings_row( + IconType::Rocket02, + "Loader Version", + 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 new file mode 100644 index 00000000..0ac84ca0 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -0,0 +1,684 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use freya::prelude::*; +use freya::query::UseQuery; +use oneclient_common::search::SearchQuery; +use oneclient_core::{LogFileInfo, LogKind, LogLevel}; + +use crate::components::upload_mclogs::{UploadToMclogs, use_mclogs_feedback}; +use crate::components::{ + Button, Icon, IconType, LogViewer, OverlayPopup, ScrollArea, Segment, SegmentedControl, + TextInput, open_folder_button, +}; +use crate::hooks::{ + ClusterLogsQuery, LogAction, UploadLogKeys, UseLogAction, UseUploadLog, + invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, use_log_action, + use_log_content, use_upload_log, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui; +use crate::utils::format_size; + +use super::cluster_not_found; +use crate::hooks::use_cluster; +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const PICKER_ROW_H: f32 = 44.; +const PICKER_ROW_SPACING: f32 = 2.; +const PICKER_LIST_MAX_H: f32 = 300.; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LevelFilter { + All, + Error, + Warn, + Info, + Debug, +} + +impl LevelFilter { + pub fn to_level(self) -> Option { + match self { + Self::All => None, + Self::Error => Some(LogLevel::Error), + Self::Warn => Some(LogLevel::Warn), + Self::Info => Some(LogLevel::Info), + Self::Debug => Some(LogLevel::Debug), + } + } + + pub fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Error => "Errors", + Self::Warn => "Warnings", + Self::Info => "Info", + Self::Debug => "Debug", + } + } +} + +#[derive(Clone, Copy, PartialEq)] +pub enum Confirm { + Clear, + Upload, +} + +/// A crash report buried among thirty rotated `.log.gz` files is one nobody finds +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Category { + Logs, + CrashReports, +} + +impl Category { + fn accepts(self, kind: LogKind) -> bool { + kind.is_crash_report() == matches!(self, Self::CrashReports) + } + + fn label(self) -> &'static str { + match self { + Self::Logs => "Logs", + Self::CrashReports => "Crash reports", + } + } + + fn empty_label(self) -> &'static str { + match self { + Self::Logs => "No logs", + Self::CrashReports => "No crash reports", + } + } + + fn empty_note(self) -> &'static str { + match self { + Self::Logs => "No logs recorded yet", + Self::CrashReports => "No crash reports, which is the good outcome", + } + } +} + +fn kind_badge(kind: LogKind) -> &'static str { + match kind { + LogKind::Game { .. } => "Game output", + LogKind::Minecraft => "Minecraft", + LogKind::CrashReport => "Crash report", + LogKind::Other => "Other", + } +} + +#[derive(PartialEq)] +pub struct ClusterLogs { + pub cluster_id: i64, +} + +impl Component for ClusterLogs { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + + let logs_query = use_cluster_logs(cluster_id); + let upload = use_upload_log(); + let action = use_log_action(); + let search = use_state(String::new); + let level = use_state(|| LevelFilter::All); + let confirm = use_state(|| None::); + let category = use_state(|| Category::Logs); + let mut selected = use_state(PathBuf::new); + + use_mclogs_feedback(upload); + + // Reads `category` too so switching the picker re-picks a file instead of keeping a stale selection + use_side_effect(move || { + let files = visible_files(&logs_query, *category.read()); + + if files.is_empty() { + if !selected.peek().as_os_str().is_empty() { + selected.set(PathBuf::new()); + } + return; + } + + let current = selected.peek().clone(); + let valid = !current.as_os_str().is_empty() && files.iter().any(|f| f.path == current); + + if !valid { + let pick = files + .iter() + .find(|f| f.name == "latest.log") + .or_else(|| files.first()); + + if let Some(file) = pick { + selected.set(file.path.clone()); + } + } + }); + + let selected_path = selected.read().clone(); + let search_query = { + 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(), + search_query, + None, + ); + + let Some(cluster) = use_cluster(cluster_id) else { + return cluster_not_found(); + }; + let folder = cluster.dir().ok().map(|d| d.join("logs")); + + let files = visible_files(&logs_query, *category.read()); + let has_log = !files.is_empty(); + let selected_info = files.iter().find(|f| f.path == selected_path).cloned(); + + let lines = try_log_content(&content_query).unwrap_or_default(); + let viewer_lines: Arc>> = Arc::new( + lines + .iter() + .map(|line| Arc::::from(line.text.as_str())) + .collect(), + ); + + let body = rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(12.) + .child(top_bar( + files.clone(), + selected_info.clone(), + folder, + selected, + confirm, + category, + )) + .child( + rect().width(Size::fill()).height(Size::flex(1.0)).child( + LogViewer::new("", viewer_lines) + .header(viewer_header(search, level, confirm, has_log)), + ), + ); + + cluster_content() + .child(body.into_element()) + .maybe_child((*confirm.read()).map(move |kind| { + confirm_overlay(kind, confirm, action, upload, selected_path.clone()) + })) + .into_element() + } +} + +pub fn visible_files(query: &UseQuery, category: Category) -> Vec { + try_cluster_logs(query) + .unwrap_or_default() + .into_iter() + .filter(|file| category.accepts(file.kind)) + .collect() +} + +fn top_bar( + files: Vec, + selected_info: Option, + folder: Option, + selected: State, + mut confirm: State>, + category: State, +) -> impl IntoElement { + let has_log = !files.is_empty(); + + let picker_label = selected_info + .as_ref() + .map(|i| i.name.clone()) + .unwrap_or_else(|| category.read().empty_label().to_string()); + + let size_text = selected_info + .as_ref() + .map(|i| format!("{} on disk", format_size(i.size_bytes))) + .unwrap_or_else(|| category.read().empty_note().to_string()); + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .child( + LogPicker { + label: picker_label, + files, + category, + on_select: (move |path: PathBuf| { + let mut selected = selected; + selected.set(path); + }) + .into(), + } + .into_element(), + ) + .child( + label() + .text(size_text) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child(rect().width(Size::flex(1.0))) + .maybe_child(folder.map(open_folder_button)) + .child( + Button::new() + .secondary() + .on_press(move |_| { + spawn(async move { + invalidate_logs_queries().await; + }); + }) + .child(Icon::new(IconType::RefreshCw01).size(15.)) + .text("Refresh"), + ) + .child( + Button::new() + .danger() + .enabled(has_log) + .on_press(move |_| confirm.set(Some(Confirm::Clear))) + .child(Icon::new(IconType::Trash01).size(15.)) + .text("Clear log"), + ) + .into_element() +} + +pub fn viewer_header( + search: State, + level: State, + confirm: State>, + has_log: bool, +) -> impl IntoElement { + let search_input = { + let base = TextInput::new(search).enabled(has_log).leading( + Icon::new(IconType::SearchMd) + .size(15.) + .color(colors::fg_secondary()) + .into_element(), + ); + if has_log { + base.placeholder("Search this log...") + } else { + base + } + }; + + let row = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(10., 14.)) + .child(rect().width(Size::flex(1.0)).child(search_input)) + .child( + SegmentedControl::new(level) + .no_tint() + .disabled(!has_log) + .segments( + [ + LevelFilter::All, + LevelFilter::Error, + LevelFilter::Warn, + LevelFilter::Info, + LevelFilter::Debug, + ] + .into_iter() + .map(|filter| Segment::new(filter).label(filter.label())), + ) + .into_element(), + ) + .child(UploadToMclogs::new(has_log, confirm)); + + rect() + .vertical() + .width(Size::fill()) + .child(row) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct LogPicker { + label: String, + files: Vec, + /// Owned by the page it filters `files` by this so both must see the same value + category: State, + on_select: EventHandler, +} + +impl Component for LogPicker { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + let filter = use_state(String::new); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let category = self.category; + let label_text = self.label.clone(); + let files = self.files.clone(); + let on_select = self.on_select.clone(); + // Stays usable with an empty list the category selector lives inside the panel + let is_open = open(); + + let query = SearchQuery::new(&filter.read()); + let no_files = files.is_empty(); + let rows: Vec = files + .into_iter() + .filter(|f| query.matches(&f.name) || query.matches(kind_badge(f.kind))) + .map(|file| { + let on_select = on_select.clone(); + let path = file.path.clone(); + PickerRow { + file, + on_press: (move |_: Event| { + on_select.call(path.clone()); + open.set(false); + }) + .into(), + } + .into_element() + }) + .collect(); + let list_h = ((rows.len() as f32 * (PICKER_ROW_H + PICKER_ROW_SPACING)) + - PICKER_ROW_SPACING) + .clamp(0., PICKER_LIST_MAX_H); + + rect() + .width(Size::px(260.)) + .child( + rect() + .width(Size::fill()) + .height(Size::px(36.)) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(9.)) + .background(colors::component_bg()) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .border(ui::border_all_color( + 1., + if focus().is_focused() { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_all_press(move |e: Event| { + e.stop_propagation(); + open.toggle(); + }) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(label_text) + .font_size(13.) + .max_lines(1) + .color(colors::fg_primary()), + ), + ) + .child( + Icon::new(IconType::ChevronDown) + .size(15.) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_open.then(|| { + rect() + .height(Size::px(0.)) + .width(Size::px(0.)) + .layer(Layer::Overlay) + .child( + rect() + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .layer(Layer::OverlayLevel(10)) + .on_press(move |_| open.set(false)), + ) + .child( + rect() + .margin(Gaps::new(4., 0., 0., 0.)) + .width(Size::px(320.)) + // Room for the category selector above the search box + .max_height(Size::px(400.)) + .layer(Layer::OverlayLevel(12)) + .vertical() + .spacing(4.) + .padding(6.) + .corner_radius(CornerRadius::new_all(10.)) + .border(ui::border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .child( + SegmentedControl::new(category) + .no_tint() + .equal_width(147.) + .segments( + [Category::Logs, Category::CrashReports] + .into_iter() + .map(|value| Segment::new(value).label(value.label())), + ), + ) + .child( + TextInput::new(filter) + .auto_focus(true) + .placeholder("Search logs...") + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ), + ) + .maybe_child(rows.is_empty().then(|| { + label() + .text(if no_files { + category.read().empty_note() + } else { + "No matches" + }) + .font_size(12.) + .width(Size::fill()) + .text_align(TextAlign::Center) + .padding(Gaps::new_symmetric(12., 0.)) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + ScrollArea::new() + .height(Size::px(list_h)) + .spacing(PICKER_ROW_SPACING) + .children(rows), + ), + ) + })) + } +} + +struct PickerRow { + file: LogFileInfo, + on_press: EventHandler>, +} + +impl PartialEq for PickerRow { + fn eq(&self, other: &Self) -> bool { + self.file.path == other.file.path + } +} + +impl Component for PickerRow { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let file = self.file.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + rect() + .width(Size::fill()) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(8.) + .padding(Gaps::new_symmetric(6., 8.)) + .corner_radius(CornerRadius::new_all(7.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_all_press(self.on_press.clone()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(1.) + .child( + label() + .text(file.name.clone()) + .font_size(13.) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} • {}", + kind_badge(file.kind), + format_size(file.size_bytes) + )) + .font_size(10.) + .color(colors::fg_secondary()), + ), + ) + .into_element() + } +} + +pub fn confirm_overlay( + kind: Confirm, + mut confirm: State>, + action: UseLogAction, + upload: UseUploadLog, + path: PathBuf, +) -> impl IntoElement { + let (title, body, confirm_label, danger) = match kind { + Confirm::Clear => ( + "Delete this log?", + "This log file will be permanently removed from disk.".to_string(), + "Delete", + true, + ), + Confirm::Upload => ( + "Upload to mclo.gs?", + "This log will be uploaded to mclo.gs and hosted publicly. Anyone with the link can read it. Don't upload logs containing sensitive information.".to_string(), + "Upload", + false, + ), + }; + + let run = move |_| { + match kind { + Confirm::Clear => action.mutate(LogAction::Delete { path: path.clone() }), + Confirm::Upload => upload.mutate(UploadLogKeys { path: path.clone() }), + } + confirm.set(None); + }; + + OverlayPopup::new() + .on_close(move |_| confirm.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(ui::border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child(Icon::new(IconType::AlertTriangle).size(20.).color( + if danger { + colors::code_error() + } else { + colors::code_warn() + }, + )) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(body) + .font_size(12.) + .max_lines(6) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| confirm.set(None)) + .text("Cancel"), + ) + .child( + if danger { + Button::new().danger() + } else { + Button::new().primary() + } + .on_press(run) + .text(confirm_label), + ), + ), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs new file mode 100644 index 00000000..94ae1ec8 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/mod.rs @@ -0,0 +1,38 @@ +pub mod logs; +mod overview; +mod package_manager; + +mod cluster_settings; +mod mods; +mod process_logs; +mod screenshots; +mod shaders; +mod textures; + +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; + +use freya::prelude::*; + +use crate::theme::colors; + +pub(crate) fn cluster_not_found() -> Element { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("Cluster not found.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs new file mode 100644 index 00000000..e5afc692 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -0,0 +1,60 @@ +use freya::prelude::*; +use oneclient_content::packages::ContentType; + +use crate::hooks::{ + bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, +}; +use crate::layout::cluster_content; + +use super::cluster_not_found; +use super::package_manager::{ + PackageManager, bundle_categories, bundle_packages, use_content_meta, +}; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterMods { + pub cluster_id: i64, +} + +impl Component for ClusterMods { + fn render(&self) -> impl IntoElement { + let content = use_cluster_content(self.cluster_id, ContentType::Mod); + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let updates = use_package_updates(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); + + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( + content_items, + &bundle_items, + &bundle_overrides_map(&overrides), + &meta, + &stale_hashes(&updates), + ContentType::Mod, + ); + + cluster_content() + .child( + PackageManager::new( + "Mods", + "mods", + "mod", + ContentType::Mod, + self.cluster_id, + items, + all_categories, + ) + .into_element(), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs new file mode 100644 index 00000000..d2cb0ce3 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/overview.rs @@ -0,0 +1,73 @@ +use freya::prelude::*; + +use crate::components::ScrollArea; +use crate::hooks::{try_cluster_analytics, use_cluster_analytics}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui::centered_note; +use crate::view::app::{analytics_body, analytics_placeholder}; + +use super::cluster_not_found; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterOverview { + pub cluster_id: i64, +} + +impl Component for ClusterOverview { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + + let Some(cluster) = use_cluster(cluster_id) else { + return cluster_content().child(cluster_not_found()).into_element(); + }; + + let query = use_cluster_analytics(cluster_id); + let analytics = try_cluster_analytics(&query); + + let body: Element = match &analytics { + None => centered_note("Loading play history…"), + Some(a) if a.playtime.session_count == 0 => analytics_placeholder( + "No sessions recorded for this cluster yet. Launch it and come back!", + ), + Some(a) => analytics_body(a), + }; + + cluster_content() + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(24.) + .child(overview_header(&cluster.name)) + .child(body), + ), + ) + .into_element() + } +} + +fn overview_header(name: &str) -> Element { + rect() + .vertical() + .spacing(2.) + .child( + label() + .text("Overview") + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("Play history & servers for {name}")) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} 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 new file mode 100644 index 00000000..22587ce6 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -0,0 +1,455 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_common::search::{MatchScore, SearchQuery}; +use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; +use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; +use oneclient_db::models::OverrideType; + +use crate::components::{CardLayout, PackageEntry}; +use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state}; + +mod views; +use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar}; + +const CARD_H: f32 = 84.; +const CARD_SPACING: f32 = 8.; +const CARD_GRID_H: f32 = 148.; +const GRID_GAP: f32 = 10.; +const GRID_MIN_W: f32 = 260.; +const LAZY_OVERSCAN: i64 = 2; + +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 { + for (file, _status) in &bundle.files { + if file.content_type() != content_type { + continue; + } + 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 +} + +/// `stale` is the artifact hashes with a newer version bundle rows never carry it so the two update flows cannot both claim a package +/// Hidden bundle files get flagged rows [`HiddenFilter`] decides whether they show +pub fn bundle_packages( + content: Vec, + bundles: &[BundleWithUpdateStatus], + overrides: &HashMap<(String, String), String>, + meta: &PackageMetaMap, + stale: &HashSet, + content_type: ContentType, +) -> Vec { + let mut by_project: HashMap<&str, &LinkedArtifactInfo> = HashMap::new(); + let mut by_hash: HashMap<&str, &LinkedArtifactInfo> = HashMap::new(); + for info in &content { + if let Some(pid) = &info.project_id { + by_project.insert(pid.as_str(), info); + } + by_hash.insert(info.hash.as_str(), info); + } + + // Hidden is per-bundle so one bundle carrying a mod as a private dependency must not suppress a bundle that offers it openly + let mut shown_elsewhere: HashSet = HashSet::new(); + for bundle in bundles { + for (file, _status) in &bundle.files { + if !file.hidden && file.content_type() == content_type { + shown_elsewhere.insert(file.kind.package_id()); + } + } + } + + let mut rows = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for bundle in bundles { + let bundle_name = &bundle.archive.manifest.name; + let category = bundle.archive.manifest.category.clone(); + for (file, _status) in &bundle.files { + if file.content_type() != content_type { + continue; + } + let pid = file.kind.package_id(); + // Let the bundle that lists it openly own the row whatever the iteration order + if file.hidden && shown_elsewhere.contains(&pid) { + continue; + } + if !seen.insert(pid.clone()) { + continue; + } + + let provider = match &file.kind { + BundleFileKind::Managed { provider, .. } => *provider, + BundleFileKind::External(_) => ProviderId::Local, + }; + let installed_info = by_project + .get(pid.as_str()) + .or_else(|| by_hash.get(pid.as_str())) + .copied(); + let ov = overrides + .get(&(bundle_name.clone(), pid.clone())) + .map(String::as_str); + let enabled = match installed_info { + Some(info) => info.enabled, + None => oneclient_core::effective_enabled(file, ov.and_then(OverrideType::parse)), + }; + + let categories = if category.is_empty() { + Vec::new() + } else { + vec![category.clone()] + }; + + rows.push(make_row( + pid, + Some(bundle_name.clone()), + provider, + file.size, + categories, + enabled, + file.enabled, + installed_info, + meta, + file.display_name(), + false, + // Flagged rather than dropped `HiddenFilter` filters on the row and the seen id stops the loose-content pass resurrecting it as a local file + file.hidden, + )); + } + } + + for info in &content { + let in_bundle = info.project_id.as_deref().is_some_and(|p| seen.contains(p)) + || seen.contains(&info.hash); + if in_bundle { + continue; + } + let provider = info.provider.unwrap_or(ProviderId::Local); + let pid = info.project_id.clone().unwrap_or_else(|| info.hash.clone()); + let outdated = stale.contains(&info.hash); + rows.push(make_row( + pid, + None, + provider, + 0, + Vec::new(), + info.enabled, + true, + Some(info), + meta, + info.display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + outdated, + false, + )); + } + + rows +} + +#[allow(clippy::too_many_arguments)] +fn make_row( + package_id: String, + bundle_name: Option, + provider: ProviderId, + size: u64, + categories: Vec, + enabled: bool, + manifest_default: bool, + installed_info: Option<&LinkedArtifactInfo>, + meta: &PackageMetaMap, + fallback_name: String, + update_available: bool, + hidden: bool, +) -> PackageEntry { + let m = meta.get(&(provider, package_id.clone())); + let name = m + .map(|p| p.name.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| fallback_name.clone()); + let file_name = installed_info + .map(|i| i.file_name.clone()) + .unwrap_or(fallback_name); + let author = m + .map(|p| p.author.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_default(); + let description = m + .map(|p| p.summary.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| { + installed_info + .and_then(|i| i.display_version.clone()) + .map(|v| format!("Version {v}")) + }) + .unwrap_or_default(); + + PackageEntry { + package_id, + bundle_name, + provider, + name, + file_name, + author, + description, + icon_url: m.and_then(|p| p.icon_url.clone()), + size, + categories, + enabled, + manifest_default, + installed: installed_info.is_some(), + hash: installed_info.map(|i| i.hash.clone()), + update_available, + hidden, + } +} + +#[derive(Clone)] +pub(super) enum Tab { + All, + Category(String), + Browser, + Local, +} + +impl Tab { + pub(super) fn label(&self) -> String { + match self { + Tab::All => "All".to_string(), + Tab::Category(c) => c.clone(), + Tab::Browser => "Browser".to_string(), + Tab::Local => "Local".to_string(), + } + } + + pub(super) fn matches(&self, p: &PackageEntry) -> bool { + match self { + Tab::All => true, + Tab::Category(c) => p.categories.iter().any(|pc| pc == c), + Tab::Browser => p.is_remote() && !p.in_bundle(), + Tab::Local => !p.is_remote(), + } + } +} + +fn query_score(p: &PackageEntry, query: &SearchQuery) -> Option { + query.best_score([p.name.as_str(), p.file_name.as_str()]) +} + +/// Search is scoped to the active tab a package must match the tab *and* the query +/// `Tab::All` searches the whole cluster +fn visible_packages( + items: &[PackageEntry], + tab: Option<&Tab>, + query: &SearchQuery, + show: EnabledFilter, + hidden: HiddenFilter, +) -> Vec { + items + .iter() + .filter(|p| tab.is_none_or(|t| t.matches(p)) && query_score(p, query).is_some()) + .filter(|p| show.keep(p) && hidden.keep(p)) + .cloned() + .collect() +} + +/// Stable so the toolbar's sort survives as the tie-break between equally good matches +fn rank_by_query(rows: &mut [PackageEntry], query: &SearchQuery) { + rows.sort_by_key(|p| std::cmp::Reverse(query_score(p, query))); +} + +pub fn bundle_categories(bundles: &[BundleWithUpdateStatus]) -> Vec { + let mut cats: Vec = Vec::new(); + for bundle in bundles { + let category = &bundle.archive.manifest.category; + if !category.is_empty() && !cats.contains(category) { + cats.push(category.clone()); + } + } + cats +} + +/// `hidden` applies here too so a category of only hidden dependencies offers no tab +fn build_tabs(categories: &[String], items: &[PackageEntry], hidden: HiddenFilter) -> Vec { + let mut cats: Vec = categories.to_vec(); + for item in items.iter().filter(|p| hidden.keep(p)) { + for c in &item.categories { + if !cats.contains(c) { + cats.push(c.clone()); + } + } + } + + // Category tabs are hidden when empty All + Browser + Local are always shown + let mut tabs: Vec = vec![Tab::All]; + tabs.extend( + cats.into_iter() + .map(Tab::Category) + .filter(|t| items.iter().any(|p| hidden.keep(p) && t.matches(p))), + ); + + tabs.push(Tab::Browser); + tabs.push(Tab::Local); + tabs +} + +#[derive(PartialEq)] +pub struct PackageManager { + title: &'static str, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + items: Vec, + categories: Vec, +} + +impl PackageManager { + pub fn new( + title: &'static str, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + items: Vec, + categories: Vec, + ) -> Self { + Self { + title, + noun_plural, + package_type, + content_type, + cluster_id, + items, + categories, + } + } +} + +impl Component for PackageManager { + fn render(&self) -> impl IntoElement { + let items = self.items.clone(); + let noun_plural = self.noun_plural; + let package_type = self.package_type; + let cluster_id = self.cluster_id; + let content_type = self.content_type; + + // Minecraft reads its content once at startup so a toggle now cannot reach the running session + let session_live = use_game_snapshot().is_active(cluster_id); + let active = use_state(|| 0usize); + + let search = use_state(String::new); + let enabled_filter = use_state(|| EnabledFilter::All); + let hidden_filter = use_state(|| HiddenFilter::Hide); + let view = use_view_state("cluster.packages"); + let sort = view.sort; + let layout = view.layout; + let query = SearchQuery::new(&search.read()); + let sort_mode = sort + .read() + .as_deref() + .and_then(SortMode::from_key) + .unwrap_or(SortMode::NameAsc); + + let show = *enabled_filter.read(); + let hidden = *hidden_filter.read(); + let card_layout = CardLayout::from(*layout.read()); + + let tabs = build_tabs(&self.categories, &items, hidden); + let active_idx = (*active.read()).min(tabs.len().saturating_sub(1)); + let tab_filter = tabs.get(active_idx); + + let mut filtered = visible_packages(&items, tab_filter, &query, show, hidden); + sort_mode.sort(&mut filtered); + // Relevance has to win while searching a fuzzy typo match would otherwise outrank the exact one + if !query.is_empty() { + rank_by_query(&mut filtered, &query); + } + + // Coming up empty during a search is about the query not the tab so it gets its own empty state + let content_kind = if filtered.is_empty() && !query.is_empty() { + ContentKind::NoMatches { + scope: match tab_filter { + Some(Tab::All) | None => None, + Some(tab) => Some(tab.label()), + }, + } + } else { + match tab_filter { + Some(Tab::Browser) => ContentKind::Browser, + Some(Tab::Local) => ContentKind::Local, + _ => ContentKind::Other, + } + }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .child(toolbar_bar( + &tabs, + active_idx, + active, + search, + sort, + sort_mode, + enabled_filter, + hidden_filter, + layout, + cluster_id, + package_type, + )) + .maybe_child(session_live.then(|| views::running_notice(noun_plural))) + .child(ContentBox::new( + filtered, + noun_plural, + package_type, + content_type, + cluster_id, + content_kind, + card_layout, + )) + } +} 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 new file mode 100644 index 00000000..c5b789e4 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -0,0 +1,814 @@ +use super::*; + +use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; +use freya::router::RouterContext; +use oneclient_content::packages::ContentType; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, + Segment, SegmentedControl, TabBar, TabItem, TextInput, +}; +use crate::hooks::use_dispatch; +use crate::routes::Route; +use crate::theme::colors; +use crate::{Actions, utils}; + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum SortMode { + NameAsc, + NameDesc, + SizeAsc, + SizeDesc, +} + +impl SortMode { + const ALL: [SortMode; 4] = [ + SortMode::NameAsc, + SortMode::NameDesc, + SortMode::SizeAsc, + SortMode::SizeDesc, + ]; + + fn key(self) -> &'static str { + match self { + SortMode::NameAsc => "name_asc", + SortMode::NameDesc => "name_desc", + SortMode::SizeAsc => "size_asc", + SortMode::SizeDesc => "size_desc", + } + } + + pub(super) fn from_key(key: &str) -> Option { + SortMode::ALL.into_iter().find(|s| s.key() == key) + } + + fn label(self) -> &'static str { + match self { + SortMode::NameAsc => "Name (A-Z)", + SortMode::NameDesc => "Name (Z-A)", + SortMode::SizeAsc => "Size (Smallest)", + SortMode::SizeDesc => "Size (Largest)", + } + } + + pub(super) fn sort(self, rows: &mut [PackageEntry]) { + let title = |p: &PackageEntry| { + if p.is_remote() { + p.name.to_lowercase() + } else { + p.file_name.to_lowercase() + } + }; + + match self { + SortMode::NameDesc => rows.sort_by_key(|p| std::cmp::Reverse(title(p))), + SortMode::SizeAsc => rows.sort_by_key(|p| p.size), + SortMode::SizeDesc => rows.sort_by_key(|p| std::cmp::Reverse(p.size)), + _ => rows.sort_by_key(title), + } + } +} + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum EnabledFilter { + All, + Enabled, + Disabled, +} + +impl EnabledFilter { + const ALL: [EnabledFilter; 3] = [ + EnabledFilter::All, + EnabledFilter::Enabled, + EnabledFilter::Disabled, + ]; + + fn label(self) -> &'static str { + match self { + EnabledFilter::All => "All packages", + EnabledFilter::Enabled => "Enabled only", + EnabledFilter::Disabled => "Disabled only", + } + } + + pub(super) fn keep(self, p: &PackageEntry) -> bool { + match self { + EnabledFilter::All => true, + EnabledFilter::Enabled => p.enabled, + EnabledFilter::Disabled => !p.enabled, + } + } +} + +/// Hidden files are dependencies the bundle manages so the list leaves them out until asked +#[derive(Clone, Copy, PartialEq)] +pub(super) enum HiddenFilter { + Hide, + Show, +} + +impl HiddenFilter { + const ALL: [HiddenFilter; 2] = [HiddenFilter::Hide, HiddenFilter::Show]; + + fn label(self) -> &'static str { + match self { + HiddenFilter::Hide => "Hide", + HiddenFilter::Show => "Show", + } + } + + pub(super) fn keep(self, p: &PackageEntry) -> bool { + match self { + HiddenFilter::Hide => !p.hidden, + HiddenFilter::Show => true, + } + } +} + +const FILTER_PANEL_W: f32 = 172.; +const FILTER_BTN_W: f32 = 34.; + +#[allow(clippy::too_many_arguments)] +pub(super) fn toolbar_bar( + tabs: &[Tab], + active_idx: usize, + active: State, + search: State, + sort: State>, + current_sort: SortMode, + enabled_filter: State, + hidden_filter: State, + layout: State, + cluster_id: i64, + package_type: &'static str, +) -> impl IntoElement { + let tab_items = tabs.iter().enumerate().map(|(i, tab)| { + let mut active = active; + TabItem::new(tab.label(), i == active_idx).on_press(move |_| *active.write() = i) + }); + + let mut top_corners = CornerRadius::new_all(0.); + top_corners.fill_top(12.); + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .overflow(Overflow::Clip) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(top_corners) + .background(colors::page_elevated()) + .child( + ScrollView::new() + .direction(Direction::Horizontal) + .show_scrollbar(false) + .width(Size::flex(1.0)) + .height(Size::auto()) + .child( + TabBar::new() + .width(Size::auto()) + .height(Size::auto()) + .spacing(20.) + .font_size(12.) + .tabs(tab_items), + ), + ) + .child( + TextInput::new(search) + .placeholder("Search...") + .width(Size::px(180.)) + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ), + ) + .child(FilterButton { + sort, + current_sort, + enabled_filter, + hidden_filter, + }) + .child( + SegmentedControl::new(layout) + .height(34.) + .icon_size(15.) + .equal_width(34.) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)), + ) + .child( + Button::new() + .primary() + .height(Size::px(34.)) + .font_size(12.) + .on_press(move |_| open_browser(cluster_id, package_type)) + .child(Icon::new(IconType::Plus).size(15.)) + .text("Add Content"), + ) + .into_element() +} + +/// Enabling still stores and applies at the next launch but the running session cannot pick it up +pub(super) fn running_notice(noun_plural: &'static str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .margin(Gaps::new(8., 0., 0., 0.)) + .padding(Gaps::new_symmetric(9., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::brand().with_a(30)) + .border(crate::ui::border_all_color(1., colors::brand().with_a(90))) + .child( + Icon::new(IconType::InfoCircle) + .size(16.) + .color(colors::brand()), + ) + .child( + label() + .text(format!( + "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." + )) + .font_size(12.) + .width(Size::flex(1.0)) + .color(colors::fg_secondary()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct FilterButton { + sort: State>, + current_sort: SortMode, + enabled_filter: State, + hidden_filter: State, +} + +impl Component for FilterButton { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + + let sort = self.sort; + let current_sort = self.current_sort; + let enabled_filter = self.enabled_filter; + let hidden_filter = self.hidden_filter; + + // Hiding hidden packages is the default so it does not count as the filters being touched + let is_open = open(); + let active = current_sort != SortMode::NameAsc + || *enabled_filter.read() != EnabledFilter::All + || *hidden_filter.read() != HiddenFilter::Hide; + + let icon_color = if active { + colors::brand() + } else { + colors::fg_secondary() + }; + + rect() + .width(Size::px(FILTER_BTN_W)) + .child( + Button::new() + .secondary() + .icon() + .width(Size::px(FILTER_BTN_W)) + .height(Size::px(34.)) + .on_press(move |e: Event| { + e.stop_propagation(); + open.toggle(); + }) + .child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)), + ) + .maybe_child(is_open.then(|| { + let on_close: EventHandler<()> = (move |()| open.set(false)).into(); + FilterPopover { + sort, + current_sort, + enabled_filter, + hidden_filter, + on_close, + } + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct FilterPopover { + sort: State>, + current_sort: SortMode, + enabled_filter: State, + hidden_filter: State, + on_close: EventHandler<()>, +} + +impl Component for FilterPopover { + fn render(&self) -> impl IntoElement { + let mut sort = self.sort; + let current_sort = self.current_sort; + let mut enabled_filter = self.enabled_filter; + let mut hidden_filter = self.hidden_filter; + let backdrop_close = self.on_close.clone(); + let key_close = self.on_close.clone(); + + let a11y_id = use_a11y(); + + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(160).ease(Ease::Out) + }); + let progress = fade.read().value(); + + let show = *enabled_filter.read(); + let hidden = *hidden_filter.read(); + + let mut panel = rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(8.)) + .opacity(progress) + .offset_y((progress - 1.0) * 6.) + .corner_radius(CornerRadius::new_all(10.)) + .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), + ))) + .a11y_id(a11y_id) + .a11y_role(AccessibilityRole::Dialog) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) { + key_close.call(()); + } + }) + .child(section_label("Sort by")); + + for mode in SortMode::ALL { + let selected = mode == current_sort; + let on_press: EventHandler> = (move |_| { + sort.set(Some(mode.key().to_string())); + }) + .into(); + panel = panel.child(ChoiceRow { + text: mode.label(), + selected, + on_press, + }); + } + + panel = panel.child(section_label("Show")); + for filter in EnabledFilter::ALL { + let selected = filter == show; + let on_press: EventHandler> = (move |_| { + enabled_filter.set(filter); + }) + .into(); + panel = panel.child(ChoiceRow { + text: filter.label(), + selected, + on_press, + }); + } + + panel = panel.child(section_label("Hidden packages")); + for filter in HiddenFilter::ALL { + let selected = filter == hidden; + let on_press: EventHandler> = (move |_| { + hidden_filter.set(filter); + }) + .into(); + panel = panel.child(ChoiceRow { + text: filter.label(), + selected, + on_press, + }); + } + + rect() + .height(Size::px(0.)) + .width(Size::px(FILTER_PANEL_W)) + .layer(Layer::Overlay) + .child( + rect() + .layer(Layer::OverlayLevel(10)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .on_press(move |_| backdrop_close.call(())), + ) + .child( + rect() + .width(Size::fill()) + .layer(Layer::OverlayLevel(12)) + .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) + .child(panel), + ) + } +} + +fn section_label(text: &'static str) -> impl IntoElement { + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()) +} + +#[derive(PartialEq)] +struct ChoiceRow { + text: &'static str, + selected: bool, + on_press: EventHandler>, +} + +impl Component for ChoiceRow { + fn render(&self) -> impl IntoElement { + let text = self.text; + let selected = self.selected; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + let bg = if selected { + colors::component_bg() + } else if focused { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(bg) + .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::MenuItemRadio) + .maybe(focused, |el| { + el.border(crate::ui::border_all_color(1., colors::brand())) + }) + .cursor(CursorIcon::Pointer) + .on_all_press(self.on_press.clone()) + .child( + label() + .text(text) + .font_size(12.) + .width(Size::flex(1.0)) + .color(color), + ) + .maybe_child(selected.then(|| { + Icon::new(IconType::Check) + .size(14.) + .color(colors::brand()) + .into_element() + })) + } +} + +fn add_from_file_button( + cluster_id: i64, + content_type: ContentType, + dispatch: Actions, +) -> impl IntoElement { + Button::new() + .primary() + .on_press(move |_| { + let dispatch = dispatch.clone(); + spawn(async move { + if let Some(handles) = rfd::AsyncFileDialog::new() + .set_title("Select files to import") + .pick_files() + .await + { + for handle in handles { + dispatch.import_local_file( + cluster_id, + content_type, + handle.path().to_path_buf(), + ); + } + } + }); + }) + .child(Icon::new(IconType::FilePlus02).size(14.)) + .text("Add from file") +} + +fn open_browser(cluster_id: i64, package_type: &'static str) { + let _ = RouterContext::get().push(Route::Browser { + cluster_id, + package_type: package_type.to_string(), + pick_cluster: false, + }); +} + +fn browse_button(cluster_id: i64, package_type: &'static str) -> impl IntoElement { + Button::new() + .primary() + .on_press(move |_| open_browser(cluster_id, package_type)) + .child(Icon::new(IconType::SearchMd).size(14.)) + .text("Browse Content") +} + +#[derive(Clone, PartialEq)] +pub(super) enum ContentKind { + Browser, + Local, + Other, + /// `scope` names the tab that was searched when it is narrower than "All" + NoMatches { + scope: Option, + }, +} + +#[derive(PartialEq)] +pub(super) struct ContentBox { + items: Vec, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + kind: ContentKind, + layout: CardLayout, +} + +impl ContentBox { + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + items: Vec, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + kind: ContentKind, + layout: CardLayout, + ) -> Self { + Self { + items, + noun_plural, + package_type, + content_type, + cluster_id, + kind, + layout, + } + } +} + +impl Component for ContentBox { + fn render(&self) -> impl IntoElement { + let items = self.items.clone(); + let package_type = self.package_type; + let content_type = self.content_type; + let cluster_id = self.cluster_id; + let noun_plural = self.noun_plural; + let kind = &self.kind; + let layout = self.layout; + + let dispatch = use_dispatch(); + + let count = items.len(); + let scroll = (count > 0).then(|| match layout { + CardLayout::List => { + let items = items.clone(); + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .lazy(count, CARD_H, CARD_SPACING, move |i| { + let item = items[i].clone(); + let key = item.package_id.clone(); + PackageRow::new(item, cluster_id, package_type) + .layout(CardLayout::List) + .key(key) + .into_element() + }) + .into_element() + } + CardLayout::Grid => ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .content(move |ctx: ScrollAreaCtx| { + grid_content(&items, package_type, cluster_id, ctx).into_element() + }) + .into_element(), + }); + + let empty = (count == 0).then(|| match kind { + ContentKind::Browser => browser_empty(cluster_id, package_type).into_element(), + ContentKind::Local => { + local_empty(cluster_id, content_type, dispatch.clone(), noun_plural).into_element() + } + ContentKind::Other => empty_state(noun_plural).into_element(), + ContentKind::NoMatches { scope } => { + search_empty(noun_plural, scope.as_deref()).into_element() + } + }); + + let header = (count > 0) + .then(|| match kind { + // The toolbar already carries a permanent "Add Content" button + ContentKind::Browser => None, + ContentKind::Local => Some( + action_header(add_from_file_button( + cluster_id, + content_type, + dispatch.clone(), + )) + .into_element(), + ), + ContentKind::Other | ContentKind::NoMatches { .. } => None, + }) + .flatten(); + + let mut bottom_corners = CornerRadius::new_all(0.); + bottom_corners.fill_bottom(12.); + + // File drops are handled window-wide by the app shell so this box does not claim them + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(8.) + .padding(Gaps::new_all(8.)) + .corner_radius(bottom_corners) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .maybe_child(header) + .maybe_child(scroll) + .maybe_child(empty) + } +} + +fn action_header(button: impl IntoElement) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child(rect().width(Size::flex(1.0))) + .child(button) +} + +fn grid_content( + items: &[PackageEntry], + package_type: &'static str, + cluster_id: i64, + ctx: ScrollAreaCtx, +) -> impl IntoElement { + let count = items.len(); + let cols = + (((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 3); + let rows_total = count.div_ceil(cols); + let slot = CARD_GRID_H + GRID_GAP; + + let first_row = (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize; + let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize; + let last_row = (first_row + span).min(rows_total); + + let top_pad = first_row as f32 * slot; + let bottom_pad = rows_total.saturating_sub(last_row) as f32 * slot; + + let mut container = rect().vertical().width(Size::fill()); + if top_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); + } + for r in first_row..last_row { + let mut row = rect() + .key(r) + .horizontal() + .width(Size::fill()) + .height(Size::px(slot)) + .spacing(GRID_GAP) + .content(Content::Flex); + for c in 0..cols { + let idx = r * cols + c; + let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H)); + row = row.child(if idx < count { + let item = items[idx].clone(); + let key = item.package_id.clone(); + cell.child( + PackageRow::new(item, cluster_id, package_type) + .layout(CardLayout::Grid) + .key(key) + .into_element(), + ) + } else { + cell + }); + } + container = container.child(row); + } + if bottom_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); + } + container.into_element() +} + +fn empty_shell(icon: IconType) -> Rect { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .spacing(8.) + .child(Icon::new(icon).size(28.).color(colors::fg_secondary())) +} + +fn empty_title(text: impl Into) -> impl IntoElement { + label() + .text(text.into()) + .font_size(14.) + .color(colors::fg_secondary()) +} + +fn empty_hint(text: impl Into) -> impl IntoElement { + label() + .text(text.into()) + .font_size(12.) + .color(colors::fg_secondary()) +} + +fn empty_state(noun_plural: &'static str) -> impl IntoElement { + empty_shell(IconType::DotsGrid) + .child(empty_title(format!("No {noun_plural} here yet."))) + .child(empty_hint( + "Add one from a file or browse provider content.", + )) + .into_element() +} + +/// The search only looks inside the active tab so name it and point at the tab that covers everything +fn search_empty(noun_plural: &'static str, scope: Option<&str>) -> impl IntoElement { + let (title, hint) = match scope { + Some(tab) => ( + format!("No {noun_plural} in \"{tab}\" match your search."), + "Try a different term, or search from the All tab.", + ), + None => ( + format!("No {noun_plural} match your search."), + "Try a different term.", + ), + }; + + empty_shell(IconType::SearchMd) + .child(empty_title(title)) + .child(empty_hint(hint)) + .into_element() +} + +fn browser_empty(cluster_id: i64, package_type: &'static str) -> impl IntoElement { + empty_shell(IconType::SearchMd) + .child(empty_title("No content installed from the browser.")) + .child(empty_hint( + "Browse providers to add mods, resource packs and more.", + )) + .child(rect().height(Size::px(6.))) + .child(browse_button(cluster_id, package_type)) + .into_element() +} + +fn local_empty( + cluster_id: i64, + content_type: ContentType, + dispatch: Actions, + noun_plural: &'static str, +) -> impl IntoElement { + let is_wayland = utils::is_wayland(); + + empty_shell(IconType::FilePlus02) + .child(empty_title(format!("No local {noun_plural} yet."))) + .maybe(!is_wayland, |e| { + e.child(empty_hint( + "Tip: drag files onto the window to install them.", + )) + }) + .child(rect().height(Size::px(6.))) + .child(add_from_file_button(cluster_id, content_type, dispatch)) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs new file mode 100644 index 00000000..4f8ca2cf --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use freya::prelude::*; +use oneclient_cluster::logs::parse_level; +use oneclient_core::{LogKind, LogLevel}; + +use crate::components::LogViewer; +use crate::components::upload_mclogs::use_mclogs_feedback; +use crate::hooks::{ + try_cluster_logs, use_cluster, use_cluster_logs, use_game_snapshot, use_log_action, + use_upload_log, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::view::app::cluster::logs::{Confirm, LevelFilter, confirm_overlay, viewer_header}; + +use super::cluster_not_found; + +#[derive(PartialEq)] +pub struct ProcessLogs { + pub cluster_id: i64, +} + +impl Component for ProcessLogs { + fn render(&self) -> impl IntoElement { + let logs_query = use_cluster_logs(self.cluster_id); + let upload = use_upload_log(); + let action = use_log_action(); + + let search = use_state(String::new); + let level = use_state(|| LevelFilter::All); + let confirm = use_state(|| None::); + + use_mclogs_feedback(upload); + + let game = use_game_snapshot(); + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let active = game.stage(self.cluster_id).is_some(); + + let body = if active { + let lines = game.logs_for(self.cluster_id); + let has_log = !lines.is_empty(); + let visible = filter_lines(&lines, &search.read(), *level.read()); + + LogViewer::new("Game output", visible) + .streaming(true) + .header(viewer_header(search, level, confirm, has_log)) + .into_element() + } else { + not_running() + }; + + let output_path = try_cluster_logs(&logs_query) + .unwrap_or_default() + .into_iter() + .find(|file| matches!(file.kind, LogKind::Game { .. })) + .map(|file| file.path); + + cluster_content() + .child(body) + .maybe_child( + (*confirm.read()) + .zip(output_path) + .map(|(kind, path)| confirm_overlay(kind, confirm, action, upload, path)), + ) + .into_element() + } +} + +fn filter_lines( + lines: &Arc>>, + search: &str, + level: LevelFilter, +) -> Arc>> { + let needle = (!search.trim().is_empty()).then(|| search.to_lowercase()); + let wanted = level.to_level(); + + if needle.is_none() && wanted.is_none() { + return lines.clone(); + } + + let mut carried = LogLevel::Unknown; + let mut out = Vec::new(); + + for line in lines.iter() { + carried = parse_level(line).unwrap_or(carried); + + if wanted.is_some_and(|wanted| wanted != carried) { + continue; + } + if needle + .as_ref() + .is_some_and(|needle| !line.to_lowercase().contains(needle)) + { + continue; + } + + out.push(line.clone()); + } + + Arc::new(out) +} + +fn not_running() -> Element { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("This cluster is not running.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs new file mode 100644 index 00000000..f5a7e849 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -0,0 +1,580 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_core::ScreenshotInfo; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, + Segment, SegmentedControl, open_folder_button, +}; +use crate::hooks::{ + ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, + use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width}; +use crate::utils::{format_res, format_size}; + +use super::cluster_not_found; +use crate::hooks::use_cluster; + +const THUMB_EDGE: u32 = 480; +const MAX_COL_W: f32 = 400.; +const GRID_GAP: f32 = 16.; +const TILE_PREVIEW_H: f32 = 168.; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const CARD_NAME: Color = Color::from_rgb(213, 219, 255); + +#[derive(PartialEq)] +pub struct ClusterScreenshots { + pub cluster_id: i64, +} + +impl Component for ClusterScreenshots { + fn render(&self) -> impl IntoElement { + let Some(cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + let folder = cluster.game_dir().ok().map(|d| d.join("screenshots")); + + let query = use_cluster_screenshots(self.cluster_id); + // Otherwise a screenshot taken in game only shows up on the next visit + use_screenshot_folder_watch(folder.clone(), query); + let action = use_screenshot_action(); + + let shots = try_cluster_screenshots(&query).unwrap_or_default(); + + let view_mode = use_view_state("cluster.screenshots").layout; + let menu_dispatch = use_dispatch(); + + let mut edit_mode = use_state(|| false); + let mut selected = use_state(HashSet::::new); + let mut viewing = use_state(|| None::); + let confirm_delete = use_state(|| false); + 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 content: Element = if shots.is_empty() { + empty_state(query_is_loading(&query)).into_element() + } else { + let mut items: Vec = Vec::new(); + for (idx, info) in shots.iter().enumerate() { + let info = info.clone(); + let is_selected = selected.read().contains(&info.path); + let editing = *edit_mode.read(); + + let activate_path = info.path.clone(); + let on_activate = move |_| { + if editing { + let p = activate_path.clone(); + let mut set = selected.write(); + if !set.remove(&p) { + set.insert(p); + } + } else { + viewing.set(Some(idx)); + } + }; + + let ctx_path = info.path.clone(); + let on_context: EventHandler<(f32, f32)> = (move |(x, y)| { + menu.set(Some((x, y, ctx_path.clone()))); + }) + .into(); + + items.push(match *view_mode.read() { + ViewLayout::Grid => ScreenshotTile { + info, + selected: is_selected, + edit_mode: editing, + on_activate: on_activate.into(), + on_context, + } + .into_element(), + + ViewLayout::List => ScreenshotRow { + info, + selected: is_selected, + edit_mode: editing, + on_activate: on_activate.into(), + on_context, + } + .into_element(), + }); + } + + match *view_mode.read() { + ViewLayout::Grid => { + let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); + flow_grid(items, cols, grid_width, GRID_GAP) + } + ViewLayout::List => rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .children(items) + .into_element(), + } + }; + + let body = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .children(vec![content]); + + let confirm_overlay = confirm_delete.read().then(|| { + let count = selected.read().len(); + confirm_panel(count, confirm_delete, move || { + let paths: Vec = selected.read().iter().cloned().collect(); + for path in paths { + action.mutate(ScreenshotAction::Delete { path }); + } + selected.write().clear(); + edit_mode.set(false); + confirm_delete.clone().set(false); + }) + }); + + let menu_overlay = menu.read().clone().map(|(x, y, path)| { + let copy_dispatch = menu_dispatch.clone(); + let open_path = path.clone(); + let copy_path = path.clone(); + let delete_path = path.clone(); + ContextMenu::new(x, y) + .on_close(move |_| menu.set(None)) + .action(IconType::Folder, "Open in folder", move |()| { + if let Some(dir) = open_path.parent() { + crate::platform::open_url(&dir.to_string_lossy()); + } + }) + .action(IconType::Copy01, "Copy", move |()| { + crate::platform::copy_image_to_clipboard(copy_path.clone()); + copy_dispatch + .notify("Copied to clipboard") + .body("Screenshot copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + }) + .separator() + .danger_action(IconType::Trash01, "Delete", move |()| { + action.mutate(ScreenshotAction::Delete { + path: delete_path.clone(), + }); + }) + .into_element() + }); + + cluster_content() + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(16.) + .child(toolbar) + .child(body), + ) + .maybe_child((*viewing.read()).map(|start| { + ScreenshotViewer::new(shots.clone(), start, move |_| viewing.clone().set(None)) + .into_element() + })) + .maybe_child(confirm_overlay) + .maybe_child(menu_overlay) + .into_element() + } +} + +fn toolbar_row( + shots: &[ScreenshotInfo], + folder: Option, + view_mode: State, + mut edit_mode: State, + mut selected: State>, + mut confirm_delete: State, +) -> impl IntoElement { + let editing = *edit_mode.read(); + let count = selected.read().len(); + let all_paths: Vec = shots.iter().map(|s| s.path.clone()).collect(); + + let mut right = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .maybe_child( + (!editing) + .then_some(folder) + .flatten() + .map(open_folder_button), + ); + + if editing { + let select_all_paths = all_paths.clone(); + right = right + .child( + label() + .text(format!("{count} selected")) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| { + let mut set = selected.write(); + *set = select_all_paths.iter().cloned().collect(); + }) + .text("Select all"), + ) + .child( + Button::new() + .ghost() + .small() + .enabled(count > 0) + .on_press(move |_| selected.write().clear()) + .text("Deselect all"), + ) + .child( + Button::new() + .danger() + .small() + .enabled(count > 0) + .on_press(move |_| confirm_delete.set(true)) + .child(Icon::new(IconType::Trash01).size(14.)) + .text(format!("Delete ({count})")), + ) + .child( + Button::new() + .ghost() + .small() + .on_press(move |_| { + selected.write().clear(); + edit_mode.set(false); + }) + .text("Cancel"), + ); + } else { + right = right + .child( + SegmentedControl::new(view_mode) + .equal_width(40.) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)), + ) + .child( + Button::new() + .secondary() + .small() + .enabled(!shots.is_empty()) + .on_press(move |_| edit_mode.set(true)) + .child(Icon::new(IconType::Pencil01).size(14.)) + .text("Select"), + ); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text("Screenshots") + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child(right) + .into_element() +} + +fn empty_state(loading: bool) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .center() + .child( + label() + .text(if loading { + "Loading screenshots..." + } else { + "No screenshots yet. Press F2 in-game to capture one." + }) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn confirm_panel( + count: usize, + confirm: State, + mut on_confirm: impl FnMut() + 'static, +) -> impl IntoElement { + let mut cancel = confirm; + + OverlayPopup::new() + .on_close(move |_| confirm.clone().set(false)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(400.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(Color::from_rgb(26, 34, 41)) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(format!( + "Move {count} screenshot{} to trash?", + if count == 1 { "" } else { "s" } + )) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("They can be restored from your system trash.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.set(false)) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| on_confirm()) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete"), + ), + ), + ), + ) + .into_element() +} + +fn res_badge(res: Option<(u32, u32)>) -> Option { + res.map(format_res).map(|text| { + rect() + .position(Position::new_absolute().bottom(6.).right(6.)) + .padding(Gaps::new_symmetric(2., 6.)) + .corner_radius(CornerRadius::new_all(5.)) + .background(Color::from_argb(170, 0, 0, 0)) + .layer(Layer::Relative(3)) + .child(label().text(text).font_size(9.).color(Color::WHITE)) + .into_element() + }) +} + +fn preview_box(path: PathBuf, height: f32, res: Option<(u32, u32)>) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(height)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .child(LocalImage::new(path, THUMB_EDGE, true).skeleton(true)) + .maybe_child(res_badge(res)) + .margin(1.) + .into_element() +} + +#[derive(PartialEq)] +struct ScreenshotTile { + info: ScreenshotInfo, + selected: bool, + edit_mode: bool, + on_activate: EventHandler<()>, + on_context: EventHandler<(f32, f32)>, +} + +impl Component for ScreenshotTile { + fn render(&self) -> impl IntoElement { + let info = self.info.clone(); + let mut hovered = use_state(|| false); + + let selected = self.selected; + let on_activate = self.on_activate.clone(); + let on_context = self.on_context.clone(); + + let border_color = if selected { + colors::brand() + } else if *hovered.read() { + colors::component_border_hover() + } else { + colors::component_border() + }; + + rect() + .vertical() + .width(Size::flex(1.0)) + .max_width(Size::px(MAX_COL_W)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .overflow(Overflow::Clip) + .border(border_all_color(1., border_color).alignment(BorderAlignment::Inner)) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| on_activate.call(())) + .on_secondary_down(move |e: Event| { + if let PressEventData::Mouse(m) = e.data() { + on_context.call((m.global_location.x as f32, m.global_location.y as f32)); + } + }) + .child(preview_box( + info.path.clone(), + TILE_PREVIEW_H, + info.resolution, + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .spacing(6.) + .child( + label() + .text(info.name.clone()) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME), + ) + .child( + label() + .text(format!( + "{} • {}", + format_size(info.size_bytes), + fmt_date(info.created) + )) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + } +} + +#[derive(PartialEq)] +struct ScreenshotRow { + info: ScreenshotInfo, + selected: bool, + edit_mode: bool, + on_activate: EventHandler<()>, + on_context: EventHandler<(f32, f32)>, +} + +impl Component for ScreenshotRow { + fn render(&self) -> impl IntoElement { + let info = self.info.clone(); + let mut hovered = use_state(|| false); + + let selected = self.selected; + let on_activate = self.on_activate.clone(); + let on_context = self.on_context.clone(); + + let thumb = rect() + .width(Size::px(64.)) + .height(Size::px(40.)) + .corner_radius(CornerRadius::new_all(6.)) + .overflow(Overflow::Clip) + .background(colors::component_bg()) + .child(LocalImage::new(info.path.clone(), THUMB_EDGE, true).skeleton(true)); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(if *hovered.read() { + colors::component_bg_hover() + } else { + CARD_BG + }) + .border(border_all_color( + if selected { 2. } else { 1. }, + if selected { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| on_activate.call(())) + .on_secondary_down(move |e: Event| { + if let PressEventData::Mouse(m) = e.data() { + on_context.call((m.global_location.x as f32, m.global_location.y as f32)); + } + }) + .child(thumb) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(info.name.clone()) + .font_size(13.) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME), + ), + ) + .maybe_child(info.resolution.map(|res| { + label() + .text(format_res(res)) + .font_size(11.) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + label() + .text(format_size(info.size_bytes)) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(fmt_date(info.created)) + .font_size(11.) + .color(colors::fg_secondary()), + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs new file mode 100644 index 00000000..cea5ad8c --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs @@ -0,0 +1,60 @@ +use freya::prelude::*; +use oneclient_content::packages::ContentType; + +use crate::hooks::{ + bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, +}; +use crate::layout::cluster_content; + +use super::cluster_not_found; +use super::package_manager::{ + PackageManager, bundle_categories, bundle_packages, use_content_meta, +}; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterShaders { + pub cluster_id: i64, +} + +impl Component for ClusterShaders { + fn render(&self) -> impl IntoElement { + let content = use_cluster_content(self.cluster_id, ContentType::Shader); + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let updates = use_package_updates(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::Shader); + + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( + content_items, + &bundle_items, + &bundle_overrides_map(&overrides), + &meta, + &stale_hashes(&updates), + ContentType::Shader, + ); + + cluster_content() + .child( + PackageManager::new( + "Shaders", + "shaders", + "shader", + ContentType::Shader, + self.cluster_id, + items, + all_categories, + ) + .into_element(), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs new file mode 100644 index 00000000..bfdf038a --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/textures.rs @@ -0,0 +1,60 @@ +use freya::prelude::*; +use oneclient_content::packages::ContentType; + +use crate::hooks::{ + bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, +}; +use crate::layout::cluster_content; + +use super::cluster_not_found; +use super::package_manager::{ + PackageManager, bundle_categories, bundle_packages, use_content_meta, +}; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterTextures { + pub cluster_id: i64, +} + +impl Component for ClusterTextures { + fn render(&self) -> impl IntoElement { + let content = use_cluster_content(self.cluster_id, ContentType::ResourcePack); + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let updates = use_package_updates(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::ResourcePack); + + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( + content_items, + &bundle_items, + &bundle_overrides_map(&overrides), + &meta, + &stale_hashes(&updates), + ContentType::ResourcePack, + ); + + cluster_content() + .child( + PackageManager::new( + "Textures", + "resource packs", + "texture", + ContentType::ResourcePack, + self.cluster_id, + items, + all_categories, + ) + .into_element(), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/mod.rs b/packages/oneclient_app/src/view/app/clusters/mod.rs new file mode 100644 index 00000000..64e98e08 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/mod.rs @@ -0,0 +1,3 @@ +mod page; + +pub use page::Clusters; diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs new file mode 100644 index 00000000..5a3c5807 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -0,0 +1,501 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_common::VersionKey; +use oneclient_common::domain::GameLoader; +use oneclient_core::clusters::Cluster; + +use crate::components::{ + Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, +}; +use crate::hooks::{ + settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, + use_launcher, use_version_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{ + ReleaseLine, default_line, default_loader, default_version_key, group_clusters_by_release, + line_title, loaders_for_line, resolve_cluster, version_keys, version_label, +}; +use crate::view::app::launch_button_state; + +const GRID_GAP_PX: f32 = 12.; +const MIN_CARD_WIDTH_PX: f32 = 280.; +const SIDEBAR_WIDTH_PX: f32 = 300.; +const CARD_HEIGHT_PX: f32 = 146.; +const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info"; + +#[derive(PartialEq)] +pub struct Clusters; + +impl Component for Clusters { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let active_id = use_active_cluster_id(); + let mut selected_line = use_state(|| None::); + let mut selected_version = use_state(|| None::); + let mut selected_loader = use_state(|| None::); + let mut grid_columns = use_state(|| 2_usize); + + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); + + let groups = group_clusters_by_release(&clusters); + // Newest version first + let lines: Vec = groups.keys().rev().copied().collect(); + + if lines.is_empty() { + return rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(40.) + .spacing(24.) + .child(page_header()) + .child( + rect() + .vertical() + .width(Size::fill()) + .main_align(Alignment::Center) + .child( + label() + .text("No versions available yet. Bundles are still syncing.") + .font_size(16.) + .color(colors::fg_secondary()), + ), + ); + } + + let active_cluster = active_id + .read() + .and_then(|id| clusters.iter().find(|c| c.id == id).cloned()); + + if selected_line.read().is_none() { + *selected_line.write() = default_line(&groups, active_cluster.clone()); + } + + let line = (*selected_line.read()) + .filter(|l| groups.contains_key(l)) + .unwrap_or(lines[0]); + + if *selected_line.read() != Some(line) { + *selected_line.write() = Some(line); + } + + let clusters_for_line = groups.get(&line).cloned().unwrap_or_default(); + + if selected_version.read().is_none() { + let preferred = active_cluster + .as_ref() + .and_then(|c| oneclient_common::parse_mc_version(&c.mc_version)) + .and_then(|p| p.key()); + *selected_version.write() = default_version_key(&clusters_for_line, preferred); + } + + if selected_loader.read().is_none() { + let preferred = active_cluster.as_ref().map(|c| c.mc_loader); + *selected_loader.write() = default_loader(&clusters_for_line, preferred); + } + + let cluster = resolve_cluster( + &clusters_for_line, + *selected_version.read(), + *selected_loader.read(), + ) + .or_else(|| clusters_for_line.first().cloned()); + + let columns = *grid_columns.read(); + let grid_rows = chunk_lines(&lines, columns); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 40.)) + .spacing(16.) + .child(page_header()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .spacing(GRID_GAP_PX) + .on_sized(move |event: Event| { + let width = event.data().area.width(); + let next = grid_columns_for_width(width); + if next != *grid_columns.peek() { + *grid_columns.write() = next; + } + }) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(GRID_GAP_PX) + .children(grid_rows.into_iter().map(|row| { + let row_len = row.len(); + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(GRID_GAP_PX) + .children(row.into_iter().map(|line| { + let list = + groups.get(&line).cloned().unwrap_or_default(); + let selected = *selected_line.read() == Some(line); + let mut selected_line = selected_line; + let mut selected_version = selected_version; + let mut selected_loader = selected_loader; + + VersionCard::new(line, &list, selected, move |_| { + *selected_line.write() = Some(line); + *selected_version.write() = None; + *selected_loader.write() = None; + }) + .into_element() + })) + .children((row_len..columns).map(|_| { + rect() + .width(Size::flex(1.0)) + .height(Size::px(CARD_HEIGHT_PX)) + .into_element() + })) + .into_element() + })), + ), + ) + .child(match cluster { + Some(cluster) => DetailSidebar { + line, + cluster_id: cluster.id, + clusters_for_line, + selected_version, + selected_loader, + } + .into_element(), + None => sidebar_error(), + }), + ) + } +} + +#[derive(PartialEq)] +struct DetailSidebar { + line: ReleaseLine, + cluster_id: i64, + clusters_for_line: Vec, + selected_version: State>, + selected_loader: State>, +} + +impl Component for DetailSidebar { + fn render(&self) -> impl IntoElement { + let active_id = use_active_cluster_id(); + let dispatch = use_dispatch(); + let game = use_game_snapshot(); + let launcher = use_launcher(); + + let clusters_for_line = self.clusters_for_line.as_slice(); + let syncing = launcher.fetching || launcher.syncing_bundles; + let keys = version_keys(clusters_for_line); + let loaders = loaders_for_line(clusters_for_line); + let version_title = line_title(self.line, clusters_for_line); + let version_value = (self.selected_version)(); + let loader_value = (self.selected_loader)(); + + let metadata = use_version_metadata(Some(self.line.major), version_value, loader_value); + + let heading = metadata + .as_ref() + .map(|m| m.name.clone()) + .unwrap_or_else(|| format!("Version {version_title}")); + let description = metadata + .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(); + + rect() + .width(Size::px(SIDEBAR_WIDTH_PX)) + .min_width(Size::px(SIDEBAR_WIDTH_PX)) + .height(Size::fill()) + .vertical() + .spacing(8.) + .padding(8.) + .corner_radius(CornerRadius::new_all(12.)) + .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( + self.line.major, + version_value, + loader_value, + false, + ), + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .padding(Gaps::new_all(8.)) + .spacing(8.) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(4.) + .child( + label() + .text(heading) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .maybe_child(tags_row(&tags)) + .child( + label() + .text(description) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .children(version_rows( + self.line, + &keys, + version_value, + self.selected_version, + )) + .children(loader_rows(&loaders, loader_value, self.selected_loader)), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .main_align(Alignment::Center) + .spacing(8.) + .child(play_button( + self.cluster_id, + dispatch, + launch_button_state(&game, self.cluster_id, syncing), + )) + .child(view_button(self.cluster_id, active_id)), + ), + ) + .into_element() + } +} + +fn tags_row(tags: &[String]) -> Option { + if tags.is_empty() { + return None; + } + + Some( + rect() + .horizontal() + .width(Size::fill()) + .spacing(6.) + .children(tags.iter().map(|tag| { + rect() + .padding(Gaps::new_symmetric(3., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(colors::component_bg()) + .child( + label() + .text(tag.clone()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element() + })) + .into_element(), + ) +} + +/// A single-version line already names it on the card and in the heading +fn version_rows( + line: ReleaseLine, + keys: &[VersionKey], + selected: Option, + mut selected_version: State>, +) -> Option { + if keys.len() <= 1 { + return None; + } + + let options: Vec = keys.iter().map(|k| version_label(line.major, *k)).collect(); + let current = selected + .and_then(|s| keys.iter().position(|k| *k == s)) + .unwrap_or(0); + let keys = keys.to_vec(); + + Some(info_row( + "Version", + Dropdown::new(options[current].clone(), options) + .on_select(move |idx: usize| { + if let Some(key) = keys.get(idx).copied() { + *selected_version.write() = Some(key); + } + }) + .into_element(), + )) +} + +fn loader_rows( + loaders: &[GameLoader], + selected: Option, + mut selected_loader: State>, +) -> Option { + if loaders.len() <= 1 { + return None; + } + + let options: Vec = loaders.iter().map(|l| l.to_string()).collect(); + let current = selected + .and_then(|s| loaders.iter().position(|l| *l == s)) + .unwrap_or(0); + let loaders = loaders.to_vec(); + + Some(info_row( + "Mod Loader", + Dropdown::new(options[current].clone(), options) + .on_select(move |idx: usize| { + if let Some(loader) = loaders.get(idx).copied() { + *selected_loader.write() = Some(loader); + } + }) + .into_element(), + )) +} + +fn info_row(label_text: impl Into, control: impl IntoElement) -> Element { + let label_text = label_text.into(); + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .padding(Gaps::new_symmetric(4.0, 0.0)) + .child( + label() + .text(label_text) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(control) + .into_element() +} + +fn play_button( + cluster_id: i64, + dispatch: crate::Actions, + state: (&'static str, bool), +) -> impl IntoElement { + let (label, enabled) = state; + Button::new() + .primary() + .width(Size::flex(1.)) + .enabled(enabled) + .on_press(move |_| { + if enabled { + dispatch.launch_cluster(cluster_id); + } + }) + .text(label) +} + +fn view_button(cluster_id: i64, mut active_id: State>) -> impl IntoElement { + Button::new() + .secondary() + .width(Size::flex(1.)) + .on_press(move |_| { + *active_id.write() = Some(cluster_id); + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); + }) + .text("View") + .child(Icon::new(IconType::ArrowRight).size(14.)) +} + +fn page_header() -> impl IntoElement { + rect() + .vertical() + .spacing(6.) + .child( + label() + .text("Versions") + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Something something in corporate style fashion about picking your \ + preferred gamemodes and versions and optionally loader so that oneclient \ + can pick something for them", + ) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) +} + +fn sidebar_error() -> Element { + rect() + .width(Size::px(SIDEBAR_WIDTH_PX)) + .height(Size::fill()) + .vertical() + .padding(16.) + .spacing(8.) + .corner_radius(CornerRadius::new_all(16.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::component_bg()) + .child( + label() + .text("Could not resolve a cluster for this version.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text("Try selecting a different version or mod loader.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn chunk_lines(lines: &[ReleaseLine], columns: usize) -> Vec> { + let columns = columns.max(1); + lines.chunks(columns).map(|chunk| chunk.to_vec()).collect() +} + +fn grid_columns_for_width(available_width_px: f32) -> usize { + const GAP: f32 = 16.; + + let grid_width = available_width_px - SIDEBAR_WIDTH_PX - GAP; + if grid_width < MIN_CARD_WIDTH_PX { + return 1; + } + + let cols = (grid_width / (MIN_CARD_WIDTH_PX + GRID_GAP_PX)).floor() as usize; + cols.clamp(1, 3) +} diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs new file mode 100644 index 00000000..5c001b65 --- /dev/null +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -0,0 +1,1177 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_db::console::{ConsoleQueryResult, run_console_query}; + +use oneclient_auth::preview_samples; +use oneclient_core::simulate::Damage; +use oneclient_net::status::{self, ServiceStatus}; + +use crate::Actions; +use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle}; +use crate::hooks::use_dispatch; +use crate::notifications::{ + ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +type SqlResult = Option>; + +#[derive(PartialEq)] +pub struct Debug; + +impl Component for Debug { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let log_debug_info = use_state(|| false); + let show_dev_stuff = use_state(|| false); + let seen_onboarding = use_state(|| true); + let use_grid_on_mods_list = use_state(|| true); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .padding(40.) + .spacing(8.) + .child( + ScrollView::new() + .child( + rect() + .vertical() + .child( + label() + .text("Debug") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The end user won't really be looking at this page.") + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .child(divider()) + .child(section( + "Settings", + vec![ + toggle_row("Log Debug Info", log_debug_info), + toggle_row("Show Dev stuff", show_dev_stuff), + toggle_row("Seen Onboarding", seen_onboarding), + toggle_row("Use Grid On Mods List", use_grid_on_mods_list), + ], + )) + .child(divider()) + .child(section( + "Toast Controller", + vec![ToastController.into_element()], + )) + .child(divider()) + .child(section( + "Cluster Update Simulator", + vec![ClusterUpdateSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Launcher Auto Update", + vec![LauncherUpdateSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Connectivity Status Bar", + vec![StatusBarSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Auth Error Guidance", + vec![AuthGuidancePreview.into_element()], + )) + .child(divider()) + .child(section( + "Corruption Simulator", + vec![CorruptionSimulator.into_element()], + )) + .child(divider()) + .child(section("SQL Console", vec![SqlConsole.into_element()])) + .child(divider()) + .child(section( + "Other", + vec![action_row( + &dispatch, + vec![ + ("Open Dev Tools", IconType::CodeSnippet02), + ("Open Onboarding", IconType::Rocket02), + ("Open Launcher Data", IconType::Folder), + ("Log Running Processes", IconType::Terminal), + ], + )], + )), + ) + } +} + +#[derive(PartialEq)] +struct ToastController; + +impl Component for ToastController { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let title = use_state(|| "Downloading assets".to_string()); + let body = use_state(|| "PolyBlock by Polyfrost".to_string()); + let mut progress = use_state(|| 0u64); + + let info = dispatch.clone(); + let error = dispatch.clone(); + let prog = dispatch.clone(); + let reset = dispatch; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(title).placeholder("Title")), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(body).placeholder("Body")), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::InfoCircle).size(16.)) + .text("Send Info") + .on_press(move |_| { + info.notify(title.read().clone()) + .body(body.read().clone()) + .info() + .send(); + }), + ) + .child( + Button::new() + .danger() + .child(Icon::new(IconType::AlertTriangle).size(16.)) + .text("Send Error") + .on_press(move |_| { + error + .notify(title.read().clone()) + .body(body.read().clone()) + .error() + .send(); + }), + ) + .child( + Button::new() + .secondary() + .child(Icon::new(IconType::FolderDownload).size(16.)) + .text("Progress +10") + .on_press(move |_| { + let next = (*progress.read() + 10).min(100); + progress.set(next); + prog.send_test_progress(next, 100); + }), + ) + .child( + Button::new() + .secondary() + .text("Reset Progress") + .on_press(move |_| { + progress.set(0); + reset.send_test_progress(0, 100); + }), + ), + ) + .into_element() + } +} + +#[derive(PartialEq)] +struct ClusterUpdateSimulator; + +impl Component for ClusterUpdateSimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let cluster_id = use_state(|| "1".to_string()); + let cluster_name = use_state(|| "PolyBlock".to_string()); + let updated = use_state(|| "Sodium 0.5 → 0.6, Iris 1.7 → 1.8".to_string()); + let added = use_state(|| "Lithium".to_string()); + let removed = use_state(|| "OptiFine".to_string()); + + let simulate = dispatch.clone(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::px(120.)) + .child(TextInput::new(cluster_id).placeholder("Cluster ID")), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(cluster_name).placeholder("Cluster name")), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(updated).placeholder("Updated (comma separated)"), + ), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(added).placeholder("Added (comma separated)")), + ) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(removed).placeholder("Removed (comma separated)"), + ), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::DownloadCloud02).size(16.)) + .text("Simulate Cluster Update") + .on_press(move |_| { + let summary = ClusterUpdateSummary { + cluster_id: cluster_id.read().trim().parse().unwrap_or(1), + cluster_name: cluster_name.read().clone(), + updated: cluster_update_items(&split_csv(&updated.read())), + added: cluster_update_items(&split_csv(&added.read())), + removed: cluster_update_items(&split_csv(&removed.read())), + }; + send_cluster_update(&simulate, vec![summary]); + }), + ) + .child( + label() + .text("Presets: every shape the notification and modal can take.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .children(cluster_update_preset_rows(&dispatch)) + .into_element() + } +} + +type ClusterUpdatePreset = fn() -> Vec; + +/// Each preset pins one variation plural copy empty category tab single- vs multi-cluster footer grouping header overflow +const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ + ( + "1 cluster · all categories", + IconType::DownloadCloud02, + || { + vec![preset_summary( + 1, + "PolyBlock", + &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8", "Patcher 1.8 → 1.9"], + &["Lithium", "FerriteCore"], + &["OptiFine"], + )] + }, + ), + ("1 cluster · updates only", IconType::RefreshCw01, || { + vec![preset_summary( + 1, + "PolyBlock", + &[ + "Sodium 0.5 → 0.6", + "Iris 1.7 → 1.8", + "Indium 1.0 → 1.1", + "Fabric API 0.92 → 0.100", + ], + &[], + &[], + )] + }), + ("1 cluster · single change", IconType::Plus, || { + vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[])] + }), + ("3 clusters · mixed", IconType::DotsGrid, || { + vec![ + preset_summary( + 1, + "PolyBlock", + &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8"], + &["Lithium"], + &[], + ), + preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"]), + preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[]), + ] + }), + ("2 clusters · removals only", IconType::Trash01, || { + vec![ + preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"]), + preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"]), + ] + }), + ("6 clusters · long names", IconType::Database01, || { + (1..=6) + .map(|i| ClusterUpdateSummary { + cluster_id: i, + cluster_name: format!( + "Cluster {i} with a deliberately overlong name that has to truncate" + ), + updated: cluster_update_items(&[ + format!("Sodium 0.{i} → 0.{}", i + 1), + format!("Iris 1.{i} → 1.{}", i + 1), + ]), + added: cluster_update_items(&[format!("Lithium {i}")]), + removed: Vec::new(), + }) + .collect() + }), +]; + +fn cluster_update_preset_rows(dispatch: &crate::Actions) -> Vec { + CLUSTER_UPDATE_PRESETS + .chunks(3) + .map(|chunk| { + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + + for (text, icon, build) in chunk { + let dispatch = dispatch.clone(); + let build = *build; + row = row.child( + Button::new() + .secondary() + .child(Icon::new(*icon).size(16.)) + .text(*text) + .on_press(move |_| send_cluster_update(&dispatch, build())), + ); + } + + row.into_element() + }) + .collect() +} + +fn preset_summary( + cluster_id: i64, + cluster_name: &str, + updated: &[&str], + added: &[&str], + removed: &[&str], +) -> ClusterUpdateSummary { + ClusterUpdateSummary { + cluster_id, + cluster_name: cluster_name.to_string(), + updated: cluster_update_items(updated), + added: cluster_update_items(added), + removed: cluster_update_items(removed), + } +} + +fn cluster_update_items(names: &[impl AsRef]) -> Vec { + names + .iter() + .map(|name| ClusterUpdateItem::from_name(name.as_ref())) + .collect() +} + +/// Mirrors the copy the bridge builds so the simulated notification matches the real one +fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec) { + if summaries.is_empty() { + return; + } + + let total: usize = summaries.iter().map(|s| s.total()).sum(); + let plural = if total == 1 { "" } else { "s" }; + let (title, body) = match summaries.as_slice() { + [only] => ( + "Cluster updated", + format!("{total} package{plural} changed in {}", only.cluster_name), + ), + many => ( + "Mods updated", + format!( + "{total} package{plural} updated across {} clusters", + many.len() + ), + ), + }; + + dispatch + .notify(title) + .body(body) + .icon(IconType::DownloadCloud02) + .action(NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summaries), + }) + .send(); +} + +#[derive(PartialEq)] +struct LauncherUpdateSimulator; + +impl Component for LauncherUpdateSimulator { + fn render(&self) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Drives the real auto-update UX (prompt → download progress → \"restart to apply\") against a fake release, with no network or disk. \"Check for Updates Now\" runs the real check against the release feed.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::DownloadCloud02).size(16.)) + .text("Simulate Auto Update") + .on_press(|_| crate::updater::spawn_simulated_update()), + ) + .child( + Button::new() + .secondary() + .child(Icon::new(IconType::RefreshCw01).size(16.)) + .text("Check for Updates Now") + .on_press(|_| { + if let Ok(state) = crate::launcher::state() { + crate::updater::spawn_update_check( + false, + state.services.events.clone(), + ); + } + }), + ), + ) + .into_element() + } +} + +/// Pins `oneclient_net::status` so the connectivity banner can be seen without unplugging anything +#[derive(PartialEq)] +struct StatusBarSimulator; + +const STATUS_PRESETS: [(&str, IconType, ServiceStatus); 4] = [ + ( + "No internet", + IconType::Globe01, + ServiceStatus { + online: false, + mc_auth_up: false, + polyfrost_up: false, + }, + ), + ( + "Minecraft auth down", + IconType::AlertTriangle, + ServiceStatus { + online: true, + mc_auth_up: false, + polyfrost_up: true, + }, + ), + ( + "Polyfrost down", + IconType::AlertCircle, + ServiceStatus { + online: true, + mc_auth_up: true, + polyfrost_up: false, + }, + ), + ( + "Both services down", + IconType::AlertTriangle, + ServiceStatus { + online: true, + mc_auth_up: false, + polyfrost_up: false, + }, + ), +]; + +impl Component for StatusBarSimulator { + fn render(&self) -> impl IntoElement { + let mut pinned = use_state(status::forced); + + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + for (text, icon, forced) in STATUS_PRESETS { + row = row.child( + Button::new() + .secondary() + .child(Icon::new(icon).size(16.)) + .text(text) + .on_press(move |_| { + status::force(Some(forced)); + pinned.set(Some(forced)); + }), + ); + } + + let current = *pinned.read(); + let state_text = match current { + Some(_) => "Pinned. Probing still runs, but its result is not published.".to_string(), + None => "Not pinned; showing the real probe result.".to_string(), + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Forces what the connectivity prober reports, so the real status bar renders its banner. Dismissing a banner is remembered until the issue clears.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(row) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::RefreshCw01).size(16.)) + .text("Clear + recheck") + .enabled(current.is_some()) + .on_press(move |_| { + status::force(None); + pinned.set(None); + }), + ) + .child( + label() + .text(state_text) + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .into_element() + } +} + +#[derive(PartialEq)] +struct AuthGuidancePreview; + +impl Component for AuthGuidancePreview { + fn render(&self) -> impl IntoElement { + let samples = preview_samples(); + // Index 0 is the no-error state the rest line up with `samples` + let mut choice = use_state(|| 0usize); + let mut open = use_state(|| false); + + let mut options: Vec = vec!["No error (in progress)".to_string()]; + options.extend(samples.iter().map(|s| s.label.to_string())); + + let index = (*choice.read()).min(options.len() - 1); + let selected_label = options[index].clone(); + + let popup = open().then(|| { + let (error, guidance) = match index.checked_sub(1) { + None => (None, None), + Some(i) => { + let s = &samples[i]; + (Some(s.message.clone()), s.guidance.clone()) + } + }; + login_dialog( + "https://login.live.com/oauth20_authorize (preview)".to_string(), + "ABCD-EFGH".to_string(), + "https://www.microsoft.com/link".to_string(), + false, + None, + error, + guidance, + move || open.set(false), + ) + }); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Opens the real Microsoft sign-in popup exactly as users see it, in the chosen error state.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + Dropdown::new(selected_label, options) + .width(Size::px(300.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| choice.set(idx)), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::Eye).size(16.)) + .text("Open preview") + .on_press(move |_| open.set(true)), + ), + ) + .maybe_child(popup) + .into_element() + } +} + +/// Presets are grouped by which layer should catch them same-length corruption only the hashing pass finds truncation the launch-time size check +#[derive(PartialEq)] +struct CorruptionSimulator; + +/// `(label, icon, what it does, which check is meant to catch it)` +type DamagePreset = (&'static str, IconType, DamageKind, &'static str); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DamageKind { + Assets(usize, Damage), + Libraries(usize, Damage), + Content(usize, Damage), + DeleteAssetsTree, +} + +const DAMAGE_PRESETS: [DamagePreset; 6] = [ + ( + "Corrupt 25 assets", + IconType::AlertTriangle, + DamageKind::Assets(25, Damage::Corrupt), + "Survives launch; only Verify Files finds it", + ), + ( + "Truncate 25 assets", + IconType::FileX02, + DamageKind::Assets(25, Damage::Truncate), + "Caught at launch by the size check", + ), + ( + "Delete 25 assets", + IconType::Trash01, + DamageKind::Assets(25, Damage::Delete), + "Caught at launch, re-downloaded", + ), + ( + "Corrupt 5 libraries", + IconType::AlertCircle, + DamageKind::Libraries(5, Damage::Corrupt), + "Crashes with ZipException, then offers repair", + ), + ( + "Corrupt 5 mods", + IconType::File02, + DamageKind::Content(5, Damage::Corrupt), + "Repaired from the provider, not Mojang", + ), + ( + "Delete the assets tree", + IconType::Folder, + DamageKind::DeleteAssetsTree, + "The reported bug; auto-repairs on launch", + ), +]; + +impl Component for CorruptionSimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let cluster_id = use_state(|| "1".to_string()); + + let mut rows = Vec::new(); + for chunk in DAMAGE_PRESETS.chunks(3) { + let mut row = rect().horizontal().width(Size::fill()).spacing(8.); + + for (text, icon, kind, hint) in chunk { + let dispatch = dispatch.clone(); + let kind = *kind; + let hint = *hint; + + row = row.child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + Button::new() + .danger() + .width(Size::fill()) + .child(Icon::new(*icon).size(16.)) + .text(*text) + .on_press(move |_| { + let target = + cluster_id.read().trim().parse::().unwrap_or(1); + run_damage(&dispatch, kind, target); + }), + ) + .child( + label() + .text(hint) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ); + } + + rows.push(row.into_element()); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching — which is the point.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("Cluster (for mods)") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::px(120.)) + .child(TextInput::new(cluster_id).placeholder("Cluster ID")), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(rows), + ) + .child( + label() + .text("Modals: the real prompts, built by the code that raises them for real.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child(prompt_button( + "Failed assets prompt", + IconType::AlertTriangle, + 3, + 0, + )) + .child(prompt_button( + "Failed libraries prompt", + IconType::AlertCircle, + 0, + 2, + )) + .child(prompt_button("Mixed failures prompt", IconType::File02, 4, 1)), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child(crash_repair_button( + "Crash repair prompt (named jar)", + Some("fabric-loader-0.15.11.jar"), + cluster_id, + )) + .child(crash_repair_button( + "Crash repair prompt (no jar named)", + None, + cluster_id, + )), + ) + .into_element() + } +} + +/// Calls the same function the download path calls so the simulated modal cannot drift from the real one +fn prompt_button( + text: &'static str, + icon: IconType, + failed_assets: usize, + failed_libraries: usize, +) -> impl IntoElement { + Button::new() + .secondary() + .child(Icon::new(icon).size(16.)) + .text(text) + .on_press(move |_| { + spawn(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + let answer = oneclient_core::game::confirm_incomplete_install( + &state.services.mc(), + failed_assets, + failed_libraries, + ) + .await; + tracing::info!(?answer, "incomplete install prompt answered"); + }); + }) +} + +/// Accepting really runs the verify pass +/// To test detection itself corrupt libraries above and launch the game +fn crash_repair_button( + text: &'static str, + jar: Option<&'static str>, + cluster_id: State, +) -> impl IntoElement { + Button::new() + .secondary() + .child(Icon::new(IconType::AlertCircle).size(16.)) + .text(text) + .on_press(move |_| { + let target = cluster_id.read().trim().parse::().unwrap_or(1); + spawn(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + let diagnosis = oneclient_core::game::CrashDiagnosis::CorruptArchive { + file: jar.map(str::to_string), + }; + oneclient_core::game::offer_repair(&state, target, &diagnosis).await; + }); + }) +} + +fn run_damage(dispatch: &crate::Actions, kind: DamageKind, cluster_id: i64) { + let dispatch = dispatch.clone(); + spawn(async move { + let result = match kind { + DamageKind::Assets(count, damage) => { + oneclient_core::simulate::damage_assets(count, damage) + .await + .map(|report| (report, damage.verb())) + } + DamageKind::Libraries(count, damage) => { + oneclient_core::simulate::damage_libraries(count, damage) + .await + .map(|report| (report, damage.verb())) + } + DamageKind::Content(count, damage) => match crate::launcher::state() { + Ok(state) => oneclient_core::simulate::damage_cluster_content( + &state, cluster_id, count, damage, + ) + .await + .map(|report| (report, damage.verb())), + Err(err) => Err(err), + }, + DamageKind::DeleteAssetsTree => oneclient_core::simulate::delete_assets_tree() + .await + .map(|report| (report, "Deleted")), + }; + + match result { + Ok((report, verb)) => { + dispatch + .notify("Damage simulated") + .body(report.summary(verb)) + .icon(IconType::AlertTriangle) + .error() + .send(); + } + Err(err) => { + dispatch + .notify("Simulation failed") + .body(err.to_string()) + .error() + .send(); + } + } + }); +} + +#[derive(PartialEq)] +struct SqlConsole; + +impl Component for SqlConsole { + fn render(&self) -> impl IntoElement { + let query = use_state(|| "SELECT * FROM clusters;".to_string()); + let result = use_state(|| None::>); + let running = use_state(|| false); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(query) + .placeholder("SELECT * FROM …") + .on_submit(move |_| run_sql(query, result, running)), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::Terminal).size(16.)) + .text(if *running.read() { "Running…" } else { "Run" }) + .on_press(move |_| run_sql(query, result, running)), + ), + ) + .child(sql_result(&result.read())) + .into_element() + } +} + +fn run_sql(query: State, mut result: State, mut running: State) { + if *running.read() { + return; + } + let sql = query.read().clone(); + if sql.trim().is_empty() { + return; + } + + running.set(true); + spawn(async move { + let res = match crate::launcher::state() { + Ok(state) => run_console_query(&state.services.db, &sql) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; + result.set(Some(res)); + running.set(false); + }); +} + +fn sql_result(state: &SqlResult) -> Element { + match state { + None => label() + .text("No query run yet.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(), + Some(Err(err)) => rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::danger())) + .child( + label() + .text(err.clone()) + .font_size(13.) + .color(colors::danger()), + ) + .into_element(), + Some(Ok(res)) if !res.is_select => label() + .text(format!("OK, {} row(s) affected.", res.rows_affected)) + .font_size(13.) + .color(colors::success()) + .into_element(), + Some(Ok(res)) => sql_table(res), + } +} + +fn sql_table(res: &ConsoleQueryResult) -> Element { + if res.columns.is_empty() { + return label() + .text("0 rows returned.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(); + } + + let header = table_row(&res.columns, true); + let mut table = rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border())) + .child(header); + + for row in &res.rows { + table = table.child(table_row(row, false)); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(format!("{} row(s)", res.rows.len())) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(table) + .into_element() +} + +fn table_row(cells: &[String], header: bool) -> Element { + let mut row = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .background(if header { + colors::page_elevated() + } else { + colors::component_bg() + }); + + for cell in cells { + row = row.child( + rect() + .width(Size::flex(1.0)) + .padding(Gaps::new_symmetric(6., 10.)) + .border(border_all_color(0.5, colors::component_border())) + .child( + label() + .text(cell.clone()) + .font_size(12.) + .max_lines(1) + .font_weight(if header { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(if header { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ); + } + + row.into_element() +} + +fn split_csv(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn divider() -> impl IntoElement { + crate::ui::divider() + .margin(Gaps::new_symmetric(8., 0.)) + .corner_radius(CornerRadius::new_all(1.)) + .into_element() +} + +fn section(title: &'static str, rows: Vec) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text(title) + .font_size(22.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .children(rows) + .into_element() +} + +fn toggle_row(title: &'static str, on: State) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated()) + .child( + label() + .text(title) + .font_size(14.) + .color(colors::fg_primary()), + ) + .child(toggle(on)) + .into_element() +} + +fn action_row(dispatch: &Actions, buttons: Vec<(&'static str, IconType)>) -> Element { + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + + for (text, icon) in buttons { + let mut button = Button::new() + .secondary() + .child(Icon::new(icon).size(16.)) + .text(text); + + if text == "Open Onboarding" { + dispatch.reset_onboarding(); + button = button.on_press(|_| { + let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); + }); + } + + row = row.child(button); + } + + row.into_element() +} diff --git a/packages/oneclient_app/src/view/app/home/index.rs b/packages/oneclient_app/src/view/app/home/index.rs new file mode 100644 index 00000000..2ccb85ff --- /dev/null +++ b/packages/oneclient_app/src/view/app/home/index.rs @@ -0,0 +1,29 @@ +use freya::prelude::*; + +use crate::components::{ActiveClusterPanel, RecentsRow}; +use crate::theme; + +#[derive(PartialEq)] +pub struct Home; + +impl Component for Home { + fn render(&self) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .overflow(Overflow::Clip) + .padding(theme::HOME_PADDING_PX) + .main_align(Alignment::Center) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .main_align(Alignment::Center) + .child(ActiveClusterPanel), + ) + .child(RecentsRow) + } +} diff --git a/packages/oneclient_app/src/view/app/home/mod.rs b/packages/oneclient_app/src/view/app/home/mod.rs new file mode 100644 index 00000000..4b7dc64b --- /dev/null +++ b/packages/oneclient_app/src/view/app/home/mod.rs @@ -0,0 +1,3 @@ +mod index; + +pub use index::Home; diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs new file mode 100644 index 00000000..2f95edd3 --- /dev/null +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -0,0 +1,41 @@ +mod analytics; +mod clusters; +mod debug; +mod home; +mod skins; +mod stats; + +pub mod browser; +pub mod cluster; +pub mod settings; + +use crate::state::GameState as GameSnapshot; +use oneclient_events::LaunchStage; + +pub fn launch_button_state( + game: &GameSnapshot, + cluster_id: i64, + syncing: bool, +) -> (&'static str, bool) { + let state = match game.stage(cluster_id) { + Some(LaunchStage::Checking) => ("Checking", false), + Some(LaunchStage::Downloading) => ("Downloading", false), + Some(LaunchStage::Launching) => ("Launching", false), + Some(LaunchStage::Running) => ("Running", false), + // The click's own flag held for a beat after a failure so a burst of clicks starts one game + _ if game.is_launch_pending(cluster_id) => ("Launching", false), + _ => ("Launch", true), + }; + // Block launching while the startup bundle download is still running + if syncing && state.1 { + return (state.0, false); + } + state +} + +pub(crate) use analytics::{analytics_body, analytics_placeholder}; +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/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs new file mode 100644 index 00000000..1f26ba82 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -0,0 +1,746 @@ +use chrono::{DateTime, Utc}; +use freya::animation::{AnimNum, Function, OnFinish, use_animation}; +use freya::prelude::*; +use freya::query::{MutationCapability, MutationStateData, UseMutation}; +use oneclient_auth::{AccountKind, MinecraftAccount}; +use uuid::Uuid; + +use super::{section_header, settings_page}; +use crate::components::{ + Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput, use_microsoft_login, +}; +use crate::hooks::{ + AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, + accounts_have_microsoft, try_accounts, try_default_account, use_accounts, + use_add_offline_account, use_current_account, use_refresh_account, use_remove_account, + use_set_default_account, +}; +use crate::theme::colors; +use crate::ui::border_all_color; + +/// The shader scales the model off `res.y` alone so height makes the player bigger width only needs to clear the arms +const MODEL_WIDTH_PX: f32 = 160.; +const MODEL_HEIGHT_PX: f32 = 264.; + +const AVATAR_SIZE_PX: f32 = 36.; + +#[derive(PartialEq)] +pub struct SettingsAccounts; + +impl Component for SettingsAccounts { + fn render(&self) -> impl IntoElement { + let accounts_query = use_accounts(); + let default_query = use_current_account(); + + let msa = use_microsoft_login(); + let add_offline = use_add_offline_account(); + let set_default = use_set_default_account(); + let remove = use_remove_account(); + let refresh = use_refresh_account(); + + let mut username = use_state(String::new); + let mut show_offline = use_state(|| false); + let edit_account = use_state(|| None::<(Uuid, String)>); + let edit_name = use_state(|| String::new()); + let mut closing_offline = use_state(|| false); + + use_side_effect(move || { + if !*closing_offline.read() { + return; + } + match &*add_offline.read().state() { + MutationStateData::Settled { res: Ok(_), .. } => { + closing_offline.set(false); + show_offline.set(false); + username.set(String::new()); + } + MutationStateData::Settled { res: Err(_), .. } => { + closing_offline.set(false); + } + _ => {} + } + }); + + let accounts = try_accounts(&accounts_query).unwrap_or_default(); + let default_account = try_default_account(&default_query); + let default_id = default_account.as_ref().map(|a| a.id); + let has_microsoft = accounts_have_microsoft(&accounts); + + let offline_name = username.read().trim().to_string(); + let offline_uuid = (!offline_name.is_empty()) + .then(|| oneclient_auth::offline_uuid(&offline_name).to_string()); + + let offline_error = mutation_err_text(&add_offline); + + let on_confirm_offline = move |_| { + let name = username.peek().trim().to_string(); + if name.is_empty() { + return; + } + add_offline.mutate(AddOfflineAccountKeys { username: name }); + closing_offline.set(true); + }; + + let mut rows: Vec = accounts + .iter() + .map(|account| { + 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, + edit_account, + edit_name, + } + .into_element() + }) + .collect(); + if rows.is_empty() { + rows.push(empty_state()); + } + + settings_page() + .child(hero( + default_account, + has_microsoft, + msa.pending, + msa.error.clone(), + move |_| show_offline.set(true), + { + let msa = msa.clone(); + move |_| msa.start() + }, + )) + .child(section_header("YOUR ACCOUNTS")) + .children(rows) + .maybe_child(show_offline.read().then(|| { + offline_dialog( + username, + offline_uuid, + offline_error, + on_confirm_offline, + show_offline, + ) + })) + .maybe_child(edit_account.read().as_ref().map(|(id, _)| { + let id = *id; + let mut edit_account = edit_account; + let edit_name = edit_name; + OverlayPopup::new() + .on_close(move |()| edit_account.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(380.)) + .max_width(Size::window_percent(90.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Edit offline username") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child(field_label("New Username")) + .child(TextInput::new(edit_name).placeholder("Enter new username")), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| edit_account.set(None)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let new_name = edit_name.peek().trim().to_string(); + if !new_name.is_empty() { + let mut edit_account = edit_account; + spawn(async move { + if let Ok(state) = crate::launcher::state() { + let _ = state.auth.remove_account(id).await; + if let Ok(new_acc) = state.auth.add_offline_account(new_name).await { + let _ = state.auth.set_default_account(Some(new_acc.id)).await; + crate::hooks::invalidate_auth_queries(Some(new_acc.id)).await; + } + } + edit_account.set(None); + }); + } + }) + .text("Save"), + ), + ), + ), + ) + })) + .maybe_child(msa.popup()) + .into_element() + } +} + +fn hero( + account: Option, + _has_microsoft: bool, + microsoft_pending: bool, + error: Option, + on_open_offline: impl FnMut(Event) + 'static, + on_add_microsoft: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let (name, subtitle) = match &account { + Some(account) => (account.username.clone(), kind_label(account.kind)), + None => ( + "No active account".to_string(), + "Add an account to start playing", + ), + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child(model_frame(account.as_ref().map(|account| account.id))) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(16.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(2.) + .child( + label() + .text("ACTIVE ACCOUNT") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(name) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + // The two buttons overflow the ~245px beside the model at the 800px minimum so they wrap + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(8.)) + .spacing(8.) + .child( + Button::new() + .primary() + .enabled(!microsoft_pending) + .on_press(on_add_microsoft) + .child(Icon::new(IconType::Globe01).size(16.)) + .text(if microsoft_pending { + "Signing in..." + } else { + "Add Microsoft" + }), + ) + .child( + Button::new() + .secondary() + .enabled(true) + .on_press(on_open_offline) + .child(Icon::new(IconType::Plus).size(16.)) + .text("Add offline"), + ), + ) + .map(error, |el, msg| { + el.child(hint_line(IconType::AlertTriangle, msg, colors::danger())) + }) + .maybe(false, |el| { + el.child(hint_line( + IconType::InfoCircle, + "Add a Microsoft account before creating offline accounts." + .to_string(), + colors::fg_secondary(), + )) + }), + ), + ) + .into_element() +} + +fn model_frame(id: Option) -> impl IntoElement { + rect() + .vertical() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + rect() + .width(Size::px(MODEL_WIDTH_PX)) + .height(Size::px(MODEL_HEIGHT_PX)) + .center() + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child(match id { + Some(id) => PlayerModel::new(id) + .yaw(-0.5) + .width(Size::fill()) + .height(Size::fill()) + .into_element(), + None => Icon::new(IconType::Users01) + .size(28.) + .color(colors::fg_secondary()) + .into_element(), + }), + ) + .maybe_child(id.map(|_| { + label() + .text("Drag to rotate") + .font_size(10.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() +} + +fn mutation_err_text(mutation: &UseMutation) -> Option +where + M: MutationCapability, + M::Err: std::fmt::Display, +{ + match &*mutation.read().state() { + MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()), + MutationStateData::Loading { + res: Some(Err(err)), + } => Some(err.to_string()), + _ => None, + } +} + +fn offline_dialog( + username: State, + uuid_preview: Option, + error: Option, + on_confirm: impl FnMut(Event) + 'static, + mut show_offline: State, +) -> impl IntoElement { + OverlayPopup::new() + .on_close(move |()| show_offline.set(false)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(380.)) + .max_width(Size::window_percent(90.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Add offline account") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child(field_label("Username")) + .child(TextInput::new(username).placeholder("Offline username")), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child(field_label("UUID")) + .child( + label() + .text(uuid_preview.unwrap_or_else(|| "—".to_string())) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .map(error, |el, msg| { + el.child(hint_line(IconType::AlertTriangle, msg, colors::danger())) + }) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| show_offline.set(false)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(on_confirm) + .child(Icon::new(IconType::Plus).size(16.)) + .text("Add account"), + ), + ), + ), + ) + .into_element() +} + +fn field_label(text: &str) -> impl IntoElement { + label() + .text(text.to_string()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()) + .into_element() +} + +/// Sits in whatever is left of the hero beside the model so the text must wrap +fn hint_line(icon: IconType, text: String, color: Color) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(6.) + .child(Icon::new(icon).size(13.).color(color)) + .child( + label() + .text(text) + .font_size(12.) + .width(Size::flex(1.0)) + .color(color), + ) + .into_element() +} + +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, + edit_account: State>, + edit_name: State, +} + +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 + && self.edit_account == other.edit_account + && self.edit_name == other.edit_name + } +} + +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(12.) + .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(AVATAR_SIZE_PX)) + .height(Size::px(AVATAR_SIZE_PX)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(4.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(self.username.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe_child(is_default.then(default_badge)) + .maybe_child(expired.then(expired_badge)), + ) + // The kind rides with the id a third badge and a full uuid do not both fit + .child( + label() + .text(format!("{} · {id}", kind_label(self.kind))) + .font_size(11.) + .max_lines(1) + .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({ + let is_offline = !is_microsoft; + let mut edit_acc_state = self.edit_account; + let mut edit_name_state = self.edit_name; + let username_val = self.username.clone(); + let id_val = self.id; + Button::new() + .ghost() + .icon() + .enabled(is_offline) + .on_press(move |e: Event| { + e.stop_propagation(); + edit_name_state.set(username_val.clone()); + edit_acc_state.set(Some((id_val, username_val.clone()))); + }) + .child( + Icon::new(IconType::Pencil01) + .size(18.) + .color(if is_offline { + colors::fg_primary() + } else { + 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) + .size(18.) + .color(colors::fg_secondary()), + ), + ) + .into_element() + } +} + +fn kind_label(kind: AccountKind) -> &'static str { + match kind { + AccountKind::Microsoft => "Microsoft", + AccountKind::Offline => "Offline", + } +} + +fn default_badge() -> impl IntoElement { + badge( + Icon::new(IconType::CheckCircle) + .size(12.) + .color(colors::brand()) + .into_element(), + "Default".to_string(), + colors::brand(), + colors::brand(), + ) +} + +fn expired_badge() -> impl IntoElement { + badge( + Icon::new(IconType::AlertTriangle) + .size(12.) + .color(colors::danger()) + .into_element(), + "Expired".to_string(), + colors::danger(), + colors::danger(), + ) +} + +fn badge(icon: impl IntoElement, text: String, border: Color, fg: Color) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .border(border_all_color(1., border)) + .background(colors::component_bg()) + .child(icon) + .child( + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::MEDIUM) + .color(fg), + ) + .into_element() +} + +fn empty_state() -> Element { + rect() + .vertical() + .width(Size::fill()) + .center() + .padding(Gaps::new_all(32.)) + .spacing(8.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + Icon::new(IconType::Users01) + .size(32.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text("No accounts yet. Add one above.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/apis.rs b/packages/oneclient_app/src/view/app/settings/apis.rs new file mode 100644 index 00000000..13dd6729 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/apis.rs @@ -0,0 +1,97 @@ +use freya::prelude::*; + +use super::settings_page; +use crate::components::{IconType, TextInput}; +use crate::hooks::{use_dispatch, use_settings_snapshot}; +use crate::view::app::settings::{section_header, settings_row}; + +fn normalize(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[derive(PartialEq)] +pub struct SettingsApis; + +impl Component for SettingsApis { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + + let modrinth_key = use_state({ + let v = settings.modrinth_api_key.clone().unwrap_or_default(); + move || v + }); + let curseforge_key = use_state({ + let v = settings.curseforge_api_key.clone().unwrap_or_default(); + move || v + }); + let custom_api_endpoint = use_state({ + let v = settings.custom_api_endpoint.clone().unwrap_or_default(); + move || v + }); + let custom_meta_url_base = use_state({ + let v = settings.custom_meta_url_base.clone().unwrap_or_default(); + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let modrinth = modrinth_key.read().clone(); + let curseforge = curseforge_key.read().clone(); + let endpoint = custom_api_endpoint.read().clone(); + let meta_url_base = custom_meta_url_base.read().clone(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.modrinth_api_key = normalize(&modrinth); + next.curseforge_api_key = normalize(&curseforge); + next.custom_api_endpoint = normalize(&endpoint); + next.custom_meta_url_base = normalize(&meta_url_base); + dispatch.set_settings(next); + }); + + settings_page() + .child(section_header("PROVIDERS")) + .child(settings_row( + IconType::Key01, + "Modrinth", + "Personal access token used for Modrinth requests.", + TextInput::new(modrinth_key) + .placeholder("Optional") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::Key01, + "CurseForge", + "API key used for CurseForge requests.", + TextInput::new(curseforge_key) + .placeholder("Default") + .width(Size::px(220.)), + )) + .child(section_header("ADVANCED")) + .child(settings_row( + IconType::Globe01, + "Custom API Endpoint", + "Override the default OneClient backend endpoint.", + TextInput::new(custom_api_endpoint) + .placeholder("Default") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::Globe01, + "Custom Meta URL Base", + "Override the default bundles and versions data host.", + TextInput::new(custom_meta_url_base) + .placeholder("Default") + .width(Size::px(220.)), + )) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/appearance.rs b/packages/oneclient_app/src/view/app/settings/appearance.rs new file mode 100644 index 00000000..accfc8b6 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/appearance.rs @@ -0,0 +1,264 @@ +use freya::prelude::*; + +use super::settings_page; +use crate::components::{Icon, IconType, toggle}; +use crate::hooks::{use_dispatch, use_settings_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::settings::{settings_row, settings_row_disabled}; + +#[derive(Clone, Copy)] +struct ThemePreview { + background: Color, + accent: Color, + light: bool, +} + +fn themes() -> [ThemePreview; 5] { + [ + ThemePreview { + background: Color::from_rgb(17, 23, 28), + accent: Color::from_rgb(43, 75, 255), + light: false, + }, + ThemePreview { + background: Color::from_rgb(0, 0, 0), + accent: Color::from_rgb(111, 143, 255), + light: false, + }, + ThemePreview { + background: Color::from_rgb(40, 42, 54), + accent: Color::from_rgb(121, 112, 169), + light: false, + }, + ThemePreview { + background: Color::from_rgb(244, 250, 255), + accent: Color::from_rgb(57, 87, 255), + light: true, + }, + ThemePreview { + background: Color::from_rgb(255, 255, 255), + accent: Color::from_rgb(192, 195, 210), + light: true, + }, + ] +} + +#[derive(PartialEq)] +pub struct SettingsAppearance; + +impl Component for SettingsAppearance { + fn render(&self) -> impl IntoElement { + let selected_theme = use_state(|| 0usize); + + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + + let dynamic_bg = use_state({ + let v = settings.dynamic_background_enabled; + move || v + }); + + let animations_on = use_state({ + let v = settings.animations_enabled; + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let parallax = *dynamic_bg.read(); + let animations = *animations_on.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.dynamic_background_enabled = parallax; + next.animations_enabled = animations; + dispatch.set_settings(next); + }); + + settings_page() + .child(theme_section(selected_theme)) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new(8., 0., 0., 0.)) + .child(accent_color_row()) + .child(custom_theme_row()) + .child(dynamic_background_row(dynamic_bg)) + .child(animations_row(animations_on)), + ) + .child( + rect().padding(Gaps::new(8., 0., 0., 0.)).child( + label() + .text("Looking to change your language?") + .font_size(12.) + .color(colors::fg_primary()), + ), + ) + .into_element() + } +} + +fn theme_section(selected: State) -> impl IntoElement { + let list = themes(); + let current = list[(*selected.read()).min(list.len() - 1)]; + + rect() + .horizontal() + .spacing(16.) + .opacity(0.4) + .child(big_preview(current)) + .child( + rect() + .vertical() + .spacing(16.) + .child( + rect() + .horizontal() + .spacing(16.) + .children((0..3).map(|i| theme_card(list[i], i, selected))), + ) + .child( + rect() + .horizontal() + .spacing(16.) + .children((3..5).map(|i| theme_card(list[i], i, selected))), + ), + ) + .into_element() +} + +fn big_preview(theme: ThemePreview) -> impl IntoElement { + rect() + .width(Size::px(296.)) + .height(Size::px(183.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page()) + .border(border_all_color(3., Color::from_argb(26, 255, 255, 255))) + .center() + .child( + rect() + .vertical() + .width(Size::px(168.)) + .height(Size::px(123.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(theme.background) + .border(border_all_color(1., colors::component_border())) + .padding(20.) + .spacing(20.) + .child(mock_line(108., theme.accent)) + .child(mock_line(84., line_tint(theme))) + .child(split_bar(theme.accent, 96.)), + ) + .into_element() +} + +fn theme_card(theme: ThemePreview, index: usize, selected: State) -> Element { + let is_selected = *selected.read() == index; + let border_color = if is_selected { + colors::brand() + } else { + colors::component_border() + }; + + rect() + .vertical() + .width(Size::px(126.)) + .height(Size::px(78.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(theme.background) + .border(border_all_color(1., border_color)) + .padding(8.) + .spacing(20.) + .child(mock_line(108., line_tint(theme))) + .child(split_bar(theme.accent, 96.)) + .into_element() +} + +fn mock_line(width: f32, color: Color) -> impl IntoElement { + rect() + .width(Size::px(width)) + .height(Size::px(10.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(color) + .into_element() +} + +fn split_bar(accent: Color, width: f32) -> impl IntoElement { + rect() + .horizontal() + .width(Size::px(width)) + .height(Size::px(16.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(accent.with_a(128)) + .child( + rect() + .width(Size::px(width / 3.)) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(4.)) + .background(accent), + ) + .into_element() +} + +fn line_tint(theme: ThemePreview) -> Color { + if theme.light { + Color::from_argb(38, 0, 0, 0) + } else { + Color::from_argb(46, 255, 255, 255) + } +} + +fn accent_color_row() -> impl IntoElement { + settings_row_disabled( + IconType::PaintPour, + "Accent color", + "The main color used across the launcher. This doesn't edit your theme.", + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 16.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::brand()) + .child(Icon::new(IconType::Colors).size(18.)) + .child( + label() + .text("#2B4BFF") + .font_size(12.) + .color(colors::fg_primary()), + ) + .into_element(), + ) +} + +fn custom_theme_row() -> impl IntoElement { + settings_row_disabled( + IconType::Colors, + "Custom theme", + "Create, edit, and import launcher themes", + Icon::new(IconType::ChevronRight).size(18.).into_element(), + ) +} + +fn dynamic_background_row(enabled: State) -> impl IntoElement { + settings_row( + IconType::Eye, + "Parallax background", + "Make the home screen background drift with your cursor. Turning it off keeps the background but disables the motion.", + toggle(enabled), + ) +} + +fn animations_row(animations_on: State) -> impl IntoElement { + settings_row( + IconType::Play, + "Animations", + "Disable all launcher animations and transitions.", + toggle(animations_on), + ) +} diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs new file mode 100644 index 00000000..110d71d5 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -0,0 +1,192 @@ +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, +}; +use freya::prelude::*; + +use super::settings_page; +use crate::components::{Icon, IconType}; +use crate::hooks::{ + changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, + use_changelog, use_dispatch, use_settings_snapshot, +}; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct SettingsChangelog; + +impl Component for SettingsChangelog { + fn render(&self) -> impl IntoElement { + let query = use_changelog(); + let dispatch = use_dispatch(); + let settings = use_settings_snapshot().settings; + let mut marked = use_state(|| None::); + let installed_version = env!("CARGO_PKG_VERSION").to_string(); + + // Opening this page counts as reading it `marked` avoids re-sending while the settings snapshot catches up + if let Some(latest) = latest_changelog_version(&query) { + let already_marked = marked.peek().as_deref() == Some(latest.as_str()); + let already_seen = settings.seen_changelog_version.as_deref() == Some(latest.as_str()); + + if !already_marked && !already_seen { + marked.set(Some(latest.clone())); + dispatch.set_seen_changelog_version(latest); + } + } + + if changelog_is_loading(&query) { + return settings_page() + .child( + label() + .text("Loading changelog...") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element(); + } + + if let Some(error) = changelog_error(&query) { + return settings_page() + .child( + label() + .text(format!("Couldn't load changelog: {error}")) + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element(); + } + + let groups = changelog_groups(&query).unwrap_or_default(); + + settings_page() + .children(groups.into_iter().enumerate().map(|(i, group)| { + let current = group.version == installed_version; + ReleaseCard { + version: group.version, + current, + changes: group.changes, + initially_open: i == 0, + } + .into_element() + })) + .into_element() + } +} + +/// `ChevronDown` swung a quarter turn anticlockwise points right +const CHEVRON_CLOSED_DEG: f32 = -90.; + +#[derive(PartialEq)] +struct ReleaseCard { + version: String, + current: bool, + changes: Vec, + initially_open: bool, +} + +impl Component for ReleaseCard { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| self.initially_open); + let is_open = *open.read(); + + // One chevron that swings so the arrow tracks the section instead of cutting to a different glyph + let swing = use_animation_with_dependencies(&is_open, |conf, open| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + let (from, to) = if *open { + (CHEVRON_CLOSED_DEG, 0.) + } else { + (0., CHEVRON_CLOSED_DEG) + }; + AnimNum::new(from, to) + .time(180) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let chevron_deg = swing.get().value(); + + let title = if self.current { + format!("{} (Currently Installed)", self.version) + } else { + self.version.clone() + }; + + let changes = self.changes.clone(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let next = !*open.peek(); + *open.write() = next; + }) + .child( + label() + .text(title) + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + Icon::new(IconType::ChevronDown) + .size(18.) + .rotate(chevron_deg), + ), + ) + .maybe_child(is_open.then(|| { + rect() + .vertical() + .width(Size::fill()) + .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(), + ] + } else { + changes + .into_iter() + .map(|change| { + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text("•") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(change) + .font_size(12.) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .into_element() + }) + .collect() + }) + .into_element() + })) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/developer.rs b/packages/oneclient_app/src/view/app/settings/developer.rs new file mode 100644 index 00000000..3c28b5d1 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/developer.rs @@ -0,0 +1,69 @@ +use freya::prelude::*; +use freya::router::RouterContext; + +use super::settings_page; +use crate::Route; +use crate::components::{IconType, link_button, toggle}; +use crate::hooks::{use_browser_compat, use_dispatch, use_settings_snapshot}; +use crate::view::app::settings::{section_header, settings_row}; +use crate::view::console::open_log_console; + +#[derive(PartialEq)] +pub struct SettingsDeveloper; + +impl Component for SettingsDeveloper { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + + let log_debug = use_state({ + let v = settings.log_debug; + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let enabled = *log_debug.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.log_debug = enabled; + dispatch.set_settings(next); + }); + + let browser_compat = use_browser_compat(); + + settings_page() + .child(section_header("BROWSER")) + .child(settings_row( + IconType::SearchMd, + "Compatible content only", + "Filter the content browser to the active cluster's version and loader.", + toggle(browser_compat), + )) + .child(section_header("DEV TOOLS")) + .child(settings_row( + IconType::Sliders04, + "Log Debug Info", + "WARNING! This requires a restart to apply. Logs out debug info.", + toggle(log_debug), + )) + .child(settings_row( + IconType::Terminal, + "Log Console", + "Stream the launcher's logs in a separate window. Nothing is captured while it is closed.", + link_button().on_press(move |_| open_log_console()), + )) + .child(settings_row( + IconType::CodeSnippet02, + "Debug Page", + "View the debug page.", + link_button().on_press(move |_| { + let _ = RouterContext::get().push(Route::Debug {}); + }), + )) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/feedback.rs b/packages/oneclient_app/src/view/app/settings/feedback.rs new file mode 100644 index 00000000..d60b586d --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/feedback.rs @@ -0,0 +1,55 @@ +use freya::prelude::*; + +use crate::components::{Button, TextInput}; +use crate::layout::{SettingsShell, SettingsTab}; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct SettingsFeedback; + +impl Component for SettingsFeedback { + fn render(&self) -> impl IntoElement { + SettingsShell::new(SettingsTab::Feedback, "Feedback") + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new(0., 0., 8., 0.)) + .child( + label() + .text("Help us improve OneClient") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("Tell us what's working, what isn't, and what you'd like to see next.") + .font_size(12.) + .color(colors::fg_primary()), + ), + ) + .child(feedback_box()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .padding(Gaps::new(12., 0., 0., 0.)) + .child( + Button::new().primary().text("Send Feedback"), + ), + ) + .into_element() + } +} + +fn feedback_box() -> impl IntoElement { + let feedback = use_state(String::new); + + TextInput::new(feedback) + .width(Size::fill()) + .placeholder("Write your feedback here...") + .height(Size::px(160.)) +} diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs new file mode 100644 index 00000000..bf191f72 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -0,0 +1,293 @@ +use freya::prelude::*; +use oneclient_java::{JavaRuntime, JavaVendor}; + +use super::settings_page; +use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea}; +use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes}; +use crate::invalidate_java_queries; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::settings::section_header; + +#[derive(PartialEq)] +pub struct SettingsJava; + +impl Component for SettingsJava { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let runtimes_query = use_java_runtimes(); + let runtimes = java_runtimes(&runtimes_query); + let mut show_manager = use_state(|| false); + + fn invalidate_runtimes(dispatch: Actions) { + spawn(async move { + invalidate_java_queries().await; + dispatch + .notify("Java runtimes refreshed") + .body("The installed runtime list is up to date") + .info() + .send(); + }); + } + + let refresh_dispatch = dispatch.clone(); + + let mut shell = settings_page() + .child(section_header("ADD RUNTIME")) + .child(AddRow { show_manager }.into_element()) + .child( + rect() + .width(Size::Fill) + .direction(Direction::Horizontal) + .main_align(Alignment::SpaceBetween) + .cross_align(Alignment::Center) + .child(section_header("INSTALLED RUNTIMES")) + .child( + Button::new() + .secondary() + .small() + .enabled(false) // disabled for now + .on_press(move |_| { + invalidate_runtimes(refresh_dispatch.clone()); + }) + .child(label().text("Refresh")), + ), + ) + .child(runtimes_table(runtimes)); + + if *show_manager.read() { + shell = shell.child( + JavaInstallManager::new() + .on_install(move |(vendor, major): (JavaVendor, u32)| { + dispatch.install_java_runtime(vendor, major); + show_manager.set(false); + }) + .on_close(move |()| show_manager.set(false)) + .into_element(), + ); + } + + shell.into_element() + } +} + +#[derive(PartialEq)] +struct AddRow { + show_manager: State, +} + +impl Component for AddRow { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let mut show_manager = self.show_manager; + + let pick = move |_| { + let dispatch = dispatch.clone(); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Select a Java installation folder") + .pick_folder() + .await + { + dispatch.add_custom_java_runtime(handle.path().to_path_buf()); + } + }); + }; + + rect() + .horizontal() + .width(Size::fill()) + .spacing(10.) + .child( + Button::new() + .primary() + .on_press(move |_| show_manager.set(true)) + .child(Icon::new(IconType::Download01).size(14.)) + .text("Install Manager"), + ) + .child( + Button::new() + .secondary() + .on_press(pick) + .child(Icon::new(IconType::Folder).size(14.)) + .text("Add from folder"), + ) + } +} + +fn runtimes_table(runtimes: Vec) -> impl IntoElement { + if runtimes.is_empty() { + return rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(16., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text("No Java runtimes installed yet.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(); + } + + let mut table = rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .overflow(Overflow::Clip) + .child(table_header()); + + let count = runtimes.len(); + for (idx, runtime) in runtimes.into_iter().enumerate() { + table = table.child( + RuntimeRow { + runtime, + last: idx + 1 == count, + } + .into_element(), + ); + } + + table.into_element() +} + +fn table_header() -> impl IntoElement { + fn head(text: &'static str, width: Size) -> impl IntoElement { + rect() + .width(width) + .child( + label() + .text(text) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element() + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 14.)) + .background(colors::component_bg()) + .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()), + ), + ) + .child(rect().width(Size::px(34.))) + .into_element() +} + +#[derive(PartialEq)] +struct RuntimeRow { + runtime: JavaRuntime, + last: bool, +} + +impl Component for RuntimeRow { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let runtime = &self.runtime; + let path = runtime.absolute_path.clone(); + + // so the scrollarea becomes bigger when horizontal scroll bar is visible (then it won't obscure the file path) + let mut viewport_w = use_state(|| 0f32); + let content_w = path_content_width(&runtime.absolute_path); + let measured_w = *viewport_w.read(); + let has_scrollbar = measured_w <= 0. || measured_w < content_w; + let path_height = if has_scrollbar { 28. } else { 18. }; + + fn cell(text: String, width: Size) -> impl IntoElement { + rect() + .width(width) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_primary()), + ) + .into_element() + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 14.)) + .maybe(!self.last, |el| { + el.border( + Border::new() + .width(BorderWidth { + bottom: 1., + ..Default::default() + }) + .fill(colors::component_border()), + ) + }) + .child(cell( + format!("{} {}", runtime.vendor, runtime.major), + Size::px(130.), + )) + .child(cell(runtime.version.clone(), Size::px(90.))) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(path_height)) + .overflow(Overflow::Clip) + .on_sized(move |e: Event| { + let w = e.area.width(); + if (*viewport_w.read() - w).abs() > 0.5 { + viewport_w.set(w); + } + }) + .child( + ScrollArea::new() + .horizontal(content_w) + .width(Size::fill()) + .height(Size::px(path_height)) + .show_scrollbar(false) + .child( + label() + .text(runtime.absolute_path.clone()) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + ), + ), + ) + .child(remove_button(dispatch, path)) + } +} + +fn path_content_width(path: &str) -> f32 { + (path.chars().count() as f32 * 7.0).max(1.0) +} + +fn remove_button(dispatch: Actions, path: String) -> impl IntoElement { + Button::new() + .ghost() + .small() + .on_press(move |_| dispatch.remove_java_runtime(path.clone())) + .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 new file mode 100644 index 00000000..809e7b8d --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/language.rs @@ -0,0 +1,70 @@ +use freya::prelude::*; + +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)")]; + +#[derive(PartialEq)] +pub struct SettingsLanguage; + +impl Component for SettingsLanguage { + fn render(&self) -> impl IntoElement { + let selected = use_state(|| 0usize); + + settings_page() + .child(section_header("AVAILABLE LANGUAGES")) + .children(LANGUAGES.iter().enumerate().map(|(i, (native, english))| { + language_row(i, native, english, selected).into_element() + })) + .into_element() + } +} + +fn language_row( + index: usize, + native: &'static str, + english: &'static str, + mut selected: State, +) -> impl IntoElement { + let is_selected = *selected.read() == index; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| *selected.write() = index) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(native) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(english) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .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 new file mode 100644 index 00000000..c5420fb7 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -0,0 +1,71 @@ +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 crate::platform; +use crate::view::app::settings::{section_header, settings_row}; + +#[derive(PartialEq)] +pub struct SettingsLauncher; + +impl Component for SettingsLauncher { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let data_dir = use_launcher().data_dir; + let dispatch = use_dispatch(); + + let discord_rpc = use_state({ + let v = settings.discord_enabled; + 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); + }); + } + + let folder = data_dir.clone(); + let open_folder = link_button().on_press(move |_| platform::open_url(&folder)); + + settings_page() + .child(section_header("GENERAL")) + .child(settings_row( + IconType::Link03, + "Discord RPC", + "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, + "Launcher Folder", + data_dir, + open_folder, + )) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs new file mode 100644 index 00000000..43758807 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -0,0 +1,237 @@ +use freya::prelude::*; +use oneclient_common::Patch; +use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; + +use super::settings_page; +use crate::components::{ + Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number, +}; +use crate::hooks::{use_dispatch, use_settings_snapshot}; +use crate::theme::colors; +use crate::view::app::settings::{section_header, settings_row}; + +#[derive(PartialEq)] +pub struct SettingsMinecraft; + +impl Component for SettingsMinecraft { + fn render(&self) -> impl IntoElement { + let profile = use_settings_snapshot().settings.global_game_settings; + let dispatch = use_dispatch(); + + let fullscreen = use_state({ + let v = profile.force_fullscreen.unwrap_or(false); + move || v + }); + let width = use_state({ + 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(); + move || v + }); + let memory = use_state({ + let v = profile.mem_max.map(|m| m.to_string()).unwrap_or_default(); + move || v + }); + let jvm_args = use_state({ + let v = profile.launch_args.clone().unwrap_or_default(); + move || v + }); + let pre_launch_command = use_state({ + let v = profile.hook_pre.clone().unwrap_or_default(); + move || v + }); + let wrapper_command = use_state({ + let v = profile.hook_wrapper.clone().unwrap_or_default(); + move || v + }); + let post_exit_command = use_state({ + let v = profile.hook_post.clone().unwrap_or_default(); + move || v + }); + + let mut first = use_state(|| true); + let batched = dispatch.clone(); + use_side_effect(move || { + let update = build_update( + *fullscreen.read(), + &width.read(), + &height.read(), + &memory.read(), + &jvm_args.read(), + &pre_launch_command.read(), + &wrapper_command.read(), + &post_exit_command.read(), + ); + if *first.peek() { + first.set(false); + return; + } + batched.update_global_profile(update); + }); + + settings_page() + .child(section_header("GAME")) + .child(settings_row( + IconType::Maximize01, + "Force Fullscreen", + "Force Minecraft to start in fullscreen mode.", + toggle(fullscreen), + )) + .child(settings_row( + IconType::LayoutTop, + "Resolution", + "The game window resolution in pixels.", + resolution_field(width, height), + )) + .child(settings_row( + IconType::Database01, + "Memory", + "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", + memory_field(memory), + )) + .child(settings_row( + IconType::Terminal, + "JVM Arguments", + "Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.", + TextInput::new(jvm_args) + .placeholder("-XX:+UseG1GC") + .width(Size::px(220.)), + )) + .child(section_header("CONTENT")) + .child(settings_row( + IconType::RefreshCw01, + "Browser Package Updates", + "What to do when content you installed from the browser has a newer version. Packs from bundles are not affected.", + update_mode_field( + profile.browser_update_mode.unwrap_or_default(), + dispatch, + ), + )) + .child(section_header("PROCESS")) + .child(settings_row( + IconType::FilePlus02, + "Pre-Launch Command", + "Command to run before launching the game.", + TextInput::new(pre_launch_command) + .placeholder("echo 'Game started'") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::ParagraphWrap, + "Wrapper Command", + "Command to run when launching the game.", + TextInput::new(wrapper_command) + .placeholder("gamescope") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::FileX02, + "Post-Exit Command", + "Command to run after exiting the game.", + TextInput::new(post_exit_command) + .placeholder("echo 'Game exited'") + .width(Size::px(220.)), + )) + .into_element() + } +} + +#[allow(clippy::too_many_arguments)] +fn build_update( + fullscreen: bool, + width: &str, + height: &str, + memory: &str, + jvm_args: &str, + pre: &str, + wrapper: &str, + post: &str, +) -> ProfileUpdate { + let resolution = match (width.trim(), height.trim()) { + ("", "") => Patch::Clear, + (w, h) => match (w.parse::(), h.parse::()) { + (Ok(w), Ok(h)) => Patch::Set(Resolution::new(w, h)), + _ => Patch::Unchanged, + }, + }; + + let mem_max = match memory.trim() { + "" => Patch::Clear, + m => m.parse::().map(Patch::Set).unwrap_or(Patch::Unchanged), + }; + + ProfileUpdate { + force_fullscreen: Patch::Set(fullscreen), + resolution, + mem_max, + launch_args: command_patch(jvm_args), + hook_pre: command_patch(pre), + hook_wrapper: command_patch(wrapper), + hook_post: command_patch(post), + ..Default::default() + } +} + +/// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states +fn update_mode_field(selected: PackageUpdateMode, dispatch: crate::Actions) -> impl IntoElement { + let options: Vec = PackageUpdateMode::ALL + .iter() + .map(|mode| mode.label().to_string()) + .collect(); + + Dropdown::new(selected.label(), options) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(mode) = PackageUpdateMode::ALL.get(idx).copied() { + dispatch.update_global_profile(ProfileUpdate { + browser_update_mode: Patch::Set(mode), + ..Default::default() + }); + } + }) +} + +fn command_patch(value: &str) -> Patch { + match value.trim() { + "" => Patch::Clear, + v => Patch::Set(v.to_string()), + } +} + +fn resolution_field(width: State, height: State) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + TextInput::new(width) + .placeholder("854") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)) + .text_align(TextAlign::Center), + ) + .child( + Icon::new(IconType::X) + .size(14.) + .color(colors::fg_secondary()), + ) + .child( + TextInput::new(height) + .placeholder("480") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)) + .text_align(TextAlign::Center), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs new file mode 100644 index 00000000..5f41b7b1 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/mod.rs @@ -0,0 +1,106 @@ +mod accounts; +mod appearance; + +mod apis; +mod changelog; +mod developer; +mod java; +mod language; +mod launcher; +mod minecraft; +mod storage; + +use freya::prelude::*; + +pub use accounts::SettingsAccounts; +pub use apis::SettingsApis; +pub use appearance::SettingsAppearance; +pub use changelog::SettingsChangelog; +pub use developer::SettingsDeveloper; +pub use java::SettingsJava; +pub use language::SettingsLanguage; +pub use launcher::SettingsLauncher; +pub use minecraft::SettingsMinecraft; +pub use storage::SettingsStorage; + +use crate::{ + components::{Icon, IconType}, + theme::colors, +}; + +pub fn settings_page() -> Rect { + rect().vertical().width(Size::fill()).spacing(4.) +} + +pub fn section_header(text: &'static str) -> impl IntoElement { + rect() + .padding(Gaps::new(16., 0., 8., 2.)) + .child( + label() + .text(text) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element() +} + +pub fn settings_row( + icon: IconType, + title: &'static str, + description: impl Into, + trailing: impl IntoElement, +) -> impl IntoElement { + settings_row_inner(icon, title, description, trailing, false) +} + +pub fn settings_row_disabled( + icon: IconType, + title: &'static str, + description: impl Into, + trailing: impl IntoElement, +) -> impl IntoElement { + settings_row_inner(icon, title, description, trailing, true) +} + +fn settings_row_inner( + icon: IconType, + title: &'static str, + description: impl Into, + trailing: impl IntoElement, + disabled: bool, +) -> impl IntoElement { + let description = description.into(); + rect() + .maybe(disabled, |el| el.opacity(0.4)) + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child(Icon::new(icon)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(description) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child(trailing) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs new file mode 100644 index 00000000..00d97769 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -0,0 +1,325 @@ +use freya::prelude::*; +use oneclient_core::storage::{ReclaimableEntry, StorageEntry, StorageReport, format_bytes}; + +use super::{section_header, settings_page}; +use crate::components::{Button, Icon, IconType, open_folder_button}; +use crate::hooks::{ + StorageAction, mutation_is_running, try_storage_report, use_storage_action, use_storage_report, +}; +use crate::theme::colors; + +/// Rows narrower than this would render as a sliver so they get a floor +const MIN_BAR_FRACTION: f32 = 0.015; + +#[derive(PartialEq)] +pub struct SettingsStorage; + +impl Component for SettingsStorage { + fn render(&self) -> impl IntoElement { + // Every hook before any early return the report is absent on the first render and a later-only hook would change the hook order + let report_query = use_storage_report(); + + let Some(report) = try_storage_report(&report_query) else { + return settings_page().child(hero_placeholder()).into_element(); + }; + + let refresh = Button::new() + .secondary() + .small() + .on_press(move |_| { + report_query.invalidate(); + }) + .child(label().text("Refresh")); + + let mut page = settings_page() + .child(hero(&report, refresh.into_element())) + .child(section_header("FREE UP SPACE")) + .child( + ReclaimRow { + icon: IconType::FileX02, + title: "Unreferenced cache files", + description: unused_cache_description(&report.unreferenced_cache), + action: StorageAction::CleanUnreferencedCache, + empty: report.unreferenced_cache.is_empty(), + } + .into_element(), + ) + .child( + ReclaimRow { + icon: IconType::Trash01, + title: "Leftover cluster content", + description: legacy_content_description(&report.legacy_cluster_content), + action: StorageAction::CleanLegacyClusterContent, + empty: report.legacy_cluster_content.is_empty(), + } + .into_element(), + ); + + page = page.child(section_header("WHAT'S USING SPACE")); + page = match largest(&report.categories) { + Some(largest) => { + let mut list = page; + for entry in report.categories.iter().filter(|e| e.bytes > 0) { + list = list.child(usage_row(entry, largest, colors::brand())); + } + list + } + None => page.child(empty_note("Nothing stored yet.")), + }; + + page = page.child(section_header("CLUSTERS")); + page = match largest(&report.clusters) { + Some(largest) => { + let mut list = page; + for entry in report.clusters.iter().filter(|e| e.bytes > 0) { + list = list.child(usage_row(entry, largest, colors::fg_secondary())); + } + list + } + None => page.child(empty_note("No cluster is using any space.")), + }; + + page.child(footnote()).into_element() + } +} + +fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement { + let reclaimable = report.unreferenced_cache.bytes + report.legacy_cluster_content.bytes; + + let subtitle = if reclaimable > 0 { + format!("{} can be freed", format_bytes(reclaimable)) + } else { + "Nothing to free up".to_string() + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(format_bytes(report.total_bytes)) + .font_size(28.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .color(if reclaimable > 0 { + colors::brand() + } else { + colors::fg_secondary() + }), + ), + ) + .child(refresh) + .into_element() +} + +fn hero_placeholder() -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text("Measuring disk usage…") + .font_size(16.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +/// A component rather than a function because it owns a hook each instance gets its own scope so a button tracks only its own progress +#[derive(PartialEq)] +struct ReclaimRow { + icon: IconType, + title: &'static str, + description: String, + action: StorageAction, + empty: bool, +} + +impl Component for ReclaimRow { + fn render(&self) -> impl IntoElement { + let action = use_storage_action(); + let running = mutation_is_running(&action); + + let kind = self.action; + let button = Button::new() + .secondary() + .small() + .disabled(self.empty || running) + .on_press(move |_| { + action.mutate(kind); + }) + .child(label().text(if running { "Cleaning…" } else { "Clean up" })); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .maybe(self.empty, |el| el.opacity(0.55)) + .child(Icon::new(self.icon)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(self.title) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(self.description.clone()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child(button) + .into_element() + } +} + +fn usage_row(entry: &StorageEntry, largest: u64, bar: Color) -> impl IntoElement { + let fraction = if largest == 0 { + 0.0 + } else { + (entry.bytes as f32 / largest as f32).max(MIN_BAR_FRACTION) + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(6.) + .child( + label() + .text(entry.label.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(proportion_bar(fraction, bar)), + ) + .child( + label() + .text(format_bytes(entry.bytes)) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(open_folder_button(entry.path.clone())) + .into_element() +} + +fn proportion_bar(fraction: f32, fill: Color) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::component_bg()) + .child( + rect() + .width(Size::percent(fraction.clamp(0.0, 1.0) * 100.)) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(2.)) + .background(fill), + ) + .into_element() +} + +fn largest(entries: &[StorageEntry]) -> Option { + entries.iter().map(|e| e.bytes).max().filter(|max| *max > 0) +} + +fn unused_cache_description(entry: &ReclaimableEntry) -> String { + if entry.is_empty() { + return "Nothing here — every cached file belongs to an installed package.".to_string(); + } + + format!( + "{} across {} file{} that no installed package points at.", + format_bytes(entry.bytes), + entry.files, + plural(entry.files) + ) +} + +fn legacy_content_description(entry: &ReclaimableEntry) -> String { + if entry.is_empty() { + return "Nothing here — no cluster folder is holding old content.".to_string(); + } + + format!( + "{} across {} file{} left in cluster folders by an older version. Already ignored when \ + you play; this just reclaims the space.", + format_bytes(entry.bytes), + entry.files, + plural(entry.files) + ) +} + +fn empty_note(text: &'static str) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(14., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn footnote() -> impl IntoElement { + rect() + .padding(Gaps::new(16., 4., 8., 4.)) + .child( + label() + .text( + "Packages live in a shared cache and are copied into the game folder only \ + while you play. Removing a package frees its cache entry automatically.", + ) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn plural(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs new file mode 100644 index 00000000..5ea92368 --- /dev/null +++ b/packages/oneclient_app/src/view/app/skins.rs @@ -0,0 +1,169 @@ +use freya::prelude::*; + +use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel}; +use crate::hooks::{try_default_account, use_current_account}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const ANIMATIONS: &[&str] = &["Idle", "Walking", "Running", "Crouch", "Flying"]; + +#[derive(PartialEq)] +pub struct AccountSkins; + +impl Component for AccountSkins { + fn render(&self) -> impl IntoElement { + let account_uuid = try_default_account(&use_current_account()).map(|a| a.id.to_string()); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(40.) + .spacing(24.) + .child(preview_panel(account_uuid)) + .child(side_panel()) + } +} + +fn preview_panel(account_uuid: Option) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(320.)) + .height(Size::fill()) + .spacing(16.) + .child( + rect() + .center() + .width(Size::fill()) + .height(Size::flex(1.0)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(match account_uuid { + Some(uuid) => PlayerModel::new(uuid) + .width(Size::fill()) + .height(Size::fill()) + .into_element(), + None => Icon::new(IconType::Users01) + .size(64.) + .color(colors::fg_secondary()) + .into_element(), + }), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .children( + ANIMATIONS + .iter() + .enumerate() + .map(|(i, name)| anim_chip(name, i == 0).into_element()), + ), + ) + .into_element() +} + +fn anim_chip(name: &'static str, active: bool) -> impl IntoElement { + rect() + .center() + .padding(Gaps::new_symmetric(6., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if active { + colors::brand() + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .child( + label() + .text(name) + .font_size(12.) + .color(colors::fg_primary()), + ) + .into_element() +} + +fn side_panel() -> impl IntoElement { + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .spacing(20.) + .child( + label() + .text("Skins") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .horizontal() + .spacing(12.) + .child(action_button("Import Skin", IconType::Plus, true)) + .child(action_button("Download", IconType::Download01, false)) + .child(action_button("Remove", IconType::Trash01, false)), + ) + .child(section_label("SKIN HISTORY")) + .child(skin_grid(8)) + .child(section_label("CAPES")) + .child(skin_grid(4)) + .into_element() +} + +fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement { + Button::new() + .variant(if primary { + ButtonVariant::Primary + } else { + ButtonVariant::Secondary + }) + .child(Icon::new(icon).size(16.).color(colors::fg_primary())) + .text(text) + .into_element() +} + +fn section_label(text: &'static str) -> impl IntoElement { + label() + .text(text) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()) + .into_element() +} + +fn skin_grid(count: usize) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .children((0..count).map(|i| { + rect() + .center() + .width(Size::px(64.)) + .height(Size::px(80.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::page_elevated()) + .border(border_all_color( + if i == 0 { 2. } else { 1. }, + if i == 0 { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .child( + Icon::new(IconType::Users01) + .size(24.) + .color(colors::fg_secondary()), + ) + .into_element() + })) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/stats.rs b/packages/oneclient_app/src/view/app/stats.rs new file mode 100644 index 00000000..49340a53 --- /dev/null +++ b/packages/oneclient_app/src/view/app/stats.rs @@ -0,0 +1,69 @@ +use freya::prelude::*; + +use crate::components::ScrollArea; +use crate::hooks::{try_global_analytics, use_global_analytics}; +use crate::theme::colors; +use crate::ui::centered_note; +use crate::view::app::{analytics_body, analytics_placeholder}; + +#[derive(PartialEq)] +pub struct Stats; + +impl Component for Stats { + fn render(&self) -> impl IntoElement { + let query = use_global_analytics(); + let analytics = try_global_analytics(&query); + + let body: Element = match &analytics { + None => loading_state(), + Some(a) if a.playtime.session_count == 0 => { + analytics_placeholder("No playtime recorded yet. Launch a game and come back!") + } + Some(a) => analytics_body(a), + }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new(0., 40., 40., 40.)) + .spacing(24.) + .child(header()) + .child(body), + ), + ) + } +} + +fn header() -> Element { + rect() + .vertical() + .spacing(2.) + .child( + label() + .text("Statistics") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("How, when, and how much you play.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn loading_state() -> Element { + centered_note("Crunching your playtime…") +} diff --git a/packages/oneclient_app/src/view/console.rs b/packages/oneclient_app/src/view/console.rs new file mode 100644 index 00000000..85bfcd41 --- /dev/null +++ b/packages/oneclient_app/src/view/console.rs @@ -0,0 +1,298 @@ +//! Its own Freya window sharing no radio station or query storage with the main one all it needs is the process-wide channel in [`oneclient_core::logger::console`] + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use freya::prelude::*; +use freya::winit::window::WindowId; +use oneclient_core::logger; +use oneclient_core::logger::console::{self, ConsoleLine}; +use tokio::sync::broadcast::error::RecvError; +use tracing::Level; + +use crate::components::{Button, Icon, IconType, LogViewer, Segment, SegmentedControl, TextInput}; +use crate::theme::colors; + +const WINDOW_TITLE: &str = "OneClient — Log Console"; + +/// Old lines are dropped from the front this window can be left open for a whole session +const MAX_LINES: usize = 5_000; + +/// `Opening` is cleared by the window itself not the task that asks for it so a cancelled launch cannot strand the state +enum ConsoleWindow { + Closed, + Opening, + Open(WindowId), +} + +static CONSOLE_WINDOW: Mutex = Mutex::new(ConsoleWindow::Closed); + +pub fn open_log_console() { + { + let mut state = CONSOLE_WINDOW.lock().expect("console window state"); + match *state { + ConsoleWindow::Open(id) => { + Platform::get().focus_window(Some(id)); + return; + } + ConsoleWindow::Opening => return, + ConsoleWindow::Closed => *state = ConsoleWindow::Opening, + } + } + + // Taken in the caller's scope the future below runs detached and `Platform::get()` needs a component scope + let platform = Platform::get(); + spawn(async move { + platform.launch_window(window_config()).await; + }); +} + +fn window_config() -> WindowConfig { + WindowConfig::new_app(LogConsoleApp) + .with_title(WINDOW_TITLE) + .with_app_id(crate::constants::WINDOW_APP_ID) + .with_icon(LaunchConfig::window_icon(include_bytes!( + "../../icons/128x128.png" + ))) + .with_size(1000., 640.) + .with_min_size(520., 320.) + .with_background(colors::page()) + // Native decorations a developer tool is not worth a second bespoke titlebar + .with_decorations(true) + .with_window_handle(|window| { + *CONSOLE_WINDOW.lock().expect("console window state") = + ConsoleWindow::Open(window.id()); + }) + .with_on_close(|_, _| { + *CONSOLE_WINDOW.lock().expect("console window state") = ConsoleWindow::Closed; + CloseDecision::Close + }) +} + +struct LogConsoleApp; + +impl App for LogConsoleApp { + fn render(&self) -> impl IntoElement { + LogConsole + } +} + +/// A floor rather than an exact match unlike the cluster logs page +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Severity { + All, + Debug, + Info, + Warn, + Error, +} + +impl Severity { + fn floor(self) -> Option { + match self { + Self::All => None, + Self::Debug => Some(Level::DEBUG), + Self::Info => Some(Level::INFO), + Self::Warn => Some(Level::WARN), + Self::Error => Some(Level::ERROR), + } + } + + fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Debug => "Debug", + Self::Info => "Info", + Self::Warn => "Warnings", + Self::Error => "Errors", + } + } +} + +#[derive(PartialEq)] +struct LogConsole; + +impl Component for LogConsole { + fn render(&self) -> impl IntoElement { + let mut entries = use_state(VecDeque::::new); + let severity = use_state(|| Severity::All); + let search = use_state(String::new); + let directives = use_state(logger::active_directives); + let directives_error = use_state(|| None::); + + use_hook(move || { + spawn(async move { + // The subscription lives only in this task so closing the window drops the receiver and switches capture off + let mut subscription = console::subscribe(); + tracing::info!("live log console attached"); + + loop { + match subscription.recv().await { + Ok(line) => push(&mut entries, line), + Err(RecvError::Lagged(skipped)) => push( + &mut entries, + ConsoleLine { + level: Level::WARN, + text: format!( + "[console] dropped {skipped} lines, the console fell behind" + ) + .into(), + }, + ), + Err(RecvError::Closed) => break, + } + } + }) + }); + + let floor = severity.read().floor(); + let query = search.read().to_lowercase(); + let lines: Arc>> = Arc::new( + entries + .read() + .iter() + .filter(|line| floor.is_none_or(|floor| line.level <= floor)) + .filter(|line| query.is_empty() || line.text.to_lowercase().contains(&query)) + .map(|line| line.text.clone()) + .collect(), + ); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .padding(Gaps::new_all(12.)) + .background(colors::page()) + .child( + LogViewer::new("Launcher logs", lines.clone()) + .streaming(true) + .header( + rect() + .vertical() + .width(Size::fill()) + .child(view_row(search, severity, lines.len(), entries)) + .child(crate::ui::divider()) + .child(filter_row(directives, directives_error)) + .child(crate::ui::divider()), + ), + ) + } +} + +fn push(entries: &mut State>, line: ConsoleLine) { + let mut entries = entries.write(); + while entries.len() >= MAX_LINES { + entries.pop_front(); + } + entries.push_back(line); +} + +/// Purely local nothing here changes what the launcher records +fn view_row( + search: State, + severity: State, + shown: usize, + mut entries: State>, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(10., 14.)) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(search) + .placeholder("Filter the stream...") + .leading( + Icon::new(IconType::SearchMd) + .size(15.) + .color(colors::fg_secondary()) + .into_element(), + ), + ), + ) + .child( + label() + .text(format!("{shown} lines")) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child( + SegmentedControl::new(severity) + .no_tint() + .segments( + [ + Severity::All, + Severity::Error, + Severity::Warn, + Severity::Info, + Severity::Debug, + ] + .into_iter() + .map(|value| Segment::new(value).label(value.label())), + ) + .into_element(), + ) + .child( + Button::new() + .secondary() + .on_press(move |_| entries.write().clear()) + .child(Icon::new(IconType::Trash01).size(15.)) + .text("Clear"), + ) + .into_element() +} + +/// Applying reloads the subscriber's filter so a target the launcher was not recording at all starts arriving +fn filter_row(directives: State, mut error: State>) -> impl IntoElement { + let apply = move |_| match logger::set_filter(&directives.read()) { + Ok(()) => error.set(None), + Err(err) => error.set(Some(err.to_string())), + }; + + let mut reset = directives; + let row = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(10., 14.)) + .child( + label() + .text("Capture") + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(directives).placeholder("RUST_LOG directives...")), + ) + .child( + Button::new() + .secondary() + .on_press(move |_| reset.set(logger::default_directives())) + .text("Defaults"), + ) + .child(Button::new().primary().on_press(apply).text("Apply")); + + rect() + .vertical() + .width(Size::fill()) + .child(row) + .maybe_child(error.read().clone().map(|message| { + label() + .text(message) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .padding(Gaps::new(0., 14., 10., 14.)) + .color(colors::code_error()) + .into_element() + })) + .into_element() +} diff --git a/packages/oneclient_app/src/view/mod.rs b/packages/oneclient_app/src/view/mod.rs new file mode 100644 index 00000000..e063a4c9 --- /dev/null +++ b/packages/oneclient_app/src/view/mod.rs @@ -0,0 +1,9 @@ +mod not_found; +mod startup; + +pub mod app; +pub mod console; +pub mod onboarding; + +pub use not_found::NotFound; +pub use startup::Startup; diff --git a/packages/oneclient_app/src/view/not_found.rs b/packages/oneclient_app/src/view/not_found.rs new file mode 100644 index 00000000..8dfac539 --- /dev/null +++ b/packages/oneclient_app/src/view/not_found.rs @@ -0,0 +1,35 @@ +use freya::prelude::*; + +use crate::theme; + +#[derive(PartialEq, Clone)] +pub struct NotFound { + pub path: Vec, +} + +impl Component for NotFound { + fn render(&self) -> impl IntoElement { + tracing::warn!("Route not found: /{}", self.path.join("/")); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(40.) + .spacing(16.) + .child( + label() + .text("404 - Not Found") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(theme::colors::fg_primary()), + ) + .child( + label() + .text("The page you are looking for does not exist.") + .font_size(16.) + .color(theme::colors::fg_secondary()), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs new file mode 100644 index 00000000..176fced8 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -0,0 +1,159 @@ +use freya::prelude::*; +use oneclient_auth::MinecraftAccount; + +use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login}; +use crate::hooks::{try_default_account, use_current_account}; +use crate::routes::Route; +use crate::theme::colors; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +#[derive(PartialEq)] +pub struct OnboardingAccount; + +impl Component for OnboardingAccount { + fn render(&self) -> impl IntoElement { + let account_query = use_current_account(); + let msa = use_microsoft_login(); + + let account = try_default_account(&account_query); + let has_account = account.is_some(); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(24.) + .child(step_heading( + "Account", + "Before you continue, we require you to own a copy of Minecraft: Java Edition.", + )) + .child(match &account { + Some(account) => account_preview(account).into_element(), + None => { + let start = msa.clone(); + rect() + .vertical() + .spacing(12.) + .child(sign_in_card(msa.pending, msa.error.clone(), move |_| { + start.start() + })) + .child( + Button::new() + .secondary() + .large() + .on_press(move |_| { + spawn(async move { + if let Ok(state) = crate::launcher::state() { + if let Ok(acc) = state + .auth + .add_offline_account("Player".to_string()) + .await + { + let _ = state + .auth + .set_default_account(Some(acc.id)) + .await; + } + } + }); + }) + .text("Play Offline as Player"), + ) + .into_element() + } + }) + .into_element(); + + let page = onboarding_page( + onboarding_illustration(IconType::OnboardingAccount), + content, + onboarding_nav( + Some(Route::OnboardingLanguage {}), + Route::OnboardingBundles {}, + has_account, + ), + ); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(page) + .maybe_child(msa.popup()) + } +} + +fn account_preview(account: &MinecraftAccount) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .spacing(24.) + .child( + rect() + .horizontal() + .spacing(12.) + .cross_align(Alignment::Center) + .child( + Avatar::new(account.id.to_string()) + .width(Size::px(48.)) + .height(Size::px(48.)), + ) + .child( + rect() + .vertical() + .spacing(4.) + .child( + label() + .text(account.username.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(account.id.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ), + ) + .into_element() +} + +fn sign_in_card( + pending: bool, + error: Option, + on_add: impl FnMut(Event) + 'static, +) -> impl IntoElement { + rect() + .vertical() + .spacing(12.) + .cross_align(Alignment::Start) + .child( + Button::new() + .primary() + .large() + .enabled(!pending) + .on_press(on_add) + .child(Icon::new(IconType::Globe01).size(16.)) + .text(if pending { + "Signing in..." + } else { + "Add Account" + }), + ) + .maybe_child(error.map(|message| { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::AlertTriangle) + .size(13.) + .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/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs new file mode 100644 index 00000000..647d13d3 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -0,0 +1,532 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_content::packages::ProviderId; +use oneclient_core::clusters::Cluster; +use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; + +use crate::components::ScrollArea; +use crate::hooks::{ + ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, + use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch, +}; + +type MetaMap = HashMap; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{ + archive_selected, choice_row_sized, is_default_bundle, is_optional_file, onboarding_nav, + onboarding_slide, pkg_key, predownload_toggle_row, set_archive_selected, step_heading, + version_chip, +}; + +mod row; +use row::{CARD_GRID_H, GRID_GAP, OnboardingModCard, empty_hint}; + +const CHIPS_PER_ROW: usize = 4; + +const MOD_GRID_COLS: usize = 3; + +const ANSWER_H: f32 = 48.; + +const FPS_WARNING_BUNDLE: &str = "SkyBlock"; + +const FPS_WARNING_RUNS: &[(&str, bool, bool)] = &[ + ("YOUR ", false, false), + ("FPS", true, false), + (" WILL BE ", false, false), + ("SIGNIFICANTLY WORSE", true, false), + (" WITH SKYBLOCK MODS. ", false, false), + ("DO NOT INSTALL", true, true), + (" UNLESS YOU PLAY HYPIXEL SKYBLOCK.", false, true), +]; + +fn fps_warning_banner() -> impl IntoElement { + let mut text = paragraph(); + for (run, bold, underlined) in FPS_WARNING_RUNS { + let mut span = Span::new(*run) + .font_size(12.) + .font_weight(if *bold { + FontWeight::BOLD + } else { + FontWeight::NORMAL + }) + .color(colors::code_warn()); + if *underlined { + span = span.text_decoration(TextDecoration::Underline); + } + text = text.span(span); + } + + rect() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::code_warn())) + .padding(Gaps::new_all(8.)) + .child(text) +} + +fn file_provider(file: &BundleFile) -> ProviderId { + match &file.kind { + BundleFileKind::Managed { provider, .. } => *provider, + BundleFileKind::External(_) => ProviderId::Local, + } +} + +fn bundle_display_name(archive: &BundleArchive) -> String { + let category = archive.manifest.category.trim(); + if category.is_empty() { + archive.manifest.name.clone() + } else { + category.to_string() + } +} + +#[derive(Clone)] +struct OptInBundle { + display_name: String, + members: Vec<(Cluster, BundleArchive)>, +} + +impl OptInBundle { + fn chosen(&self, selected: &HashSet) -> Vec { + self.members + .iter() + .filter(|(cluster, archive)| archive_selected(cluster.id, archive, selected)) + .map(|(cluster, _)| cluster.id) + .collect() + } + + fn wanted(&self, selected: &HashSet) -> bool { + !self.chosen(selected).is_empty() + } + + fn mod_count(&self) -> usize { + self.members + .first() + .map(|(_, a)| { + a.manifest + .files + .iter() + .filter(|f| f.enabled && !f.hidden) + .count() + }) + .unwrap_or(0) + } +} + +fn opt_in_bundles(clusters: &[ClusterBundles]) -> Vec { + let mut order: Vec = Vec::new(); + let mut map: HashMap = HashMap::new(); + + for cb in clusters { + for archive in &cb.archives { + if is_default_bundle(archive) { + continue; + } + let name = bundle_display_name(archive); + let entry = map.entry(name.clone()).or_insert_with(|| { + order.push(name.clone()); + OptInBundle { + display_name: name.clone(), + members: Vec::new(), + } + }); + entry.members.push((cb.cluster.clone(), archive.clone())); + } + } + + for bundle in map.values_mut() { + bundle + .members + .sort_by(|(a, _), (b, _)| a.mc_version.cmp(&b.mc_version)); + } + + order.into_iter().filter_map(|n| map.remove(&n)).collect() +} + +#[derive(Clone)] +struct OptionalMod { + package_id: String, + provider: ProviderId, + fallback_name: String, + size: u64, + /// `(cluster_id, bundle_name)` pairs this mod is available from + locations: Vec<(i64, String)>, +} + +impl OptionalMod { + fn keys(&self) -> Vec { + self.locations + .iter() + .map(|(cluster_id, bundle)| pkg_key(*cluster_id, bundle, &self.package_id)) + .collect() + } + + fn selected(&self, selected: &HashSet) -> bool { + self.keys().iter().any(|k| selected.contains(k)) + } +} + +fn optional_mods(clusters: &[ClusterBundles]) -> Vec { + let mut order: Vec = Vec::new(); + let mut map: HashMap = HashMap::new(); + + for cb in clusters { + for archive in &cb.archives { + for file in archive + .manifest + .files + .iter() + .filter(|f| is_optional_file(f)) + { + let package_id = file.kind.package_id(); + let entry = map.entry(package_id.clone()).or_insert_with(|| { + order.push(package_id.clone()); + OptionalMod { + package_id: package_id.clone(), + provider: file_provider(file), + fallback_name: file.display_name(), + size: file.size, + locations: Vec::new(), + } + }); + entry + .locations + .push((cb.cluster.id, archive.manifest.name.clone())); + } + } + } + + order.into_iter().filter_map(|n| map.remove(&n)).collect() +} + +fn set_bundle_versions( + mut selected: State>, + mut user_touched: State, + bundle: &OptInBundle, + clusters: &[i64], +) { + let mut set = selected.peek().clone(); + for (cluster, archive) in &bundle.members { + let on = clusters.contains(&cluster.id); + set_archive_selected(cluster.id, archive, on, &mut set); + } + user_touched.set(true); + selected.set(set); +} + +fn toggle_optional_mod( + mut selected: State>, + mut user_touched: State, + entry: &OptionalMod, +) { + let mut set = selected.peek().clone(); + let on = entry.selected(&set); + for key in entry.keys() { + if on { + set.remove(&key); + } else { + set.insert(key); + } + } + user_touched.set(true); + selected.set(set); +} + +#[derive(PartialEq)] +pub struct OnboardingBundles; + +impl Component for OnboardingBundles { + fn render(&self) -> impl IntoElement { + let bundles_query = use_onboarding_bundles(); + let selection = use_onboarding_selection(); + let selected = selection.selected; + let user_touched = selection.user_touched; + let predownload = selection.predownload; + + let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); + let catalog_msg = match query_error(&bundles_query) { + Some(err) => format!("Couldn't load bundles: {err}"), + None if query_is_loading(&bundles_query) => "Loading bundles...".to_string(), + None => "No optional bundles available".to_string(), + }; + + let selected_set = selected.read().clone(); + let opt_ins = opt_in_bundles(&clusters); + let extras = optional_mods(&clusters); + + // Hooks so they run every render regardless of whether there are extras yet + let (mr_ids, cf_ids) = collect_ids(&extras); + let mr_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Modrinth, mr_ids)); + let cf_meta = package_meta_batch(&use_package_meta_batch(ProviderId::CurseForge, cf_ids)); + + let mut sections: Vec = Vec::new(); + + for bundle in &opt_ins { + sections + .push(opt_in_card(bundle, &selected_set, selected, user_touched).into_element()); + } + + if !extras.is_empty() { + sections.push(extras_section( + &extras, + &selected_set, + selected, + user_touched, + &mr_meta, + &cf_meta, + )); + } + + let nothing_to_ask = opt_ins.is_empty() && extras.is_empty(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(40., 80., 12., 80.)) + .spacing(16.) + .child(step_heading( + "Mods", + "Everything you need is already included. Pick anything extra you want.", + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .padding(Gaps::new_all(12.)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(20.) + .children(sections), + ) + .maybe_child(nothing_to_ask.then(|| empty_hint(&catalog_msg))), + ) + .child(predownload_toggle_row(predownload)), + )) + .child(onboarding_nav( + Some(Route::OnboardingAccount {}), + Route::OnboardingPreferences {}, + bundles_loaded, + )) + } +} + +fn opt_in_card( + bundle: &OptInBundle, + selected_set: &HashSet, + selected: State>, + user_touched: State, +) -> impl IntoElement { + let name = bundle.display_name.clone(); + let wanted = bundle.wanted(selected_set); + let chosen = bundle.chosen(selected_set); + let all_ids: Vec = bundle.members.iter().map(|(c, _)| c.id).collect(); + + let yes_bundle = bundle.clone(); + let yes_ids = all_ids.clone(); + let no_bundle = bundle.clone(); + + let mut card = rect().vertical().width(Size::fill()).spacing(10.).child( + label() + .text(format!("Do you want to install {name} mods?")) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); + + card = card.child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "Yes", + &format!("{} mods", yes_bundle.mod_count()), + wanted, + Size::fill(), + move |()| { + if wanted { + return; + } + set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids) + }, + )), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "No", + "", + !wanted, + Size::fill(), + move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]), + )), + ), + ); + + if wanted && name.eq_ignore_ascii_case(FPS_WARNING_BUNDLE) { + card = card.child(fps_warning_banner()); + } + + if wanted { + let mut rows: Vec = Vec::new(); + for chunk in bundle.members.chunks(CHIPS_PER_ROW) { + let mut row = rect().horizontal().spacing(8.); + for (cluster, _) in chunk { + let active = chosen.contains(&cluster.id); + let chip_bundle = bundle.clone(); + let chosen_now = chosen.clone(); + let cluster_id = cluster.id; + row = row.child(version_chip(&cluster.mc_version, active, move |()| { + let mut next = chosen_now.clone(); + if active { + next.retain(|id| *id != cluster_id); + } else { + next.push(cluster_id); + } + set_bundle_versions(selected, user_touched, &chip_bundle, &next); + })); + } + rows.push(row.into_element()); + } + + card = card.child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .padding(Gaps::new(4., 0., 0., 0.)) + .child( + label() + .text(format!("What versions do you play {name} on?")) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(rect().vertical().spacing(8.).children(rows)), + ); + } + + card +} + +fn extras_section( + extras: &[OptionalMod], + selected_set: &HashSet, + selected: State>, + user_touched: State, + mr_meta: &MetaMap, + cf_meta: &MetaMap, +) -> Element { + let cards: Vec = extras + .iter() + .map(|entry| { + let meta = match entry.provider { + ProviderId::Modrinth => mr_meta.get(&entry.package_id), + ProviderId::CurseForge => cf_meta.get(&entry.package_id), + ProviderId::Local => None, + }; + let name = meta + .map(|m| m.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| entry.fallback_name.clone()); + let author = meta.map(|m| m.author.clone()).unwrap_or_default(); + let description = meta.map(|m| m.summary.clone()).unwrap_or_default(); + let icon_url = meta.and_then(|m| m.icon_url.clone()); + let toggle_entry = entry.clone(); + + OnboardingModCard { + provider: entry.provider, + name, + author, + description, + icon_url, + size: entry.size, + enabled: entry.selected(selected_set), + on_toggle: (move |()| toggle_optional_mod(selected, user_touched, &toggle_entry)) + .into(), + } + .into_element() + }) + .collect(); + + let mut rows: Vec = Vec::new(); + for (index, chunk) in cards.chunks(MOD_GRID_COLS).enumerate() { + let mut row = rect() + .key(index) + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_GRID_H)) + .spacing(GRID_GAP) + .content(Content::Flex); + for card in chunk { + row = row.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(card.clone()), + ); + } + for _ in chunk.len()..MOD_GRID_COLS { + row = row.child(rect().width(Size::flex(1.0)).height(Size::fill())); + } + rows.push(row.into_element()); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Try out these additional mods:") + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(GRID_GAP) + .children(rows), + ) + .into_element() +} + +fn collect_ids(extras: &[OptionalMod]) -> (Vec, Vec) { + let mut mr = Vec::new(); + let mut cf = Vec::new(); + for entry in extras { + match entry.provider { + ProviderId::Modrinth => mr.push(entry.package_id.clone()), + ProviderId::CurseForge => cf.push(entry.package_id.clone()), + ProviderId::Local => {} + } + } + (mr, cf) +} diff --git a/packages/oneclient_app/src/view/onboarding/bundles/row.rs b/packages/oneclient_app/src/view/onboarding/bundles/row.rs new file mode 100644 index 00000000..479bf341 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/bundles/row.rs @@ -0,0 +1,157 @@ +use freya::prelude::*; +use oneclient_content::packages::ProviderId; + +use crate::components::{Icon, provider_badge}; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const CARD_NAME: Color = Color::from_rgb(213, 219, 255); + +pub(super) const CARD_GRID_H: f32 = 148.; +pub(super) const GRID_GAP: f32 = 10.; + +#[derive(PartialEq)] +pub(super) struct OnboardingModCard { + pub(super) provider: ProviderId, + pub(super) name: String, + pub(super) author: String, + pub(super) description: String, + pub(super) icon_url: Option, + pub(super) size: u64, + pub(super) enabled: bool, + pub(super) on_toggle: EventHandler<()>, +} + +impl Component for OnboardingModCard { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 128); + let loaded = loaded_image(self.icon_url.as_deref(), &icon_query); + + let icon = match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(44.)) + .height(Size::px(44.)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(8.)) + .into_element(), + + None => icon_box(self.provider).into_element(), + }; + + let (bg, border, alpha) = if self.enabled { + (colors::brand().with_a(38), colors::brand(), 255) + } else { + (CARD_BG, colors::component_border(), 140) + }; + + let on_toggle = self.on_toggle.clone(); + let size = self.size; + + let header = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME.with_a(alpha)), + ) + .maybe(!self.author.is_empty(), |el| { + el.child( + label() + .text(format!("by {}", self.author)) + .font_size(10.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(alpha)), + ) + }) + .child(provider_badge(self.provider)), + ); + + let description = (!self.description.is_empty()).then(|| { + label() + .text(self.description.clone()) + .font_size(11.) + .max_lines(3) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(alpha)) + .into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(bg) + .border(border_all_color(1.5, border)) + .content(Content::Flex) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_toggle.call(())) + .child(header) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .maybe_child(description), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .maybe_child((size > 0).then(|| { + label() + .text(format_size(size)) + .font_size(11.) + .color(colors::fg_secondary().with_a(alpha)) + .into_element() + })), + ) + } +} + +fn icon_box(provider: ProviderId) -> impl IntoElement { + rect() + .center() + .width(Size::px(44.)) + .height(Size::px(44.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child(Icon::new(provider).size(20.).color(colors::fg_secondary())) + .into_element() +} + +pub(super) fn empty_hint(text: &str) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_all(24.)) + .center() + .child( + label() + .text(text.to_string()) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs new file mode 100644 index 00000000..c346abe1 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs @@ -0,0 +1,187 @@ +use super::*; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use oneclient_core::clusters::Cluster; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; +use crate::hooks::use_settings_snapshot; +use crate::layout::gradient_overlay_radial; +use crate::theme::colors; + +pub(super) const BACKDROP_INTERVAL_SECS: u64 = 12; + +const ROTATION_PERIOD: Duration = Duration::from_secs(BACKDROP_INTERVAL_SECS); + +fn since_epoch() -> Duration { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) +} + +pub(super) fn rotation_tick() -> usize { + (since_epoch().as_secs() / BACKDROP_INTERVAL_SECS) as usize +} + +pub(super) fn next_rotation_delay() -> Duration { + let into_period = since_epoch().as_nanos() % ROTATION_PERIOD.as_nanos(); + ROTATION_PERIOD - Duration::from_nanos(into_period as u64) +} + +const PARALLAX_SCALE: f32 = 1.10; +const PARALLAX_STRENGTH: f32 = 0.008; +const PARALLAX_STEP: f32 = 0.12; +const PARALLAX_SETTLE: f32 = 0.002; +const PARALLAX_FRAME: Duration = Duration::from_millis(33); + +#[derive(PartialEq)] +pub struct LoadingBackdrop { + pub clusters: Vec, +} + +impl Component for LoadingBackdrop { + fn render(&self) -> impl IntoElement { + let clusters = &self.clusters; + let count = clusters.len().max(1); + // Offset against the shared tick so the first art shown is still the + // first cluster + let start = use_hook(rotation_tick); + let mut tick = use_state(rotation_tick); + + let stop = use_hook(|| Arc::new(AtomicBool::new(false))); + + use_drop({ + let stop = stop.clone(); + move || stop.store(true, Ordering::Relaxed) + }); + + use_hook({ + let stop = stop.clone(); + move || { + if count <= 1 { + return; + } + + spawn(async move { + loop { + tokio::time::sleep(next_rotation_delay()).await; + + if stop.load(Ordering::Relaxed) { + break; + } + + tick.set(rotation_tick()); + } + }); + } + }); + + let current = (*tick.read()).wrapping_sub(start) % count; + + let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled; + + let mut size = use_state(|| (0f32, 0f32)); + let mut target = use_state(|| (0f32, 0f32)); + let mut pan = use_state(|| (0f32, 0f32)); + + let mut anim_task = use_state(|| None::); + use_side_effect_with_deps(¶llax_enabled, move |enabled| { + if *enabled { + let handle = spawn(async move { + loop { + let (tx, ty) = *target.peek(); + let (cx, cy) = *pan.peek(); + + if (tx - cx).abs() > PARALLAX_SETTLE || (ty - cy).abs() > PARALLAX_SETTLE { + pan.set(( + cx + (tx - cx) * PARALLAX_STEP, + cy + (ty - cy) * PARALLAX_STEP, + )); + } + + tokio::time::sleep(PARALLAX_FRAME).await; + } + }); + anim_task.set(Some(handle.owned())); + } else { + anim_task.set(None); + target.set((0.0, 0.0)); + pan.set((0.0, 0.0)); + } + }); + + let (cx, cy) = if parallax_enabled { + *pan.read() + } else { + (0.0, 0.0) + }; + let (sw, sh) = *size.read(); + let pan_x = cx * sw * PARALLAX_STRENGTH; + let pan_y = cy * sh * PARALLAX_STRENGTH; + + // Default edge on purpose home shows the same art next so the same variant avoids a second fetch + let art = match clusters.get(current) { + Some(cluster) => DynamicArt::for_cluster(cluster), + None => DynamicArt::fallback(), + } + .preview_edge(ART_PREVIEW_EDGE); + + let fade = use_animation_with_dependencies(¤t, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0.0, 1.0) + .time(900) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .interactive(false) + .background(colors::page()) + .on_sized(move |e: Event| { + let (nw, nh) = (e.area.width(), e.area.height()); + let (pw, ph) = *size.peek(); + if (pw - nw).abs() > 0.5 || (ph - nh).abs() > 0.5 { + size.set((nw, nh)); + } + }) + .maybe(parallax_enabled, |el| { + el.on_capture_global_pointer_move(move |e: Event| { + let (sw, sh) = *size.peek(); + if sw > 0.0 && sh > 0.0 { + let loc = e.global_location(); + target.set(( + (loc.x as f32 / sw) * 2.0 - 1.0, + (loc.y as f32 / sh) * 2.0 - 1.0, + )); + } + }) + }) + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(0)) + .scale(PARALLAX_SCALE) + .offset_x(pan_x) + .offset_y(pan_y) + .opacity(fade.get().value()) + .child(art), + ) + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(1)) + .child(gradient_overlay_radial()), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs new file mode 100644 index 00000000..56f99787 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -0,0 +1,1146 @@ +use std::collections::{BTreeMap, HashMap}; +use std::time::{Duration, Instant}; + +use freya::animation::*; +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::{ + BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, SentryExclusion, + VersionMetadata, +}; +use oneclient_db::models::OverrideType; +use oneclient_events::{ + EventBus, GroupedProgressEvent, GroupedProgressSession, ProgressEvent, TaskCategory, +}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::hooks::{ + Actions, ClusterBundles, invalidate_cluster_queries, migration_detection, + onboarding_bundles_items, try_default_account, use_current_account, use_dispatch, + use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot, + use_versions, versions_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::transfer::{TransferMeter, TransferStats}; +use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; + +mod backdrop; +mod progress; +mod summary; +mod tips; +pub use backdrop::LoadingBackdrop; +use progress::{ProgressView, failure_panel, progress_panel}; +use summary::summary_view; +use tips::{tip_panel, use_onboarding_tip}; + +const FADE_DURATION_MS: u64 = 700; +const MAX_TASK_ROWS: usize = 6; + +#[derive(Clone, PartialEq)] +struct ClusterPlan { + cluster_id: i64, + mc_version: String, + /// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default + overrides: Vec<(String, String, OverrideType)>, + /// Manifest `predownload` flag versions without it fetch on first launch + predownload: bool, +} + +#[derive(Clone, PartialEq)] +struct InstallFailure { + plan: ClusterPlan, + reason: String, +} + +#[derive(Clone, PartialEq)] +struct TaskLine { + label: String, + phase: &'static str, + current: u64, + total: u64, + category: TaskCategory, +} + +/// Display order for the task rows mirroring the notification centre +const TASK_LANES: [TaskCategory; 7] = [ + TaskCategory::Java, + TaskCategory::Client, + TaskCategory::Libraries, + TaskCategory::Natives, + TaskCategory::Assets, + TaskCategory::Metadata, + TaskCategory::Packages, +]; + +#[derive(Clone, PartialEq, Default)] +struct GroupedAgg { + children: HashMap, + done_units: u64, + carried: u64, +} + +impl GroupedAgg { + fn fraction(&self) -> f32 { + let completed: u64 = self.done_units + + self + .children + .values() + .map(|t| t.current.min(t.total)) + .sum::(); + let total: u64 = self.done_units + self.children.values().map(|t| t.total).sum::(); + if total == 0 { + 0.0 + } else { + (completed as f32 / total as f32).clamp(0.0, 1.0) + } + } + + /// Round-robin assets run 32-at-a-time and sort first so a flat sorted list showed nothing but assets for the whole download + fn task_list(&self) -> Vec { + let mut lanes: Vec> = TASK_LANES + .iter() + .map(|category| { + let mut lane: Vec = self + .children + .values() + .filter(|task| task.category == *category) + .cloned() + .collect(); + lane.sort_by(|a, b| a.label.cmp(&b.label)); + lane + }) + .collect(); + + let deepest = lanes.iter().map(Vec::len).max().unwrap_or(0); + let mut tasks = Vec::new(); + for index in 0..deepest { + for lane in &mut lanes { + if let Some(task) = lane.get(index) { + tasks.push(task.clone()); + } + } + } + tasks + } + + fn downloaded_bytes(&self) -> u64 { + self.carried + + self.done_units + + self + .children + .values() + .map(|t| t.current.min(t.total)) + .sum::() + } + + fn summary(&self) -> Option { + if self.children.is_empty() { + return None; + } + + let mut kinds: BTreeMap<&'static str, usize> = BTreeMap::new(); + let mut phases: BTreeMap<&'static str, usize> = BTreeMap::new(); + for task in self.children.values() { + *kinds.entry(categorize(&task.label)).or_default() += 1; + *phases.entry(task.phase).or_default() += 1; + } + + let verb = phases + .iter() + .max_by_key(|(_, n)| **n) + .map(|(p, _)| *p) + .unwrap_or("Downloading"); + + let mut ordered: Vec<(&str, usize)> = kinds.into_iter().collect(); + ordered.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); + let parts: Vec = ordered + .into_iter() + .take(3) + .map(|(kind, count)| { + if count > 1 { + format!("{kind} ×{count}") + } else { + kind.to_string() + } + }) + .collect(); + + Some(format!("{verb} {}", parts.join(", "))) + } +} + +fn categorize(label: &str) -> &'static str { + if label.starts_with("Assets index") { + "asset index" + } else if label.starts_with("Asset") { + "assets" + } else if label.starts_with("Library") { + "libraries" + } else if label.starts_with("Natives") { + "natives" + } else if label.starts_with("Client") { + "game client" + } else if label.starts_with("Version metadata") || label.starts_with("Loader metadata") { + "metadata" + } else if label.starts_with("Mod") { + "mods" + } else { + "content" + } +} + +#[derive(Clone, PartialEq, Default)] +struct DownloadStage { + index: usize, + total: usize, + label: String, +} + +enum InstallUiEvent { + Progress(usize, usize), + Stage(DownloadStage), + Activity(String), + TotalEstimate(u64), + Finished(Vec), +} + +#[derive(Clone, Copy)] +struct InstallHandles { + progress: State<(usize, usize)>, + agg: State, + stage: State, + activity: State, + started_at: State>, + meter: State>, + total_estimate: State, + failures: State>, + running: State, + complete: State, +} + +#[derive(PartialEq)] +pub struct OnboardingDownloading; + +impl Component for OnboardingDownloading { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let bundles_query = use_onboarding_bundles(); + let migration_query = use_migration(); + let versions_query = use_versions(); + let selection = use_onboarding_selection(); + let settings = use_settings_snapshot().settings; + let account_query = use_current_account(); + + let selected_state = selection.selected; + let language_state = selection.language; + let reduce_motion_state = selection.reduce_motion; + let predownload_state = selection.predownload; + let mut setup_started = selection.setup_started; + + let mut confirmed = use_state(|| false); + let mut started = use_state(|| false); + let progress = use_state(|| (0usize, 0usize)); + let agg = use_state(GroupedAgg::default); + let stage = use_state(DownloadStage::default); + let activity = use_state(String::new); + let started_at = use_state(|| None::); + let meter = use_state(|| None::); + let total_estimate = use_state(|| 0u64); + let failures = use_state(Vec::::new); + let running = use_state(|| false); + let complete = use_state(|| false); + let mut leaving = use_state(|| false); + + let handles = InstallHandles { + progress, + agg, + stage, + activity, + started_at, + meter, + total_estimate, + failures, + running, + complete, + }; + + use_side_effect(move || { + if !*confirmed.read() || *started.peek() { + return; + } + let Some(items) = onboarding_bundles_items(&bundles_query) else { + return; + }; + let Some(versions) = versions_metadata(&versions_query) else { + return; + }; + let selected = selected_state.peek().clone(); + let plans = build_plans(&items, &selected, &versions); + let predownload = *predownload_state.peek(); + started.set(true); + run_install_batch(plans, predownload, handles); + }); + + let dispatch_finish = dispatch.clone(); + use_side_effect(move || { + if !*complete.read() || !failures.read().is_empty() || *leaving.peek() { + return; + } + + leaving.set(true); + finish_onboarding(dispatch_finish.clone(), &bundles_query); + }); + + let (done, total) = *progress.read(); + let stage_now = stage.read().clone(); + let agg_now = agg.read().clone(); + let activity_now = activity.read().clone(); + let transfer = *meter.read(); + let total_estimate_now = *total_estimate.read(); + let elapsed_secs = (*started_at.read()).map(|s| s.elapsed().as_secs()); + let predownload = *predownload_state.read(); + let is_running = *running.read(); + let is_complete = *complete.read(); + + let global = if is_complete { + 100.0 + } else if total == 0 { + 0.0 + } else if total_estimate_now > 0 { + (agg_now.downloaded_bytes() as f32 / total_estimate_now as f32 * 100.0).clamp(0.0, 99.0) + } else { + ((done as f32 + agg_now.fraction()) / total as f32 * 100.0).clamp(0.0, 100.0) + }; + + let has_failures = !failures.read().is_empty(); + let heading = if has_failures && is_complete { + "Download issues found" + } else if predownload { + "Downloading..." + } else { + "Finishing up..." + }; + + let is_leaving = *leaving.read(); + let fade = use_animation_with_dependencies(&is_leaving, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(FADE_DURATION_MS) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let opacity = if is_leaving { + 1.0 - fade.get().value() + } else { + 1.0 + }; + + let tip = use_onboarding_tip(); + + if !*confirmed.read() { + let items = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let selected = selected_state.read().clone(); + let language = language_state.read().clone(); + let reduce_motion = *reduce_motion_state.read(); + let account_name = try_default_account(&account_query) + .map(|account| account.username.clone()) + .unwrap_or_else(|| "Not signed in".to_string()); + + let import_dispatch = dispatch.clone(); + let import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + let import_detection = migration_detection(&migration_query); + let import_items = items.clone(); + + let migration_summary = import_detection.as_ref().map(|detection| { + let source_name = detection.source.display_name().to_string(); + let is_vanilla = detection.source == MigrationSource::Vanilla; + match import_folder.read().clone() { + Some(folder) => { + let dedicated = !is_vanilla + && *import_dedicated.read() + && detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &items)) + .is_some(); + let target = if dedicated { + "This version only" + } else { + "Shared game directory" + }; + let files = if is_vanilla { + "Your Minecraft folder".to_string() + } else { + folder + }; + (source_name, files, target.to_string()) + } + None => (source_name, "No files imported".to_string(), String::new()), + } + }); + + return summary_view( + &items, + &selected, + &versions_metadata(&versions_query).unwrap_or_default(), + &language, + reduce_motion, + settings.dynamic_background_enabled, + account_name, + migration_summary, + predownload_state, + move |_| { + if let (Some(detection), Some(folder)) = + (import_detection.as_ref(), import_folder.peek().clone()) + { + let target = if detection.source != MigrationSource::Vanilla + && *import_dedicated.peek() + { + detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &import_items)) + .map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id }) + .unwrap_or(ImportTarget::Shared) + } else { + ImportTarget::Shared + }; + import_dispatch.import_launcher(detection.source, folder, target); + } + + setup_started.set(true); + confirmed.set(true); + }, + ) + .into_element(); + } + + let dispatch_continue = dispatch.clone(); + let continue_query = bundles_query; + let mut continue_leaving = leaving; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(16., 48., 48., 48.)) + .opacity(opacity) + .child( + label() + .text(heading) + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .maybe_child((has_failures && is_complete).then(|| { + failure_panel( + &failures.read(), + done, + total, + is_running, + move |_| { + let retry_plans: Vec = + failures.peek().iter().map(|f| f.plan.clone()).collect(); + if retry_plans.is_empty() || *running.peek() { + return; + } + let predownload = *predownload_state.peek(); + run_install_batch(retry_plans, predownload, handles); + }, + move |_| { + if *continue_leaving.peek() { + return; + } + continue_leaving.set(true); + finish_onboarding(dispatch_continue.clone(), &continue_query); + }, + ) + .into_element() + })) + .child(rect().width(Size::fill()).height(Size::flex(1.0))) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::End) + .child(progress_panel(ProgressView { + global, + stage: &stage_now, + agg: &agg_now, + activity: &activity_now, + transfer, + total_estimate: total_estimate_now, + elapsed_secs, + done, + total, + predownload, + running: is_running, + })) + .child(rect().width(Size::flex(1.0))) + .child(tip_panel(tip)), + ) + .into_element() + } +} + +fn build_plans( + items: &[ClusterBundles], + selected: &std::collections::HashSet, + versions: &[VersionMetadata], +) -> Vec { + items + .iter() + .map(|cb| { + let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); + let mut overrides = Vec::new(); + for archive in &cb.archives { + overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept)); + } + ClusterPlan { + cluster_id: cb.cluster.id, + mc_version: cb.cluster.mc_version.clone(), + overrides, + predownload: cluster_predownloads(versions, &cb.cluster), + } + }) + .collect() +} + +/// Clusters with no manifest row (e.g. seeded from the bundle catalog alone) are not predownloaded +fn cluster_predownloads(versions: &[VersionMetadata], cluster: &Cluster) -> bool { + let loader = cluster.mc_loader.to_string(); + versions.iter().any(|m| { + m.predownload + && m.mc_version().as_deref() == Some(cluster.mc_version.as_str()) + && m.loader + .as_deref() + .is_none_or(|l| l.eq_ignore_ascii_case(&loader)) + }) +} + +/// True once anything visible in the bundle was taken including one opted-in extra that would otherwise lose its dependencies +fn bundle_taken( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, +) -> bool { + archive.manifest.files.iter().any(|file| { + !file.hidden + && selected.contains(&pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + )) + }) +} + +/// Bundles overlap deciding each archive alone recorded a removal for a package an accepted bundle was about to install +fn kept_package_ids( + cluster_id: i64, + archives: &[BundleArchive], + selected: &std::collections::HashSet, +) -> std::collections::HashSet { + let mut kept = std::collections::HashSet::new(); + + for archive in archives { + let takes_hidden = bundle_taken(cluster_id, archive, selected); + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + let wanted = if file.hidden { + takes_hidden + } else { + selected.contains(&pkg_key(cluster_id, &archive.manifest.name, &package_id)) + }; + if wanted { + kept.insert(package_id); + } + } + } + + kept +} + +fn archive_overrides( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, + kept_elsewhere: &std::collections::HashSet, +) -> Vec<(String, String, OverrideType)> { + let bundle_name = &archive.manifest.name; + let wants = |file: &BundleFile| { + selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id())) + }; + + // Hidden files follow the bundle kept if anything from it was taken dropped if not + let takes_hidden = bundle_taken(cluster_id, archive, selected); + + let mut overrides = Vec::new(); + for file in &archive.manifest.files { + let wanted = if file.hidden { + takes_hidden + } else { + wants(file) + }; + + let override_type = match (wanted, file.enabled) { + // Matches the manifest default nothing to record + (true, true) | (false, false) => continue, + // Opting into a mod the bundle ships turned off + (true, false) => OverrideType::Enabled, + // Only really declined if no bundle the user took ships the same package + (false, true) => { + if kept_elsewhere.contains(&file.kind.package_id()) { + continue; + } + OverrideType::Removed + } + }; + + overrides.push((bundle_name.clone(), file.kind.package_id(), override_type)); + } + overrides +} + +const GAME_SIZE_GUESS: u64 = 180_000_000; +const JRE_SIZE_GUESS: u64 = 45_000_000; + +fn rough_download_estimate( + items: &[ClusterBundles], + selected: &std::collections::HashSet, + versions: &[VersionMetadata], +) -> u64 { + let mut total: u64 = 0; + for cb in items { + if !cluster_predownloads(versions, &cb.cluster) { + continue; + } + // Counted once per package two bundles shipping the same library is one download + let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); + let mut counted: std::collections::HashSet = std::collections::HashSet::new(); + for archive in &cb.archives { + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + if kept.contains(&package_id) && counted.insert(package_id) { + total += file.size; + } + } + } + total += GAME_SIZE_GUESS + JRE_SIZE_GUESS; + } + total +} + +fn run_install_batch(plans: Vec, predownload: bool, handles: InstallHandles) { + let InstallHandles { + mut progress, + mut agg, + mut stage, + mut activity, + mut started_at, + mut meter, + mut total_estimate, + mut failures, + mut running, + mut complete, + } = handles; + + running.set(true); + complete.set(false); + failures.set(Vec::new()); + agg.set(GroupedAgg::default()); + meter.set(None); + total_estimate.set(0); + activity.set("Getting started...".to_string()); + started_at.set(Some(Instant::now())); + let total = plans.len(); + progress.set((0, total)); + + let (events, mut notif_rx) = EventBus::channel(); + + let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::(); + + spawn(async move { + let mut transfer = TransferMeter::default(); + + loop { + tokio::time::sleep(Duration::from_millis(1000)).await; + + let downloaded = agg.peek().downloaded_bytes(); + meter.set(transfer.sample(downloaded, *total_estimate.peek())); + + if !*running.peek() { + break; + } + } + }); + + spawn(async move { + let mut local = GroupedAgg::default(); + loop { + let Some(first) = notif_rx.recv().await else { + break; + }; + if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = first { + apply_grouped(&mut local, event); + } + while let Ok(notification) = notif_rx.try_recv() { + if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = + notification + { + apply_grouped(&mut local, event); + } + } + agg.set(local.clone()); + tokio::time::sleep(Duration::from_millis(50)).await; + } + }); + + spawn(async move { + while let Some(event) = ui_rx.recv().await { + match event { + InstallUiEvent::Progress(done, total) => progress.set((done, total)), + InstallUiEvent::Stage(next) => stage.set(next), + InstallUiEvent::Activity(text) => activity.set(text), + InstallUiEvent::TotalEstimate(bytes) => total_estimate.set(bytes), + InstallUiEvent::Finished(failed) => { + agg.set(GroupedAgg::default()); + activity.set("Wrapping up...".to_string()); + invalidate_cluster_queries().await; + failures.set(failed); + running.set(false); + complete.set(true); + break; + } + } + } + }); + + tokio::spawn(async move { + let mut failed = Vec::new(); + + if predownload { + let _ = ui_tx.send(InstallUiEvent::Activity( + "Calculating download size...".to_string(), + )); + if let Ok(state) = crate::launcher::state() { + let mut sum = 0u64; + for plan in plans.iter().filter(|p| p.predownload) { + match oneclient_core::estimate_cluster_download( + &state, + plan.cluster_id, + state.bundles.as_ref(), + ) + .await + { + Ok(bytes) => sum += bytes, + Err(err) => tracing::warn!( + cluster_id = plan.cluster_id, + "download size estimate failed: {err}" + ), + } + let _ = ui_tx.send(InstallUiEvent::TotalEstimate(sum)); + } + } + } + + for (index, plan) in plans.into_iter().enumerate() { + let _ = ui_tx.send(InstallUiEvent::Stage(DownloadStage { + index, + total, + label: plan.mc_version.clone(), + })); + + let fetch_now = predownload && plan.predownload; + if let Err(err) = install_one(&plan, fetch_now, &events, &ui_tx).await { + // Expected failures (out of disk offline no Java build) are not crashes warn! is a breadcrumb error! reports to Sentry + if err.is_sentry_excluded() { + tracing::warn!( + cluster_id = plan.cluster_id, + mc_version = %plan.mc_version, + "onboarding install failed (expected): {err}" + ); + } else { + tracing::error!( + cluster_id = plan.cluster_id, + mc_version = %plan.mc_version, + "onboarding install failed: {err}" + ); + } + failed.push(InstallFailure { + plan, + reason: err.to_string(), + }); + } + let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total)); + } + + drop(events); + let _ = ui_tx.send(InstallUiEvent::Finished(failed)); + }); +} + +async fn install_one( + plan: &ClusterPlan, + predownload: bool, + events: &EventBus, + ui_tx: &mpsc::UnboundedSender, +) -> oneclient_core::LauncherResult<()> { + let state = crate::launcher::state()?; + + if !plan.overrides.is_empty() { + let _ = ui_tx.send(InstallUiEvent::Activity( + "Saving your package choices...".to_string(), + )); + } + oneclient_core::set_bundle_package_overrides( + plan.cluster_id, + &plan.overrides, + &state.services.content(), + ) + .await?; + + if !predownload { + return Ok(()); + } + + let session = GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version)); + + let _ = ui_tx.send(InstallUiEvent::Activity(format!( + "Downloading Minecraft {}...", + plan.mc_version + ))); + + let prepared = oneclient_core::clusters::prepare_cluster_locked( + &state, + plan.cluster_id, + false, + true, + true, + Some(&session), + ) + .await; + + let bundles_result = if prepared.is_ok() { + let _ = ui_tx.send(InstallUiEvent::Activity(format!( + "Installing mods & content for {}...", + plan.mc_version + ))); + oneclient_core::install_cluster_bundles( + plan.cluster_id, + state.bundles.as_ref(), + Some(&session), + &state.services.content(), + ) + .await + } else { + Ok(()) + }; + + session.finish(); + + prepared?; + bundles_result?; + Ok(()) +} + +fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) { + match event { + GroupedProgressEvent::Start { .. } => { + agg.children.clear(); + agg.done_units = 0; + } + GroupedProgressEvent::Expect { .. } => {} + GroupedProgressEvent::AddChild { + child_id, + label, + total, + category, + .. + } => { + agg.children.insert( + child_id, + TaskLine { + label, + phase: "Downloading", + current: 0, + total: total.max(1), + category, + }, + ); + } + GroupedProgressEvent::UpdateChild { + child_id, + current, + total, + .. + } => { + if let Some(task) = agg.children.get_mut(&child_id) { + task.current = current; + task.total = total.max(1); + } + } + GroupedProgressEvent::SetChildPhase { + child_id, phase, .. + } => { + if let Some(task) = agg.children.get_mut(&child_id) { + task.phase = phase.label(); + } + } + GroupedProgressEvent::FinishChild { child_id, .. } => { + if let Some(task) = agg.children.remove(&child_id) { + agg.done_units += task.total; + } + } + GroupedProgressEvent::End { .. } => { + agg.carried += agg.done_units + + agg + .children + .values() + .map(|t| t.current.min(t.total)) + .sum::(); + agg.children.clear(); + agg.done_units = 0; + } + } +} + +fn finish_onboarding( + dispatch: Actions, + bundles_query: &freya::query::UseQuery, +) { + let versions: Vec = onboarding_bundles_items(bundles_query) + .map(|items| { + let mut versions: Vec = items + .iter() + .map(|cb| cb.cluster.mc_version.clone()) + .collect(); + + versions.sort(); + versions.dedup(); + versions + }) + .unwrap_or_default(); + + spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(FADE_DURATION_MS)).await; + + for version in versions { + dispatch.record_seen_version(version); + } + + dispatch.mark_onboarding_seen(); + let _ = RouterContext::get().replace(Route::Home {}); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + use std::collections::HashSet; + + fn skyblock() -> BundleArchive { + archive( + "SkyBlock", + false, + vec![ + file("skyblock-main", true, false), + file("skycubed", false, false), + file("sb-dep", true, true), + ], + ) + } + + fn items(archives: Vec) -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives, + }] + } + + fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet { + pids.iter() + .map(|p| pkg_key(1, &archive.manifest.name, p)) + .collect() + } + + /// Manifest row matching `test_support::cluster` (1.21.11 fabric) + fn version_row(predownload: bool) -> VersionMetadata { + VersionMetadata { + major_version: 21, + minor_version: Some(11), + patch_version: None, + loader: Some("fabric".to_string()), + name: "Mounts of Mayhem".to_string(), + art_url: None, + long_description: None, + tags: Vec::new(), + predownload, + } + } + + fn versions() -> Vec { + vec![version_row(true)] + } + + #[test] + fn declining_a_bundle_removes_its_visible_and_hidden_files() { + let sb = skyblock(); + let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new(), &versions()); + + let ov = &plans[0].overrides; + // The hidden dependency must be dropped too or it installs anyway + assert!(ov.contains(&( + sb.manifest.name.clone(), + "sb-dep".to_string(), + OverrideType::Removed + ))); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed")); + } + + #[test] + fn accepting_a_bundle_records_nothing_for_its_defaults() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions()); + + assert!(plans[0].overrides.is_empty()); + } + + #[test] + fn opting_into_an_extra_writes_enabled() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main", "skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions()); + + assert_eq!( + plans[0].overrides, + vec![( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + )] + ); + } + + #[test] + fn an_extra_alone_still_keeps_hidden_dependencies() { + let sb = skyblock(); + let selected = keys(&sb, &["skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions()); + let ov = &plans[0].overrides; + + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep")); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + } + + #[test] + fn rejections_are_removed_never_disabled() { + let plans = build_plans(&items(vec![skyblock()]), &HashSet::new(), &versions()); + + assert!( + !plans[0] + .overrides + .iter() + .any(|(_, _, ty)| *ty == OverrideType::Disabled) + ); + } + + #[test] + fn per_cluster_choices_are_independent() { + let sb = skyblock(); + let both = vec![ + ClusterBundles { + cluster: cluster(1), + archives: vec![sb.clone()], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![sb.clone()], + }, + ]; + let selected: HashSet = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); + let plans = build_plans(&both, &selected, &versions()); + + assert!(plans[0].overrides.is_empty()); + assert!( + plans[1] + .overrides + .iter() + .any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed) + ); + } + + #[test] + fn estimate_skips_declined_bundles_and_counts_opted_in_extras() { + let sb = skyblock(); + let all = items(vec![sb.clone()]); + let baseline = GAME_SIZE_GUESS + JRE_SIZE_GUESS; + + let declined = rough_download_estimate(&all, &HashSet::new(), &versions()); + assert_eq!(declined, baseline, "declined bundle should cost nothing"); + + let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]), &versions()); + assert_eq!(accepted, baseline + 2); + + let with_extra = rough_download_estimate( + &all, + &keys(&sb, &["skyblock-main", "skycubed"]), + &versions(), + ); + assert_eq!(with_extra, baseline + 3); + } + + #[test] + fn plans_take_predownload_from_the_manifest() { + let all = items(vec![skyblock()]); + assert!(build_plans(&all, &HashSet::new(), &versions())[0].predownload); + assert!(!build_plans(&all, &HashSet::new(), &[version_row(false)])[0].predownload); + // A cluster with no manifest row at all is never predownloaded + assert!(!build_plans(&all, &HashSet::new(), &[])[0].predownload); + } + + #[test] + fn a_predownload_row_for_another_version_does_not_match() { + let mut other = version_row(true); + other.minor_version = Some(10); + assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload); + } + + #[test] + fn a_predownload_row_for_another_loader_does_not_match() { + let mut other = version_row(true); + other.loader = Some("forge".to_string()); + assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload); + } + + #[test] + fn the_estimate_ignores_versions_that_are_not_predownloaded() { + let all = items(vec![skyblock()]); + let selected = keys(&skyblock(), &["skyblock-main"]); + + assert_eq!(rough_download_estimate(&all, &selected, &[]), 0); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs new file mode 100644 index 00000000..f1cd56db --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -0,0 +1,390 @@ +use super::*; + +use crate::components::{Button, progress_track}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{format_duration_hms, format_size}; + +pub(super) fn failure_panel( + failures: &[InstallFailure], + done: usize, + total: usize, + running: bool, + on_retry: impl FnMut(Event) + 'static, + on_continue: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let lines: Vec = failures + .iter() + .take(8) + .map(|failure| { + label() + .text(format!( + "{} (cluster {}): {}", + failure.plan.mc_version, failure.plan.cluster_id, failure.reason + )) + .font_size(12.) + .color(colors::fg_primary()) + .into_element() + }) + .collect(); + let extra = failures.len().saturating_sub(8); + + rect() + .vertical() + .width(Size::px(640.)) + .margin(Gaps::new(24., 0., 0., 0.)) + .padding(Gaps::new_all(20.)) + .spacing(10.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::danger().with_a(26)) + .border(border_all_color(1., colors::danger().with_a(128))) + .child( + label() + .text("Some versions could not be prepared.") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("Processed: {done} / {total}")) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(Color::BLACK.with_a(51)) + .children(lines) + .maybe_child((extra > 0).then(|| { + label() + .text(format!("+{extra} more failures")) + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + })), + ) + .child( + rect() + .horizontal() + .spacing(10.) + .child( + Button::new() + .primary() + .enabled(!running) + .on_press(on_retry) + .text("Retry Failed Versions"), + ) + .child( + Button::new() + .secondary() + .on_press(on_continue) + .text("Continue Anyway"), + ), + ) + .into_element() +} + +pub(super) struct ProgressView<'a> { + pub(super) global: f32, + pub(super) stage: &'a DownloadStage, + pub(super) agg: &'a GroupedAgg, + pub(super) activity: &'a str, + /// `None` until the meter has two samples to work from + pub(super) transfer: Option, + pub(super) total_estimate: u64, + pub(super) elapsed_secs: Option, + pub(super) done: usize, + pub(super) total: usize, + pub(super) predownload: bool, + pub(super) running: bool, +} + +pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement { + let ProgressView { + global, + stage, + agg, + activity, + transfer, + total_estimate, + elapsed_secs, + done, + total, + predownload, + running, + } = view; + + let percentage = global.round() as i32; + + let position = if !running && done >= total && total > 0 { + "Done".to_string() + } else if predownload && stage.total > 0 { + format!( + "Version {} of {}: {}", + (stage.index + 1).min(stage.total), + stage.total, + stage.label + ) + } else if total > 0 { + format!("Step {done} of {total}") + } else { + "Preparing...".to_string() + }; + + let tasks = agg.task_list(); + let live_count = tasks.len(); + let shown = live_count.min(MAX_TASK_ROWS); + let overflow = live_count.saturating_sub(shown); + let task_rows: Vec = tasks.into_iter().take(shown).map(task_row).collect(); + + let activity_text = agg.summary().unwrap_or_else(|| { + if activity.is_empty() { + position.clone() + } else { + activity.to_string() + } + }); + + let stats = build_stats(StatsView { + global, + transfer, + total_estimate, + elapsed_secs, + running, + done, + total, + }); + + rect() + .vertical() + .width(Size::px(460.)) + .spacing(10.) + .cross_align(Alignment::Start) + .main_align(Alignment::End) + .maybe_child((!task_rows.is_empty()).then(|| { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(task_rows) + .maybe_child((overflow > 0).then(|| { + label() + .text(format!("+{overflow} more")) + .font_size(11.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() + })) + .child( + ActivityIndicator { + running, + text: activity_text, + } + .into_element(), + ) + .maybe_child(stats) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child( + label() + .text(format!("{percentage}%")) + .width(Size::flex(1.0)) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(position) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(progress_track( + global, + 4., + Color::WHITE, + Color::WHITE.with_a(51), + )) + .into_element() +} + +struct StatsView { + global: f32, + transfer: Option, + total_estimate: u64, + elapsed_secs: Option, + running: bool, + done: usize, + total: usize, +} + +fn build_stats(view: StatsView) -> Option { + let StatsView { + global, + transfer, + total_estimate, + elapsed_secs, + running, + done, + total, + } = view; + + let elapsed = elapsed_secs?; + let finished = !running && done >= total && total > 0; + + let mut parts: Vec = vec![format!("Elapsed {}", format_duration_hms(elapsed as i64))]; + + if finished { + parts.push("Complete".to_string()); + } else { + // Both readings come off the same smoothed rate so they always agree + match transfer.and_then(|t| t.eta_secs) { + Some(remaining) => { + parts.push(format!("~{} left", format_duration_hms(remaining as i64))) + } + None => parts.push("~— left".to_string()), + } + + if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) { + parts.push(format!("{}/s", format_size(speed as u64))); + } + } + + if total_estimate > 0 { + let downloaded = ((global as f64 / 100.0) * total_estimate as f64) as u64; + parts.push(format!( + "{} / ~{}", + format_size(downloaded), + format_size(total_estimate) + )); + } + + Some( + label() + .text(parts.join(" · ")) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + ) +} + +fn task_row(task: TaskLine) -> Element { + let is_bytes = task.total > 1; + let pct = if task.total > 0 { + (task.current as f32 / task.total as f32 * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let detail = if is_bytes { + format!( + "{} / {}", + format_size(task.current), + format_size(task.total) + ) + } else { + task.phase.to_string() + }; + + let track_pct = if is_bytes { pct } else { 100.0 }; + let fill = if is_bytes { + colors::brand() + } else { + colors::brand().with_a(120) + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text(format!("{} {}", task.phase, task.label)) + .width(Size::flex(1.0)) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(detail) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(progress_track(track_pct, 3., fill, Color::WHITE.with_a(28))) + .into_element() +} + +#[derive(PartialEq)] +struct ActivityIndicator { + running: bool, + text: String, +} + +impl Component for ActivityIndicator { + fn render(&self) -> impl IntoElement { + let pulse = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + AnimNum::new(0.3, 1.0) + .time(650) + .ease(Ease::InOut) + .function(Function::Cubic) + }); + + let dot_opacity = if self.running { + pulse.read().value() + } else { + 0.9 + }; + let color = if self.running { + colors::brand() + } else { + colors::fg_secondary() + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(color) + .opacity(dot_opacity), + ) + .child( + label() + .text(self.text.clone()) + .width(Size::flex(1.0)) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs new file mode 100644 index 00000000..d0b44c1a --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs @@ -0,0 +1,254 @@ +use super::*; + +use freya::router::RouterContext; + +use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea}; +use crate::hooks::ClusterBundles; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; +use crate::view::onboarding::{onboarding_illustration, predownload_toggle_row, step_heading}; + +#[allow(clippy::too_many_arguments)] +pub(super) fn summary_view( + items: &[ClusterBundles], + selected: &std::collections::HashSet, + versions: &[VersionMetadata], + language: &str, + reduce_motion: bool, + parallax: bool, + account_name: String, + migration: Option<(String, String, String)>, + predownload: State, + on_setup: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let version_rows: Vec = items + .iter() + .map(|cb| { + let prefix = format!("{}|", cb.cluster.id); + let count = selected.iter().filter(|k| k.starts_with(&prefix)).count(); + summary_line( + &cb.cluster.mc_version, + &format!("{count} package{}", if count == 1 { "" } else { "s" }), + ) + }) + .collect(); + + let ready_now = items + .iter() + .filter(|cb| cluster_predownloads(versions, &cb.cluster)) + .count(); + + let packages_note = if *predownload.read() && ready_now > 0 { + let est = rough_download_estimate(items, selected, versions); + let (scope, rest) = if ready_now == items.len() { + ("Your selected content is".to_string(), "") + } else { + ( + format!( + "Content for {ready_now} version{} is", + if ready_now == 1 { "" } else { "s" } + ), + " Everything else is downloaded the first time you launch it.", + ) + }; + format!( + "{scope} downloaded now, during setup. About {} total.{rest}", + format_size(est) + ) + } else { + "Content is downloaded the first time you launch each version.".to_string() + }; + + let migration_section: Element = match migration { + Some((source, folder, target)) => { + let mut rows = vec![ + summary_line("From", &source), + summary_line("Files", &folder), + ]; + if !target.is_empty() { + rows.push(summary_line("Destination", &target)); + } + summary_section("Migration", None, rows).into_element() + } + None => rect().into_element(), + }; + + let content = rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .spacing(20.) + .child(step_heading( + "You're all set", + "Review your setup below, then finish.", + )) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(20.) + .child(summary_section( + "General", + None, + vec![ + summary_line("Language", language), + summary_line("Account", &account_name), + ], + )) + .child(migration_section) + .child(summary_section( + "Accessibility", + None, + vec![ + summary_line("Animations", if reduce_motion { "Off" } else { "On" }), + summary_line("Parallax background", if parallax { "On" } else { "Off" }), + ], + )) + .child(summary_section( + "Packages per version", + Some(packages_note.as_str()), + if version_rows.is_empty() { + vec![summary_line("No versions", "")] + } else { + version_rows + }, + )) + .child(predownload_toggle_row(predownload)), + ); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(onboarding_illustration(IconType::OnboardingComplete)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(48., 80., 24., 24.)) + .child(content), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .child( + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(Route::OnboardingPreferences {}); + }) + .text("Back"), + ) + .child( + Button::new() + .primary() + .width(Size::px(160.)) + .on_press(on_setup) + .text("Setup") + .child(Icon::new(IconType::Download01).size(16.)), + ), + ) + .child(prefetch_art(items)) +} + +fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(1.)) + .overflow(Overflow::Clip) + .opacity(0.) + .interactive(false) + .children(items.iter().map(|cb| { + rect() + .width(Size::px(1.)) + .height(Size::px(1.)) + .child(DynamicArt::for_cluster(&cb.cluster).max_edge(ART_PREVIEW_EDGE)) + .into_element() + })) + .into_element() +} + +fn summary_section(title: &str, note: Option<&str>, rows: Vec) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()), + ) + .maybe_child(note.map(|text| { + label() + .text(text.to_string()) + .font_size(11.) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(2.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .padding(Gaps::new_symmetric(6., 4.)) + .children(rows), + ) + .into_element() +} + +fn summary_line(label_text: &str, value: &str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(10., 12.)) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(label_text.to_string()) + .font_size(14.) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(value.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs new file mode 100644 index 00000000..5e2e9112 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs @@ -0,0 +1,150 @@ +use super::*; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::backdrop::{next_rotation_delay, rotation_tick}; +use crate::theme::colors; + +const ONBOARDING_TIPS_BACKUP: &[&str] = &[ + "418 I'm a teapot", + "Floors 8 through 10 of the Catacombs were originally planned for release, but portions of it were later merged into Floor 7 and its Master Mode counterpart.", + "At one point, Feather Client was willing to pay $300 USD to add Skytils, a popular SkyBlock mod.", + "Grass is a plant with narrow leaves growing from the base. [...]\nGrasses are monocotyledon, herbaceous plants.", + "In Canada, Santa's postal code is H0H 0H0.", + "Guvf vf abg na vzcbegnag fragrapr.", + "Vs lbh qrpvcure guvf, lbh fubhyq yvfgra gb Mrgihr'f zhfvp.", + "Try to do something creative! It could be music, art, coding...\nYou won't know how much you like it until you try it, so give it a shot :)", + "At one point in Minecraft, there was a rare chance of Snow Golems naturally spawning.", + "W[h]yvest", + "RUMOUR. Open your ears; 9r\"5j5&?OWTY Z0d", + "Make sure to drink water! You never know if you might be dehydrated.", + "People asking you to \"test their mod\" are likely trying to hack you.\nThe only ones you can trust are ones widely trusted by the community.", + "Have a great day!", + "You can sleep through a thunderstorm, even during the day", +]; + +const FUNFACTS_URL: &str = "https://data-v2.polyfrost.org/oneclient/funfacts.txt"; + +fn normalize_line_breaks(value: &str) -> String { + value.replace("/n", "\n").replace("\\n", "\n") +} + +fn parse_funfacts(text: &str) -> Vec { + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(normalize_line_breaks) + .collect() +} + +/// A random tip that rotates in step with the backdrop art +pub(super) fn use_onboarding_tip() -> String { + let mut tips = use_state(|| { + ONBOARDING_TIPS_BACKUP + .iter() + .map(|tip| normalize_line_breaks(tip)) + .collect::>() + }); + + use_hook(move || { + spawn(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + 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); + if !parsed.is_empty() { + tips.set(parsed); + } + } + Err(err) => { + tracing::warn!("onboarding tips: reading body failed: {err}"); + } + }, + Ok(res) => { + tracing::warn!("onboarding tips: unexpected status {}", res.status()); + } + Err(err) => { + tracing::warn!("onboarding tips: request failed, using backup facts: {err}"); + } + } + }); + }); + + // Random offset against the shared tick so the run does not always open on + // the same fact + let seed = use_hook(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos() as usize) + .unwrap_or(0) + }); + let mut tick = use_state(rotation_tick); + + let stop = use_hook(|| Arc::new(AtomicBool::new(false))); + + use_drop({ + let stop = stop.clone(); + move || stop.store(true, Ordering::Relaxed) + }); + + use_hook({ + let stop = stop.clone(); + move || { + spawn(async move { + loop { + tokio::time::sleep(next_rotation_delay()).await; + + if stop.load(Ordering::Relaxed) { + break; + } + + tick.set(rotation_tick()); + } + }); + } + }); + + // Wrapped at read time the list is swapped out once the remote facts land + let list = tips.read(); + if list.is_empty() { + String::new() + } else { + list[seed.wrapping_add(*tick.read()) % list.len()].clone() + } +} + +pub(super) fn tip_panel(tip: String) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(320.)) + .spacing(4.) + .cross_align(Alignment::End) + .child( + label() + .text("TIP") + .width(Size::fill()) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(tip) + .width(Size::fill()) + .font_size(13.) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/view/onboarding/language.rs b/packages/oneclient_app/src/view/onboarding/language.rs new file mode 100644 index 00000000..c34b0e39 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/language.rs @@ -0,0 +1,129 @@ +use freya::prelude::*; + +use crate::components::IconType; +use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +struct Language { + name: &'static str, + percentage: u32, +} + +const LANGUAGES: [Language; 1] = [Language { + name: "English", + percentage: 100, +}]; + +#[derive(PartialEq)] +pub struct OnboardingLanguage; + +impl Component for OnboardingLanguage { + fn render(&self) -> impl IntoElement { + let selected = use_state(|| 0usize); + let language = use_onboarding_selection().language; + let migration_query = use_migration(); + let back = if has_migration_data(&migration_query) { + Route::OnboardingMigration {} + } else { + Route::OnboardingTerms {} + }; + + let rows: Vec = LANGUAGES + .iter() + .enumerate() + .map(|(index, lang)| language_row(lang, index == *selected.read(), selected, language)) + .collect(); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "OneClient", + "Let's get you all set-up with the most advanced client.", + )) + .child( + label() + .text("Choose your preferred language") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .children(rows), + ) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::OnboardingLanguage), + content, + onboarding_nav(Some(back), Route::OnboardingAccount {}, true), + ) + } +} + +fn language_row( + language: &Language, + active: bool, + mut selected: State, + mut chosen: State, +) -> Element { + let index = LANGUAGES + .iter() + .position(|l| l.name == language.name) + .unwrap_or(0); + let name = language.name; + + let background = if active { + colors::brand() + } else { + Color::TRANSPARENT + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .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())) + }) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + selected.set(index); + chosen.set(name.to_string()); + }) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(language.name) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(format!("{}% localized", language.percentage)) + .font_size(13.) + .color(if active { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs new file mode 100644 index 00000000..b09f74c0 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -0,0 +1,432 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::{MigrationDetection, MigrationSource, SourceInstance}; + +use crate::components::{Button, Icon, IconType, ScrollArea}; +use crate::hooks::{ + ClusterBundles, migration_detection, onboarding_bundles_items, use_migration, + use_onboarding_bundles, use_onboarding_selection, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{ + choice_row, onboarding_illustration, onboarding_slide, step_heading, +}; + +fn migrated_categories(source: &[SourceInstance]) -> Vec { + let mut categories: Vec = Vec::new(); + for instance in source { + for category in &instance.categories { + if !categories.iter().any(|c| c.eq_ignore_ascii_case(category)) { + categories.push(category.clone()); + } + } + } + categories +} + +/// Needed for the "dedicated dir" import option to have a target +pub(crate) fn matching_new_cluster_id( + instance: &SourceInstance, + new: &[ClusterBundles], +) -> Option { + let want = instance.import_version(); + new.iter() + .find(|cb| cb.cluster.mc_version == want && cb.cluster.mc_loader == instance.mc_loader) + .map(|cb| cb.cluster.id) +} + +#[derive(PartialEq)] +pub struct OnboardingMigration; + +impl Component for OnboardingMigration { + fn render(&self) -> impl IntoElement { + let migration_query = use_migration(); + let bundles_query = use_onboarding_bundles(); + let selection = use_onboarding_selection(); + let mut categories = selection.migrated_categories; + // Shared across the flow the file import is dispatched later on Setup + let mut import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + + // The shell only routes here when a source was detected so this is transient + let Some(detection) = migration_detection(&migration_query) else { + return rect() + .width(Size::fill()) + .height(Size::fill()) + .into_element(); + }; + let source_name = detection.source.display_name(); + let is_vanilla = detection.source == MigrationSource::Vanilla; + let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); + + let chosen_folder = import_folder.read().clone(); + let any_importable = detection.instances.iter().any(|c| c.has_game_dir); + + let (title, subtitle) = if is_vanilla { + ( + "Bring over your Minecraft files", + "We found a Minecraft installation on this PC. We can copy your worlds, \ + settings, screenshots and servers over. Mods, resource packs and shaders stay \ + behind; OneClient manages those for you." + .to_string(), + ) + } else { + ( + "Bring over your setup", + format!( + "We found data from your previous {source_name} install. Your installed \ + categories are reselected automatically. Optionally, pick one version to \ + copy its files (worlds, config, and packs)." + ), + ) + }; + let heading = step_heading(title, &subtitle); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(heading) + // "Don't import files" is the default choice pinned above the list + .maybe_child(any_importable.then(|| { + import_choice_card( + "Don't import files", + if is_vanilla { + "Start fresh. Nothing is copied over." + } else { + "Start fresh. Only your bundle selection carries over." + }, + None, + chosen_folder.is_none(), + move |_| import_folder.set(None), + ) + })) + .child(import_body( + &detection, + is_vanilla, + chosen_folder.as_deref(), + &new_clusters, + import_folder, + import_dedicated, + )) + .into_element(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(onboarding_illustration(if is_vanilla { + IconType::FolderDownload + } else { + IconType::ClockRewind + })), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(48., 80., 24., 24.)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(content), + ), + ), + )) + .child(migration_nav(bundles_loaded, move || { + categories.set(Some(migrated_categories(&detection.instances))); + + let _ = RouterContext::get().replace(Route::OnboardingLanguage {}); + })) + .into_element() + } +} + +fn import_body( + detection: &MigrationDetection, + is_vanilla: bool, + chosen_folder: Option<&str>, + new_clusters: &[ClusterBundles], + mut import_folder: State>, + import_dedicated: State, +) -> Element { + if is_vanilla { + let folder = detection + .instances + .first() + .map(|instance| instance.folder_name.clone()) + .unwrap_or_default(); + + return import_choice_card( + "Copy my Minecraft files", + "Worlds, settings, screenshots and servers move to your shared game directory.", + None, + chosen_folder.is_some(), + move |_| import_folder.set(Some(folder.clone())), + ); + } + + let mut version_cards: Vec = Vec::new(); + for instance in &detection.instances { + let selected_import = chosen_folder == Some(instance.folder_name.as_str()); + let dedicated_available = matching_new_cluster_id(instance, new_clusters).is_some(); + + version_cards.push(version_card( + instance, + selected_import, + *import_dedicated.read(), + dedicated_available, + import_dedicated, + { + let folder = instance.folder_name.clone(); + move |_| import_folder.set(Some(folder.clone())) + }, + )); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Detected versions") + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(version_cards), + ) + .into_element() +} + +/// When the instance has a game dir it also acts as the file-import selector and exposes the shared/dedicated target choice +fn version_card( + instance: &SourceInstance, + selected: bool, + dedicated: bool, + dedicated_available: bool, + mut import_dedicated: State, + on_select: impl FnMut(()) + 'static, +) -> Element { + let importable = instance.has_game_dir; + let mut on_select = on_select; + + let categories = if instance.categories.is_empty() { + "No bundle categories".to_string() + } else { + instance.categories.join(", ") + }; + let subtitle = format!("{} · {}", instance.mc_version, instance.mc_loader); + + let (bg, border_color) = if selected { + (colors::brand(), colors::brand()) + } else { + (colors::page_elevated(), colors::component_border()) + }; + + let header = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(instance.folder_name.clone()) + .font_size(14.) + .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() + })), + ) + // Selection affordance only when the instance actually has files + .maybe_child(importable.then(|| { + if selected { + Icon::new(IconType::Check).size(18.).into_element() + } else { + label() + .text("Import files") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } + })); + + let mut card = rect() + .vertical() + .width(Size::fill()) + .spacing(12.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(bg) + .border(border_all_color(1., border_color)) + .child(header); + + if importable { + card = card + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_select(())); + } + + if selected { + let mut targets = rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Where should these files go?") + .font_size(12.) + .color(colors::fg_primary()), + ) + .child(choice_row( + "Shared game directory", + "Available to every version.", + !dedicated, + move |_| import_dedicated.set(false), + )); + + if dedicated_available { + targets = targets.child(choice_row( + "This version only", + "Keep the files isolated to the matching version.", + dedicated, + move |_| import_dedicated.set(true), + )); + } + + card = card.child(targets); + } + + card.into_element() +} + +/// A standalone import choice with no associated version (e.g. "Don't import") +fn import_choice_card( + title: &str, + subtitle: &str, + trailing: Option, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let (bg, border_color) = if active { + (colors::brand(), colors::brand()) + } else { + (colors::page_elevated(), colors::component_border()) + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(bg) + .border(border_all_color(1., border_color)) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press(())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.to_string()) + .font_size(11.) + .color(if active { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ) + .maybe_child( + active + .then(|| Icon::new(IconType::Check).size(16.)) + .or_else(|| trailing.map(|i| Icon::new(i).size(16.))), + ) + .into_element() +} + +/// Like `onboarding_nav` but Next runs `on_next` (apply migration + navigate) instead of a plain route replace +fn migration_nav(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.)) + .child( + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(Route::OnboardingTerms {}); + }) + .text("Back"), + ) + .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/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs new file mode 100644 index 00000000..9abb4f19 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -0,0 +1,349 @@ +mod account; +mod bundles; +mod downloading; +mod language; +mod migration; +mod preferences; +mod selection; +mod terms; +#[cfg(test)] +pub(crate) mod test_support; +mod welcome; + +pub(crate) use selection::{ + archive_selected, default_selection, is_default_bundle, is_optional_file, pkg_key, + set_archive_selected, +}; + +pub use account::OnboardingAccount; +pub use bundles::OnboardingBundles; +pub use downloading::{LoadingBackdrop, OnboardingDownloading}; +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}; + +use freya::animation::*; +use freya::prelude::*; +use freya::router::{RouterContext, use_route}; + +use crate::Route; +use crate::components::{Button, Icon, IconType, toggle}; +use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; +use crate::theme::colors; +use crate::ui::{border_all_color, entrance_motion_layer}; + +/// 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 { 8 } else { 7 } +} + +pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { + let shift = if has_migration { 1 } else { 0 }; + + match route { + Route::OnboardingWelcome {} => 0, + 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, + } +} + +static LAST_STEP: AtomicUsize = AtomicUsize::new(0); + +const SLIDE_DISTANCE: f32 = 44.; + +pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { + let route = use_route::(); + let migration_query = use_migration(); + let step = onboarding_step_index(&route, has_migration_data(&migration_query)); + let reduce_motion = use_onboarding_selection().reduce_motion; + + let direction = use_hook(|| { + let prev = LAST_STEP.swap(step, Ordering::Relaxed); + if step >= prev { 1.0_f32 } else { -1.0 } + }); + + let anim = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(320) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + let anim_finished = *anim.has_run_yet().read() && !*anim.is_running().read(); + let motion = !*reduce_motion.read(); + + use_side_effect_with_deps(&anim_finished, move |&finished| { + if finished { + Platform::get().send(UserEvent::RequestRedraw); + } + }); + + 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() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(entrance_motion_layer(slide_x, 0., opacity, content)) + .into_element() +} + +pub(crate) fn onboarding_page( + illustration: impl IntoElement, + content: impl IntoElement, + nav: impl IntoElement, +) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(illustration), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .main_align(Alignment::Center) + .padding(Gaps::new(48., 80., 24., 24.)) + .spacing(24.) + .child(content), + ), + )) + .child(nav) + .into_element() +} + +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 { + 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 |_| { + let _ = RouterContext::get().replace(next.clone()); + }) + .text("Next") + .child(Icon::new(IconType::ArrowRight).size(16.)), + ) + .into_element() +} + +pub(crate) fn predownload_toggle_row(predownload: 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("Download content now") + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Downloads Minecraft and your chosen mods now, so you can play as \ + soon as setup finishes. Turn it off to download each version the \ + first time you play it.", + ) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .child(toggle(predownload)) + .into_element() +} + +pub(crate) fn choice_row( + title: &str, + subtitle: &str, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + choice_row_sized(title, subtitle, active, Size::auto(), on_press) +} + +pub(crate) fn choice_row_sized( + title: &str, + subtitle: &str, + active: bool, + height: Size, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let border_color = if active { + colors::fg_primary() + } else { + colors::component_border() + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(height) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., border_color)) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press(())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .maybe_child((!subtitle.is_empty()).then(|| { + label() + .text(subtitle.to_string()) + .font_size(11.) + .color(colors::fg_primary()) + .into_element() + })), + ) + .maybe_child(active.then(|| Icon::new(IconType::Check).size(15.))) + .into_element() +} + +pub(crate) fn version_chip( + text: &str, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let (bg, border, fg) = if active { + ( + colors::brand().with_a(38), + colors::brand(), + colors::fg_primary(), + ) + } else { + ( + Color::TRANSPARENT, + colors::component_border(), + colors::fg_secondary(), + ) + }; + + rect() + .horizontal() + .height(Size::px(32.)) + .center() + .padding(Gaps::new_symmetric(0., 14.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(bg) + .border(border_all_color(1.5, border)) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press(())) + .child( + label() + .text(text.to_string()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(fg), + ) + .into_element() +} + +pub(crate) fn step_heading(title: &str, subtitle: &str) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(title.to_string()) + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.to_string()) + .font_size(16.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/preferences.rs b/packages/oneclient_app/src/view/onboarding/preferences.rs new file mode 100644 index 00000000..e5c461cd --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/preferences.rs @@ -0,0 +1,85 @@ +use freya::prelude::*; + +use crate::components::{IconType, toggle}; +use crate::hooks::{use_dispatch, use_onboarding_selection, use_settings_snapshot}; +use crate::routes::Route; +use crate::view::app::settings::settings_row; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +#[derive(PartialEq)] +pub struct OnboardingPreferences; + +impl Component for OnboardingPreferences { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + let mut reduce_motion = use_onboarding_selection().reduce_motion; + + let animations_on = use_state({ + let v = settings.animations_enabled; + move || v + }); + use_side_effect(move || { + reduce_motion.set_if_modified(!*animations_on.read()); + }); + + let parallax_on = use_state({ + let v = settings.dynamic_background_enabled; + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let parallax = *parallax_on.read(); + let animations = *animations_on.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.dynamic_background_enabled = parallax; + next.animations_enabled = animations; + dispatch.set_settings(next); + }); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(20.) + .child(step_heading( + "Accessibility", + "Tune the launcher to your comfort. You can change these later in settings.", + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child(settings_row( + IconType::Play, + "Animations", + "Disable all launcher animations and transitions.", + toggle(animations_on), + )) + .child(settings_row( + IconType::Eye, + "Parallax background", + "Make the background drift with your cursor. Turning it off disables the motion.", + toggle(parallax_on), + )), + ) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::OnboardingPreferences), + content, + onboarding_nav( + Some(Route::OnboardingBundles {}), + Route::OnboardingDownloading {}, + true, + ), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/selection.rs b/packages/oneclient_app/src/view/onboarding/selection.rs new file mode 100644 index 00000000..c2b38160 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/selection.rs @@ -0,0 +1,216 @@ +use std::collections::HashSet; + +use oneclient_core::{BundleArchive, BundleFile}; + +use crate::hooks::ClusterBundles; + +pub fn pkg_key(cluster_id: i64, bundle_name: &str, package_id: &str) -> String { + format!("{cluster_id}|{bundle_name}|{package_id}") +} + +pub fn is_default_bundle(archive: &BundleArchive) -> bool { + archive.manifest.enabled +} + +pub fn is_default_file(file: &BundleFile) -> bool { + file.enabled && !file.hidden +} + +pub fn is_optional_file(file: &BundleFile) -> bool { + !file.enabled && !file.hidden +} + +pub fn default_selection( + items: &[ClusterBundles], + opted_in_categories: Option<&[String]>, +) -> HashSet { + let mut selected = HashSet::new(); + + for cb in items { + for archive in &cb.archives { + if !is_default_bundle(archive) && !category_opted_in(archive, opted_in_categories) { + continue; + } + for file in &archive.manifest.files { + if is_default_file(file) { + selected.insert(pkg_key( + cb.cluster.id, + &archive.manifest.name, + &file.kind.package_id(), + )); + } + } + } + } + + selected +} + +fn category_opted_in(archive: &BundleArchive, opted_in: Option<&[String]>) -> bool { + let Some(categories) = opted_in else { + return false; + }; + let category = archive.manifest.category.trim(); + if category.is_empty() { + return false; + } + categories.iter().any(|c| c.eq_ignore_ascii_case(category)) +} + +pub fn archive_selected( + cluster_id: i64, + archive: &BundleArchive, + selected: &HashSet, +) -> bool { + let mut any = false; + for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) { + any = true; + if !selected.contains(&pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + )) { + return false; + } + } + any +} + +pub fn set_archive_selected( + cluster_id: i64, + archive: &BundleArchive, + on: bool, + selected: &mut HashSet, +) { + for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) { + let key = pkg_key(cluster_id, &archive.manifest.name, &file.kind.package_id()); + if on { + selected.insert(key); + } else { + selected.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + + fn catalog() -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives: vec![ + archive("QoL", true, vec![file("qol-a", true, false)]), + archive( + "Utility", + true, + vec![file("util-a", true, false), file("util-opt", false, false)], + ), + archive("PvP", false, vec![file("pvp-a", true, false)]), + archive("SkyBlock", false, vec![file("sb-a", true, false)]), + ], + }] + } + + #[test] + fn opt_in_bundles_are_not_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(!selected.iter().any(|k| k.contains("[PvP]"))); + assert!(!selected.iter().any(|k| k.contains("[SkyBlock]"))); + } + + #[test] + fn default_bundles_are_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "qol-a"))); + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [Utility]", "util-a"))); + } + + #[test] + fn files_the_bundle_ships_off_are_not_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(!selected.contains(&pkg_key( + 1, + "OneClient 1.21.11 Fabric [Utility]", + "util-opt" + ))); + } + + #[test] + fn migrated_categories_opt_into_their_bundles_without_losing_defaults() { + let cats = vec!["SkyBlock".to_string()]; + let selected = default_selection(&catalog(), Some(&cats)); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [SkyBlock]", "sb-a"))); + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "qol-a"))); + assert!(!selected.iter().any(|k| k.contains("[PvP]"))); + } + + #[test] + fn migrated_category_match_ignores_case() { + let cats = vec!["skyblock".to_string()]; + let selected = default_selection(&catalog(), Some(&cats)); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [SkyBlock]", "sb-a"))); + } + + #[test] + fn hidden_files_are_never_selected() { + let items = vec![ClusterBundles { + cluster: cluster(1), + archives: vec![archive( + "QoL", + true, + vec![file("shown", true, false), file("dep", true, true)], + )], + }]; + let selected = default_selection(&items, None); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "shown"))); + assert!(!selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "dep"))); + } + + #[test] + fn selection_is_per_cluster() { + let items = [ + ClusterBundles { + cluster: cluster(1), + archives: vec![archive("SkyBlock", false, vec![file("sb-a", true, false)])], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![archive("SkyBlock", false, vec![file("sb-a", true, false)])], + }, + ]; + let mut selected = HashSet::new(); + let sb = &items[0].archives[0]; + set_archive_selected(1, sb, true, &mut selected); + + assert!(archive_selected(1, sb, &selected)); + assert!(!archive_selected(2, &items[1].archives[0], &selected)); + } + + #[test] + fn set_archive_selected_round_trips() { + let items = catalog(); + let pvp = &items[0].archives[2]; + let mut selected = HashSet::new(); + + set_archive_selected(1, pvp, true, &mut selected); + assert!(archive_selected(1, pvp, &selected)); + + set_archive_selected(1, pvp, false, &mut selected); + assert!(!archive_selected(1, pvp, &selected)); + assert!(selected.is_empty()); + } + + #[test] + fn archive_with_no_default_files_is_not_selected() { + let a = archive("Odd", true, vec![file("opt", false, false)]); + assert!(!archive_selected(1, &a, &HashSet::new())); + } +} 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..b4eb2ecf --- /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_common::constants::TOS_URL.to_string()); + let privacy_url = document + .as_ref() + .map(|doc| doc.privacy_url().to_string()) + .unwrap_or_else(|| oneclient_common::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/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs new file mode 100644 index 00000000..87f25aa8 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -0,0 +1,66 @@ +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_content::packages::types::ExternalFile; +use oneclient_core::clusters::{Cluster, ClusterStage}; +use oneclient_core::{Bundle, BundleArchive, BundleFile, BundleFileKind, BundleManifest}; +use std::path::PathBuf; + +pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile { + BundleFile { + enabled, + hidden, + path: format!("mods/{package_id}.jar"), + size: 1, + kind: BundleFileKind::External(ExternalFile { + name: format!("{package_id}.jar"), + url: format!("https://example.invalid/{package_id}.jar"), + sha1: package_id.to_string(), + size: 1, + content_type: ContentType::Mod, + }), + } +} + +pub fn archive(category: &str, enabled: bool, files: Vec) -> BundleArchive { + let name = format!("OneClient 1.21.11 Fabric [{category}]"); + BundleArchive { + bundle: Bundle { + remote_path: format!("/bundles/{category}.mrpack"), + mc_version: "1.21.11".to_string(), + loader: GameLoader::Fabric, + file_name: format!("{category}.mrpack"), + name: name.clone(), + version_id: "1.0.0".to_string(), + category: category.to_string(), + loader_version: "0.16.0".to_string(), + path: PathBuf::from("/tmp/unused.mrpack"), + hidden: false, + }, + manifest: BundleManifest { + name, + version_id: "1.0.0".to_string(), + category: category.to_string(), + mc_version: "1.21.11".to_string(), + loader: GameLoader::Fabric, + loader_version: "0.16.0".to_string(), + enabled, + files, + }, + } +} + +pub fn cluster(id: i64) -> Cluster { + Cluster { + id, + name: format!("1.21.11 Fabric #{id}"), + folder_name: format!("cluster-{id}"), + setting_profile_name: None, + mc_version: "1.21.11".to_string(), + mc_loader: GameLoader::Fabric, + mc_loader_version: None, + stage: ClusterStage::default(), + created_at: None, + last_played: None, + overall_played: std::time::Duration::ZERO, + linked_modpack_hash: None, + } +} diff --git a/packages/oneclient_app/src/view/onboarding/welcome.rs b/packages/oneclient_app/src/view/onboarding/welcome.rs new file mode 100644 index 00000000..37ad66e6 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/welcome.rs @@ -0,0 +1,37 @@ +use freya::prelude::*; + +use crate::components::IconType; +use crate::routes::Route; +use crate::theme::colors; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +#[derive(PartialEq)] +pub struct OnboardingWelcome; + +impl Component for OnboardingWelcome { + fn render(&self) -> impl IntoElement { + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "OneClient", + "Let's get you all set-up with the most advanced client.", + )) + .child( + label() + .text("This quick setup will pick your language, sign you in, and prepare your first versions. It only takes a minute.") + .font_size(15.) + .color(colors::fg_secondary()), + ) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::OnboardingWelcome), + content, + onboarding_nav(None, Route::OnboardingTerms {}, true), + ) + } +} diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs new file mode 100644 index 00000000..1acc63b5 --- /dev/null +++ b/packages/oneclient_app/src/view/startup.rs @@ -0,0 +1,257 @@ +use freya::animation::{AnimNum, Ease, Function, OnCreation, OnFinish, use_animation}; +use freya::{prelude::*, router::*}; + +use crate::AppAssets; +use crate::Route; +use crate::components::{Button, progress_track}; +use crate::hooks::use_dispatch; +use crate::hooks::{ + terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, + use_settings_snapshot, use_splash, use_terms, +}; +use crate::theme::colors; + +/// Startup is a sequence of boolean gates, not measurable work, so the bar steps +/// once per gate the launcher clears instead of tracking bytes +const STARTUP_STEPS: u8 = 3; + +#[derive(PartialEq)] +struct RecoveryActions { + snapshots: usize, +} + +impl Component for RecoveryActions { + fn render(&self) -> impl IntoElement { + let actions = use_dispatch(); + let snapshots = self.snapshots; + + let restore = actions.clone(); + + let mut row = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .secondary() + .small() + .on_press(|_| crate::recovery::open_data_folder()) + .text("Open data folder"), + ); + + if snapshots > 0 { + row = row.child( + Button::new() + .primary() + .small() + .on_press(move |_| crate::recovery::restore_latest(&restore)) + .text("Restore last backup"), + ); + } + + row.child( + Button::new() + .danger() + .small() + .on_press(move |_| crate::recovery::reset(&actions)) + .text("Reset database"), + ) + } +} + +#[derive(PartialEq)] +pub struct Startup; + +impl Component for Startup { + fn render(&self) -> impl IntoElement { + let launcher = use_launcher(); + let settings = use_settings_snapshot(); + let notifications = use_notifications_snapshot(); + let terms = use_terms(); + let splash = use_splash(); + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(620) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + let pulse = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + AnimNum::new(0.82, 1.0) + .time(1600) + .ease(Ease::InOut) + .function(Function::Sine) + }); + + let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); + + if launcher.ready && !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 {} + }; + + // Onboarding needs the bundle catalog before it can render its steps so gate it on the fetch + let heading_to_onboarding = matches!( + destination, + Route::OnboardingWelcome { .. } | Route::OnboardingTerms { .. } + ); + if !heading_to_onboarding || !launcher.fetching { + // Raise the splash curtain so the hard route swap is hidden until Home settles onboarding animates itself + if !heading_to_onboarding { + let mut active = splash.active; + let mut home_ready = splash.home_ready; + home_ready.set(false); + active.set(true); + } + let _ = RouterContext::get().replace(destination); + return rect().into_element(); + } + } + + let active = notifications.inbox.iter().find(|entry| entry.is_loading); + + let (message, detail, step): (String, Option, u8) = + if let Some(err) = launcher.error.as_deref() { + let step = if launcher.ready { 2 } else { 1 }; + ( + "Couldn't start OneClient".to_string(), + Some(err.to_string()), + step, + ) + } 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, 3) + } else if launcher.ready { + ("Fetching versions and bundles...".to_string(), None, 2) + } else { + ("Starting OneClient...".to_string(), None, 1) + }; + + let is_error = launcher.error.is_some(); + + let appear = intro.get().value(); + let rise = (1.0 - appear) * 16.; + let breath = pulse.read().value(); + + let dot_color = if is_error { + colors::danger() + } else { + colors::brand() + }; + + let filled = f32::from(step) / f32::from(STARTUP_STEPS + 1) * 100.; + + let progress = rect().width(Size::px(288.)).child(progress_track( + filled, + 4., + dot_color, + colors::fg_primary().with_a(30), + )); + + let loading_row = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(9.) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(dot_color), + ) + .child( + label() + .text(message) + .font_size(14.) + .color(colors::fg_primary().with_a(180)), + ); + + let mut content = rect() + .center() + .vertical() + .spacing(14.) + .child( + SvgViewer::new(("logo.svg", logo.read().cloned())) + .show_loader(false) + .width(Size::px(288.)) + .height(Size::px(60.)) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The only Minecraft launcher you'll need.") + .font_size(15.) + .color(colors::fg_primary().with_a(140)), + ) + .child(rect().height(Size::px(14.))) + .child(loading_row) + .child(progress); + + if let Some(detail) = detail { + content = content.child( + label() + .text(detail) + .font_size(12.) + .max_lines(if is_error { 4 } else { 1 }) + .color(colors::fg_secondary().with_a(180)), + ); + } + + if is_error { + content = content + .child(rect().height(Size::px(6.))) + .child(RecoveryActions { + snapshots: launcher.snapshots, + }) + .child( + label() + .text(format!( + "Your data stays in {}. Nothing here deletes it.", + launcher.data_dir + )) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary().with_a(130)), + ); + } + + let content = content + .position(Position::new_absolute()) + .width(Size::fill()) + .height(Size::fill()) + .center() + .opacity(appear * breath) + .margin(Gaps::new(rise, 0., 0., 0.)); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::page()) + .window_drag() + .child(content) + .into_element() + } +} diff --git a/packages/oneclient_auth/Cargo.toml b/packages/oneclient_auth/Cargo.toml new file mode 100644 index 00000000..8d404d70 --- /dev/null +++ b/packages/oneclient_auth/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "oneclient_auth" +description = "Microsoft/Xbox/Minecraft authentication and the credentials store" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +reqwest.workspace = true +tokio.workspace = true +tokio-util.workspace = true + +base64.workspace = true +chrono.workspace = true +# Offline UUIDs are MD5 of "OfflinePlayer:", per Mojang. +md-5.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_auth/src/data.rs b/packages/oneclient_auth/src/data.rs new file mode 100644 index 00000000..63beb128 --- /dev/null +++ b/packages/oneclient_auth/src/data.rs @@ -0,0 +1,80 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountKind { + Microsoft, + Offline, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinecraftAccount { + pub id: Uuid, + pub username: String, + pub access_token: String, + pub refresh_token: String, + pub expires: DateTime, + #[serde(default = "default_account_kind")] + pub kind: AccountKind, +} + +fn default_account_kind() -> AccountKind { + AccountKind::Microsoft +} + +impl MinecraftAccount { + pub fn is_microsoft(&self) -> bool { + self.kind == AccountKind::Microsoft + } + + pub fn is_offline(&self) -> bool { + self.kind == AccountKind::Offline + } + + pub fn is_expired(&self) -> bool { + self.expires <= Utc::now() + chrono::TimeDelta::seconds(60) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DeviceCodeLogin { + pub user_code: String, + pub device_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BrowserLogin { + pub auth_url: String, + pub state: String, + pub redirect_uri: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MicrosoftLoginSession { + pub browser: BrowserLogin, + pub device: DeviceCodeLogin, +} + +impl MicrosoftLoginSession { + pub fn dedupe_key(&self) -> &str { + &self.browser.state + } + + pub fn auth_url(&self) -> &str { + &self.browser.auth_url + } + + pub fn user_code(&self) -> &str { + &self.device.user_code + } + + pub fn verification_uri(&self) -> &str { + &self.device.verification_uri + } +} diff --git a/packages/oneclient_auth/src/diagnostics.rs b/packages/oneclient_auth/src/diagnostics.rs new file mode 100644 index 00000000..820e0b49 --- /dev/null +++ b/packages/oneclient_auth/src/diagnostics.rs @@ -0,0 +1,349 @@ +//! Adapted from Modrinth's `minecraft-auth-errors.ts` +//! (GPL-3.0) + +use oneclient_net::{NetworkFailure, classify_network_failure}; +use reqwest::StatusCode; + +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AuthErrorGuidance { + pub what_happened: String, + pub steps_to_fix: Vec, +} + +impl AuthErrorGuidance { + fn new(what_happened: &str, steps_to_fix: &[&str]) -> Self { + Self { + what_happened: what_happened.to_string(), + steps_to_fix: steps_to_fix.iter().map(|s| (*s).to_string()).collect(), + } + } +} + +#[must_use] +pub fn diagnose_auth_error(err: &MinecraftAuthError) -> Option { + match err { + MinecraftAuthError::XboxError { error_code, .. } => guidance_for_xerr(*error_code), + + MinecraftAuthError::ServiceError { step, status_code } => { + guidance_for_service_error(*step, *status_code) + } + + MinecraftAuthError::DeserializeError { step, .. } => guidance_for_step(*step), + + MinecraftAuthError::RequestError { source, .. } => { + Some(network_guidance(classify_network_failure(source))) + } + + MinecraftAuthError::BrowserAuthorizationExpired + | MinecraftAuthError::BrowserLoginNotFound => Some(AuthErrorGuidance::new( + "The Microsoft sign-in window was closed or expired before sign-in finished.", + &[ + "Start the sign-in again", + "Complete the Microsoft sign-in in your browser without closing the window", + "If your browser blocked the redirect back, allow it and try once more", + ], + )), + + _ => None, + } +} + +fn guidance_for_xerr(code: u64) -> Option { + Some(match code { + 2_148_916_222 => AuthErrorGuidance::new( + "This account requires age verification to comply with UK regulations before it can sign in.", + &[ + "Go to the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Follow the instructions to verify your age", + "Once verified, try signing in again", + "For more help see UK age verification on Xbox (https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification)", + ], + ), + 2_148_916_227 => AuthErrorGuidance::new( + "This account was suspended for violating Xbox Community Standards.", + &[ + "Visit Xbox Support and review the enforcement details (https://support.xbox.com)", + "Submit an appeal if one is available", + ], + ), + 2_148_916_229 => AuthErrorGuidance::new( + "This account is restricted and does not have permission to play online.", + &[ + "Have a guardian sign in to Microsoft Family (https://account.microsoft.com/family/)", + "Update the online play permissions", + "Once finished, try signing in again", + ], + ), + 2_148_916_233 => AuthErrorGuidance::new( + "This account does not have an Xbox profile set up, or does not own Minecraft.", + &[ + "Make sure Minecraft is purchased on this account", + "Visit the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Complete Xbox profile setup if prompted", + "Once finished, try signing in again", + ], + ), + 2_148_916_234 => AuthErrorGuidance::new( + "This account has not accepted Xbox's Terms of Service.", + &[ + "Visit Xbox and sign in (https://www.xbox.com)", + "Accept the Terms if prompted", + "Once finished, try signing in again", + ], + ), + 2_148_916_235 => AuthErrorGuidance::new( + "Xbox Live is not available in your region, so sign-in is blocked.", + &[ + "Xbox services must be supported in your country before you can sign in", + "Check Xbox availability for supported regions (https://www.xbox.com/en-US/regions)", + ], + ), + 2_148_916_236 | 2_148_916_237 => AuthErrorGuidance::new( + "This account requires adult verification under South Korean regulations.", + &[ + "Visit Xbox and sign in (https://www.xbox.com)", + "Complete the identity verification process", + "Once finished, try signing in again", + ], + ), + 2_148_916_238 => AuthErrorGuidance::new( + "This account is underage and not linked to a Microsoft family group.", + &[ + "Review the Minecraft Family Setup guide (https://help.minecraft.net/hc/en-us/articles/4408968616077)", + "Join or create a family group as instructed", + "Once finished, try signing in again", + ], + ), + _ => return None, + }) +} + +fn guidance_for_service_error( + step: MinecraftAuthStep, + status: StatusCode, +) -> Option { + if step == MinecraftAuthStep::MinecraftToken { + if status == StatusCode::TOO_MANY_REQUESTS { + return Some(AuthErrorGuidance::new( + "Microsoft or Minecraft temporarily blocked the sign-in because there were too many recent attempts.", + &[ + "Wait about an hour before trying again", + "Restart OneClient after waiting", + "Try signing in once more", + "If it keeps happening, wait longer before retrying so the temporary limit can clear", + ], + )); + } + if status.is_server_error() { + return Some(AuthErrorGuidance::new( + "Minecraft's authentication service is returning a server error, so sign-in cannot finish right now.", + &[ + "Wait a few minutes and try signing in again", + "Check Xbox status for current service issues (https://support.xbox.com/xbox-live-status)", + "Try the official Minecraft Launcher to confirm whether Minecraft sign-in is affected there too (https://www.minecraft.net/en-us/download)", + "If the service is healthy and this keeps happening, contact support with the debug information", + ], + )); + } + } + + guidance_for_step(step) +} + +fn guidance_for_step(step: MinecraftAuthStep) -> Option { + Some(match step { + MinecraftAuthStep::RefreshToken | MinecraftAuthStep::AuthCodeExchange => { + AuthErrorGuidance::new( + "Your saved Microsoft sign-in has expired or was revoked, so your Minecraft session could not be renewed.", + &[ + "Sign out of the affected Minecraft account in OneClient", + "Sign in to the account again", + "Once the new sign-in finishes, try launching Minecraft again", + ], + ) + } + MinecraftAuthStep::XblAuthenticate => AuthErrorGuidance::new( + "Xbox rejected the first sign-in step. This is most often caused by your system clock or time zone being out of sync, or by a temporary Xbox block.", + &[ + "Open your system date and time settings", + "Turn on automatic time zone and automatic time, if available", + "Use the sync option in your settings to synchronise the clock", + "Restart OneClient and try signing in again", + "If it persists, check Xbox status (https://support.xbox.com/xbox-live-status)", + ], + ), + MinecraftAuthStep::XstsAuthorize => AuthErrorGuidance::new( + "Xbox rejected the request to authorize this account for Minecraft, but did not return a specific account restriction we recognise.", + &[ + "Sign in with the official Minecraft Launcher (https://www.minecraft.net/en-us/download)", + "Complete any prompts shown by Microsoft, Xbox, or Minecraft", + "Try signing in to OneClient again", + "If the official launcher also fails, follow the error shown there or contact Xbox Support", + ], + ), + MinecraftAuthStep::MinecraftProfile => AuthErrorGuidance::new( + "Minecraft services could not return a Java Edition profile for this account. This usually means the game was purchased recently, the Java profile is not finished being created, or the wrong Microsoft account is being used.", + &[ + "Sign in with the official Minecraft Launcher and launch Java Edition once (https://www.minecraft.net/en-us/download)", + "Wait up to an hour if the purchase or profile setup was recent", + "Make sure you are using the Microsoft account that owns Minecraft", + "Try signing in to OneClient again", + ], + ), + _ => return None, + }) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AuthErrorSample { + pub label: &'static str, + pub message: String, + pub guidance: Option, +} + +#[must_use] +pub fn preview_samples() -> Vec { + use MinecraftAuthStep::{ + MinecraftProfile, MinecraftToken, RefreshToken, XblAuthenticate, XstsAuthorize, + }; + + let errors: Vec<(&'static str, MinecraftAuthError)> = vec![ + ( + "Xbox 403 (RUST-4)", + MinecraftAuthError::ServiceError { + step: XblAuthenticate, + status_code: StatusCode::FORBIDDEN, + }, + ), + ("No Xbox profile", xerr(2_148_916_233)), + ("Region blocked", xerr(2_148_916_235)), + ("Child account", xerr(2_148_916_238)), + ("Account suspended", xerr(2_148_916_227)), + ("Restricted / no online play", xerr(2_148_916_229)), + ("ToS not accepted", xerr(2_148_916_234)), + ("UK age verification", xerr(2_148_916_222)), + ("KR adult verification", xerr(2_148_916_236)), + ( + "Rate limited (429)", + MinecraftAuthError::ServiceError { + step: MinecraftToken, + status_code: StatusCode::TOO_MANY_REQUESTS, + }, + ), + ( + "MC service error (503)", + MinecraftAuthError::ServiceError { + step: MinecraftToken, + status_code: StatusCode::SERVICE_UNAVAILABLE, + }, + ), + ( + "Profile fetch failed", + MinecraftAuthError::ServiceError { + step: MinecraftProfile, + status_code: StatusCode::NOT_FOUND, + }, + ), + ( + "Session expired", + MinecraftAuthError::ServiceError { + step: RefreshToken, + status_code: StatusCode::BAD_REQUEST, + }, + ), + ( + "Generic Xbox reject", + MinecraftAuthError::ServiceError { + step: XstsAuthorize, + status_code: StatusCode::UNAUTHORIZED, + }, + ), + ]; + + let mut out: Vec = errors + .into_iter() + .map(|(label, err)| AuthErrorSample { + label, + message: err.to_string(), + guidance: diagnose_auth_error(&err), + }) + .collect(); + + out.push(AuthErrorSample { + label: "Network unreachable", + message: "error sending request for url (https://user.auth.xboxlive.com/user/authenticate): connection closed".to_string(), + guidance: Some(network_guidance(NetworkFailure::Generic)), + }); + out.push(AuthErrorSample { + label: "TLS certificate rejected", + message: "error sending request for url (https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode): invalid peer certificate: UnknownIssuer".to_string(), + guidance: Some(network_guidance(NetworkFailure::Certificate)), + }); + out.push(AuthErrorSample { + label: "TLS handshake failed", + message: "error sending request for url (https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode): received fatal alert: HandshakeFailure".to_string(), + guidance: Some(network_guidance(NetworkFailure::Handshake)), + }); + out.push(AuthErrorSample { + label: "DNS lookup failed", + message: "error sending request for url (https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode): dns error: failed to lookup address information".to_string(), + guidance: Some(network_guidance(NetworkFailure::Dns)), + }); + out +} + +fn xerr(code: u64) -> MinecraftAuthError { + MinecraftAuthError::XboxError { + step: MinecraftAuthStep::XstsAuthorize, + error_code: code, + message: friendly_xbox_error(code) + .unwrap_or("Xbox rejected sign-in.") + .to_string(), + redirect: None, + } +} + +fn network_guidance(failure: NetworkFailure) -> AuthErrorGuidance { + match failure { + NetworkFailure::Certificate => AuthErrorGuidance::new( + "OneClient reached the Microsoft sign-in service, but could not verify its security certificate. Something on this machine or network is intercepting the encrypted connection — usually antivirus HTTPS scanning, a school or workplace filter, or a VPN.", + &[ + "Turn off HTTPS/SSL scanning in your antivirus (often called \"encrypted connection scanning\", \"web shield\", or \"SSL interception\")", + "Temporarily disable your VPN or proxy and try signing in again", + "If you are on a school or workplace network, try a home network or phone hotspot to confirm", + "Check that your system date, time, and time zone are correct", + ], + ), + NetworkFailure::Handshake => AuthErrorGuidance::new( + "OneClient reached the Microsoft sign-in service, but the two could not agree on a secure connection. This normally means a proxy, filter, or antivirus in between is using outdated encryption settings.", + &[ + "Turn off HTTPS/SSL scanning in your antivirus and try signing in again", + "Temporarily disable your VPN or proxy software", + "If you are on a school or workplace network, try a home network or phone hotspot to confirm", + "Make sure OneClient is up to date", + ], + ), + NetworkFailure::Dns => AuthErrorGuidance::new( + "OneClient could not look up the address of the Microsoft sign-in service. This is a DNS problem on this machine or network, not a problem with your account.", + &[ + "Check that your internet connection is working", + "Temporarily disable your VPN or proxy and try signing in again", + "Check your hosts file for entries blocking Microsoft or Minecraft domains", + "Try a different DNS server, such as Cloudflare (1.1.1.1) or Google (8.8.8.8)", + "Restart OneClient and try signing in again", + ], + ), + NetworkFailure::Generic => AuthErrorGuidance::new( + "OneClient could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.", + &[ + "Restart OneClient and try signing in again", + "Check that your internet connection is working", + "Allow OneClient through your firewall, antivirus, proxy, VPN, and hosts file rules", + "Try a different network, or temporarily disable VPN/proxy software if you use one", + "If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help", + ], + ), + } +} diff --git a/packages/oneclient_auth/src/error.rs b/packages/oneclient_auth/src/error.rs new file mode 100644 index 00000000..a3014884 --- /dev/null +++ b/packages/oneclient_auth/src/error.rs @@ -0,0 +1,139 @@ +use serde::Serialize; + +use oneclient_net::RequestError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum MinecraftAuthStep { + DeviceCodeRequest, + DeviceCodePoll, + AuthCodeExchange, + RefreshToken, + XblAuthenticate, + XstsAuthorize, + MinecraftToken, + MinecraftEntitlements, + MinecraftProfile, +} + +#[derive(Debug, thiserror::Error)] +pub enum MinecraftAuthError { + #[error("failed to serialize JSON during MSA step {step:?}: {source}")] + SerializeError { + step: MinecraftAuthStep, + #[source] + source: serde_json::Error, + }, + #[error( + "failed to deserialize JSON during MSA step {step:?}: {source}! status {status_code} - body: {raw}" + )] + DeserializeError { + step: MinecraftAuthStep, + raw: String, + #[source] + source: serde_json::Error, + status_code: reqwest::StatusCode, + }, + // Rendered via `error_chain` reqwest's `Display` names only the URL and the + // cause we need (certificate handshake DNS refused) is under it + #[error( + "failed to request using HTTP during MSA step {step:?}: {}", + oneclient_net::error_chain(source) + )] + RequestError { + step: MinecraftAuthStep, + #[source] + source: reqwest::Error, + }, + #[error("waiting for user to complete device authorization")] + DeviceAuthorizationPending, + #[error("device authorization polling interval increased; retrying")] + DeviceAuthorizationSlowDown, + #[error("device authorization expired before the user signed in")] + DeviceAuthorizationExpired, + #[error("device authorization failed: {error}")] + DeviceAuthorizationFailed { error: String }, + #[error("failed to start the loopback redirect server: {0}")] + LoopbackBind(String), + #[error("browser sign-in was cancelled or timed out before completing")] + BrowserAuthorizationExpired, + #[error("this browser sign-in is no longer active")] + BrowserLoginNotFound, + #[error("browser authorization failed: {error}")] + BrowserAuthorizationFailed { error: String }, + #[error("failed to read user hash from Xbox token")] + HashError, + #[error("{message}")] + XboxError { + step: MinecraftAuthStep, + error_code: u64, + message: String, + redirect: Option, + }, + #[error( + "The sign-in service returned an error (HTTP {status_code}) during step {step:?}. Please wait a moment and try again." + )] + ServiceError { + step: MinecraftAuthStep, + status_code: reqwest::StatusCode, + }, +} + +#[must_use] +pub fn friendly_xbox_error(code: u64) -> Option<&'static str> { + Some(match code { + 2_148_916_227 => "This account has been banned or suspended from Xbox.", + 2_148_916_229 => { + "This account is a child account that must be added to a Family group by an adult before signing in." + } + 2_148_916_233 => { + "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again." + } + 2_148_916_234 => { + "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first." + } + 2_148_916_235 => { + "Xbox Live is not available in your country or region, so sign-in is blocked." + } + 2_148_916_236 | 2_148_916_237 => { + "This account requires adult verification (South Korea) before it can sign in." + } + 2_148_916_238 => { + "This is a child account. An adult must add it to a Microsoft Family group before it can sign in." + } + _ => return None, + }) +} + +pub type AuthResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error(transparent)] + Minecraft(#[from] MinecraftAuthError), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error(transparent)] + Request(#[from] RequestError), + + /// An outcome rather than a failure nothing was written by the time this is + /// produced + #[error("sign-in was cancelled")] + LoginCancelled, + + #[error("offline mode requires at least one Microsoft account to be signed in")] + OfflineRequiresMicrosoft, + + #[error("invalid offline username: {reason}")] + InvalidOfflineUsername { reason: String }, + + #[error("account {0} is not registered")] + AccountNotFound(uuid::Uuid), + + #[error("account already exists for username {username:?}")] + DuplicateUsername { username: String }, +} diff --git a/packages/oneclient_auth/src/lib.rs b/packages/oneclient_auth/src/lib.rs new file mode 100644 index 00000000..920dc164 --- /dev/null +++ b/packages/oneclient_auth/src/lib.rs @@ -0,0 +1,21 @@ +//! Construct [`AuthService`] in the composition layer and pass it down nothing +//! in here reaches for a global or a database +//! Accounts persist to `auth.json` + +mod data; +mod diagnostics; +mod error; +mod msa; +mod offline; +mod service; +mod store; + +pub use data::{ + AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, +}; +pub use diagnostics::{AuthErrorGuidance, AuthErrorSample, diagnose_auth_error, preview_samples}; +pub use error::{AuthError, AuthResult, MinecraftAuthError, MinecraftAuthStep}; +pub use msa::PendingBrowserLogin; +pub use offline::{offline_account, offline_uuid, validate_offline_username}; +pub use service::{AuthService, MICROSOFT_LOGIN_PROGRESS}; +pub use store::CredentialsStore; diff --git a/packages/oneclient_auth/src/msa.rs b/packages/oneclient_auth/src/msa.rs new file mode 100644 index 00000000..b94a721e --- /dev/null +++ b/packages/oneclient_auth/src/msa.rs @@ -0,0 +1,823 @@ +use std::future::Future; +use std::time::Duration; + +use base64::Engine; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::Deserialize; +use serde::de::Error as DeError; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; +use uuid::Uuid; + +use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; +use oneclient_common::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; + +const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; + +const AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; + +const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + +const AUTH_STEPS: u64 = 5; + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn begin_device_login(client: &Client) -> Result { + request_device_code(client).await +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn finish_dual_login( + client: &Client, + pending: PendingBrowserLogin, + device: &DeviceCodeLogin, + on_progress: impl Fn(&str, u64, u64), +) -> Result { + tracing::info!("waiting for Microsoft sign-in (browser or device code)"); + on_progress("Waiting for sign-in", 0, AUTH_STEPS); + + let deadline = Duration::from_secs(device.expires_in); + let msa = tokio::time::timeout(deadline, race_msa_token(client, &pending, device)) + .await + .map_err(|_| MinecraftAuthError::DeviceAuthorizationExpired)??; + + account_from_msa_token(client, msa, on_progress).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn race_msa_token( + client: &Client, + pending: &PendingBrowserLogin, + device: &DeviceCodeLogin, +) -> Result { + let browser = browser_msa_token(client, pending); + let device = poll_device_token(client, device); + tokio::pin!(browser, device); + + let mut browser_done = false; + let mut device_done = false; + let mut last_err: Option = None; + + loop { + tokio::select! { + res = &mut browser, if !browser_done => match res { + Ok(token) => return Ok(token), + Err(err) => { + browser_done = true; + if device_done { + return Err(err); + } + last_err = Some(err); + } + }, + res = &mut device, if !device_done => match res { + Ok(token) => return Ok(token), + Err(err) => { + device_done = true; + if browser_done { + return Err(err); + } + last_err = Some(err); + } + }, + else => return Err(last_err.unwrap_or(MinecraftAuthError::DeviceAuthorizationExpired)), + } + } +} + +pub struct PendingBrowserLogin { + listener: TcpListener, + pkce_verifier: String, + redirect_uri: String, + csrf_state: String, +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin), MinecraftAuthError> +{ + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))?; + let port = listener + .local_addr() + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))? + .port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + + let pkce_verifier = random_token(); + let code_challenge = pkce_challenge(&pkce_verifier); + let csrf_state = random_token(); + + let mut url = Url::parse(AUTHORIZE_URL).expect("authorize url is valid"); + url.query_pairs_mut() + .append_pair("client_id", MICROSOFT_CLIENT_ID) + .append_pair("response_type", "code") + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_mode", "query") + .append_pair("scope", MINECRAFT_SCOPES) + .append_pair("state", &csrf_state) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("prompt", "select_account"); + + let login = BrowserLogin { + auth_url: url.to_string(), + state: csrf_state.clone(), + redirect_uri: redirect_uri.clone(), + }; + let pending = PendingBrowserLogin { + listener, + pkce_verifier, + redirect_uri, + csrf_state, + }; + + Ok((login, pending)) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn browser_msa_token( + client: &Client, + pending: &PendingBrowserLogin, +) -> Result { + let code = wait_for_redirect(pending).await?; + exchange_auth_code(client, &code, pending).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result { + loop { + let (mut stream, _) = pending + .listener + .accept() + .await + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))?; + + let mut buf = [0u8; 2048]; + let read = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..read]); + let Some(target) = request.split_whitespace().nth(1) else { + respond(&mut stream, RedirectPage::Waiting).await; + continue; + }; + + if !target.contains('?') { + respond(&mut stream, RedirectPage::Waiting).await; + continue; + } + + let full = format!("http://127.0.0.1{target}"); + let parsed = Url::parse(&full).ok(); + let mut code = None; + let mut state = None; + let mut error = None; + if let Some(parsed) = &parsed { + for (key, value) in parsed.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + "error_description" => error = Some(value.into_owned()), + _ => {} + } + } + } + + if let Some(error) = error { + tracing::warn!(%error, "browser authorization failed"); + respond(&mut stream, RedirectPage::Failed).await; + return Err(MinecraftAuthError::BrowserAuthorizationFailed { error }); + } + + if state.as_deref() != Some(pending.csrf_state.as_str()) { + respond(&mut stream, RedirectPage::Failed).await; + continue; + } + + if let Some(code) = code { + respond(&mut stream, RedirectPage::Success).await; + return Ok(code); + } + + respond(&mut stream, RedirectPage::Waiting).await; + } +} + +#[derive(Clone, Copy)] +enum RedirectPage { + Waiting, + Success, + Failed, +} + +async fn respond(stream: &mut tokio::net::TcpStream, page: RedirectPage) { + let body = redirect_page_html(page); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +fn redirect_page_html(page: RedirectPage) -> String { + let (accent, glyph, heading, detail) = match page { + RedirectPage::Waiting => ( + "#2567d8", + r#"

    "#, + "Signing you in", + "Finish signing in with Microsoft in this window.", + ), + RedirectPage::Success => ( + "#45de2b", + r#""#, + "You're signed in!", + "You can close this tab and return to OneClient.", + ), + RedirectPage::Failed => ( + "#ff0000", + r#""#, + "Sign-in failed", + "Something went wrong. Close this tab and try again from OneClient.", + ), + }; + + format!( + r##" + + +OneClient + + +
    +
    {glyph}
    +

    {heading}

    +

    {detail}

    +
    +"## + ) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn exchange_auth_code( + client: &Client, + code: &str, + pending: &PendingBrowserLogin, +) -> Result { + let body = [ + ("client_id", MICROSOFT_CLIENT_ID), + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", pending.redirect_uri.as_str()), + ("code_verifier", pending.pkce_verifier.as_str()), + ("scope", MINECRAFT_SCOPES), + ]; + + let res = auth_retry(|| { + client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::AuthCodeExchange, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::AuthCodeExchange) + .await + .map(MsaToken::from_response) +} + +fn random_token() -> String { + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + BASE64_URL_SAFE_NO_PAD.encode(bytes) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + BASE64_URL_SAFE_NO_PAD.encode(digest) +} + +#[tracing::instrument(level = "debug", skip_all, fields(username = %creds.username))] +pub async fn refresh_microsoft_account( + client: &Client, + creds: &MinecraftAccount, +) -> Result { + tracing::debug!("refreshing Microsoft account token"); + let msa = refresh_msa_token(client, &creds.refresh_token).await?; + let mut account = account_from_msa_token(client, msa, |_, _, _| {}).await?; + account.id = creds.id; + Ok(account) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn request_device_code(client: &Client) -> Result { + let body = [ + ("client_id", MICROSOFT_CLIENT_ID), + ("scope", MINECRAFT_SCOPES), + ]; + + let res = auth_retry(|| { + client + .post(DEVICE_CODE_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::DeviceCodeRequest, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::DeviceCodeRequest).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn poll_device_token( + client: &Client, + flow: &DeviceCodeLogin, +) -> Result { + let deadline = Utc::now() + chrono::TimeDelta::seconds(flow.expires_in as i64); + let mut interval_secs = flow.interval.max(5); + + loop { + if Utc::now() >= deadline { + return Err(MinecraftAuthError::DeviceAuthorizationExpired); + } + + let body = [ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", MICROSOFT_CLIENT_ID), + ("device_code", flow.device_code.as_str()), + ]; + + let res = auth_retry(|| { + client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::DeviceCodePoll, + source, + })?; + + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::DeviceCodePoll, + source, + })?; + + if status.is_success() { + let raw: MsaTokenResponse = serde_json::from_str(&text).map_err(|source| { + MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::DeviceCodePoll, + raw: text, + source, + status_code: status, + } + })?; + return Ok(MsaToken::from_response(raw)); + } + + if let Ok(err) = serde_json::from_str::(&text) { + match err.error.as_str() { + "authorization_pending" => { + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + continue; + } + "slow_down" => { + interval_secs = interval_secs.saturating_add(5); + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + continue; + } + "expired_token" => return Err(MinecraftAuthError::DeviceAuthorizationExpired), + other => { + return Err(MinecraftAuthError::DeviceAuthorizationFailed { + error: err.error_description.unwrap_or_else(|| other.to_string()), + }); + } + } + } + + return Err(MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::DeviceCodePoll, + raw: text, + source: DeError::custom("unexpected device code poll response"), + status_code: status, + }); + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn refresh_msa_token( + client: &Client, + refresh_token: &str, +) -> Result { + let body = [ + ("client_id", MICROSOFT_CLIENT_ID), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ("scope", MINECRAFT_SCOPES), + ]; + + let res = auth_retry(|| { + client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::RefreshToken, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::RefreshToken) + .await + .map(MsaToken::from_response) +} + +struct MsaToken { + access_token: String, + refresh_token: String, + expires_in: u64, + obtained_at: DateTime, +} + +impl MsaToken { + fn from_response(raw: MsaTokenResponse) -> Self { + Self { + access_token: raw.access_token, + refresh_token: raw.refresh_token, + expires_in: raw.expires_in, + obtained_at: Utc::now(), + } + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn account_from_msa_token( + client: &Client, + msa: MsaToken, + on_progress: impl Fn(&str, u64, u64), +) -> Result { + let rps_ticket = format!("d={}", msa.access_token); + on_progress("Authenticating with Xbox Live", 1, AUTH_STEPS); + let xbl = xbl_authenticate(client, &rps_ticket).await?; + on_progress("Authorizing access (XSTS)", 2, AUTH_STEPS); + let xsts = xsts_authorize(client, &xbl.token).await?; + + let xsts_token = xsts.token.ok_or(MinecraftAuthError::HashError)?; + + let uhs: &str = xsts + .display_claims + .as_ref() + .and_then(|c| c.xui.first()) + .map(|x| x.uhs.as_str()) + .ok_or(MinecraftAuthError::HashError)?; + + on_progress("Requesting Minecraft token", 3, AUTH_STEPS); + let minecraft_token = minecraft_login(client, uhs, &xsts_token).await?; + on_progress("Verifying game ownership", 4, AUTH_STEPS); + minecraft_entitlements(client, &minecraft_token.access_token).await?; + let profile = minecraft_profile(client, &minecraft_token.access_token).await?; + on_progress("Loading profile", AUTH_STEPS, AUTH_STEPS); + + Ok(MinecraftAccount { + id: profile.id.unwrap_or_default(), + username: profile.name, + access_token: minecraft_token.access_token, + refresh_token: msa.refresh_token, + #[allow(clippy::cast_possible_wrap)] + expires: msa.obtained_at + chrono::TimeDelta::seconds(msa.expires_in as i64), + kind: AccountKind::Microsoft, + }) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn xbl_authenticate( + client: &Client, + rps_ticket: &str, +) -> Result { + let res = auth_retry(|| { + client + .post("https://user.auth.xboxlive.com/user/authenticate") + .header("Accept", "application/json") + .header("x-xbl-contract-version", "1") + .json(&json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": rps_ticket + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + })) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::XblAuthenticate, + source, + })?; + + let token: XboxTokenResponse = + parse_json_response(res, MinecraftAuthStep::XblAuthenticate).await?; + check_xbox_error(MinecraftAuthStep::XblAuthenticate, &token)?; + + Ok(XblUserToken { + token: token.token.ok_or(MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::XblAuthenticate, + raw: String::new(), + source: DeError::custom("missing Token"), + status_code: reqwest::StatusCode::OK, + })?, + }) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn xsts_authorize( + client: &Client, + user_token: &str, +) -> Result { + let res = auth_retry(|| { + client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .header("Accept", "application/json") + .header("x-xbl-contract-version", "1") + .json(&json!({ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [user_token] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + })) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::XstsAuthorize, + source, + })?; + + let token: XboxTokenResponse = + parse_json_response(res, MinecraftAuthStep::XstsAuthorize).await?; + check_xbox_error(MinecraftAuthStep::XstsAuthorize, &token)?; + Ok(token) +} + +#[derive(Deserialize)] +struct MinecraftToken { + access_token: String, +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn minecraft_login( + client: &Client, + uhs: &str, + xsts_token: &str, +) -> Result { + let res = auth_retry(|| { + client + .post("https://api.minecraftservices.com/authentication/login_with_xbox") + .header("Accept", "application/json") + .json(&json!({ + "identityToken": format!("XBL3.0 x={uhs};{xsts_token}"), + })) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftToken, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::MinecraftToken).await +} + +#[derive(Deserialize)] +struct MinecraftProfile { + id: Option, + name: String, +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn minecraft_profile( + client: &Client, + token: &str, +) -> Result { + let res = auth_retry(|| { + client + .get("https://api.minecraftservices.com/minecraft/profile") + .header("Accept", "application/json") + .bearer_auth(token) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftProfile, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::MinecraftProfile).await +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MinecraftEntitlements {} + +#[tracing::instrument(level = "debug", skip_all)] +async fn minecraft_entitlements( + client: &Client, + token: &str, +) -> Result { + let res = auth_retry(|| { + client + .get("https://api.minecraftservices.com/entitlements/mcstore") + .header("Accept", "application/json") + .bearer_auth(token) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftEntitlements, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::MinecraftEntitlements).await +} + +#[derive(Deserialize)] +struct OAuthErrorResponse { + error: String, + error_description: Option, +} + +#[derive(Deserialize)] +struct MsaTokenResponse { + access_token: String, + refresh_token: String, + expires_in: u64, +} + +struct XblUserToken { + token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct XboxTokenResponse { + #[serde(default)] + #[serde(rename = "XErr")] + x_err: u64, + #[serde(default)] + #[serde(rename = "Message")] + message: String, + #[serde(rename = "Redirect", default)] + redirect: Option, + #[serde(default)] + token: Option, + #[serde(default)] + display_claims: Option, +} + +#[derive(Debug, Deserialize)] +struct DisplayClaims { + #[serde(default)] + xui: Vec, +} + +#[derive(Debug, Deserialize)] +struct XuiClaim { + uhs: String, +} + +fn check_xbox_error( + step: MinecraftAuthStep, + token: &XboxTokenResponse, +) -> Result<(), MinecraftAuthError> { + if token.x_err == 0 { + return Ok(()); + } + + let message = friendly_xbox_error(token.x_err) + .map(String::from) + .unwrap_or_else(|| { + if token.message.is_empty() { + format!("Xbox rejected sign-in (error {}).", token.x_err) + } else { + token.message.clone() + } + }); + + Err(MinecraftAuthError::XboxError { + step, + error_code: token.x_err, + message, + redirect: token.redirect.clone(), + }) +} + +async fn parse_json_response Deserialize<'de>>( + res: reqwest::Response, + step: MinecraftAuthStep, +) -> Result { + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { step, source })?; + + if !status.is_success() { + tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status"); + } + + serde_json::from_str(&text).map_err(|source| { + if !status.is_success() { + MinecraftAuthError::ServiceError { + step, + status_code: status, + } + } else { + MinecraftAuthError::DeserializeError { + step, + raw: text, + source, + status_code: status, + } + } + }) +} + +async fn auth_retry(request_fn: impl Fn() -> F) -> Result +where + F: Future>, +{ + const RETRY_COUNT: usize = 5; + const RETRY_WAIT: Duration = Duration::from_millis(250); + let mut resp = request_fn().await; + + for i in 0..RETRY_COUNT { + match &resp { + Ok(_) => break, + Err(err) if (err.is_connect() || err.is_timeout()) && i < RETRY_COUNT - 1 => { + tokio::time::sleep(RETRY_WAIT).await; + resp = request_fn().await; + } + _ => break, + } + } + + resp +} diff --git a/packages/oneclient_auth/src/offline.rs b/packages/oneclient_auth/src/offline.rs new file mode 100644 index 00000000..fe292200 --- /dev/null +++ b/packages/oneclient_auth/src/offline.rs @@ -0,0 +1,52 @@ +use chrono::{Duration, Utc}; +use md5::{Digest, Md5}; +use uuid::Uuid; + +use super::data::{AccountKind, MinecraftAccount}; +use super::error::AuthError; +use crate::error::AuthResult; + +pub fn offline_uuid(username: &str) -> Uuid { + let mut hasher = Md5::new(); + hasher.update(format!("OfflinePlayer:{username}").as_bytes()); + let digest = hasher.finalize(); + + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest); + bytes[6] = (bytes[6] & 0x0f) | 0x30; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} + +pub fn validate_offline_username(username: &str) -> AuthResult<()> { + let len = username.chars().count(); + if !(3..=16).contains(&len) { + return Err(AuthError::InvalidOfflineUsername { + reason: "username must be 3-16 characters".into(), + }); + } + + if !username + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(AuthError::InvalidOfflineUsername { + reason: "username may only contain letters, digits, and underscores".into(), + }); + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", fields(username = %username))] +pub fn offline_account(username: String) -> MinecraftAccount { + tracing::info!("creating offline account"); + MinecraftAccount { + id: offline_uuid(&username), + username, + access_token: String::new(), + refresh_token: String::new(), + expires: Utc::now() + Duration::days(3650), + kind: AccountKind::Offline, + } +} diff --git a/packages/oneclient_auth/src/service.rs b/packages/oneclient_auth/src/service.rs new file mode 100644 index 00000000..763bc9a2 --- /dev/null +++ b/packages/oneclient_auth/src/service.rs @@ -0,0 +1,428 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; + +use oneclient_events::EventBus; +use oneclient_net::RequestClient; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::data::{MicrosoftLoginSession, MinecraftAccount}; +use crate::error::{AuthError, AuthResult, MinecraftAuthError}; +use crate::msa::{self, PendingBrowserLogin}; +use crate::store::{self, CredentialsStore}; + +/// Stable id for the Microsoft login's progress so a front-end can recognise +/// it among everything else on the bus +pub const MICROSOFT_LOGIN_PROGRESS: Uuid = + Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); + +/// `finish_microsoft_login` takes the listener and runs with it while the +/// token stays here so a cancel from the UI has something to flip +struct PendingLogin { + /// `None` once a flow owns the listener or once a cancel freed the port + /// before any flow got that far + browser: Option, + cancel: CancellationToken, +} + +pub struct AuthService { + store: Mutex, + /// Keyed by CSRF state token + pending_logins: Mutex>, + /// Serialises token renewal per account Microsoft rotates the refresh token + /// on every use so concurrent renewals would sign the account out + refresh_guards: StdMutex>>>, + net: RequestClient, + events: EventBus, +} + +impl AuthService { + /// A missing or unreadable `auth.json` yields an empty store rather than + /// failing so a corrupt file means "sign in again" not "will not start" + pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { + Ok(Self::with_store( + CredentialsStore::load().await?, + net, + events, + )) + } + + #[must_use] + pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { + Self { + store: Mutex::new(store), + pending_logins: Mutex::new(HashMap::new()), + refresh_guards: StdMutex::new(HashMap::new()), + net, + events, + } + } + + #[tracing::instrument(skip_all)] + pub async fn begin_microsoft_login(&self) -> AuthResult { + tracing::info!("beginning Microsoft login"); + let client = self.net.http(); + + let (browser, pending) = msa::begin_browser_login().await?; + let device = msa::begin_device_login(client).await?; + + let mut logins = self.pending_logins.lock().await; + // A login cancelled before its flow ever started has nobody left to remove + // it so the next login sweeps it up + logins.retain(|_, login| !login.cancel.is_cancelled()); + logins.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); + + Ok(MicrosoftLoginSession { browser, device }) + } + + /// Only one caller can ever claim a login + async fn claim_pending_login( + &self, + state_token: &str, + ) -> AuthResult<(PendingBrowserLogin, CancellationToken)> { + let mut logins = self.pending_logins.lock().await; + + // Cancelled between `begin` and here the listener is already gone and + // there is nothing to run + if logins + .get(state_token) + .is_some_and(|login| login.cancel.is_cancelled()) + { + logins.remove(state_token); + return Err(AuthError::LoginCancelled); + } + + let login = logins.get_mut(state_token).ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + let cancel = login.cancel.clone(); + let browser = login.browser.take().ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + + Ok((browser, cancel)) + } + + #[tracing::instrument(skip_all)] + pub async fn finish_microsoft_login( + &self, + session: MicrosoftLoginSession, + ) -> AuthResult { + tracing::info!("finishing Microsoft login"); + + let (pending, cancel) = self.claim_pending_login(&session.browser.state).await?; + + let events = self.events.clone(); + let flow = msa::finish_dual_login( + self.net.http(), + pending, + &session.device, + |label, current, total| { + events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); + }, + ); + + // Losing the race drops `flow` and with it the device-code poll the + // loopback listener and whichever request was mid-flight + let result = tokio::select! { + biased; + () = cancel.cancelled() => Err(AuthError::LoginCancelled), + res = flow => res.map_err(AuthError::from), + }; + + // Whoever runs the flow owns the cleanup a cancel only flips the token + self.pending_logins + .lock() + .await + .remove(&session.browser.state); + + // Drives the card to 100% so the front-end can clear it a cancelled flow + // needs this as much as a successful one + self.events + .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); + + // Re-read rather than trusting `result` the last handshake step can + // complete in the same breath as a cancel + if cancel.is_cancelled() { + tracing::info!("Microsoft login cancelled by the user"); + return Err(AuthError::LoginCancelled); + } + + if let Err(err) = &result { + let mut chain = String::new(); + let mut source = std::error::Error::source(err); + while let Some(cause) = source { + chain.push_str(&format!("\n caused by: {cause}")); + source = cause.source(); + } + tracing::warn!("Microsoft login failed: {err}{chain}"); + } + + let account = result?; + tracing::info!(username = %account.username, "Microsoft login succeeded"); + self.store + .lock() + .await + .commit_account(account, &self.events) + .await + } + + /// The entry is deliberately left in the map `finish_microsoft_login` may + /// not have claimed it yet and must find the cancelled token not a hole + #[tracing::instrument(level = "debug", skip_all)] + pub async fn cancel_microsoft_login(&self, state_token: &str) { + let mut logins = self.pending_logins.lock().await; + let Some(login) = logins.get_mut(state_token) else { + return; + }; + + login.cancel.cancel(); + // Frees the port now if no flow ever took the listener once one has the + // flow drops it as it unwinds + login.browser = None; + } + + #[tracing::instrument(skip(self), fields(username = %username))] + pub async fn add_offline_account(&self, username: String) -> AuthResult { + self.store + .lock() + .await + .add_offline_account_and_save(username) + .await + } + + pub async fn list_accounts(&self) -> Vec { + self.store.lock().await.list_accounts() + } + + pub async fn get_account(&self, id: Uuid) -> Option { + self.store.lock().await.get_account(id).cloned() + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&self) -> AuthResult> { + self.store.lock().await.default_account().await + } + + #[tracing::instrument(level = "debug", skip(self), fields(?id))] + pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { + self.store.lock().await.set_default_user(id).await + } + + #[tracing::instrument(skip(self), fields(%id))] + pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { + self.store.lock().await.remove_account(id).await?; + Ok(()) + } + + pub async fn has_microsoft_account(&self) -> bool { + self.store.lock().await.has_microsoft_account() + } + + fn refresh_guard(&self, id: Uuid) -> Arc> { + Arc::clone( + self.refresh_guards + .lock() + .expect("refresh guard registry poisoned") + .entry(id) + .or_default(), + ) + } + + async fn account_snapshot(&self, id: Uuid) -> AuthResult { + self.store + .lock() + .await + .get_account(id) + .cloned() + .ok_or(AuthError::AccountNotFound(id)) + } + + /// The store lock is never held across the Microsoft handshake only around + /// the reads and the final write + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { + let existing = self.account_snapshot(id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; + + // Re-read under the guard whoever held it may have just refreshed + let existing = self.account_snapshot(id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + tracing::info!(username = %existing.username, "renewing Microsoft access token"); + match msa::refresh_microsoft_account(self.net.http(), &existing).await { + Ok(refreshed) => { + self.store + .lock() + .await + .commit_refreshed_account(refreshed.clone()) + .await?; + Ok(refreshed) + } + Err(err) => { + let err = AuthError::from(err); + if store::is_transient_auth_error(&err) { + tracing::warn!("keeping existing token after transient renewal failure: {err}"); + Ok(existing) + } else { + Err(err) + } + } + } + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn refresh_account(&self, id: Uuid) -> AuthResult { + self.renew_token(id, true).await + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn refresh_all_accounts(&self) -> AuthResult> { + let ids: Vec = self.store.lock().await.users.keys().copied().collect(); + let mut refreshed = Vec::with_capacity(ids.len()); + + for id in ids { + refreshed.push(self.renew_token(id, true).await?); + } + + Ok(refreshed) + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { + let account = self.renew_token(id, false).await?; + + Ok(account) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account_for_launch(&self) -> AuthResult> { + let Some(id) = self.store.lock().await.resolve_default_id().await? else { + return Ok(None); + }; + Ok(Some(self.account_for_launch(id).await?)) + } +} + +#[cfg(test)] +mod tests { + use oneclient_net::NetConfig; + + use super::*; + use crate::data::DeviceCodeLogin; + + fn service(events: EventBus) -> AuthService { + let net = RequestClient::new(NetConfig::default()).expect("net client"); + AuthService::with_store(CredentialsStore::default(), net, events) + } + + /// Registers a login the way `begin_microsoft_login` does minus the device + /// code request which is the only half that needs the network + async fn register_login(service: &AuthService) -> MicrosoftLoginSession { + let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind"); + + service.pending_logins.lock().await.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); + + MicrosoftLoginSession { + browser, + device: DeviceCodeLogin { + user_code: "ABCD-EFGH".to_string(), + device_code: "device-code".to_string(), + verification_uri: "https://microsoft.com/link".to_string(), + expires_in: 900, + interval: 5, + message: String::new(), + }, + } + } + + #[tokio::test] + async fn a_cancel_that_beats_the_flow_is_still_a_cancel() { + // A quick cancel can land before the flow claims the login + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + service.cancel_microsoft_login(session.dedupe_key()).await; + let err = service.finish_microsoft_login(session).await.unwrap_err(); + + assert!(matches!(err, AuthError::LoginCancelled), "{err}"); + assert!(service.pending_logins.lock().await.is_empty()); + assert!(service.store.lock().await.list_accounts().is_empty()); + } + + #[tokio::test] + async fn a_cancel_frees_the_loopback_port_it_was_holding() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + service.cancel_microsoft_login(session.dedupe_key()).await; + + let logins = service.pending_logins.lock().await; + let login = logins + .get(session.dedupe_key()) + .expect("the entry is what a flow starting late reads the cancel from"); + assert!(login.cancel.is_cancelled()); + assert!( + login.browser.is_none(), + "the listener should be dropped rather than left bound" + ); + } + + #[tokio::test] + async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() { + // Once claimed the entry is the only channel a cancel has to the flow + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + let (_listener, cancel) = service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + service.cancel_microsoft_login(session.dedupe_key()).await; + + assert!(cancel.is_cancelled()); + } + + #[tokio::test] + async fn a_login_can_only_be_claimed_once() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + let claimed_again = service.claim_pending_login(session.dedupe_key()).await; + + assert!(matches!( + claimed_again, + Err(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound + )) + )); + } +} diff --git a/packages/oneclient_auth/src/store.rs b/packages/oneclient_auth/src/store.rs new file mode 100644 index 00000000..8e2e1738 --- /dev/null +++ b/packages/oneclient_auth/src/store.rs @@ -0,0 +1,191 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use oneclient_common::paths; +use oneclient_events::EventBus; + +use crate::data::{AccountKind, MinecraftAccount}; +use crate::error::{AuthError, AuthResult}; +use crate::offline::{offline_account, validate_offline_username}; + +#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] +pub struct CredentialsStore { + pub users: HashMap, + pub default_user: Option, +} + +impl CredentialsStore { + #[tracing::instrument(level = "debug")] + pub async fn load() -> AuthResult { + let path = paths::auth_file()?; + if !path.exists() { + return Ok(Self::default()); + } + + match polyio::read_json(&path).await { + Ok(store) => Ok(store), + Err(err) => { + tracing::warn!("failed to read auth file: {err}"); + Ok(Self::default()) + } + } + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn save(&self) -> AuthResult<()> { + let path = paths::auth_file()?; + + polyio::write_json_atomic(&path, self).await?; + Ok(()) + } + + pub fn has_microsoft_account(&self) -> bool { + self.users + .values() + .any(|account| account.kind == AccountKind::Microsoft) + } + + pub fn list_accounts(&self) -> Vec { + self.users.values().cloned().collect() + } + + pub fn get_account(&self, id: Uuid) -> Option<&MinecraftAccount> { + self.users.get(&id) + } + + #[tracing::instrument(level = "debug", skip_all, fields(username = %account.username))] + pub async fn commit_account( + &mut self, + account: MinecraftAccount, + events: &EventBus, + ) -> AuthResult { + self.users.insert(account.id, account.clone()); + + if self.default_user.is_none() { + self.default_user = Some(account.id); + } + + self.save().await?; + tracing::debug!("committed account to credentials store"); + events + .notify("Account added") + .body(format!("Signed in as {}", account.username)) + .send(); + Ok(account) + } + + pub fn add_offline_account(&mut self, username: String) -> AuthResult { + let account = self.insert_offline_account(username)?; + Ok(account) + } + + #[tracing::instrument(level = "debug", skip(self), fields(username = %username))] + pub async fn add_offline_account_and_save( + &mut self, + username: String, + ) -> AuthResult { + let account = self.insert_offline_account(username)?; + self.save().await?; + Ok(account) + } + + fn insert_offline_account(&mut self, username: String) -> AuthResult { + if self.default_user.is_none() { + // will be set after inserting account + } + + validate_offline_username(&username)?; + + if self + .users + .values() + .any(|u| u.username.eq_ignore_ascii_case(&username)) + { + return Err(AuthError::DuplicateUsername { username }); + } + + let account = offline_account(username); + self.users.insert(account.id, account.clone()); + Ok(account) + } + + #[tracing::instrument(level = "debug", skip_all, fields(%account.id))] + pub async fn commit_refreshed_account(&mut self, account: MinecraftAccount) -> AuthResult<()> { + self.users.insert(account.id, account); + self.save().await?; + tracing::debug!("stored refreshed Microsoft account"); + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn remove_account(&mut self, id: Uuid) -> AuthResult> { + let removed = self.users.remove(&id); + + if self.default_user == Some(id) { + self.default_user = self.users.keys().copied().next(); + } + + self.save().await?; + Ok(removed) + } + + #[tracing::instrument(level = "debug", skip(self), fields(?id))] + pub async fn set_default_user(&mut self, id: Option) -> AuthResult<()> { + if let Some(id) = id + && !self.users.contains_key(&id) + { + return Err(AuthError::AccountNotFound(id)); + } + + self.default_user = id; + self.save().await?; + Ok(()) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn resolve_default_id(&mut self) -> AuthResult> { + let id = self + .default_user + .or_else(|| self.users.keys().copied().next()); + + let Some(id) = id else { + return Ok(None); + }; + + if !self.users.contains_key(&id) { + return Ok(None); + } + + if self.default_user != Some(id) { + self.default_user = Some(id); + self.save().await?; + } + + Ok(Some(id)) + } + + /// Never touches the network callers that need a usable token go through + /// [`crate::AuthService::account_for_launch`] + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&mut self) -> AuthResult> { + let Some(id) = self.resolve_default_id().await? else { + return Ok(None); + }; + let Some(account) = self.users.get(&id).cloned() else { + return Ok(None); + }; + + Ok(Some(account)) + } +} + +/// A transient failure must keep the existing token discarding it because +/// Wi-Fi dropped would sign the user out of a working account +pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool { + matches!( + err, + AuthError::Minecraft(crate::error::MinecraftAuthError::RequestError { source, .. }) + if source.is_connect() || source.is_timeout() + ) +} diff --git a/packages/oneclient_auth/tests/offline.rs b/packages/oneclient_auth/tests/offline.rs new file mode 100644 index 00000000..21325eec --- /dev/null +++ b/packages/oneclient_auth/tests/offline.rs @@ -0,0 +1,107 @@ +use chrono::{TimeDelta, Utc}; +use oneclient_auth::{AccountKind, CredentialsStore, offline_account, offline_uuid}; +use uuid::Uuid; + +fn isolate_launcher_dir() { + oneclient_common::paths::set_launcher_dir( + std::env::temp_dir().join(format!("oneclient-auth-test-{}", Uuid::new_v4())), + ); +} + +fn fake_microsoft_account(username: &str) -> oneclient_auth::MinecraftAccount { + let mut account = offline_account(username.to_string()); + account.kind = AccountKind::Microsoft; + account.access_token = "access".into(); + account.refresh_token = "refresh".into(); + account +} + +#[test] +fn offline_uuid_matches_vanilla_algorithm() { + let id = offline_uuid("Notch"); + + assert_eq!( + id, + Uuid::parse_str("b50ad385-829d-3141-a216-7e7d7539ba7f").unwrap() + ); + assert_ne!(id, offline_uuid("Steve")); +} + +#[tokio::test] +async fn offline_account_blocked_without_microsoft() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let err = store.add_offline_account("Steve".into()).unwrap_err(); + + assert!(err.to_string().contains("Microsoft")); +} + +#[tokio::test] +async fn offline_account_allowed_when_microsoft_exists() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let msa = fake_microsoft_account("MsaUser"); + store.users.insert(msa.id, msa); + + let offline = store.add_offline_account("Steve".into()).unwrap(); + assert_eq!(offline.kind, AccountKind::Offline); + assert_eq!(offline.username, "Steve"); +} + +#[tokio::test] +async fn default_offline_fails_when_last_microsoft_removed() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let msa = fake_microsoft_account("MsaUser"); + let msa_id = msa.id; + store.users.insert(msa_id, msa); + + let offline = store.add_offline_account("Steve".into()).unwrap(); + store.default_user = Some(offline.id); + store.users.remove(&msa_id); + + let err = store.default_account().await.unwrap_err(); + assert!(err.to_string().contains("Microsoft")); +} + +#[test] +fn token_expiring_imminently_counts_as_expired() { + let mut account = fake_microsoft_account("MsaUser"); + + account.expires = Utc::now() + TimeDelta::hours(1); + assert!(!account.is_expired(), "a token with an hour left is usable"); + + account.expires = Utc::now() + TimeDelta::seconds(5); + assert!( + account.is_expired(), + "a token about to lapse must be renewed before launching" + ); + + account.expires = Utc::now() - TimeDelta::hours(1); + assert!(account.is_expired()); +} + +#[tokio::test] +async fn default_account_returns_expired_token_without_refreshing() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let mut msa = fake_microsoft_account("MsaUser"); + msa.expires = Utc::now() - TimeDelta::hours(5); + let msa_id = msa.id; + store.users.insert(msa_id, msa); + + store.default_user = Some(msa_id); + + let account = store + .default_account() + .await + .expect("an expired token must not fail the read path") + .expect("the only account should be the default"); + + assert_eq!(account.id, msa_id); + assert!(account.is_expired()); +} diff --git a/packages/oneclient_cluster/Cargo.toml b/packages/oneclient_cluster/Cargo.toml new file mode 100644 index 00000000..9643c8f3 --- /dev/null +++ b/packages/oneclient_cluster/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "oneclient_cluster" +description = "Cluster records, their settings profiles, and their on-disk files" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_db.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +tokio.workspace = true + +bytes.workspace = true +chrono.workspace = true +image.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_repr.workspace = true +strum.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tracing.workspace = true +trash.workspace = true diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs new file mode 100644 index 00000000..acfafca0 --- /dev/null +++ b/packages/oneclient_cluster/src/cluster.rs @@ -0,0 +1,117 @@ +use std::path::PathBuf; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use oneclient_db::models::ClusterId; +use oneclient_db::models::ClusterRow; +use serde::{Deserialize, Serialize}; + +use crate::error::ClusterResult; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +use crate::error::ClusterError; +use crate::stage::ClusterStage; + +pub use oneclient_common::paths::DEDICATED_MARKER; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cluster { + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub stage: ClusterStage, + pub created_at: Option>, + pub last_played: Option>, + #[serde(with = "serde_duration_secs")] + pub overall_played: Duration, + pub linked_modpack_hash: Option, +} + +impl PartialEq for Cluster { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +mod serde_duration_secs { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> Result { + duration.as_secs().serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Ok(Duration::from_secs(u64::deserialize(deserializer)?)) + } +} + +impl Cluster { + pub fn try_from_row(row: ClusterRow) -> Result { + let mc_loader = GameLoader::from_repr(row.mc_loader as u8) + .ok_or(ClusterError::InvalidLoader(row.mc_loader))?; + let stage = + ClusterStage::from_repr(row.stage).ok_or(ClusterError::InvalidStage(row.stage))?; + + Ok(Self { + id: row.id, + name: row.name, + folder_name: row.folder_name, + setting_profile_name: row.setting_profile_name, + mc_version: row.mc_version, + mc_loader, + mc_loader_version: row.mc_loader_version, + stage, + created_at: parse_timestamp(row.created_at), + last_played: parse_timestamp(row.last_played), + overall_played: Duration::from_secs(row.overall_played.unwrap_or(0).max(0) as u64), + linked_modpack_hash: row.linked_modpack_hash, + }) + } + + pub fn dir(&self) -> ClusterResult { + Ok(paths::cluster_dir(&self.folder_name)?) + } + + pub fn dedicated_marker(&self) -> ClusterResult { + Ok(self.dir()?.join(DEDICATED_MARKER)) + } + + pub fn uses_dedicated_dir(&self) -> bool { + paths::cluster_uses_dedicated_dir(&self.folder_name) + } + + pub fn game_dir(&self) -> ClusterResult { + Ok(paths::cluster_game_dir(&self.folder_name)?) + } + + pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { + ClusterLinkTarget { + id: self.id, + folder_name: &self.folder_name, + mc_version: &self.mc_version, + mc_loader: self.mc_loader, + } + } +} + +#[derive(Debug, Clone)] +pub struct ClusterLinkTarget<'a> { + pub id: ClusterId, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: GameLoader, +} + +fn parse_timestamp(value: Option) -> Option> { + value.and_then(|text| { + DateTime::parse_from_rfc3339(&text) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) +} diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs new file mode 100644 index 00000000..b160b90f --- /dev/null +++ b/packages/oneclient_cluster/src/error.rs @@ -0,0 +1,75 @@ +use thiserror::Error; + +use crate::stage::ClusterStage; +use oneclient_common::domain::GameLoader; + +pub type ClusterResult = Result; + +#[derive(Debug, Error)] +pub enum ClusterError { + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Db(#[from] oneclient_db::DbError), + + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), + + #[error("invalid settings profile: {reason}")] + InvalidProfile { reason: String }, + + #[error(transparent)] + Logs(#[from] crate::logs::LogsError), + + #[error(transparent)] + Screenshots(#[from] crate::screenshots::ScreenshotsError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error("cluster {0} not found")] + NotFound(i64), + + #[error("cluster folder '{0}' not found on disk")] + FolderNotFound(String), + + #[error("cluster is in {0} stage")] + Busy(ClusterStage), + + #[error("invalid Minecraft version '{0}'")] + InvalidVersion(String), + + #[error("cluster is missing a required Java version")] + MissingJavaVersion, + + #[error("loader {0} is not compatible with {1}")] + MismatchedLoader(GameLoader, GameLoader), + + #[error("setting profile '{0}' not found")] + ProfileNotFound(String), + + #[error("cluster has no settings profile assigned")] + NoProfile, + + #[error("cluster {0} is already running")] + AlreadyRunning(i64), + + #[error("cluster name is empty after sanitization")] + EmptyName, + + #[error("unknown loader id {0} in database")] + InvalidLoader(i64), + + #[error("unknown stage id {0} in database")] + InvalidStage(i64), +} diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs new file mode 100644 index 00000000..825353fb --- /dev/null +++ b/packages/oneclient_cluster/src/lib.rs @@ -0,0 +1,22 @@ +//! Launch prep catalog provisioning and version migrations stay in +//! `oneclient_core` they need Java MC metadata and the package store +//! No event bus here the network client exists only for mclo.gs uploads + +mod cluster; +mod error; +mod manager; +mod options; +mod profile; +mod stage; + +pub mod logs; +pub mod profiles; +pub mod screenshots; + +pub use cluster::{Cluster, ClusterLinkTarget}; +pub use error::{ClusterError, ClusterResult}; +pub use manager::ClusterManager; +pub use options::{ClusterUpdate, CreateClusterOptions}; +pub use profile::{GameSettingsProfile, PackageUpdateMode, SettingsOsExtra}; +pub use profiles::ProfileUpdate; +pub use stage::ClusterStage; diff --git a/packages/oneclient_cluster/src/logs/error.rs b/packages/oneclient_cluster/src/logs/error.rs new file mode 100644 index 00000000..838b9a53 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LogsError { + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("invalid log file name: {0}")] + InvalidName(String), + + #[error("log file is too large to upload")] + TooLarge, + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error("mclo.gs upload failed: {0}")] + Upload(String), +} diff --git a/packages/oneclient_cluster/src/logs/manage.rs b/packages/oneclient_cluster/src/logs/manage.rs new file mode 100644 index 00000000..c2ad8558 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/manage.rs @@ -0,0 +1,157 @@ +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; + +use crate::cluster::Cluster; +use crate::error::ClusterResult; +use oneclient_common::paths; + +use super::parse::parse_level; +use super::{LogFileInfo, LogKind, LogLevel, LogLine, LogsError, ReadOptions}; + +/// Restricted to cluster dirs the launcher's own logs belong to the live +/// console and must not be readable deletable or uploadable from a cluster page +pub(super) fn ensure_allowed(path: &Path) -> ClusterResult { + polyio::ensure_under(path, [paths::clusters_dir()?])? + .ok_or_else(|| LogsError::InvalidName(path.display().to_string()).into()) +} + +fn push_file(path: PathBuf, name: String, kind: LogKind, out: &mut Vec) { + let Ok(meta) = std::fs::metadata(&path) else { + return; + }; + if !meta.is_file() { + return; + } + let modified = meta + .modified() + .map(DateTime::::from) + .unwrap_or_else(|_| Utc::now()); + + out.push(LogFileInfo { + name, + kind, + size_bytes: meta.len(), + modified, + path, + }); +} + +fn collect_dir( + dir: &Path, + kind: LogKind, + accept: impl Fn(&str) -> bool, + out: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if !accept(name) { + continue; + } + push_file(path.clone(), name.to_string(), kind, out); + } +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn list_cluster_logs(cluster: &Cluster) -> ClusterResult> { + let mut out = Vec::new(); + + let dir = cluster.dir()?; + + collect_dir( + &dir.join("logs"), + LogKind::Minecraft, + |name| name.ends_with(".log") || name.ends_with(".log.gz"), + &mut out, + ); + + collect_dir( + &dir.join("crash-reports"), + LogKind::CrashReport, + |name| name.ends_with(".txt") || name.ends_with(".log"), + &mut out, + ); + + push_file( + cluster_output_log(cluster)?, + "Game output".to_string(), + LogKind::Game { + cluster_id: cluster.id, + }, + &mut out, + ); + + out.sort_by_key(|info| std::cmp::Reverse(info.modified)); + Ok(out) +} + +pub fn cluster_output_log(cluster: &Cluster) -> ClusterResult { + Ok(cluster.dir()?.join("cluster-output.log")) +} + +pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec { + let mut prev = LogLevel::Unknown; + let mut lines: Vec = Vec::new(); + for (idx, raw) in content.lines().enumerate() { + let level = match parse_level(raw) { + Some(level) => { + prev = level; + level + } + None => prev, + }; + lines.push(LogLine { + number: idx + 1, + level, + text: raw.to_string(), + }); + } + + if let Some(filter) = opts.level_filter { + lines.retain(|l| l.level == filter); + } + if let Some(query) = opts.search.as_ref().filter(|q| !q.is_empty()) { + let query = query.to_lowercase(); + lines.retain(|l| l.text.to_lowercase().contains(&query)); + } + if let Some(max) = opts.max_lines + && lines.len() > max + { + lines.drain(0..lines.len() - max); + } + + lines +} + +pub(super) async fn read_file_string(path: &Path) -> ClusterResult { + let is_gz = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("gz")); + + if is_gz { + Ok(polyio::read_gz_to_string(path).await?) + } else { + Ok(polyio::read_to_string(path).await?) + } +} + +#[tracing::instrument(level = "debug", skip(opts))] +pub async fn read_log_at(path: &Path, opts: &ReadOptions) -> ClusterResult> { + let path = ensure_allowed(path)?; + let content = read_file_string(&path).await?; + Ok(lines_from(&content, opts)) +} + +#[tracing::instrument(level = "debug")] +pub async fn delete_log_at(path: &Path) -> ClusterResult<()> { + let path = ensure_allowed(path)?; + + Ok(polyio::remove_file(&path).await?) +} diff --git a/packages/oneclient_cluster/src/logs/mclogs.rs b/packages/oneclient_cluster/src/logs/mclogs.rs new file mode 100644 index 00000000..1dc22673 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/mclogs.rs @@ -0,0 +1,80 @@ +use std::path::Path; + +use serde::Deserialize; + +use crate::error::ClusterResult; +use oneclient_net::RequestError; + +use super::manage::{ensure_allowed, read_file_string}; +use super::{LogsError, MclogsUploadResponse}; + +const MCLOGS_URL: &str = "https://api.mclo.gs/1/log"; + +const MAX_LINES: usize = 25_000; +const MAX_BYTES: usize = 10 * 1024 * 1024; + +#[derive(Deserialize)] +struct MclogsResponse { + success: bool, + #[serde(default)] + id: Option, + #[serde(default)] + url: Option, + #[serde(default)] + raw: Option, + #[serde(default)] + error: Option, +} + +#[tracing::instrument(skip(net))] +pub async fn upload_log_at( + net: &oneclient_net::RequestClient, + path: &Path, +) -> ClusterResult { + let path = ensure_allowed(path)?; + let mut content = read_file_string(&path).await?; + + let line_count = content.lines().count(); + if line_count > MAX_LINES { + content = content + .lines() + .skip(line_count - MAX_LINES) + .collect::>() + .join("\n"); + } + if content.len() > MAX_BYTES { + let mut cut = content.len() - MAX_BYTES; + while cut < content.len() && !content.is_char_boundary(cut) { + cut += 1; + } + content = content[cut..].to_string(); + } + + let response = net + .http() + .post(MCLOGS_URL) + .form(&[("content", content.as_str())]) + .send() + .await + .map_err(RequestError::ReqwestError)?; + + let bytes = response.bytes().await.map_err(RequestError::ReqwestError)?; + let parsed: MclogsResponse = serde_json::from_slice(&bytes)?; + + if !parsed.success { + let reason = parsed.error.unwrap_or_else(|| "unknown error".into()); + tracing::warn!(reason = %reason, "mclogs upload failed"); + return Err(LogsError::Upload(reason).into()); + } + + tracing::info!( + url = parsed.url.as_deref().unwrap_or(""), + "uploaded log to mclo.gs" + ); + + Ok(MclogsUploadResponse { + id: parsed.id.unwrap_or_default(), + url: parsed.url.unwrap_or_default(), + raw: parsed.raw.unwrap_or_default(), + }) +} diff --git a/packages/oneclient_cluster/src/logs/mod.rs b/packages/oneclient_cluster/src/logs/mod.rs new file mode 100644 index 00000000..c4483dbb --- /dev/null +++ b/packages/oneclient_cluster/src/logs/mod.rs @@ -0,0 +1,131 @@ +mod error; +mod manage; +mod mclogs; +mod parse; + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; + +pub use error::LogsError; +pub use manage::{cluster_output_log, delete_log_at, list_cluster_logs, read_log_at}; +pub use mclogs::upload_log_at; +pub use parse::parse_level; + +/// No launcher variant on purpose the launcher's own logs live in the log console +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LogKind { + Game { cluster_id: i64 }, + Minecraft, + CrashReport, + Other, +} + +impl LogKind { + pub fn is_crash_report(self) -> bool { + matches!(self, Self::CrashReport) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LogFileInfo { + pub name: String, + pub kind: LogKind, + pub size_bytes: u64, + pub modified: DateTime, + pub path: PathBuf, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, + Fatal, + Unknown, +} + +impl LogLevel { + pub fn from_token(token: &str) -> Option { + match token.to_ascii_uppercase().as_str() { + "TRACE" => Some(Self::Trace), + "DEBUG" => Some(Self::Debug), + "INFO" => Some(Self::Info), + "WARN" | "WARNING" => Some(Self::Warn), + "ERROR" => Some(Self::Error), + "FATAL" => Some(Self::Fatal), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Trace => "TRACE", + Self::Debug => "DEBUG", + Self::Info => "INFO", + Self::Warn => "WARN", + Self::Error => "ERROR", + Self::Fatal => "FATAL", + Self::Unknown => "", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LogLine { + pub number: usize, + pub level: LogLevel, + pub text: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct ReadOptions { + pub level_filter: Option, + pub search: Option, + pub max_lines: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MclogsUploadResponse { + pub id: String, + pub url: String, + pub raw: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bracket_levels() { + assert_eq!( + parse_level("[12:00:00] [Render thread/INFO]: hello"), + Some(LogLevel::Info) + ); + assert_eq!( + parse_level("[12:00:00] [Server thread/WARN]: uh oh"), + Some(LogLevel::Warn) + ); + assert_eq!(parse_level("[ERROR] boom"), Some(LogLevel::Error)); + assert_eq!(parse_level("[main/FATAL]"), Some(LogLevel::Fatal)); + + assert_eq!(parse_level("\tat java.base/java.lang.Thread.run"), None); + assert_eq!(parse_level("[12:34:56] just a timestamp"), None); + } + + #[test] + fn carries_level_forward() { + let opts = ReadOptions::default(); + + let lines = super::manage::lines_from( + "[12:00:00] [Server thread/ERROR]: boom\n\tat Foo.bar\nplain line", + &opts, + ); + + assert_eq!(lines[0].level, LogLevel::Error); + assert_eq!(lines[1].level, LogLevel::Error); + assert_eq!(lines[2].level, LogLevel::Error); + } +} diff --git a/packages/oneclient_cluster/src/logs/parse.rs b/packages/oneclient_cluster/src/logs/parse.rs new file mode 100644 index 00000000..089afb32 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/parse.rs @@ -0,0 +1,25 @@ +use super::LogLevel; + +pub fn parse_level(line: &str) -> Option { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'[' + && let Some(rel_end) = line[i + 1..].find(']') + { + let seg = &line[i + 1..i + 1 + rel_end]; + if let Some(level) = level_from_segment(seg) { + return Some(level); + } + i = i + 1 + rel_end + 1; + continue; + } + i += 1; + } + None +} + +fn level_from_segment(seg: &str) -> Option { + let token = seg.rsplit('/').next().unwrap_or(seg).trim(); + LogLevel::from_token(token) +} diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs new file mode 100644 index 00000000..20fc90ef --- /dev/null +++ b/packages/oneclient_cluster/src/manager.rs @@ -0,0 +1,347 @@ +use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; +use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; + +use crate::error::ClusterResult; +use crate::profile::GameSettingsProfile; +use crate::profiles::ProfileUpdate; +use crate::profiles::{create_profile_from_global, resolve_cluster_profile, update_named_profile}; +use oneclient_common::domain::ContentType; +use oneclient_common::patch::Patch; + +use oneclient_db::DbPool; +use tokio::sync::Mutex; + +use crate::cluster::Cluster; +use crate::error::ClusterError; +use crate::options::{ClusterUpdate, CreateClusterOptions}; +use crate::stage::ClusterStage; + +pub struct ClusterManager { + db: DbPool, + /// Serialises creation so two concurrent creates cannot resolve to the same + /// folder name and race each other onto disk + provisioning: Mutex<()>, +} + +impl ClusterManager { + #[must_use] + pub fn new(db: DbPool) -> Self { + Self { + db, + provisioning: Mutex::new(()), + } + } + + /// Orphan-folder recovery must hold this across its whole scan or a + /// concurrent create's folder is adopted into a duplicate row + pub async fn provisioning_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.provisioning.lock().await + } + + pub fn sanitize_name(name: &str) -> String { + let mut name = name.to_string(); + name.retain(|c| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')') + }); + name.trim().to_string() + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn get(&self, cluster_id: ClusterId) -> ClusterResult { + let row = cluster_dao::get_by_id(&self.db, cluster_id) + .await? + .ok_or(ClusterError::NotFound(cluster_id))?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list(&self) -> ClusterResult> { + let rows = cluster_dao::list_all(&self.db).await?; + rows.into_iter() + .map(Cluster::try_from_row) + .collect::, _>>() + } + + #[tracing::instrument(skip(self))] + pub async fn create( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let _guard = self.provisioning.lock().await; + self.create_core(global, options).await + } + + /// Returns `None` if a cluster for this version/loader already exists + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_provisioned( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult> { + let _guard = self.provisioning.lock().await; + if cluster_dao::find_by_version_loader( + &self.db, + &options.mc_version, + options.mc_loader as i64, + ) + .await? + .is_some() + { + return Ok(None); + } + self.create_core(global, options).await.map(Some) + } + + /// Callers MUST hold `self.provisioning` this does not lock + #[tracing::instrument(level = "debug", skip(self, global))] + async fn create_core( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let name = Self::sanitize_name(&options.name); + if name.is_empty() { + return Err(ClusterError::EmptyName); + } + + let folder_name = resolve_unique_folder_name(&name).await?; + let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); + + match create_inner( + &self.db, + global, + &options, + &name, + &folder_name, + &cluster_path, + ) + .await + { + Ok(cluster) => { + tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); + Ok(cluster) + } + Err(err) => { + tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory"); + let _ = polyio::remove_dir_all(&cluster_path).await; + Err(err) + } + } + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn update( + &self, + cluster_id: ClusterId, + update: ClusterUpdate, + ) -> ClusterResult { + let _ = self.get(cluster_id).await?; + + if let Patch::Set(ref profile_name) = update.setting_profile_name { + ensure_profile_exists(&self.db, profile_name).await?; + } + + let name = update.name.as_deref().map(Self::sanitize_name); + if name.as_deref().is_some_and(str::is_empty) { + return Err(ClusterError::EmptyName); + } + + let patch = ClusterPatch { + name, + setting_profile_name: update.setting_profile_name.into_db_patch(), + mc_loader_version: update.mc_loader_version.into_db_patch(), + linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), + }; + + let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(skip(self))] + pub async fn delete(&self, cluster_id: ClusterId, remove_files: bool) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + + if !cluster_dao::delete_by_id(&self.db, cluster_id).await? { + return Err(ClusterError::NotFound(cluster_id)); + } + + if remove_files { + let path = cluster.dir()?; + if path.exists() { + polyio::remove_dir_all(&path).await?; + } + } + + tracing::info!(cluster_id, remove_files, "deleted cluster"); + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_stage( + &self, + cluster_id: ClusterId, + stage: ClusterStage, + ) -> ClusterResult { + let row = cluster_dao::set_stage(&self.db, cluster_id, stage as i64).await?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn uses_dedicated_dir(&self, cluster_id: ClusterId) -> ClusterResult { + Ok(self.get(cluster_id).await?.uses_dedicated_dir()) + } + + #[tracing::instrument(level = "debug", skip(self))] + /// `is_running` is passed in because process liveness belongs to the game + /// lifecycle not to cluster records + pub async fn set_dedicated_dir( + &self, + cluster_id: ClusterId, + dedicated: bool, + is_running: bool, + ) -> ClusterResult<()> { + if is_running { + return Err(ClusterError::AlreadyRunning(cluster_id)); + } + + let cluster = self.get(cluster_id).await?; + let marker = cluster.dedicated_marker()?; + if dedicated { + polyio::create_dir_all(cluster.dir()?).await?; + polyio::write(&marker, b"").await.ok(); + } else if marker.exists() { + polyio::remove_file(&marker).await.ok(); + } + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn add_playtime( + &self, + cluster_id: ClusterId, + duration: std::time::Duration, + ) -> ClusterResult { + let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); + let row = cluster_dao::add_playtime(&self.db, cluster_id, seconds).await?; + + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self, global, cluster), fields(cluster_id = cluster.id))] + pub async fn resolve_settings( + &self, + global: &GameSettingsProfile, + cluster: &Cluster, + ) -> ClusterResult { + resolve_cluster_profile(&self.db, global, cluster.setting_profile_name.as_deref()).await + } + + #[tracing::instrument(level = "debug", skip(self, update))] + pub async fn update_profile( + &self, + cluster_id: ClusterId, + update: ProfileUpdate, + ) -> ClusterResult { + let cluster = self.get(cluster_id).await?; + let profile_name = cluster + .setting_profile_name + .ok_or(ClusterError::NoProfile)?; + + update_named_profile(&self.db, &profile_name, update).await + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_and_assign_profile( + &self, + global: &GameSettingsProfile, + cluster_id: ClusterId, + profile_name: &str, + ) -> ClusterResult { + let profile = + create_profile_from_global(&self.db, global, profile_name, None, None).await?; + + self.update( + cluster_id, + ClusterUpdate::default().setting_profile(&profile.name), + ) + .await?; + + Ok(profile) + } +} + +#[tracing::instrument(level = "debug", skip(db, global, options))] +async fn create_inner( + db: &DbPool, + global: &GameSettingsProfile, + options: &CreateClusterOptions, + name: &str, + folder_name: &str, + cluster_path: &std::path::Path, +) -> ClusterResult { + polyio::create_dir_all(cluster_path).await?; + ensure_content_dirs(cluster_path).await?; + + let profile = create_profile_from_global(db, global, name, options.mem_max, None).await?; + + let row = cluster_dao::insert( + db, + &NewCluster { + name, + folder_name, + mc_version: &options.mc_version, + mc_loader: options.mc_loader as i64, + mc_loader_version: options.mc_loader_version.as_deref(), + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + }, + ) + .await?; + + Cluster::try_from_row(row) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> { + if profile_dao::get_by_name(pool, name).await?.is_none() { + return Err(ClusterError::ProfileNotFound(name.to_string())); + } + Ok(()) +} + +#[tracing::instrument(level = "debug")] +async fn resolve_unique_folder_name(name: &str) -> ClusterResult { + let cluster_dir = oneclient_common::paths::clusters_dir()?; + let mut folder_name = name.to_string(); + let mut path = cluster_dir.join(&folder_name); + + if path.exists() { + let mut which = 1; + loop { + let candidate = format!("{folder_name} ({which})"); + path = cluster_dir.join(&candidate); + if !path.exists() { + folder_name = candidate; + break; + } + which += 1; + } + } + + Ok(folder_name) +} + +#[tracing::instrument(level = "debug")] +async fn ensure_content_dirs(cluster_path: &std::path::Path) -> ClusterResult<()> { + for content_type in [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, + ContentType::World, + ] { + polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; + } + Ok(()) +} diff --git a/packages/oneclient_cluster/src/options.rs b/packages/oneclient_cluster/src/options.rs new file mode 100644 index 00000000..3b9ced50 --- /dev/null +++ b/packages/oneclient_cluster/src/options.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::GameLoader; +use oneclient_common::patch::Patch; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateClusterOptions { + pub name: String, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub mem_max: Option, +} + +impl CreateClusterOptions { + pub fn new( + name: impl Into, + mc_version: impl Into, + mc_loader: GameLoader, + ) -> Self { + Self { + name: name.into(), + mc_version: mc_version.into(), + mc_loader, + mc_loader_version: None, + mem_max: None, + } + } + + pub fn mem_max(mut self, megabytes: u32) -> Self { + self.mem_max = Some(megabytes); + self + } + + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Some(version.into()); + self + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ClusterUpdate { + pub name: Option, + pub setting_profile_name: Patch, + pub mc_loader_version: Patch, + pub linked_modpack_hash: Patch, +} + +impl ClusterUpdate { + pub fn setting_profile(mut self, name: impl Into) -> Self { + self.setting_profile_name = Patch::Set(name.into()); + self + } + + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Patch::Set(version.into()); + self + } +} diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs new file mode 100644 index 00000000..5c4a2488 --- /dev/null +++ b/packages/oneclient_cluster/src/profile.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; + +use oneclient_db::models::SettingProfileRow; + +pub const GLOBAL_PROFILE_NAME: &str = "Global"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GameSettingsProfile { + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// Never applies to bundle content + pub browser_update_mode: Option, +} + +// `Resolution` lives in oneclient_common the launch-argument builder needs it +// and cannot depend on the launcher settings +pub use oneclient_common::Resolution; +pub use oneclient_common::domain::PackageUpdateMode; + +cfg_select! { + target_os = "windows" => { + #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct SettingsOsExtra {} + } + target_os = "macos" => { + #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct SettingsOsExtra {} + } + not(any(target_os = "windows", target_os = "macos")) => { + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct SettingsOsExtra { + pub enable_gamemode: Option, + } + + impl Default for SettingsOsExtra { + fn default() -> Self { + Self { + enable_gamemode: Some(true), + } + } + } + } +} + +impl GameSettingsProfile { + pub fn default_global_profile() -> Self { + Self { + name: GLOBAL_PROFILE_NAME.into(), + java_path: None, + resolution: None, + force_fullscreen: Some(false), + mem_max: Some(4096), + launch_args: None, + launch_env: None, + hook_pre: None, + hook_wrapper: None, + hook_post: None, + os_extra: Some(SettingsOsExtra::default()), + browser_update_mode: Some(PackageUpdateMode::default()), + } + } + + pub fn is_global(&self) -> bool { + self.name == GLOBAL_PROFILE_NAME + } + + pub fn merge_global(&mut self, global: &Self) { + if self.java_path.is_none() { + self.java_path = global.java_path.clone(); + } + if self.resolution.is_none() { + self.resolution = global.resolution; + } + if self.force_fullscreen.is_none() { + self.force_fullscreen = global.force_fullscreen; + } + if self.mem_max.is_none() { + self.mem_max = global.mem_max; + } + if self.launch_args.is_none() { + self.launch_args = global.launch_args.clone(); + } + if self.launch_env.is_none() { + self.launch_env = global.launch_env.clone(); + } + if self.hook_pre.is_none() { + self.hook_pre = global.hook_pre.clone(); + } + if self.hook_wrapper.is_none() { + self.hook_wrapper = global.hook_wrapper.clone(); + } + if self.hook_post.is_none() { + self.hook_post = global.hook_post.clone(); + } + if self.os_extra.is_none() { + self.os_extra = global.os_extra.clone(); + } + if self.browser_update_mode.is_none() { + self.browser_update_mode = global.browser_update_mode; + } + } + + pub fn from_row(row: SettingProfileRow) -> crate::ClusterResult { + Ok(Self { + name: row.name, + java_path: row.java_path, + resolution: row + .resolution + .map(|json| serde_json::from_str(&json)) + .transpose()?, + force_fullscreen: row.force_fullscreen.map(|v| v != 0), + mem_max: row.mem_max.map(|v| v as u32), + launch_args: row.launch_args, + launch_env: row.launch_env, + hook_pre: row.hook_pre, + hook_wrapper: row.hook_wrapper, + hook_post: row.hook_post, + os_extra: row + .os_extra + .map(|json| serde_json::from_str(&json)) + .transpose()?, + // Unrecognised values inherit rather than error so a profile from a + // newer build cannot make the cluster unloadable + browser_update_mode: row + .browser_update_mode + .as_deref() + .and_then(PackageUpdateMode::parse), + }) + } + + pub fn into_row(&self) -> crate::ClusterResult { + Ok(SettingProfileRow { + name: self.name.clone(), + java_path: self.java_path.clone(), + resolution: self + .resolution + .map(|res| serde_json::to_string(&res)) + .transpose()?, + force_fullscreen: self.force_fullscreen.map(i64::from), + mem_max: self.mem_max.map(i64::from), + launch_args: self.launch_args.clone(), + launch_env: self.launch_env.clone(), + hook_pre: self.hook_pre.clone(), + hook_wrapper: self.hook_wrapper.clone(), + hook_post: self.hook_post.clone(), + os_extra: self + .os_extra + .as_ref() + .map(serde_json::to_string) + .transpose()?, + browser_update_mode: self + .browser_update_mode + .map(|mode| mode.as_str().to_string()), + }) + } +} diff --git a/packages/oneclient_cluster/src/profiles.rs b/packages/oneclient_cluster/src/profiles.rs new file mode 100644 index 00000000..10126b71 --- /dev/null +++ b/packages/oneclient_cluster/src/profiles.rs @@ -0,0 +1,166 @@ +//! A profile is a sparse override on the global baseline a `None` field +//! inherits so `Patch::Clear` means "inherit again" not "set to nothing" +//! The baseline is passed in so this crate needn't depend on launcher config + +use oneclient_common::patch::Patch; +use oneclient_db::DbPool; + +use crate::error::{ClusterError, ClusterResult}; +use crate::profile::{GameSettingsProfile, SettingsOsExtra}; +use oneclient_common::Resolution; +use oneclient_common::domain::PackageUpdateMode; + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn get_profile_or_default( + pool: &DbPool, + global: &GameSettingsProfile, + name: Option<&str>, +) -> ClusterResult { + if let Some(name) = name + && let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? + { + return GameSettingsProfile::from_row(row); + } + + Ok(global.clone()) +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +/// Falls back to the whole baseline when the name is missing or unknown so a +/// deleted profile cannot brick a cluster +pub async fn resolve_cluster_profile( + pool: &DbPool, + global: &GameSettingsProfile, + profile_name: Option<&str>, +) -> ClusterResult { + let Some(name) = profile_name else { + return Ok(global.clone()); + }; + + let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { + return Ok(global.clone()); + }; + + let mut profile = GameSettingsProfile::from_row(row)?; + if !profile.is_global() { + profile.merge_global(global); + } + + Ok(profile) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn upsert_named_profile( + pool: &DbPool, + profile: &GameSettingsProfile, +) -> ClusterResult { + if profile.is_global() { + return Err(ClusterError::InvalidProfile { + reason: "the Global profile is stored in settings.json".into(), + }); + } + + let row = profile.into_row()?; + let saved = oneclient_db::dao::setting_profile::upsert(pool, &row).await?; + GameSettingsProfile::from_row(saved) +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn create_profile_from_global( + pool: &DbPool, + global: &GameSettingsProfile, + name: &str, + mem_max: Option, + force_fullscreen: Option, +) -> ClusterResult { + let mut profile = global.clone(); + + profile.name = name.to_string(); + if let Some(mem) = mem_max { + profile.mem_max = Some(mem); + } + + if let Some(fullscreen) = force_fullscreen { + profile.force_fullscreen = Some(fullscreen); + } + + upsert_named_profile(pool, &profile).await +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn create_settings_profile( + pool: &DbPool, + global: &GameSettingsProfile, + name: &str, +) -> ClusterResult { + create_profile_from_global(pool, global, name, None, None).await +} + +#[tracing::instrument(level = "debug", skip(pool))] +pub async fn list_named_profiles(pool: &DbPool) -> ClusterResult> { + let rows = oneclient_db::dao::setting_profile::list_all(pool).await?; + rows.into_iter() + .map(GameSettingsProfile::from_row) + .collect() +} + +#[tracing::instrument(level = "debug", skip(pool))] +pub async fn delete_named_profile(pool: &DbPool, name: &str) -> ClusterResult<()> { + oneclient_db::dao::setting_profile::delete_by_name(pool, name).await?; + Ok(()) +} + +#[derive(Debug, Clone, Default)] +pub struct ProfileUpdate { + pub java_path: Patch, + pub resolution: Patch, + pub force_fullscreen: Patch, + pub mem_max: Patch, + pub launch_args: Patch, + pub launch_env: Patch, + pub hook_pre: Patch, + pub hook_wrapper: Patch, + pub hook_post: Patch, + pub os_extra: Patch, + pub browser_update_mode: Patch, +} + +impl ProfileUpdate { + pub fn apply(&self, profile: &mut GameSettingsProfile) { + self.java_path.apply_to_option(&mut profile.java_path); + self.resolution.apply_to_option(&mut profile.resolution); + self.force_fullscreen + .apply_to_option(&mut profile.force_fullscreen); + self.mem_max.apply_to_option(&mut profile.mem_max); + self.launch_args + .apply_to_command_option(&mut profile.launch_args); + self.launch_env + .apply_to_command_option(&mut profile.launch_env); + self.hook_pre.apply_to_command_option(&mut profile.hook_pre); + self.hook_wrapper + .apply_to_command_option(&mut profile.hook_wrapper); + self.hook_post + .apply_to_command_option(&mut profile.hook_post); + self.os_extra.apply_to_option(&mut profile.os_extra); + self.browser_update_mode + .apply_to_option(&mut profile.browser_update_mode); + } +} + +#[tracing::instrument(level = "debug", skip(pool, update))] +pub async fn update_named_profile( + pool: &DbPool, + name: &str, + update: ProfileUpdate, +) -> ClusterResult { + let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { + return Err(ClusterError::InvalidProfile { + reason: format!("profile '{name}' not found"), + }); + }; + + let mut profile = GameSettingsProfile::from_row(row)?; + update.apply(&mut profile); + + upsert_named_profile(pool, &profile).await +} diff --git a/packages/oneclient_cluster/src/screenshots/mod.rs b/packages/oneclient_cluster/src/screenshots/mod.rs new file mode 100644 index 00000000..c2a2fcf4 --- /dev/null +++ b/packages/oneclient_cluster/src/screenshots/mod.rs @@ -0,0 +1,126 @@ +use std::path::{Path, PathBuf}; + +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use crate::cluster::Cluster; +use crate::error::ClusterResult; +use oneclient_common::paths; + +#[derive(Debug, Error)] +pub enum ScreenshotsError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("path is not inside a cluster directory: {0}")] + InvalidPath(String), + #[error("failed to move screenshot to trash: {0}")] + Trash(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScreenshotInfo { + pub name: String, + pub size_bytes: u64, + pub created: DateTime, + pub resolution: Option<(u32, u32)>, + pub path: PathBuf, +} + +fn is_image(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") +} + +fn ensure_in_clusters(path: &Path) -> ClusterResult { + polyio::ensure_under( + path, + [paths::clusters_dir()?, paths::shared_minecraft_dir()?], + )? + .ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into()) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn list_cluster_screenshots(cluster: &Cluster) -> ClusterResult> { + let dir = cluster.game_dir()?.join("screenshots"); + let mut out = Vec::new(); + + let Ok(entries) = std::fs::read_dir(&dir) else { + return Ok(out); + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + + if !is_image(name) { + continue; + } + + let Ok(meta) = std::fs::metadata(&path) else { + continue; + }; + + if !meta.is_file() { + continue; + } + + let created = meta + .created() + .or_else(|_| meta.modified()) + .map(DateTime::::from) + .unwrap_or_else(|_| Utc::now()); + + let resolution = image::image_dimensions(&path).ok(); + + out.push(ScreenshotInfo { + name: name.to_string(), + size_bytes: meta.len(), + created, + resolution, + path, + }); + } + + out.sort_by_key(|s| std::cmp::Reverse(s.created)); + Ok(out) +} + +#[tracing::instrument(level = "debug")] +pub fn load_screenshot(path: &Path, max_edge: Option) -> ClusterResult { + let path = ensure_in_clusters(path)?; + let raw = std::fs::read(&path).map_err(ScreenshotsError::Io)?; + let bytes = match max_edge { + Some(edge) => thumbnail(&raw, edge).unwrap_or_else(|| Bytes::from(raw)), + None => Bytes::from(raw), + }; + Ok(bytes) +} + +fn thumbnail(raw: &[u8], max_edge: u32) -> Option { + let img = image::load_from_memory(raw).ok()?; + if img.width().max(img.height()) <= max_edge { + return None; + } + let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Triangle); + + let mut out = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut out); + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; + Some(Bytes::from(out)) +} + +#[tracing::instrument(level = "debug")] +pub fn delete_screenshot(path: &Path) -> ClusterResult<()> { + let path = ensure_in_clusters(path)?; + + // TODO maybe async? + match trash::delete(&path) { + Ok(()) => Ok(()), + Err(err) => Err(ScreenshotsError::Trash(err.to_string()).into()), + } +} diff --git a/packages/oneclient_cluster/src/stage.rs b/packages/oneclient_cluster/src/stage.rs new file mode 100644 index 00000000..fc605ef5 --- /dev/null +++ b/packages/oneclient_cluster/src/stage.rs @@ -0,0 +1,30 @@ +use serde_repr::{Deserialize_repr, Serialize_repr}; +use strum::{Display, FromRepr}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize_repr, + Deserialize_repr, + FromRepr, + Display, + Default, +)] +#[repr(i64)] +pub enum ClusterStage { + #[default] + NotReady = 0, + Downloading = 1, + Repairing = 2, + Ready = 3, +} + +impl ClusterStage { + pub fn is_busy(self) -> bool { + matches!(self, Self::Downloading | Self::Repairing) + } +} diff --git a/packages/oneclient_common/Cargo.toml b/packages/oneclient_common/Cargo.toml new file mode 100644 index 00000000..7ba3aa7d --- /dev/null +++ b/packages/oneclient_common/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oneclient_common" +description = "Shared vocabulary for OneClient: paths, constants, platform and content types" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +# Minecraft/modded metadata vocabulary (Os, loader format versions) +interfrost.workspace = true + +directories.workspace = true +serde.workspace = true +strum.workspace = true +thiserror.workspace = true diff --git a/packages/oneclient_common/src/constants.rs b/packages/oneclient_common/src/constants.rs new file mode 100644 index 00000000..ed35f8c8 --- /dev/null +++ b/packages/oneclient_common/src/constants.rs @@ -0,0 +1,49 @@ +pub const DUMMY_REPLACE_NEWLINE: &str = "\n"; + +pub const MICROSOFT_CLIENT_ID: &str = "9419b7ee-1448-4d1b-b52a-550d8f36ab56"; +pub const MINECRAFT_SCOPES: &str = "XboxLive.SignIn XboxLive.offline_access"; +pub const CURSEFORGE_API_KEY: &str = "$2a$10$6utA1UNSmFPrE/Lh7b7ndeeGmiOkjKNY8kpFB0fsmE/d42ZAfFgCe"; +pub const DISCORD_CLIENT_ID: &str = "1426999264633946334"; + +pub const MODRINTH_API_URL: &str = "https://api.modrinth.com"; +pub const MODRINTH_CDN_PREFIX: &str = "https://cdn.modrinth.com/data/"; +pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com/v1"; +pub const CURSEFORGE_GAME_ID: u32 = 432; +pub const METADATA_API_URL: &str = "https://meta.polyfrost.org"; +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", + target_os = "macos" => "osx", + target_os = "linux" => "linux" +}; + +pub const NATIVE_ARCH: &str = cfg_select! { + target_arch = "x86" => "32", + target_arch = "x86_64" => "64", + _ => "64" +}; + +pub const JAVA_BIN: &str = if cfg!(windows) { "javaw.exe" } else { "java" }; + +pub const ARCH_WIDTH: &str = if cfg!(target_pointer_width = "64") { + "64" +} else { + "32" +}; + +pub const LINE_ENDING: &str = if cfg!(windows) { "\r\n" } else { "\n" }; +pub const CLASSPATH_SEPARATOR: &str = if cfg!(windows) { ";" } else { ":" }; diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs new file mode 100644 index 00000000..bdf68e81 --- /dev/null +++ b/packages/oneclient_common/src/domain.rs @@ -0,0 +1,289 @@ +use std::fmt::Display; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use strum::{Display as StrumDisplay, EnumIter, FromRepr}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + EnumIter, + FromRepr, + StrumDisplay, +)] +#[repr(u8)] +pub enum ContentType { + #[serde(rename = "mod")] + Mod = 0, + #[serde(rename = "resourcepack")] + ResourcePack = 1, + #[serde(rename = "shader")] + Shader = 2, + #[serde(rename = "datapack")] + DataPack = 3, + #[serde(rename = "world")] + World = 4, + #[serde(rename = "modpack")] + Modpack = 5, +} + +impl ContentType { + pub fn modrinth_type(self) -> &'static str { + match self { + Self::Mod => "mod", + Self::ResourcePack => "resourcepack", + Self::Shader => "shader", + Self::DataPack => "datapack", + Self::World => "world", + Self::Modpack => "modpack", + } + } + + pub fn folder_name(self) -> &'static str { + match self { + Self::Mod => "mods", + Self::ResourcePack => "resourcepacks", + Self::Shader => "shaderpacks", + Self::DataPack => "datapacks", + Self::World => "worlds", + Self::Modpack => "modpacks", + } + } + + pub fn from_folder_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "mods" | "mod" => Some(Self::Mod), + "resourcepacks" | "resourcepack" => Some(Self::ResourcePack), + "shaderpacks" | "shaders" | "shader" => Some(Self::Shader), + "datapacks" | "datapack" => Some(Self::DataPack), + "worlds" | "world" | "saves" => Some(Self::World), + "modpacks" | "modpack" => Some(Self::Modpack), + _ => None, + } + } +} + +impl From<&str> for ContentType { + fn from(value: &str) -> Self { + Self::from_folder_name(value).unwrap_or(Self::Mod) + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + EnumIter, + FromRepr, + StrumDisplay, +)] +#[repr(u8)] +pub enum ProviderId { + Modrinth = 0, + CurseForge = 1, + Local = 2, +} + +impl ProviderId { + pub const REMOTE_PROVIDERS: &[Self] = &[Self::Modrinth, Self::CurseForge]; + pub const REMOTE_PROVIDERS_STR: &[&str] = &["Modrinth", "CurseForge"]; + + pub fn dir_name(self) -> &'static str { + match self { + Self::Modrinth => "modrinth", + Self::CurseForge => "curseforge", + Self::Local => "local", + } + } + + pub fn website(self) -> &'static str { + match self { + Self::Modrinth => "https://modrinth.com/", + Self::CurseForge => "https://www.curseforge.com/", + Self::Local => "", + } + } + + pub const fn remote_providers() -> &'static [Self] { + &[Self::Modrinth, Self::CurseForge] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlgorithm { + Sha1, +} + +/// Applies only to browser-installed packages bundles have their own update flow +/// The check always runs regardless of variant so "out of date" markers stay populated +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, EnumIter, StrumDisplay, +)] +#[serde(rename_all = "snake_case")] +pub enum PackageUpdateMode { + Automatic, + Skip, + #[default] + Prompt, +} + +impl PackageUpdateMode { + /// Display order defaulting variant first + pub const ALL: &[Self] = &[Self::Prompt, Self::Automatic, Self::Skip]; + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Automatic => "Automatically update", + Self::Skip => "Always skip auto updates", + Self::Prompt => "Show the modal", + } + } + + /// The value stored in the `setting_profiles.browser_update_mode` column + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Automatic => "automatic", + Self::Skip => "skip", + Self::Prompt => "prompt", + } + } + + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "automatic" => Some(Self::Automatic), + "skip" => Some(Self::Skip), + "prompt" => Some(Self::Prompt), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, FromRepr)] +#[repr(u8)] +pub enum GameLoader { + #[default] + #[serde(rename = "minecraft")] + Vanilla = 0, + Forge = 1, + NeoForge = 2, + Quilt = 3, + Fabric = 4, + LegacyFabric = 5, +} + +impl GameLoader { + pub const fn is_modded(self) -> bool { + !matches!(self, Self::Vanilla) + } + + pub const fn get_format_version(self) -> usize { + match self { + Self::Vanilla => interfrost::api::minecraft::CURRENT_FORMAT_VERSION, + Self::Forge => interfrost::api::modded::CURRENT_FORGE_FORMAT_VERSION, + Self::NeoForge => interfrost::api::modded::CURRENT_NEOFORGE_FORMAT_VERSION, + Self::Quilt => interfrost::api::modded::CURRENT_QUILT_FORMAT_VERSION, + Self::Fabric => interfrost::api::modded::CURRENT_FABRIC_FORMAT_VERSION, + Self::LegacyFabric => interfrost::api::modded::CURRENT_LEGACY_FABRIC_FORMAT_VERSION, + } + } + + pub fn get_format_name(self) -> String { + match self { + Self::Vanilla => String::from("minecraft"), + Self::NeoForge => String::from("neo"), + _ => self.to_string().to_lowercase().replace(['_', '.', ' '], ""), + } + } + + pub const fn modded_loaders() -> &'static [Self] { + &[Self::Forge, Self::NeoForge, Self::Quilt, Self::Fabric] + } + + pub fn compatible_with(self, other: Self) -> bool { + match self { + Self::Vanilla => other == Self::Vanilla, + Self::Forge => other == Self::Forge, + Self::NeoForge => other == Self::NeoForge, + Self::Quilt => matches!(other, Self::Quilt | Self::Fabric), + Self::Fabric => other == Self::Fabric, + Self::LegacyFabric => other == Self::LegacyFabric, + } + } + + pub fn modrinth_name(self) -> &'static str { + match self { + Self::Vanilla => "minecraft", + Self::Forge => "forge", + Self::NeoForge => "neoforge", + Self::Quilt => "quilt", + Self::Fabric => "fabric", + Self::LegacyFabric => "legacy-fabric", + } + } +} + +impl FromStr for GameLoader { + type Err = String; + + fn from_str(s: &str) -> Result { + Ok( + match s.to_lowercase().replace(['_', '.', ' '], "").as_str() { + "vanilla" | "minecraft" => Self::Vanilla, + "forge" => Self::Forge, + "neoforge" | "neo" => Self::NeoForge, + "quilt" | "quiltloader" => Self::Quilt, + "fabric" | "fabricloader" => Self::Fabric, + "legacyfabric" => Self::LegacyFabric, + _ => return Err(format!("'{s}' is not a valid game loader")), + }, + ) + } +} + +impl Display for GameLoader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Vanilla => "Vanilla", + Self::Forge => "Forge", + Self::NeoForge => "NeoForge", + Self::Quilt => "Quilt", + Self::Fabric => "Fabric", + Self::LegacyFabric => "Legacy Fabric", + }) + } +} + +/// Shared by the settings profile (which stores it) and the launch-argument +/// builder (which turns it into `--width`/`--height`) +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct Resolution { + pub width: u32, + pub height: u32, +} + +impl Default for Resolution { + fn default() -> Self { + Self::new(854, 480) + } +} + +impl Resolution { + #[must_use] + pub const fn new(width: u32, height: u32) -> Self { + Self { width, height } + } +} diff --git a/packages/oneclient_common/src/error.rs b/packages/oneclient_common/src/error.rs new file mode 100644 index 00000000..ef1fbcc8 --- /dev/null +++ b/packages/oneclient_common/src/error.rs @@ -0,0 +1,8 @@ +pub type PathsResult = Result; + +/// Kept separate from the launcher-wide error so this crate stays a leaf +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PathsError { + #[error("Could not resolve launcher data directory")] + DataDirUnavailable, +} diff --git a/packages/oneclient_common/src/lib.rs b/packages/oneclient_common/src/lib.rs new file mode 100644 index 00000000..68674c91 --- /dev/null +++ b/packages/oneclient_common/src/lib.rs @@ -0,0 +1,21 @@ +//! Leaf crate must never depend on the database network stack or any +//! launcher subsystem or the dependency graph cycles back into a monolith + +pub mod constants; +pub mod domain; +pub mod os_ext; +pub mod patch; +pub mod paths; +pub mod search; +pub mod version; + +mod error; + +pub use domain::{ + ContentType, GameLoader, HashAlgorithm, PackageUpdateMode, ProviderId, Resolution, +}; +pub use error::{PathsError, PathsResult}; +pub use os_ext::OsExt; +pub use patch::Patch; +pub use search::{MatchScore, SearchQuery, normalize_query}; +pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; diff --git a/packages/oneclient_common/src/os_ext.rs b/packages/oneclient_common/src/os_ext.rs new file mode 100644 index 00000000..a71f1cdf --- /dev/null +++ b/packages/oneclient_common/src/os_ext.rs @@ -0,0 +1,53 @@ +use interfrost::api::minecraft::Os; + +pub trait OsExt { + fn native() -> Self; + fn native_arch(java_arch: &str) -> Self; + fn get_os(&self) -> Self; +} + +impl OsExt for Os { + fn native() -> Self { + match std::env::consts::OS { + "windows" => Self::Windows, + "macos" => Self::Osx, + "linux" => Self::Linux, + _ => Self::Unknown, + } + } + + fn native_arch(java_arch: &str) -> Self { + if std::env::consts::OS == "windows" { + if java_arch == "aarch64" { + Self::WindowsArm64 + } else { + Self::Windows + } + } else if std::env::consts::OS == "linux" { + if java_arch == "aarch64" { + Self::LinuxArm64 + } else if java_arch == "arm" { + Self::LinuxArm32 + } else { + Self::Linux + } + } else if std::env::consts::OS == "macos" { + if java_arch == "aarch64" { + Self::OsxArm64 + } else { + Self::Osx + } + } else { + Self::Unknown + } + } + + fn get_os(&self) -> Self { + match self { + Self::OsxArm64 => Self::Osx, + Self::LinuxArm32 | Self::LinuxArm64 => Self::Linux, + Self::WindowsArm64 => Self::Windows, + _ => self.clone(), + } + } +} diff --git a/packages/oneclient_common/src/patch.rs b/packages/oneclient_common/src/patch.rs new file mode 100644 index 00000000..5108fe38 --- /dev/null +++ b/packages/oneclient_common/src/patch.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Patch { + #[default] + Unchanged, + Clear, + Set(T), +} + +impl Patch { + pub fn is_unchanged(&self) -> bool { + matches!(self, Self::Unchanged) + } + + pub fn apply_to_option(&self, slot: &mut Option) + where + T: Clone, + { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.clone()), + } + } + + pub fn into_db_patch(self) -> Option> { + match self { + Self::Unchanged => None, + Self::Clear => Some(None), + Self::Set(value) => Some(Some(value)), + } + } +} + +impl Patch { + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } +} + +impl Patch<&str> { + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } +} diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs new file mode 100644 index 00000000..09de0163 --- /dev/null +++ b/packages/oneclient_common/src/paths.rs @@ -0,0 +1,152 @@ +use directories::ProjectDirs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::domain::{ContentType, ProviderId}; +use crate::error::{PathsError, PathsResult}; + +const QUALIFIER: &str = "org"; +const ORGANIZATION: &str = "Polyfrost"; + +#[cfg(not(debug_assertions))] +const APPLICATION: &str = "OneClient"; +// Separate dir so a running dev environment never touches prod data +#[cfg(debug_assertions)] +const APPLICATION: &str = "OneClient-dev"; + +static PROJECT_DIRS: OnceLock = OnceLock::new(); +static LAUNCHER_DIR_OVERRIDE: OnceLock = OnceLock::new(); + +pub fn set_launcher_dir(dir: PathBuf) { + let _ = LAUNCHER_DIR_OVERRIDE.set(dir); +} + +fn project_dirs() -> PathsResult<&'static ProjectDirs> { + if let Some(dirs) = PROJECT_DIRS.get() { + return Ok(dirs); + } + + let dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) + .ok_or(PathsError::DataDirUnavailable)?; + + let _ = PROJECT_DIRS.set(dirs); + PROJECT_DIRS.get().ok_or(PathsError::DataDirUnavailable) +} + +pub fn launcher_dir() -> PathsResult<&'static Path> { + if let Some(dir) = LAUNCHER_DIR_OVERRIDE.get() { + return Ok(dir); + } + + Ok(project_dirs()?.data_local_dir()) +} + +pub fn database_file() -> PathsResult { + Ok(launcher_dir()?.join("user_data.db")) +} + +pub fn settings_file() -> PathsResult { + Ok(launcher_dir()?.join("settings.json")) +} + +pub fn auth_file() -> PathsResult { + Ok(launcher_dir()?.join("auth.json")) +} + +pub fn logs_dir() -> PathsResult { + Ok(launcher_dir()?.join("logs")) +} + +pub fn java_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("java")) +} + +pub fn clusters_dir() -> PathsResult { + Ok(launcher_dir()?.join("clusters")) +} + +pub fn shared_minecraft_dir() -> PathsResult { + Ok(launcher_dir()?.join(".minecraft")) +} + +/// Presence marks a cluster folder as its own game dir instead of the shared `.minecraft` +pub const DEDICATED_MARKER: &str = ".dedicated_directory"; + +pub fn cluster_dir(folder_name: &str) -> PathsResult { + Ok(clusters_dir()?.join(folder_name)) +} + +pub fn cluster_uses_dedicated_dir(folder_name: &str) -> bool { + cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) +} + +/// Lives here not on `Cluster` so the content layer can resolve it from a bare +/// `ClusterRow` without duplicating the marker-file rule +pub fn cluster_game_dir(folder_name: &str) -> PathsResult { + if cluster_uses_dedicated_dir(folder_name) { + cluster_dir(folder_name) + } else { + shared_minecraft_dir() + } +} + +pub fn packages_cache_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("packages")) +} + +pub fn caches_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("caches")) +} + +pub fn bundles_dir() -> PathsResult { + Ok(caches_dir()?.join("bundles")) +} + +pub fn images_cache_dir() -> PathsResult { + Ok(caches_dir()?.join("images")) +} + +pub fn profiles_cache_dir() -> PathsResult { + Ok(caches_dir()?.join("profiles")) +} + +pub fn versions_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("versions")) +} + +pub fn libraries_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("libraries")) +} + +pub fn natives_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("natives")) +} + +pub fn assets_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("assets")) +} + +pub fn assets_index_dir() -> PathsResult { + Ok(assets_dir()?.join("indexes")) +} + +pub fn assets_object_dir() -> PathsResult { + Ok(assets_dir()?.join("objects")) +} + +pub fn legacy_assets_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("resources")) +} + +pub fn package_version_dir( + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, +) -> PathsResult { + Ok(packages_cache_dir()? + .join(content_type.folder_name()) + .join(provider.dir_name()) + .join(project_id) + .join(version_id)) +} diff --git a/packages/oneclient_common/src/search.rs b/packages/oneclient_common/src/search.rs new file mode 100644 index 00000000..0c496e5e --- /dev/null +++ b/packages/oneclient_common/src/search.rs @@ -0,0 +1,352 @@ +//! Local surfaces use the scoring here surfaces backed by a remote provider +//! take only the normalised text since ranking their results is their job + +/// Trims and collapses internal whitespace runs so spacing variants share one +/// provider request cache key and filter result +#[must_use] +pub fn normalize_query(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for word in raw.split_whitespace() { + if !out.is_empty() { + out.push(' '); + } + out.push_str(word); + } + out +} + +/// Ordered worst to best +/// The tier dominates the within-tier bonus can never lift a weaker kind of +/// match above a stronger one +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct MatchScore(u32); + +impl MatchScore { + /// Awarded to every candidate by an empty query preserving the caller's order + pub const NEUTRAL: Self = Self(0); +} + +/// Tier floors +/// The gap between them is wider than any bonus below can reach +const TIER_EXACT: u32 = 5_000; +const TIER_PREFIX: u32 = 4_000; +const TIER_WORD_PREFIX: u32 = 3_000; +const TIER_SUBSTRING: u32 = 2_000; +const TIER_SUBSEQUENCE: u32 = 1_000; +const TIER_TYPO: u32 = 100; + +/// Ceiling for the within-tier bonus comfortably below the 1000-wide tier gap +const BONUS_MAX: u32 = 900; + +/// Below this many characters a typo budget would match almost anything so +/// short terms are held to the exact tiers +const MIN_TYPO_LEN: usize = 4; + +/// Normalised once so matching a list of candidates doesn't redo the work per row +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SearchQuery { + text: String, + terms: Vec, +} + +impl SearchQuery { + #[must_use] + pub fn new(raw: &str) -> Self { + let text = normalize_query(raw).to_lowercase(); + let terms = text + .split(' ') + .filter(|t| !t.is_empty()) + .map(str::to_string) + .collect(); + Self { text, terms } + } + + /// Safe as a cache key inputs differing only in spacing produce the same text + #[must_use] + pub fn as_str(&self) -> &str { + &self.text + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + /// `None` if it doesn't match + /// An empty query matches everything at [`MatchScore::NEUTRAL`] + #[must_use] + pub fn score(&self, haystack: &str) -> Option { + if self.is_empty() { + return Some(MatchScore::NEUTRAL); + } + + let hay = normalize_query(haystack).to_lowercase(); + if hay.is_empty() { + return None; + } + + if let Some(score) = score_whole(&hay, &self.text) { + return Some(score); + } + + // Per-word fallback lets word order differ keeps one typo from sinking + // the query and gives the typo budget a word rather than a whole name + let mut worst = u32::MAX; + for term in &self.terms { + let best = hay + .split(' ') + .filter_map(|word| score_whole(word, term)) + .max() + .or_else(|| score_whole(&hay, term))?; + worst = worst.min(best.0); + } + // A per-word match must never outrank the tier it matched at + Some(MatchScore(worst.saturating_sub(1))) + } + + #[must_use] + pub fn best_score<'a>(&self, fields: impl IntoIterator) -> Option { + fields.into_iter().filter_map(|f| self.score(f)).max() + } + + #[must_use] + pub fn matches(&self, haystack: &str) -> bool { + self.score(haystack).is_some() + } +} + +/// Both inputs must already be normalised and lowercased +fn score_whole(hay: &str, needle: &str) -> Option { + if hay == needle { + return Some(MatchScore(TIER_EXACT)); + } + if hay.starts_with(needle) { + return Some(MatchScore(TIER_PREFIX + coverage(hay, needle))); + } + if let Some(at) = hay.find(needle) { + // A word-boundary hit reads as intentional mid-word is closer to coincidence + let word_start = hay[..at].ends_with(' '); + let tier = if word_start { + TIER_WORD_PREFIX + } else { + TIER_SUBSTRING + }; + return Some(MatchScore(tier + coverage(hay, needle))); + } + if let Some(span) = subsequence_span(hay, needle) { + // Tightness not just length sets the bonus a scattered subsequence is weaker + let tightness = scale(needle.chars().count(), span); + return Some(MatchScore(TIER_SUBSEQUENCE + tightness)); + } + + let needle_len = needle.chars().count(); + if needle_len < MIN_TYPO_LEN { + return None; + } + let budget = typo_budget(needle_len); + let distance = osa_distance(hay, needle, budget)?; + // Fewer typos forgiven ranks higher + let bonus = BONUS_MAX.saturating_sub((distance as u32) * (BONUS_MAX / (budget as u32 + 1))); + Some(MatchScore(TIER_TYPO + bonus)) +} + +/// Within-tier bonus for how much of the candidate the query accounts for +fn coverage(hay: &str, needle: &str) -> u32 { + scale(needle.chars().count(), hay.chars().count()) +} + +fn scale(part: usize, whole: usize) -> u32 { + if whole == 0 { + return 0; + } + let ratio = (part.min(whole) as u64 * u64::from(BONUS_MAX)) / whole as u64; + ratio as u32 +} + +/// Letters a term of this length may get wrong +/// Deliberately stingy a generous budget makes every query match everything +fn typo_budget(len: usize) -> usize { + if len >= 8 { 2 } else { 1 } +} + +/// Width of the tightest window of `hay` holding `needle`'s chars in order or +/// `None` if not a subsequence +/// Covers dropped/doubled-letter typos +fn subsequence_span(hay: &str, needle: &str) -> Option { + let hay: Vec = hay.chars().collect(); + let needle: Vec = needle.chars().collect(); + if needle.is_empty() || needle.len() > hay.len() { + return None; + } + + // Earliest end then latest start walking back from it the tightest window + let mut n = 0; + let mut end = None; + for (i, c) in hay.iter().enumerate() { + if *c == needle[n] { + n += 1; + if n == needle.len() { + end = Some(i); + break; + } + } + } + let end = end?; + + let mut n = needle.len(); + let mut start = 0; + for i in (0..=end).rev() { + if hay[i] == needle[n - 1] { + n -= 1; + if n == 0 { + start = i; + break; + } + } + } + Some(end - start + 1) +} + +/// Optimal string alignment distance giving up once it exceeds `max` +/// Unlike Levenshtein a transposition costs one edit +fn osa_distance(a: &str, b: &str, max: usize) -> Option { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.len().abs_diff(b.len()) > max { + return None; + } + + // Three rows rotate through these buffers `two_ago` is only read from row + // two onwards by which point it holds real values + let mut two_ago: Vec = vec![0; b.len() + 1]; + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur: Vec = vec![0; b.len() + 1]; + + for i in 1..=a.len() { + cur[0] = i; + let mut row_min = cur[0]; + for j in 1..=b.len() { + let cost = usize::from(a[i - 1] != b[j - 1]); + let mut best = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { + best = best.min(two_ago[j - 2] + 1); + } + cur[j] = best; + row_min = row_min.min(best); + } + // Every later row is at least this large so the budget is unrecoverable + if row_min > max { + return None; + } + std::mem::swap(&mut two_ago, &mut prev); + std::mem::swap(&mut prev, &mut cur); + } + + (prev[b.len()] <= max).then_some(prev[b.len()]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn best(query: &str, candidates: &[&str]) -> Vec { + let q = SearchQuery::new(query); + let mut scored: Vec<(MatchScore, &str)> = candidates + .iter() + .filter_map(|c| q.score(c).map(|s| (s, *c))) + .collect(); + scored.sort_by_key(|(s, c)| (std::cmp::Reverse(*s), *c)); + scored.into_iter().map(|(_, c)| c.to_string()).collect() + } + + #[test] + fn whitespace_never_changes_a_query() { + let canonical = SearchQuery::new("sodium extra"); + for raw in [ + " sodium extra", + "sodium extra ", + "sodium extra", + "\tsodium\nextra ", + ] { + assert_eq!(SearchQuery::new(raw), canonical, "{raw:?}"); + } + assert_eq!(canonical.as_str(), "sodium extra"); + } + + #[test] + fn a_blank_query_is_empty() { + assert!(SearchQuery::new(" ").is_empty()); + assert_eq!( + SearchQuery::new("").score("anything"), + Some(MatchScore::NEUTRAL) + ); + } + + #[test] + fn common_typos_still_find_the_package() { + for typo in ["sodum", "soduim", "sodiun", "sodiium"] { + assert!(SearchQuery::new(typo).matches("Sodium"), "{typo}"); + } + } + + #[test] + fn short_terms_are_not_fuzzed() { + assert!(!SearchQuery::new("abc").matches("Sodium")); + } + + #[test] + fn unrelated_names_do_not_match() { + assert!(!SearchQuery::new("sodium").matches("Journeymap")); + } + + #[test] + fn better_kinds_of_match_rank_first() { + assert_eq!( + best( + "sodium", + &[ + "Sodium", + "Sodium Extra", + "Indium (Sodium addon)", + "Sodum Fork", + "Sodiun" + ] + ), + [ + "Sodium", + "Sodium Extra", + "Indium (Sodium addon)", + "Sodiun", + "Sodum Fork", + ] + ); + } + + #[test] + fn a_tight_subsequence_beats_a_scattered_one() { + assert_eq!( + best("srr", &["Sodium Rendering Regression", "Sorry"]), + ["Sorry", "Sodium Rendering Regression"] + ); + } + + #[test] + fn multi_word_queries_match_out_of_order() { + let q = SearchQuery::new("extra sodium"); + assert!(q.matches("Sodium Extra")); + assert!(!q.matches("Sodium")); + } + + #[test] + fn a_typo_in_one_word_does_not_sink_the_query() { + assert!(SearchQuery::new("sodum extra").matches("Sodium Extra")); + } + + #[test] + fn best_score_picks_the_strongest_field() { + let q = SearchQuery::new("sodium"); + let whole = q.score("Sodium").unwrap(); + assert_eq!(q.best_score(["Fabric API", "Sodium"]), Some(whole)); + assert_eq!(q.best_score(["Fabric API", "fabric-api.jar"]), None); + } +} diff --git a/packages/oneclient_common/src/version.rs b/packages/oneclient_common/src/version.rs new file mode 100644 index 00000000..291f70b6 --- /dev/null +++ b/packages/oneclient_common/src/version.rs @@ -0,0 +1,132 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParsedMcVersion { + pub major: u32, + pub minor: Option, + pub patch: Option, +} + +impl ParsedMcVersion { + #[must_use] + pub fn key(&self) -> Option { + Some((self.minor?, self.patch)) + } +} + +pub type VersionKey = (u32, Option); + +pub fn parse_mc_version(version: &str) -> Option { + let parts: Vec<&str> = version.split('.').collect(); + + if parts.first() != Some(&"1") { + if parts.len() < 2 { + return None; + } + let major = parts[0].parse().ok()?; + let minor = parts[1].parse().ok()?; + let patch = match parts.get(2) { + Some(p) => Some(p.parse().ok()?), + None => None, + }; + return Some(ParsedMcVersion { + major, + minor: Some(minor), + patch, + }); + } + + if parts.len() <= 1 { + return None; + } + + let major = parts[1].parse().ok()?; + let minor = parts.get(2).and_then(|p| p.parse().ok()); + + Some(ParsedMcVersion { + major, + minor, + patch: None, + }) +} + +#[must_use] +pub fn format_mc_version(major: u32, minor: u32, patch: Option) -> String { + let base = if major >= 26 { + format!("{major}.{minor}") + } else { + format!("1.{major}.{minor}") + }; + + match patch { + Some(patch) => format!("{base}.{patch}"), + None => base, + } +} + +#[must_use] +pub fn normalize_mc_version_input(version: &str) -> String { + version.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_modern_versions() { + let v = parse_mc_version("26.1").unwrap(); + assert_eq!(v.major, 26); + assert_eq!(v.minor, Some(1)); + assert_eq!(v.patch, None); + + let v = parse_mc_version("26.2").unwrap(); + assert_eq!(v.major, 26); + assert_eq!(v.minor, Some(2)); + assert_eq!(v.patch, None); + } + + #[test] + fn parse_modern_patch_versions() { + let v = parse_mc_version("26.1.2").unwrap(); + assert_eq!(v.major, 26); + assert_eq!(v.minor, Some(1)); + assert_eq!(v.patch, Some(2)); + } + + #[test] + fn parse_legacy_versions() { + let v = parse_mc_version("1.21.5").unwrap(); + assert_eq!(v.major, 21); + assert_eq!(v.minor, Some(5)); + assert_eq!(v.patch, None); + } + + #[test] + fn reject_garbage_patch_component() { + assert!(parse_mc_version("26.1.x").is_none()); + } + + #[test] + fn format_versions() { + assert_eq!(format_mc_version(26, 1, None), "26.1"); + assert_eq!(format_mc_version(26, 1, Some(2)), "26.1.2"); + assert_eq!(format_mc_version(21, 5, None), "1.21.5"); + } + + #[test] + fn format_round_trips_through_parse() { + for (major, minor, patch) in [(26, 1, None), (26, 1, Some(2)), (21, 5, None)] { + let text = format_mc_version(major, minor, patch); + let parsed = parse_mc_version(&text).unwrap(); + assert_eq!(parsed.major, major); + assert_eq!(parsed.minor, Some(minor)); + assert_eq!(parsed.patch, patch); + } + } + + #[test] + fn patch_sorts_after_bare_minor() { + let bare = parse_mc_version("26.1").unwrap().key().unwrap(); + let patched = parse_mc_version("26.1.2").unwrap().key().unwrap(); + assert!(bare < patched); + } +} diff --git a/packages/oneclient_content/Cargo.toml b/packages/oneclient_content/Cargo.toml new file mode 100644 index 00000000..ae88b929 --- /dev/null +++ b/packages/oneclient_content/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "oneclient_content" +description = "Mod providers, the artifact store, and Polyfrost bundles" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_db.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +async-trait.workspace = true +async_zip.workspace = true +futures-lite.workspace = true +futures-util.workspace = true +tokio.workspace = true + +# HTML package descriptions -> markdown +htmd.workspace = true + +chrono.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } +polyio = { workspace = true, features = ["testing"] } diff --git a/packages/oneclient_content/src/bundles/error.rs b/packages/oneclient_content/src/bundles/error.rs new file mode 100644 index 00000000..7a785d74 --- /dev/null +++ b/packages/oneclient_content/src/bundles/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BundleError { + #[error("unknown loader '{0}' in bundles manifest")] + UnknownLoader(String), + + #[error("invalid loader value '{0}' in bundles manifest")] + InvalidLoader(i64), + + #[error("bundle path has no file name: {0}")] + InvalidPath(String), + + #[error("failed to parse polymrpack manifest")] + InvalidManifest, + + #[error("bundle file missing at {0}")] + MissingFile(String), + + #[error("bundle not found: {0}")] + NotFound(String), +} diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs new file mode 100644 index 00000000..3f43b4ce --- /dev/null +++ b/packages/oneclient_content/src/bundles/install.rs @@ -0,0 +1,885 @@ +use futures_util::StreamExt; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::ClusterRow; +use oneclient_db::models::OverrideType; + +use crate::bundles::error::BundleError; +use crate::bundles::manager::BundlesManager; +use crate::bundles::overrides; +use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; +use crate::ctx::ContentCtx; +use crate::error::ContentError; +use crate::error::ContentResult; +use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; + +fn is_base62(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) +} + +pub fn effective_enabled(file: &BundleFile, user_override: Option) -> bool { + match user_override { + Some(OverrideType::Removed | OverrideType::Disabled) => false, + Some(OverrideType::Enabled) => true, + None => file.enabled, + } +} + +fn find_override( + overrides: &[oneclient_db::models::ClusterBundleOverrideRow], + bundle_name: &str, + package_id: &str, +) -> Option { + overrides + .iter() + .find(|o| o.bundle_name == bundle_name && o.package_id == package_id) + .and_then(|o| OverrideType::parse(&o.override_type)) +} + +/// Searches all bundles +/// a package's `bundle_name` is rewritten on every install so a choice filed +/// under its old bundle still counts +/// `Removed` outranks `Disabled` +/// `Enabled` never suppresses +pub(crate) fn find_user_suppression( + overrides: &[oneclient_db::models::ClusterBundleOverrideRow], + package_id: &str, +) -> Option { + let mut found = None; + + for row in overrides.iter().filter(|o| o.package_id == package_id) { + match OverrideType::parse(&row.override_type) { + Some(OverrideType::Removed) => return Some(OverrideType::Removed), + Some(OverrideType::Disabled) => found = Some(OverrideType::Disabled), + Some(OverrideType::Enabled) | None => {} + } + } + + found +} + +/// Clears across all bundles +/// a row left behind under another bundle would keep answering "off" forever +async fn clear_suppressing_overrides( + cluster_id: i64, + package_id: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::clear_suppressing_overrides(&ctx.db, cluster_id, package_id).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(file, child, ctx), fields(package = %file.display_name()))] +pub async fn install_package_from_bundle( + file: &BundleFile, + cluster_id: i64, + bundle_name: &str, + skip_compatibility: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let hash = match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + version_id, + sha1, + } => { + let project = crate::packages::cached_project_detail( + ctx, + *provider, + project_id, + file.content_type(), + ) + .await; + + let version = if is_base62(version_id) { + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? + } else if let Ok(Some((_, version))) = ctx.providers.lookup_version(sha1, ctx).await { + version + } else { + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? + }; + + let artifact = PackageStore::install_to_cluster( + *provider, + &project, + &version, + cluster_id, + skip_compatibility, + false, + child, + ctx, + ) + .await?; + artifact.hash + } + BundleFileKind::External(ext) => { + install_external(ext, &cluster, skip_compatibility, child, ctx).await? + } + }; + + bundle_dao::track_bundle_artifact( + &ctx.db, + cluster_id, + &hash, + bundle_name, + &file.kind.bundle_version_id(), + &file.kind.package_id(), + ) + .await?; + + Ok(hash) +} + +#[tracing::instrument(level = "debug", skip(ext, cluster, child, ctx), fields(file = %ext.name))] +async fn install_external( + ext: &ExternalFile, + cluster: &ClusterRow, + skip_compatibility: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let artifact = crate::packages::store::download_external(ext, false, child, ctx).await?; + PackageStore::link_artifact(&artifact, cluster, Some(&ext.name), ctx).await?; + + let _ = skip_compatibility; + Ok(artifact.hash) +} + +#[tracing::instrument(level = "debug", skip(archive, ctx), fields(bundle = %archive.manifest.name))] +pub async fn extract_bundle_overrides_for_cluster( + archive: &BundleArchive, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + overrides::sync_bundle_overrides(&archive.bundle.path, &archive.manifest.name, &cluster, None) + .await?; + Ok(()) +} + +#[tracing::instrument(skip(bundles, ctx))] +pub async fn install_bundle( + cluster_id: i64, + bundle_name: &str, + skip_compatibility: bool, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let archive = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await? + .into_iter() + .find(|a| a.manifest.name == bundle_name) + .ok_or(BundleError::NotFound(bundle_name.to_string()))?; + + install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, ctx).await +} + +/// `suppression` comes from [`find_user_suppression`] so a choice filed under a +/// bundle the file has since left still counts +pub(crate) fn disable_was_deliberate(hidden: bool, suppression: Option) -> bool { + match suppression { + Some(OverrideType::Removed) => true, + // Hidden files are never-shown dependencies + // they follow the bundle so only an outright removal keeps one off + Some(OverrideType::Disabled) => !hidden, + Some(OverrideType::Enabled) | None => false, + } +} + +#[tracing::instrument(level = "debug", skip(archives, ctx))] +pub async fn heal_bundle_activity( + cluster_id: i64, + archives: &[BundleArchive], + ctx: &ContentCtx, +) -> ContentResult<()> { + let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + if tracked.iter().all(|row| row.enabled != 0) { + return Ok(()); + } + + let mut hidden: std::collections::HashMap<(&str, String), bool> = + std::collections::HashMap::new(); + for archive in archives { + for file in &archive.manifest.files { + hidden.insert( + (archive.manifest.name.as_str(), file.kind.package_id()), + file.hidden, + ); + } + } + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + + for row in tracked.iter().filter(|row| row.enabled == 0) { + let (Some(bundle_name), Some(package_id)) = (&row.bundle_name, &row.package_id) else { + continue; + }; + let Some(is_hidden) = hidden + .get(&(bundle_name.as_str(), package_id.clone())) + .copied() + else { + continue; + }; + + // Across bundles + // a package that moved keeps its old override row and reading only its + // current bundle would switch it back on + let suppression = find_user_suppression(&overrides, package_id); + if disable_was_deliberate(is_hidden, suppression) { + continue; + } + + tracing::info!( + cluster_id, + bundle = %bundle_name, + package_id, + hidden = is_hidden, + ?suppression, + "re-enabling bundle content that was switched off with nothing recording the choice" + ); + + // One unrepairable row must not fail the whole install + if let Err(err) = + PackageStore::set_artifact_enabled_to(cluster_id, &row.hash, true, ctx).await + { + tracing::warn!(hash = %row.hash, error = %err, "failed to re-enable bundle content"); + continue; + } + + // Drop the stale override everywhere or a copy under another bundle + // keeps answering "off" and the two records never settle + if suppression == Some(OverrideType::Disabled) { + clear_suppressing_overrides(cluster_id, package_id, ctx).await?; + } + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(archive, progress, ctx), fields(bundle = %archive.manifest.name))] +pub async fn install_enabled_bundle_files( + archive: &BundleArchive, + cluster_id: i64, + skip_compatibility: bool, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult> { + extract_bundle_overrides_for_cluster(archive, cluster_id, ctx).await?; + heal_bundle_activity(cluster_id, std::slice::from_ref(archive), ctx).await?; + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let bundle_name = archive.manifest.name.clone(); + let mut installed = Vec::new(); + + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); + // "Already installed" means the database not disk + // content lives in the cache between sessions so probing the folder would + // reinstall everything + for info in &linked { + if let Some(pid) = &info.project_id { + linked_projects.insert(pid.as_str()); + } + linked_hashes.insert(info.hash.as_str()); + } + + let to_install: Vec = archive + .manifest + .files + .iter() + .filter(|file| { + let package_id = file.kind.package_id(); + if !effective_enabled(file, find_override(&overrides, &bundle_name, &package_id)) { + return false; + } + let already_installed = match &file.kind { + BundleFileKind::Managed { project_id, .. } => { + linked_projects.contains(project_id.as_str()) + } + BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()), + }; + !already_installed + }) + .cloned() + .collect(); + + tracing::info!( + cluster_id, + bundle = %bundle_name, + to_install = to_install.len(), + "installing enabled bundle files" + ); + + if let Some(p) = progress { + let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); + p.expect( + TaskCategory::Packages, + to_install.len() as u64, + reserved_bytes, + ); + } + + let bundle_name = &bundle_name; + let results = futures_util::stream::iter(to_install.into_iter().map(|file| async move { + let child = progress.map(|p| { + let c = p.child( + format!("Mod {}", file.display_name()), + file.size.max(1), + oneclient_events::TaskCategory::Packages, + ); + c.set_phase(TaskPhase::Downloading); + c + }); + + let result = install_package_from_bundle( + &file, + cluster_id, + bundle_name, + skip_compatibility, + child.as_ref(), + ctx, + ) + .await; + + if let Some(child) = child { + child.set_phase(TaskPhase::Installing); + child.finish(); + } + (file.display_name(), result) + })) + .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) + .collect::>() + .await; + + for (name, result) in results { + match result { + Ok(hash) => installed.push(hash), + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to install bundle file"); + } + } + } + + Ok(installed) +} + +/// Kept modest each fetch also costs a provider API call rate limited per-minute +pub(crate) const BUNDLE_INSTALL_CONCURRENCY: usize = 6; + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn enabled_bundle_bytes( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + + let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for info in &linked { + if let Some(pid) = &info.project_id { + linked_projects.insert(pid.as_str()); + } + linked_hashes.insert(info.hash.as_str()); + } + + let mut total = 0u64; + for archive in &archives { + let bundle_name = &archive.manifest.name; + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + if !effective_enabled(file, find_override(&overrides, bundle_name, &package_id)) { + continue; + } + let already_installed = match &file.kind { + BundleFileKind::Managed { project_id, .. } => { + linked_projects.contains(project_id.as_str()) + } + BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()), + }; + if already_installed { + continue; + } + total += file.size; + } + } + + Ok(total) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_package_override( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + override_type: Option, + ctx: &ContentCtx, +) -> ContentResult<()> { + match override_type { + // The UI shows one row per package across bundles so an objection left + // under another bundle would let the next pass undo this switch + Some(ty @ OverrideType::Enabled) => { + clear_suppressing_overrides(cluster_id, package_id, ctx).await?; + bundle_dao::save_override(&ctx.db, cluster_id, bundle_name, package_id, ty).await?; + } + Some(ty) => { + bundle_dao::save_override(&ctx.db, cluster_id, bundle_name, package_id, ty).await?; + } + None => { + bundle_dao::remove_override(&ctx.db, cluster_id, bundle_name, package_id).await?; + } + } + + Ok(()) +} + +/// For bundle files the cluster has not installed +/// installed ones go through [`toggle_artifact_enabled`] +/// Matching the manifest default clears the override +/// switching *on* also drops objections filed under other bundles +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_package_enabled( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + enabled: bool, + manifest_default: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + let override_type = match (enabled, manifest_default) { + (true, true) | (false, false) => None, + (true, false) => Some(OverrideType::Enabled), + (false, true) => Some(OverrideType::Disabled), + }; + + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await?; + + if enabled { + clear_suppressing_overrides(cluster_id, package_id, ctx).await?; + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(overrides, ctx), fields(count = overrides.len()))] +pub async fn set_bundle_package_overrides( + cluster_id: i64, + overrides: &[(String, String, OverrideType)], + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::save_overrides(&ctx.db, cluster_id, overrides).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_package_opt_in( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + opted_in: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + let override_type = if opted_in { + Some(OverrideType::Enabled) + } else { + None + }; + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn list_cluster_bundle_overrides( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let rows = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + Ok(rows + .into_iter() + .map(|o| (o.bundle_name, o.package_id, o.override_type)) + .collect()) +} + +#[tracing::instrument(skip(bundles, progress, ctx))] +pub async fn install_cluster_bundles( + cluster_id: i64, + bundles: &BundlesManager, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + tracing::info!( + cluster_id, + mc_version = %cluster.mc_version, + bundles = archives.len(), + "installing enabled bundle content" + ); + for archive in &archives { + let installed = + install_enabled_bundle_files(archive, cluster_id, true, progress, ctx).await?; + tracing::info!( + cluster_id, + bundle = %archive.manifest.name, + installed = installed.len(), + "installed bundle files" + ); + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn on_user_remove_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Removed).await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn toggle_artifact_enabled( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult { + let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?; + + if enabled { + on_user_enable_artifact(cluster_id, hash, ctx).await?; + } else { + on_user_disable_artifact(cluster_id, hash, ctx).await?; + } + + Ok(enabled) +} + +/// Prefer this over [`toggle_artifact_enabled`] unless the current value is +/// known to be the opposite +/// a relinked artifact keeps its old `enabled` so a flip on an already-correct +/// row puts it wrong +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_artifact_enabled_to( + cluster_id: i64, + hash: &str, + enabled: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + PackageStore::set_artifact_enabled_to(cluster_id, hash, enabled, ctx).await?; + + if enabled { + on_user_enable_artifact(cluster_id, hash, ctx).await + } else { + on_user_disable_artifact(cluster_id, hash, ctx).await + } +} + +/// Writes the override alongside the flag +/// without it the losing bundle copy looks disabled-by-nobody and +/// heal_bundle_activity re-enables it every launch +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn reconcile_duplicate_activity(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<()> { + for hash in crate::packages::reconcile_duplicate_activity(cluster_id, ctx).await? { + on_user_disable_artifact(cluster_id, &hash, ctx).await?; + } + + Ok(()) +} + +pub async fn on_user_disable_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Disabled).await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn on_user_enable_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? + && let Some(package_id) = tracked.package_id + { + clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; + } + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +async fn handle_user_artifact_action( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, + override_type: OverrideType, +) -> ContentResult<()> { + let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? else { + return Ok(()); + }; + + let (Some(bundle_name), Some(package_id)) = (tracked.bundle_name, tracked.package_id) else { + return Ok(()); + }; + + bundle_dao::save_override( + &ctx.db, + cluster_id, + &bundle_name, + &package_id, + override_type, + ) + .await?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn remove_artifact_from_cluster( + cluster_id: i64, + hash: &str, + record_override: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + let bundle_data = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await?; + + // Looked up first but never allowed to block removal + // a package whose artifact row has gone missing must still be removable + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let target = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)); + + let link = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id) + .await? + .into_iter() + .find(|l| l.hash == hash); + + // Database first unconditionally + // on Windows a jar held open by a running game blocks deleting every hard + // link which used to fail the whole removal + // The folder is rebuilt from the database at the next launch + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + // Best-effort folder cleanup failure here is not an error + if let (Some(content_type), Some(link)) = (target, link) { + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + } + + // The package actually lives in the cache + // `evict_if_unused` drops it only once no other cluster still needs it + if let Err(err) = evict_if_unused(hash, ctx).await { + tracing::warn!(hash, error = %err, "failed to evict unused artifact from the cache"); + } + + if let Some(tracked) = bundle_data + && let (Some(bundle_name), Some(package_id)) = + (tracked.bundle_name.clone(), tracked.package_id.clone()) + { + if record_override { + bundle_dao::save_override( + &ctx.db, + cluster_id, + &bundle_name, + &package_id, + OverrideType::Removed, + ) + .await?; + } else { + let replacement_exists = + bundle_dao::has_bundle_mapping_for_package(&ctx.db, cluster_id, &package_id) + .await?; + if !replacement_exists { + clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ContentType; + use oneclient_db::models::ClusterBundleOverrideRow; + + fn file(enabled: bool) -> BundleFile { + BundleFile { + enabled, + hidden: false, + path: "mods/example.jar".to_string(), + size: 1, + kind: BundleFileKind::External(ExternalFile { + name: "example.jar".to_string(), + url: "https://example.invalid/example.jar".to_string(), + sha1: "abc123".to_string(), + size: 1, + content_type: ContentType::Mod, + }), + } + } + + #[test] + fn override_none_falls_back_to_manifest_default() { + assert!(effective_enabled(&file(true), None)); + assert!(!effective_enabled(&file(false), None)); + } + + #[test] + fn suppressing_overrides_win_over_enabled_manifest() { + assert!(!effective_enabled( + &file(true), + Some(OverrideType::Disabled) + )); + assert!(!effective_enabled(&file(true), Some(OverrideType::Removed))); + } + + #[test] + fn enabled_override_wins_over_disabled_manifest() { + assert!(effective_enabled(&file(false), Some(OverrideType::Enabled))); + } + + #[test] + fn enabled_override_round_trips_through_the_db_string() { + let parsed = OverrideType::parse(OverrideType::Enabled.as_str()); + assert_eq!(parsed, Some(OverrideType::Enabled)); + } + + #[test] + fn unknown_override_string_is_ignored() { + assert_eq!(OverrideType::parse("something-new"), None); + assert!(!effective_enabled(&file(false), None)); + } + + fn row(bundle: &str, pid: &str, ty: OverrideType) -> ClusterBundleOverrideRow { + ClusterBundleOverrideRow { + id: 1, + cluster_id: 1, + bundle_name: bundle.to_string(), + package_id: pid.to_string(), + override_type: ty.as_str().to_string(), + } + } + + #[test] + fn a_disable_with_no_override_behind_it_is_an_accident() { + assert!(!disable_was_deliberate(false, None)); + assert!(!disable_was_deliberate(true, None)); + } + + #[test] + fn a_users_own_disable_is_respected() { + assert!(disable_was_deliberate(false, Some(OverrideType::Disabled))); + assert!(disable_was_deliberate(false, Some(OverrideType::Removed))); + } + + #[test] + fn a_hidden_dependency_cannot_be_disabled_on_its_own() { + assert!( + !disable_was_deliberate(true, Some(OverrideType::Disabled)), + "a hidden file follows its bundle; only removing it keeps it off" + ); + assert!(disable_was_deliberate(true, Some(OverrideType::Removed))); + } + + #[test] + fn an_opt_in_override_never_reads_as_a_disable() { + assert!(!disable_was_deliberate(false, Some(OverrideType::Enabled))); + assert!(!disable_was_deliberate(true, Some(OverrideType::Enabled))); + } + + #[test] + fn suppression_is_found_under_a_bundle_the_package_has_since_left() { + let rows = vec![row("Bundle B", "fabric-api", OverrideType::Disabled)]; + + assert_eq!( + find_override(&rows, "Bundle C", "fabric-api"), + None, + "the per-bundle question is still answered per bundle" + ); + assert_eq!( + find_user_suppression(&rows, "fabric-api"), + Some(OverrideType::Disabled), + "the user's choice follows the package, not the bundle it was filed under" + ); + } + + #[test] + fn removal_outranks_a_disable_filed_elsewhere() { + let rows = vec![ + row("Bundle A", "yacl", OverrideType::Disabled), + row("Bundle B", "yacl", OverrideType::Removed), + ]; + + assert_eq!( + find_user_suppression(&rows, "yacl"), + Some(OverrideType::Removed) + ); + } + + #[test] + fn an_opt_in_is_not_an_objection() { + let rows = vec![ + row("Bundle A", "sodium", OverrideType::Enabled), + row("Bundle B", "lithium", OverrideType::Disabled), + ]; + + assert_eq!(find_user_suppression(&rows, "sodium"), None); + assert_eq!(find_user_suppression(&rows, "unheard-of"), None); + } + + #[test] + fn find_override_matches_on_bundle_and_package() { + let rows = vec![ + row("Bundle A", "pkg-1", OverrideType::Enabled), + row("Bundle B", "pkg-2", OverrideType::Disabled), + ]; + assert_eq!( + find_override(&rows, "Bundle A", "pkg-1"), + Some(OverrideType::Enabled) + ); + assert_eq!( + find_override(&rows, "Bundle B", "pkg-2"), + Some(OverrideType::Disabled) + ); + assert_eq!(find_override(&rows, "Bundle B", "pkg-1"), None); + assert_eq!(find_override(&rows, "Bundle A", "pkg-3"), None); + } +} diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs new file mode 100644 index 00000000..cef1b9a0 --- /dev/null +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -0,0 +1,318 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use chrono::Utc; +use oneclient_db::dao::bundle as bundle_dao; +use oneclient_db::models::{BundleRow, NewBundle}; +use tokio::sync::RwLock; + +use crate::bundles::error::BundleError; +use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef}; +use crate::bundles::polymrpack; +use crate::bundles::types::BundleArchive; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_net::RequestError; +use oneclient_net::{EtagPolicy, fetch_cached}; + +#[derive(Debug, Clone)] +pub struct Bundle { + pub remote_path: String, + pub mc_version: String, + pub loader: GameLoader, + pub file_name: String, + pub name: String, + pub version_id: String, + pub category: String, + pub loader_version: String, + pub path: PathBuf, + pub hidden: bool, +} + +impl Bundle { + fn try_from_row(row: BundleRow) -> ContentResult { + let loader = GameLoader::from_repr(row.mc_loader as u8) + .ok_or(BundleError::InvalidLoader(row.mc_loader))?; + + let launcher_dir = paths::launcher_dir()?; + let path = launcher_dir.join(&row.disk_path); + + Ok(Self { + remote_path: row.remote_path, + mc_version: row.mc_version, + loader, + file_name: row.file_name, + name: row.name.unwrap_or_default(), + version_id: row.version_id.unwrap_or_default(), + category: row.category.unwrap_or_default(), + loader_version: row.loader_version.unwrap_or_default(), + path, + hidden: row.hidden != 0, + }) + } +} + +pub struct BundlesManager { + manifest: RwLock, + pub(crate) archive_cache: RwLock>, +} + +impl BundlesManager { + pub fn new() -> Self { + Self { + manifest: RwLock::new(RemoteBundleManifest::default()), + archive_cache: RwLock::new(HashMap::new()), + } + } + + #[tracing::instrument(skip_all)] + pub async fn sync(&self, ctx: &ContentCtx) -> ContentResult { + let Some(fetched) = Self::fetch_manifest(ctx).await? else { + tracing::debug!( + "skipping bundle sync because no remote or cached manifest is available" + ); + return Ok(false); + }; + let FetchedManifest { + manifest, + changed: manifest_changed, + etag, + } = fetched; + *self.manifest.write().await = manifest.clone(); + self.archive_cache.write().await.clear(); + + if !manifest_changed { + tracing::debug!("bundles manifest unchanged; skipping per-bundle remote checks"); + return Ok(false); + } + + let remote_paths: Vec = manifest + .remote_paths() + .into_iter() + .map(|entry| entry.remote_path) + .collect(); + + bundle_dao::hide_bundles_not_in(&ctx.db, &remote_paths).await?; + + let bundles_root = paths::bundles_dir()?; + polyio::create_dir_all(&bundles_root.join("bundles")).await?; + + let mut synced_all = true; + for entry in manifest.remote_paths() { + if let Err(err) = self.sync_bundle(&entry, ctx, &bundles_root).await { + synced_all = false; + tracing::warn!( + remote_path = %entry.remote_path, + error = %err, + "failed to sync bundle" + ); + } + } + + if synced_all { + if let Some(etag) = etag { + let manifest_path = paths::bundles_dir()?.join("metadata.json"); + oneclient_net::commit_etag(&manifest_path, &etag).await; + } + } else { + tracing::warn!( + "bundle catalog incomplete; not caching the manifest etag so the next \ + sync retries the missing bundles" + ); + } + + Ok(true) + } + + pub async fn cache_archive_manifest( + &self, + path: std::path::PathBuf, + manifest: crate::bundles::types::BundleManifest, + ) { + self.archive_cache.write().await.insert(path, manifest); + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn archives_for( + &self, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, + ) -> ContentResult> { + let mut archives = Vec::new(); + for bundle in self.list_for(ctx, mc_version, loader).await? { + let manifest = self.manifest_for_archive(&bundle.path).await?; + archives.push(BundleArchive { bundle, manifest }); + } + Ok(archives) + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn manifest_for_archive( + &self, + path: &Path, + ) -> ContentResult { + if let Some(manifest) = self.archive_cache.read().await.get(path) { + return Ok(manifest.clone()); + } + let manifest = polymrpack::read_manifest_from_archive(path).await?; + self.archive_cache + .write() + .await + .insert(path.to_path_buf(), manifest.clone()); + Ok(manifest) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn list_for( + &self, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, + ) -> ContentResult> { + let rows = + bundle_dao::list_visible_for_version_loader(&ctx.db, mc_version, loader as i64).await?; + + rows.into_iter() + .map(Bundle::try_from_row) + .collect::, _>>() + } + + #[tracing::instrument(level = "debug", skip(self, entry, ctx, bundles_root), fields(remote_path = %entry.remote_path))] + async fn sync_bundle( + &self, + entry: &RemoteBundleRef, + ctx: &ContentCtx, + bundles_root: &Path, + ) -> ContentResult<()> { + let loader = GameLoader::from_str(&entry.loader) + .map_err(|_| BundleError::UnknownLoader(entry.loader.clone()))?; + + let file_name = entry + .remote_path + .split('/') + .next_back() + .ok_or_else(|| BundleError::InvalidPath(entry.remote_path.clone()))? + .to_string(); + + let disk_path = bundles_root.join("bundles").join(&file_name); + let remote_url = format!("{}{}", ctx.net.config().meta_url_base, entry.remote_path); + + download_bundle_if_needed(ctx, &remote_url, &disk_path, &entry.sha1).await?; + + let meta = polymrpack::read_meta_from_archive(&disk_path).await?; + + let relative_disk_path = disk_path + .strip_prefix(paths::launcher_dir()?) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| disk_path.to_string_lossy().into_owned()); + + let synced_at = Utc::now().to_rfc3339(); + + bundle_dao::upsert_bundle( + &ctx.db, + NewBundle { + remote_path: &entry.remote_path, + mc_version: &entry.mc_version, + mc_loader: loader as i64, + file_name: &file_name, + name: Some(&meta.name), + version_id: Some(&meta.version_id), + category: Some(&meta.category), + loader_version: Some(&meta.loader_version), + disk_path: &relative_disk_path, + hidden: false, + etag: Some(&entry.sha1), + synced_at: Some(&synced_at), + }, + ) + .await?; + + Ok(()) + } + + /// Defers the ETag commit + /// bundles download after the catalog so an early ETag would 304 past + /// bundles that never arrived + /// `sync` commits it once all land + #[tracing::instrument(level = "debug", skip(ctx))] + async fn fetch_manifest(ctx: &ContentCtx) -> ContentResult> { + let manifest_path = paths::bundles_dir()?.join("metadata.json"); + let url = format!( + "{}/oneclient/bundles/metadata.json", + ctx.net.config().meta_url_base + ); + + let Some(fetched) = fetch_cached(&ctx.net, &url, &manifest_path, EtagPolicy::Defer).await? + else { + return Ok(None); + }; + + Ok(Some(FetchedManifest { + manifest: fetched.json()?, + changed: fetched.changed, + etag: fetched.etag, + })) + } +} + +impl Default for BundlesManager { + fn default() -> Self { + Self::new() + } +} + +struct FetchedManifest { + manifest: RemoteBundleManifest, + changed: bool, + etag: Option, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +async fn download_bundle_if_needed( + ctx: &ContentCtx, + url: &str, + disk_path: &Path, + expected_sha1: &str, +) -> ContentResult<()> { + if oneclient_net::matches_on_disk(disk_path, expected_sha1).await { + tracing::debug!("bundle cache hit via SHA1: {url}"); + return Ok(()); + } + + tracing::debug!("downloading bundle from remote: {url}"); + let expected = polyio::Checksum::sha1(expected_sha1); + oneclient_net::download_verified( + &ctx.net, + &ctx.events, + url, + disk_path, + Some(&expected), + 0, + None, + ) + .await + .map_err(map_request_error) +} + +fn map_request_error(err: RequestError) -> ContentError { + match err { + RequestError::DeserializeError { + source, + type_name, + url, + status, + snippet, + } => ContentError::InvalidData { + reason: format!( + "failed to parse {type_name} from {url} (HTTP {status}): {source}; body: {snippet}" + ), + }, + other => ContentError::InvalidData { + reason: other.to_string(), + }, + } +} diff --git a/packages/oneclient_content/src/bundles/manifest.rs b/packages/oneclient_content/src/bundles/manifest.rs new file mode 100644 index 00000000..030f7c29 --- /dev/null +++ b/packages/oneclient_content/src/bundles/manifest.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteBundleEntry { + pub path: String, + pub sha1: String, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleManifest { + pub versions: HashMap>>, +} + +impl BundleManifest { + pub fn remote_paths(&self) -> Vec { + let mut refs = Vec::new(); + + for (mc_version, loaders) in &self.versions { + for (loader, entries) in loaders { + for entry in entries { + refs.push(RemoteBundleRef { + mc_version: mc_version.clone(), + loader: loader.clone(), + remote_path: entry.path.clone(), + sha1: entry.sha1.clone(), + }); + } + } + } + + refs + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteBundleRef { + pub mc_version: String, + pub loader: String, + pub remote_path: String, + pub sha1: String, +} diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs new file mode 100644 index 00000000..1c235b77 --- /dev/null +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -0,0 +1,29 @@ +mod error; +mod install; +mod manager; +mod manifest; +mod overrides; +mod polymrpack; +mod runtime; +mod types; +mod updates; + +pub use error::BundleError; +pub use install::{ + effective_enabled, enabled_bundle_bytes, extract_bundle_overrides_for_cluster, + heal_bundle_activity, install_bundle, install_cluster_bundles, install_enabled_bundle_files, + install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact, + on_user_enable_artifact, on_user_remove_artifact, reconcile_duplicate_activity, + remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_package_enabled, + set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides, + toggle_artifact_enabled, +}; +pub use manager::{Bundle, BundlesManager}; +pub use manifest::BundleManifest as RemoteBundleManifest; +pub use runtime::{is_bundle_syncing, sync_all_cluster_bundles}; +pub use types::{ + ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleManifest, + BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, + BundleWithUpdateStatus, FileUpdateStatus, +}; +pub use updates::{apply_bundle_updates, check_bundle_updates, get_bundles_with_update_status}; diff --git a/packages/oneclient_content/src/bundles/overrides.rs b/packages/oneclient_content/src/bundles/overrides.rs new file mode 100644 index 00000000..1a7dae13 --- /dev/null +++ b/packages/oneclient_content/src/bundles/overrides.rs @@ -0,0 +1,504 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use oneclient_db::models::ClusterRow; +use serde::{Deserialize, Serialize}; + +use crate::error::ContentResult; +use oneclient_events::EventBus; +use polyio::{sha1_bytes, sha1_file}; + +const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"]; + +const OVERRIDES_PREFIX: &str = "overrides/"; +const LOCK_REL_PATH: &str = ".oneclient/bundle_overrides.json"; + +fn cluster_root(cluster: &ClusterRow) -> ContentResult { + Ok(oneclient_common::paths::clusters_dir()?.join(&cluster.folder_name)) +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct OverrideLock { + #[serde(default)] + bundles: HashMap>, +} + +impl OverrideLock { + async fn load(root: &Path) -> Self { + let path = root.join(LOCK_REL_PATH); + match polyio::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| { + tracing::warn!(error = %err, "corrupt bundle override lock; starting fresh"); + Self::default() + }), + Err(_) => Self::default(), + } + } + + async fn save(&self, root: &Path) -> ContentResult<()> { + let path = root.join(LOCK_REL_PATH); + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await.ok(); + } + let bytes = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + polyio::write(&path, bytes).await?; + Ok(()) + } +} + +#[derive(Debug, Default)] +pub struct OverrideSyncReport { + pub written: Vec, + pub conflicts: Vec, + pub deleted: Vec, +} + +#[tracing::instrument(level = "debug", skip(cluster, events))] +pub async fn sync_bundle_overrides( + archive_path: &Path, + bundle_name: &str, + cluster: &ClusterRow, + events: Option<&EventBus>, +) -> ContentResult { + let root = cluster_root(cluster)?; + sync_bundle_overrides_at(archive_path, bundle_name, &root, events).await +} + +#[tracing::instrument(level = "debug", skip(events))] +async fn sync_bundle_overrides_at( + archive_path: &Path, + bundle_name: &str, + root: &Path, + events: Option<&EventBus>, +) -> ContentResult { + let entries = + polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX)) + .await?; + + let mut lock = OverrideLock::load(root).await; + let previous = lock.bundles.remove(bundle_name).unwrap_or_default(); + let mut next: HashMap = HashMap::new(); + let mut report = OverrideSyncReport::default(); + + for (name, bytes) in entries { + let rel = name.trim_start_matches(OVERRIDES_PREFIX); + if rel.is_empty() { + continue; + } + + let new_sha1 = sha1_bytes(&bytes); + let dest = root.join(polyio::sanitize_path(rel)); + let disk_sha1 = current_sha1(&dest).await; + let base = previous.get(rel).cloned(); + + if matches_always_update(rel) { + let wrote = write_override(&dest, &bytes, rel).await; + if wrote { + if disk_sha1.as_deref() != Some(new_sha1.as_str()) { + report.written.push(rel.to_string()); + } + next.insert(rel.to_string(), new_sha1); + } else if let Some(b) = base { + next.insert(rel.to_string(), b); + } + continue; + } + + match disk_sha1 { + None => { + if write_override(&dest, &bytes, rel).await { + report.written.push(rel.to_string()); + next.insert(rel.to_string(), new_sha1); + } + } + Some(disk) if disk == new_sha1 => { + next.insert(rel.to_string(), new_sha1); + } + Some(disk) if base.as_deref() == Some(disk.as_str()) => { + if write_override(&dest, &bytes, rel).await { + report.written.push(rel.to_string()); + next.insert(rel.to_string(), new_sha1); + } else { + next.insert(rel.to_string(), disk); + } + } + Some(_) => { + if let Some(b) = base { + if b != new_sha1 { + report.conflicts.push(rel.to_string()); + } + next.insert(rel.to_string(), b); + } + } + } + } + + for (rel, base_sha1) in &previous { + if next.contains_key(rel) { + continue; + } + let dest = root.join(polyio::sanitize_path(rel)); + + if current_sha1(&dest).await.as_deref() == Some(base_sha1.as_str()) + && polyio::remove_file(&dest).await.is_ok() + { + report.deleted.push(rel.clone()); + } + } + + lock.bundles.insert(bundle_name.to_string(), next); + if let Err(err) = lock.save(root).await { + tracing::warn!(error = %err, "failed to persist bundle override lock"); + } + + tracing::debug!( + bundle = bundle_name, + written = report.written.len(), + conflicts = report.conflicts.len(), + deleted = report.deleted.len(), + "synced bundle overrides" + ); + + if !report.conflicts.is_empty() { + notify_conflicts(events, bundle_name, &report.conflicts); + } + + Ok(report) +} + +async fn write_override(dest: &Path, bytes: &[u8], rel: &str) -> bool { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + match polyio::write(dest, bytes).await { + Ok(()) => true, + Err(err) => { + tracing::warn!(path = rel, error = %err, "failed to write bundle override"); + false + } + } +} + +async fn current_sha1(path: &Path) -> Option { + if polyio::try_exists(path).await.unwrap_or(false) { + sha1_file(path).await.ok() + } else { + None + } +} + +fn notify_conflicts(events: Option<&EventBus>, bundle_name: &str, conflicts: &[String]) { + let Some(events) = events else { + return; + }; + + const MAX_LISTED: usize = 5; + let listed = conflicts + .iter() + .take(MAX_LISTED) + .cloned() + .collect::>() + .join(", "); + let extra = conflicts.len().saturating_sub(MAX_LISTED); + let suffix = if extra > 0 { + format!(" (+{extra} more)") + } else { + String::new() + }; + + events.notify("Kept your config edits") + .body(format!( + "{bundle_name}: {} config file(s) you edited were left untouched by the update: {listed}{suffix}", + conflicts.len() + )) + .send(); +} + +fn matches_always_update(rel: &str) -> bool { + ALWAYS_UPDATE_GLOBS + .iter() + .any(|pattern| glob_match(pattern, rel)) +} + +fn glob_match(pattern: &str, path: &str) -> bool { + let pat: Vec<&str> = pattern.split('/').collect(); + let seg: Vec<&str> = path.split('/').collect(); + glob_segments(&pat, &seg) +} + +fn glob_segments(pat: &[&str], path: &[&str]) -> bool { + match pat.split_first() { + None => path.is_empty(), + Some((&"**", rest)) => (0..=path.len()).any(|i| glob_segments(rest, &path[i..])), + Some((first, rest)) => { + if let Some((head, tail)) = path.split_first() { + segment_match(first, head) && glob_segments(rest, tail) + } else { + false + } + } + } +} + +fn segment_match(pattern: &str, segment: &str) -> bool { + if !pattern.contains('*') { + return pattern == segment; + } + + let parts: Vec<&str> = pattern.split('*').collect(); + let mut pos = 0usize; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if index == 0 { + if !segment[pos..].starts_with(part) { + return false; + } + pos += part.len(); + } else if index == parts.len() - 1 { + if !segment[pos..].ends_with(part) { + return false; + } + } else if let Some(found) = segment[pos..].find(part) { + pos += found + part.len(); + } else { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn glob_exact_and_wildcards() { + assert!(glob_match( + "config/fabric_loader_dependencies.json", + "config/fabric_loader_dependencies.json" + )); + assert!(!glob_match( + "config/fabric_loader_dependencies.json", + "config/other.json" + )); + assert!(glob_match("config/*.json", "config/anything.json")); + assert!(!glob_match("config/*.json", "config/nested/anything.json")); + assert!(glob_match("config/**", "config/nested/deep/file.toml")); + assert!(glob_match("**/options.txt", "a/b/options.txt")); + assert!(glob_match("**/options.txt", "options.txt")); + assert!(glob_match("config/mod-*.cfg", "config/mod-foo.cfg")); + assert!(!glob_match("config/mod-*.cfg", "config/other-foo.cfg")); + } + + async fn write_bundle(path: &Path, entries: &[(&str, &[u8])]) { + let file = tokio::fs::File::create(path).await.unwrap(); + let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(file); + for (name, data) in entries { + let builder = async_zip::ZipEntryBuilder::new( + (*name).to_string().into(), + async_zip::Compression::Stored, + ); + writer.write_entry_whole(builder, data).await.unwrap(); + } + writer.close().await.unwrap(); + } + + async fn read(path: &Path) -> Option> { + polyio::read(path).await.ok() + } + + async fn sync(zip: &Path, root: &Path) -> OverrideSyncReport { + sync_bundle_overrides_at(zip, "test-bundle", root, None) + .await + .unwrap() + } + + #[tokio::test] + async fn fresh_install_writes_all() { + let root = polyio::testing::ScratchDir::new("fresh"); + let zip = root.join("v1.mrpack"); + write_bundle( + &zip, + &[ + ("overrides/config/a.toml", b"alpha"), + ("overrides/config/nested/b.cfg", b"beta"), + ("overrides/", b""), + ], + ) + .await; + + let report = sync(&zip, root.path()).await; + assert_eq!(report.written.len(), 2); + assert!(report.conflicts.is_empty()); + assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha"); + assert_eq!( + read(&root.join("config/nested/b.cfg")).await.unwrap(), + b"beta" + ); + } + + #[tokio::test] + async fn rerun_same_version_is_noop() { + let root = polyio::testing::ScratchDir::new("noop"); + let zip = root.join("v1.mrpack"); + write_bundle(&zip, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&zip, root.path()).await; + + let report = sync(&zip, root.path()).await; + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert!(report.deleted.is_empty()); + } + + #[tokio::test] + async fn untouched_file_updates() { + let root = polyio::testing::ScratchDir::new("update"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, root.path()).await; + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; + let report = sync(&v2, root.path()).await; + + assert_eq!(report.written, vec!["config/a.toml".to_string()]); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"alpha-v2" + ); + } + + #[tokio::test] + async fn user_edit_with_bundle_unchanged_is_kept_silently() { + let root = polyio::testing::ScratchDir::new("edit_noconflict"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, root.path()).await; + + polyio::write(root.join("config/a.toml"), b"user-edit") + .await + .unwrap(); + + let report = sync(&v1, root.path()).await; + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + } + + #[tokio::test] + async fn user_edit_and_bundle_changed_is_conflict_kept() { + let root = polyio::testing::ScratchDir::new("conflict"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, root.path()).await; + + polyio::write(root.join("config/a.toml"), b"user-edit") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; + let report = sync(&v2, root.path()).await; + + assert_eq!(report.conflicts, vec!["config/a.toml".to_string()]); + assert!(report.written.is_empty()); + + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + + let report2 = sync(&v2, root.path()).await; + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + assert_eq!(report2.conflicts, vec!["config/a.toml".to_string()]); + } + + #[tokio::test] + async fn always_update_overwrites_user_edit() { + let root = polyio::testing::ScratchDir::new("always"); + let path = "config/fabric_loader_dependencies.json"; + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[(&format!("overrides/{path}"), b"deps-v1")]).await; + sync(&v1, root.path()).await; + + polyio::write(root.join(path), b"runtime-mutated") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[(&format!("overrides/{path}"), b"deps-v2")]).await; + let report = sync(&v2, root.path()).await; + + assert!(report.conflicts.is_empty()); + assert_eq!(read(&root.join(path)).await.unwrap(), b"deps-v2"); + } + + #[tokio::test] + async fn removed_file_deleted_only_when_unmodified() { + let root = polyio::testing::ScratchDir::new("removed"); + let v1 = root.join("v1.mrpack"); + write_bundle( + &v1, + &[ + ("overrides/config/gone.toml", b"g"), + ("overrides/config/kept.toml", b"k"), + ], + ) + .await; + sync(&v1, root.path()).await; + + polyio::write(root.join("config/kept.toml"), b"edited") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"a")]).await; + let report = sync(&v2, root.path()).await; + + assert_eq!(report.deleted, vec!["config/gone.toml".to_string()]); + assert!(read(&root.join("config/gone.toml")).await.is_none()); + + assert_eq!( + read(&root.join("config/kept.toml")).await.unwrap(), + b"edited" + ); + } + + #[tokio::test] + async fn bootstrap_pre_existing_edit_is_never_overwritten() { + let root = polyio::testing::ScratchDir::new("bootstrap"); + + polyio::create_dir_all(root.join("config")).await.unwrap(); + polyio::write(root.join("config/a.toml"), b"pre-existing") + .await + .unwrap(); + + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"bundle-version")]).await; + let report = sync(&v1, root.path()).await; + + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"pre-existing" + ); + + let report2 = sync(&v1, root.path()).await; + assert!(report2.written.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"pre-existing" + ); + } +} diff --git a/packages/oneclient_content/src/bundles/polymrpack.rs b/packages/oneclient_content/src/bundles/polymrpack.rs new file mode 100644 index 00000000..f5245983 --- /dev/null +++ b/packages/oneclient_content/src/bundles/polymrpack.rs @@ -0,0 +1,176 @@ +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; + +use serde::Deserialize; + +use crate::bundles::error::BundleError; +use crate::bundles::types::{ + BundleFile, BundleFileKind, BundleManifest, content_type_from_bundle_path, +}; +use crate::error::ContentResult; +use crate::packages::types::ExternalFile; +use oneclient_common::constants::MODRINTH_CDN_PREFIX; +use oneclient_common::domain::{GameLoader, ProviderId}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolyMrpackMeta { + pub name: String, + pub version_id: String, + pub category: String, + pub enabled: bool, + pub mc_version: String, + pub loader: GameLoader, + pub loader_version: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PolyMrpackManifest { + pub category: String, + pub enabled: bool, + pub version_id: String, + pub name: String, + #[serde(default)] + pub dependencies: HashMap, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PolyMrpackFile { + pub path: String, + pub hashes: PolyMrpackHashes, + #[serde(default)] + pub downloads: Vec, + #[serde(rename = "fileSize", default)] + pub file_size: u64, + pub enabled: bool, + #[serde(default)] + pub hidden: bool, +} + +#[derive(Debug, Deserialize)] +struct PolyMrpackHashes { + pub sha1: String, +} + +#[tracing::instrument(level = "debug")] +pub async fn read_meta_from_archive(path: &Path) -> ContentResult { + let manifest = read_manifest_from_archive(path).await?; + Ok(PolyMrpackMeta { + name: manifest.name, + version_id: manifest.version_id, + category: manifest.category, + enabled: manifest.enabled, + mc_version: manifest.mc_version, + loader: manifest.loader, + loader_version: manifest.loader_version, + }) +} + +#[tracing::instrument(level = "debug")] +pub async fn read_manifest_from_archive(path: &Path) -> ContentResult { + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); + let manifest_bytes = polyio::try_read_zip_entry_bytes(reader, "modrinth.index.json").await?; + parse_manifest_bytes(&manifest_bytes) +} + +fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult { + let manifest: PolyMrpackManifest = + serde_json::from_slice(bytes).map_err(|_| BundleError::InvalidManifest)?; + + let (mc_version, loader, loader_version) = parse_dependencies(&manifest.dependencies)?; + let files = manifest + .files + .iter() + .filter_map(parse_bundle_file) + .collect(); + + Ok(BundleManifest { + name: manifest.name, + version_id: manifest.version_id, + category: manifest.category, + mc_version, + loader, + loader_version, + enabled: manifest.enabled, + files, + }) +} + +fn parse_dependencies( + deps: &HashMap, +) -> ContentResult<(String, GameLoader, String)> { + let mut mc_version = None; + let mut loader = None; + let mut loader_version = None; + + for (key, value) in deps { + let normalized = key.to_lowercase().replace(['_', '.', ' ', '-'], ""); + if normalized == "minecraft" { + mc_version = Some(value.clone()); + } else if let Ok(parsed) = GameLoader::from_str(&normalized) { + loader = Some(parsed); + loader_version = Some(value.clone()); + } + } + + Ok(( + mc_version.ok_or(BundleError::InvalidManifest)?, + loader.ok_or(BundleError::InvalidManifest)?, + loader_version.ok_or(BundleError::InvalidManifest)?, + )) +} + +fn parse_bundle_file(file: &PolyMrpackFile) -> Option { + if let Some(url) = file + .downloads + .iter() + .find(|url| url.starts_with(MODRINTH_CDN_PREFIX)) + { + let paths = url[MODRINTH_CDN_PREFIX.len()..] + .split('/') + .collect::>(); + if paths.len() >= 4 { + return Some(BundleFile { + enabled: file.enabled, + hidden: file.hidden, + path: file.path.clone(), + size: file.file_size, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: paths[0].to_string(), + version_id: paths[2].to_string(), + sha1: file.hashes.sha1.to_ascii_lowercase(), + }, + }); + } + tracing::error!("invalid modrinth file URL in bundle: '{url}'"); + return None; + } + + let download_url = file.downloads.first().cloned()?; + let file_name = file + .path + .split('/') + .next_back() + .unwrap_or(&file.path) + .to_string(); + + Some(BundleFile { + enabled: file.enabled, + hidden: file.hidden, + path: file.path.clone(), + size: file.file_size, + kind: BundleFileKind::External(ExternalFile { + name: file_name, + url: download_url, + sha1: file.hashes.sha1.to_ascii_lowercase(), + size: file.file_size, + content_type: content_type_from_bundle_path(&file.path), + }), + }) +} diff --git a/packages/oneclient_content/src/bundles/runtime.rs b/packages/oneclient_content/src/bundles/runtime.rs new file mode 100644 index 00000000..0982a3db --- /dev/null +++ b/packages/oneclient_content/src/bundles/runtime.rs @@ -0,0 +1,33 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::bundles::ApplyBundleUpdatesResult; +use crate::bundles::manager::BundlesManager; +use crate::bundles::updates::apply_bundle_updates_for_all_clusters; +use crate::ctx::ContentCtx; +use oneclient_events::GroupedProgressSession; + +static BUNDLE_SYNCING: AtomicBool = AtomicBool::new(false); + +pub fn is_bundle_syncing() -> bool { + BUNDLE_SYNCING.load(Ordering::Relaxed) +} + +/// `session` when given groups every cluster's downloads into one notification +#[tracing::instrument(skip_all)] +pub async fn sync_all_cluster_bundles( + bundles: &BundlesManager, + ctx: &ContentCtx, + session: Option<&GroupedProgressSession>, +) -> Vec<(i64, ApplyBundleUpdatesResult)> { + tracing::info!("syncing all cluster bundles"); + BUNDLE_SYNCING.store(true, Ordering::Relaxed); + let changed = match apply_bundle_updates_for_all_clusters(bundles, ctx, session).await { + Ok(changed) => changed, + Err(err) => { + tracing::error!("failed to apply bundle updates for clusters: {err:#}"); + Vec::new() + } + }; + BUNDLE_SYNCING.store(false, Ordering::Relaxed); + changed +} diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs new file mode 100644 index 00000000..36028cf9 --- /dev/null +++ b/packages/oneclient_content/src/bundles/types.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; + +use crate::bundles::Bundle; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; + +#[derive(Debug, Clone)] +pub struct BundleArchive { + pub bundle: Bundle, + pub manifest: BundleManifest, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleManifest { + pub name: String, + pub version_id: String, + pub category: String, + pub mc_version: String, + pub loader: GameLoader, + pub loader_version: String, + pub enabled: bool, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleFile { + pub enabled: bool, + pub hidden: bool, + #[serde(default)] + pub path: String, + #[serde(default)] + pub size: u64, + pub kind: BundleFileKind, +} + +impl BundleFile { + pub fn content_type(&self) -> ContentType { + if let BundleFileKind::External(ext) = &self.kind { + return ext.content_type; + } + content_type_from_bundle_path(&self.path) + } + + pub fn display_name(&self) -> String { + let from_path = self.path.rsplit('/').next().filter(|s| !s.is_empty()); + if let Some(name) = from_path { + return name.to_string(); + } + match &self.kind { + BundleFileKind::External(ext) => ext.name.clone(), + BundleFileKind::Managed { project_id, .. } => project_id.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BundleFileKind { + Managed { + provider: ProviderId, + project_id: String, + version_id: String, + sha1: String, + }, + External(ExternalFile), +} + +impl BundleFileKind { + pub fn package_id(&self) -> String { + match self { + Self::Managed { project_id, .. } => project_id.clone(), + Self::External(ext) => ext.sha1.clone(), + } + } + + pub fn bundle_version_id(&self) -> String { + match self { + Self::Managed { version_id, .. } => version_id.clone(), + Self::External(ext) => ext.sha1.clone(), + } + } +} + +pub fn managed_bundle_key(provider: ProviderId, package_id: &str) -> String { + format!("m:{}:{package_id}", provider.dir_name()) +} + +pub fn external_bundle_key(sha1: &str) -> String { + format!("e:{sha1}") +} + +pub fn content_type_from_bundle_path(path: &str) -> ContentType { + let top = path.split('/').next().unwrap_or(""); + ContentType::from_folder_name(top).unwrap_or(ContentType::Mod) +} + +#[derive(Debug, Clone)] +pub struct BundlePackageUpdate { + pub cluster_id: i64, + pub installed_hash: String, + pub installed_version_id: String, + pub bundle_name: String, + pub new_version_id: String, + pub new_file: BundleFile, +} + +#[derive(Debug, Clone)] +pub struct BundlePackageRemoval { + pub cluster_id: i64, + pub hash: String, + pub package_id: String, + pub bundle_name: String, + /// `None` for external/local files + pub provider: Option, + pub project_id: Option, + /// Captured at check time + /// fallback when the meta cache has no entry + pub display_name: Option, +} + +#[derive(Debug, Clone)] +pub struct BundlePackageAddition { + pub cluster_id: i64, + pub bundle_name: String, + pub new_file: BundleFile, +} + +#[derive(Debug, Clone, Default)] +pub struct BundleUpdateCheckResult { + pub cluster_id: i64, + pub updates_available: Vec, + pub removals_available: Vec, + pub additions_available: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ApplyBundleUpdatesResult { + pub updates_applied: Vec, + pub removals_applied: Vec, + pub additions_applied: Vec, + pub updates_failed: Vec, + pub removals_failed: Vec, + pub additions_failed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum FileUpdateStatus { + NotInstalled, + RemovedByUser, + UpToDate, + UpdateAvailable { + installed_version_id: String, + new_version_id: String, + }, +} + +#[derive(Debug, Clone)] +pub struct BundleWithUpdateStatus { + pub archive: BundleArchive, + pub files: Vec<(BundleFile, FileUpdateStatus)>, + pub has_updates: bool, +} diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs new file mode 100644 index 00000000..ba9ecf15 --- /dev/null +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -0,0 +1,944 @@ +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::ClusterPatch; +use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; +use tokio::sync::Mutex as AsyncMutex; + +use futures_util::StreamExt; + +use crate::bundles::install::{ + BUNDLE_INSTALL_CONCURRENCY, disable_was_deliberate, find_user_suppression, + heal_bundle_activity, install_package_from_bundle, remove_artifact_from_cluster, + set_artifact_enabled_to, +}; +use crate::bundles::manager::BundlesManager; +use crate::bundles::overrides; +use crate::bundles::types::{ + ApplyBundleUpdatesResult, BundleArchive, BundleFileKind, BundlePackageAddition, + BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, + FileUpdateStatus, external_bundle_key, managed_bundle_key, +}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use crate::packages::store::PackageStore; +use crate::packages::types::LinkedArtifactInfo; +use oneclient_common::domain::{GameLoader, ProviderId}; + +static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); + +fn cluster_lock(cluster_id: i64) -> Arc> { + let locks = CLUSTER_UPDATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut map = locks.lock().unwrap(); + map.entry(cluster_id) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn check_bundle_updates( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + check_bundle_updates_inner(cluster_id, bundles, ctx, &overrides).await +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx, overrides))] +async fn check_bundle_updates_inner( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, + overrides: &[ClusterBundleOverrideRow], +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let all_linked_by_hash: HashMap = + all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); + + let (all_installed_managed_keys, all_installed_external_hashes) = + installed_bundle_keys(ctx, &all_linked).await?; + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + + let tracked_bundle_names: HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + let loaded_bundle_names: HashSet = + archives.iter().map(|a| a.manifest.name.clone()).collect(); + let unavailable_tracked: HashSet = tracked_bundle_names + .into_iter() + .filter(|name| !loaded_bundle_names.contains(name)) + .collect(); + + let overrides_map: HashMap<(String, String), OverrideType> = overrides + .iter() + .filter_map(|o| { + OverrideType::parse(&o.override_type) + .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) + }) + .collect(); + + let mut bundle_versions: HashMap< + String, + HashMap, + > = HashMap::new(); + let mut hidden_dependency_keys_by_bundle: HashMap> = HashMap::new(); + let mut hidden_explicit_keys_by_bundle: HashMap> = HashMap::new(); + let mut shipped_keys_by_bundle: HashMap> = HashMap::new(); + + for archive in &archives { + let mut files_map = HashMap::new(); + let mut hidden_dependency_keys = HashSet::new(); + let mut hidden_explicit_keys = HashSet::new(); + let mut shipped_keys = HashSet::new(); + + for file in &archive.manifest.files { + shipped_keys.insert(match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + .. + } => managed_bundle_key(*provider, project_id), + BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), + }); + + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file.kind.package_id())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { + continue; + } + match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } => { + let key = managed_bundle_key(*provider, project_id); + files_map.insert(key.clone(), (version_id.clone(), file.clone())); + if file.hidden { + hidden_explicit_keys.insert(key); + } + } + BundleFileKind::External(ext) => { + let key = external_bundle_key(&ext.sha1); + files_map.insert(key.clone(), (ext.sha1.clone(), file.clone())); + if file.hidden { + hidden_explicit_keys.insert(key); + } + } + } + } + + for visible_key in files_map.keys() { + hidden_dependency_keys.remove(visible_key); + } + + hidden_dependency_keys_by_bundle + .insert(archive.manifest.name.clone(), hidden_dependency_keys); + hidden_explicit_keys_by_bundle.insert(archive.manifest.name.clone(), hidden_explicit_keys); + shipped_keys_by_bundle.insert(archive.manifest.name.clone(), shipped_keys); + bundle_versions.insert(archive.manifest.name.clone(), files_map); + } + + let mut subscribed_bundles: HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + + let mut candidate_keys_by_bundle = HashMap::new(); + for archive in &archives { + let Some(files_map) = bundle_versions.get(&archive.manifest.name) else { + continue; + }; + let hidden_keys = hidden_dependency_keys_by_bundle + .get(&archive.manifest.name) + .cloned() + .unwrap_or_default(); + let hidden_explicit = hidden_explicit_keys_by_bundle + .get(&archive.manifest.name) + .cloned() + .unwrap_or_default(); + candidate_keys_by_bundle.insert( + archive.manifest.name.clone(), + collect_inferable_managed_keys(files_map, &hidden_keys, &hidden_explicit), + ); + } + + for inferred in infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + &all_installed_managed_keys, + ) { + subscribed_bundles.insert(inferred); + } + + let subscribed_bundle_names: Vec<&str> = archives + .iter() + .filter(|a| subscribed_bundles.contains(&a.manifest.name)) + .map(|a| a.manifest.name.as_str()) + .collect(); + + let mut updates_available = Vec::new(); + let mut removals_available = Vec::new(); + + for bundle_pkg in &bundle_packages { + let Some(pkg_id) = &bundle_pkg.package_id else { + continue; + }; + let Some(installed_version_id) = &bundle_pkg.bundle_version_id else { + continue; + }; + + let installed_key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); + + let Some(bundle_name) = &bundle_pkg.bundle_name else { + continue; + }; + if unavailable_tracked.contains(bundle_name) { + continue; + } + + let mut matched_target = bundle_versions.get(bundle_name).and_then(|files_map| { + files_map + .get(&installed_key) + .map(|(new_version_id, new_file)| { + ( + bundle_name.clone(), + new_version_id.clone(), + new_file.clone(), + ) + }) + }); + + if matched_target.is_none() { + for candidate in &subscribed_bundle_names { + if *candidate == bundle_name.as_str() { + continue; + } + if let Some((new_version_id, new_file)) = bundle_versions + .get(*candidate) + .and_then(|m| m.get(&installed_key)) + { + matched_target = Some(( + (*candidate).to_string(), + new_version_id.clone(), + new_file.clone(), + )); + break; + } + } + } + + if let Some((resolved_bundle_name, new_version_id, new_file)) = matched_target { + if installed_version_id != &new_version_id { + updates_available.push(BundlePackageUpdate { + cluster_id, + installed_hash: bundle_pkg.hash.clone(), + installed_version_id: installed_version_id.clone(), + bundle_name: resolved_bundle_name, + new_version_id, + new_file, + }); + } + } else { + let user_disabled = matches!( + overrides_map.get(&(bundle_name.clone(), pkg_id.clone())), + Some(OverrideType::Disabled) + ); + let still_shipped = shipped_keys_by_bundle + .get(bundle_name) + .is_some_and(|keys| keys.contains(&installed_key)); + if user_disabled && still_shipped { + continue; + } + + let linked = all_linked_by_hash.get(&bundle_pkg.hash); + removals_available.push(BundlePackageRemoval { + cluster_id, + hash: bundle_pkg.hash.clone(), + package_id: pkg_id.clone(), + bundle_name: bundle_name.clone(), + provider: linked.and_then(|i| i.provider), + project_id: linked + .and_then(|i| i.project_id.clone()) + .or_else(|| Some(pkg_id.clone())), + display_name: linked.map(|i| { + i.display_name + .clone() + .unwrap_or_else(|| i.file_name.clone()) + }), + }); + } + } + + updates_available.retain(|u| { + let file_id = u.new_file.kind.package_id(); + !matches!( + overrides_map.get(&(u.bundle_name.clone(), file_id)), + Some(OverrideType::Removed) + ) + }); + + let addition_eligible_bundles = addition_eligible_bundles( + ctx, + &bundle_packages, + &all_linked, + &candidate_keys_by_bundle, + overrides, + ) + .await?; + + let mut additions_available = Vec::new(); + let mut planned_addition_keys = all_installed_managed_keys.clone(); + for hash in all_installed_external_hashes { + planned_addition_keys.insert(external_bundle_key(&hash)); + } + + for archive in &archives { + if !addition_eligible_bundles.contains(&archive.manifest.name) { + continue; + } + + for file in &archive.manifest.files { + let file_id = file.kind.package_id(); + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file_id.clone())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { + continue; + } + + let file_key = match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + .. + } => managed_bundle_key(*provider, project_id), + BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), + }; + if planned_addition_keys.contains(&file_key) { + continue; + } + + additions_available.push(BundlePackageAddition { + cluster_id, + bundle_name: archive.manifest.name.clone(), + new_file: file.clone(), + }); + planned_addition_keys.insert(file_key); + } + } + + Ok(BundleUpdateCheckResult { + cluster_id, + updates_available, + removals_available, + additions_available, + }) +} + +#[tracing::instrument(skip(bundles, ctx))] +pub async fn apply_bundle_updates( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let session = + oneclient_events::GroupedProgressSession::start(&ctx.events, "Updating bundle content"); + let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session)).await; + session.finish(); + result +} + +/// The caller owns `session`'s lifetime +/// sharing one across clusters makes a batch surface as a single notification +#[tracing::instrument(skip(bundles, ctx, session))] +pub async fn apply_bundle_updates_with( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, + session: Option<&oneclient_events::GroupedProgressSession>, +) -> ContentResult { + let lock = cluster_lock(cluster_id); + let _guard = lock.lock().await; + + // Must run first + // the check below never consults `enabled` so unrecorded disables would + // read as healthy and be left untouched + { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { + heal_bundle_activity(cluster_id, &archives, ctx).await?; + } + } + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let check = check_bundle_updates_inner(cluster_id, bundles, ctx, &overrides).await?; + + tracing::info!( + cluster_id, + updates = check.updates_available.len(), + additions = check.additions_available.len(), + removals = check.removals_available.len(), + "applying bundle updates" + ); + + let mut result = ApplyBundleUpdatesResult::default(); + + for removal in check.removals_available { + match remove_artifact_from_cluster(cluster_id, &removal.hash, false, ctx).await { + Ok(()) => result.removals_applied.push(removal), + Err(err) => result + .removals_failed + .push(format!("{}: {err:#}", removal.package_id)), + } + } + + let has_downloads = + !check.updates_available.is_empty() || !check.additions_available.is_empty(); + if let Some(s) = session.filter(|_| has_downloads) { + let count = (check.updates_available.len() + check.additions_available.len()) as u64; + let bytes: u64 = check + .updates_available + .iter() + .map(|u| u.new_file.size.max(1)) + .chain( + check + .additions_available + .iter() + .map(|a| a.new_file.size.max(1)), + ) + .sum(); + s.expect(oneclient_events::TaskCategory::Packages, count, bytes); + } + + // Fetch concurrently reconcile in order + // splitting the two keeps the fan-out safe since no artifact is unlinked + // while another download still needs it + let fetched_updates = futures_util::stream::iter(check.updates_available.into_iter().map( + |update| async move { + let child = session.map(|s| { + let c = s.child( + update.new_file.display_name(), + update.new_file.size.max(1), + oneclient_events::TaskCategory::Packages, + ); + c.set_phase(oneclient_events::TaskPhase::Downloading); + c + }); + let installed = install_package_from_bundle( + &update.new_file, + update.cluster_id, + &update.bundle_name, + true, + child.as_ref(), + ctx, + ) + .await; + if let Some(child) = child { + child.set_phase(oneclient_events::TaskPhase::Installing); + child.finish(); + } + (update, installed) + }, + )) + .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) + .collect::>() + .await; + + for (update, installed) in fetched_updates { + match installed { + Ok(hash) => match reconcile_update(&update, &hash, &overrides, ctx).await { + Ok(()) => result.updates_applied.push(update), + Err(err) => result.updates_failed.push(err.to_string()), + }, + Err(err) => result.updates_failed.push(err.to_string()), + } + } + + let fetched_additions = futures_util::stream::iter(check.additions_available.into_iter().map( + |addition| async move { + let child = session.map(|s| { + let c = s.child( + addition.new_file.display_name(), + addition.new_file.size.max(1), + oneclient_events::TaskCategory::Packages, + ); + c.set_phase(oneclient_events::TaskPhase::Downloading); + c + }); + let installed = install_package_from_bundle( + &addition.new_file, + addition.cluster_id, + &addition.bundle_name, + true, + child.as_ref(), + ctx, + ) + .await; + if let Some(child) = child { + child.set_phase(oneclient_events::TaskPhase::Installing); + child.finish(); + } + (addition, installed) + }, + )) + .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) + .collect::>() + .await; + + for (addition, installed) in fetched_additions { + let file_id = addition.new_file.kind.package_id(); + match installed { + Ok(hash) => match reconcile_addition(&addition, &hash, &overrides, ctx).await { + Ok(()) => result.additions_applied.push(addition), + Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), + }, + Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), + } + } + + { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { + for archive in archives { + if let Err(err) = overrides::sync_bundle_overrides( + &archive.bundle.path, + &archive.manifest.name, + &cluster, + Some(&ctx.events), + ) + .await + { + tracing::warn!( + bundle = %archive.manifest.name, + error = %err, + "failed to sync bundle overrides during update" + ); + } + } + } + } + + let failures = + result.updates_failed.len() + result.removals_failed.len() + result.additions_failed.len(); + if failures == 0 { + sync_cluster_loader_version_from_bundles(cluster_id, bundles, ctx).await?; + } + + Ok(result) +} + +/// `enabled` is set not flipped and overrides are read across all bundles +/// packages get re-resolved between bundles so a per-bundle lookup misses +/// objections filed while the file lived elsewhere +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = update.cluster_id, bundle = %update.bundle_name, new_version = %update.new_version_id))] +async fn reconcile_update( + update: &BundlePackageUpdate, + hash: &str, + overrides: &[ClusterBundleOverrideRow], + ctx: &ContentCtx, +) -> ContentResult<()> { + let file_id = update.new_file.kind.package_id(); + let suppression = find_user_suppression(overrides, &file_id); + let enabled = !disable_was_deliberate(update.new_file.hidden, suppression); + set_artifact_enabled_to(update.cluster_id, hash, enabled, ctx).await?; + + if hash == update.installed_hash { + return Ok(()); + } + + remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await +} + +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = addition.cluster_id, bundle = %addition.bundle_name))] +async fn reconcile_addition( + addition: &BundlePackageAddition, + hash: &str, + overrides: &[ClusterBundleOverrideRow], + ctx: &ContentCtx, +) -> ContentResult<()> { + let file_id = addition.new_file.kind.package_id(); + let suppression = find_user_suppression(overrides, &file_id); + let enabled = !disable_was_deliberate(addition.new_file.hidden, suppression); + set_artifact_enabled_to(addition.cluster_id, hash, enabled, ctx).await +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +async fn sync_cluster_loader_version_from_bundles( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + + let (managed_keys, _) = installed_bundle_keys(ctx, &all_linked).await?; + let subscribed: HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .chain(infer_subscribed_from_archives(&archives, &managed_keys)) + .collect(); + + let target = select_highest_loader_version( + archives + .iter() + .filter(|a| subscribed.contains(&a.manifest.name)) + .map(|a| a.manifest.loader_version.as_str()), + ); + + let Some(target) = target else { + return Ok(()); + }; + + if cluster.mc_loader_version.as_deref() == Some(target.as_str()) { + return Ok(()); + } + + cluster_dao::update( + &ctx.db, + cluster_id, + &ClusterPatch { + mc_loader_version: Some(Some(target)), + ..Default::default() + }, + ) + .await?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn get_bundles_with_update_status( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let all_linked_by_hash: HashMap = + all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); + + let mut installed_map: HashMap = HashMap::new(); + for bundle_pkg in &bundle_packages { + let Some(pkg_id) = &bundle_pkg.package_id else { + continue; + }; + let key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); + installed_map.insert(key, bundle_pkg); + } + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let overrides_map: HashMap<(String, String), OverrideType> = overrides + .iter() + .filter_map(|o| { + OverrideType::parse(&o.override_type) + .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) + }) + .collect(); + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + let mut results = Vec::new(); + + for archive in archives { + let mut files = Vec::new(); + let mut has_updates = false; + + for file in &archive.manifest.files { + let status = match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } => { + let key = managed_bundle_key(*provider, project_id); + if let Some(installed) = installed_map.get(&key) { + let installed_version = + installed.bundle_version_id.as_deref().unwrap_or(""); + if installed_version == version_id.as_str() { + FileUpdateStatus::UpToDate + } else { + has_updates = true; + FileUpdateStatus::UpdateAvailable { + installed_version_id: installed_version.to_string(), + new_version_id: version_id.clone(), + } + } + } else if matches!( + overrides_map.get(&(archive.manifest.name.clone(), project_id.clone())), + Some(OverrideType::Removed) + ) { + FileUpdateStatus::RemovedByUser + } else { + FileUpdateStatus::NotInstalled + } + } + BundleFileKind::External(ext) => { + let key = external_bundle_key(&ext.sha1); + if installed_map.contains_key(&key) { + FileUpdateStatus::UpToDate + } else if matches!( + overrides_map.get(&(archive.manifest.name.clone(), ext.sha1.clone())), + Some(OverrideType::Removed) + ) { + FileUpdateStatus::RemovedByUser + } else { + FileUpdateStatus::NotInstalled + } + } + }; + files.push((file.clone(), status)); + } + + results.push(BundleWithUpdateStatus { + archive, + files, + has_updates, + }); + } + + Ok(results) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn apply_bundle_updates_for_all_clusters( + bundles: &BundlesManager, + ctx: &ContentCtx, + session: Option<&oneclient_events::GroupedProgressSession>, +) -> ContentResult> { + let mut changed = Vec::new(); + for cluster in cluster_dao::list_all(&ctx.db).await? { + match apply_bundle_updates_with(cluster.id, bundles, ctx, session).await { + Ok(result) => { + if !result.updates_applied.is_empty() + || !result.additions_applied.is_empty() + || !result.removals_applied.is_empty() + { + changed.push((cluster.id, result)); + } + } + Err(err) => tracing::warn!( + cluster_id = cluster.id, + error = %err, + "bundle update apply failed for cluster" + ), + } + } + Ok(changed) +} + +fn bundle_package_key( + bundle_pkg: &BundleTrackedArtifactRow, + linked: &HashMap, + package_id: &str, +) -> String { + if package_id == bundle_pkg.hash { + external_bundle_key(&bundle_pkg.hash) + } else if let Some(info) = linked.get(&bundle_pkg.hash) { + managed_bundle_key(info.provider.unwrap_or(ProviderId::Modrinth), package_id) + } else { + managed_bundle_key(ProviderId::Modrinth, package_id) + } +} + +/// Stricter than subscription +/// emptying a bundle is how a user opts out and an absent override is not +/// consent for a file that did not exist back then +#[tracing::instrument(level = "debug", skip_all)] +async fn addition_eligible_bundles( + ctx: &ContentCtx, + bundle_packages: &[BundleTrackedArtifactRow], + all_linked: &[LinkedArtifactInfo], + candidate_keys_by_bundle: &HashMap>, + overrides: &[ClusterBundleOverrideRow], +) -> ContentResult> { + let (live_managed_keys, _) = + installed_bundle_keys(ctx, all_linked.iter().filter(|item| item.enabled)).await?; + + let mut eligible: HashSet = bundle_packages + .iter() + .filter(|bp| bp.enabled != 0) + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + + eligible.extend(infer_bundle_names_from_unique_installed_keys( + candidate_keys_by_bundle, + &live_managed_keys, + )); + + eligible.extend( + overrides + .iter() + .filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled)) + .map(|o| o.bundle_name.clone()), + ); + + Ok(eligible) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn installed_bundle_keys<'a>( + ctx: &ContentCtx, + linked: impl IntoIterator, +) -> ContentResult<(HashSet, HashSet)> { + let mut managed = HashSet::new(); + let mut external = HashSet::new(); + + for item in linked { + if let Some(release) = artifact_dao::get_release_by_hash(&ctx.db, &item.hash).await? { + let provider = + ProviderId::from_repr(release.provider as u8).unwrap_or(ProviderId::Modrinth); + managed.insert(managed_bundle_key(provider, &release.project_id)); + } else { + external.insert(item.hash.clone()); + } + } + + Ok((managed, external)) +} + +fn collect_inferable_managed_keys( + bundle_files_map: &HashMap, + hidden_dependency_keys: &HashSet, + hidden_explicit_keys: &HashSet, +) -> HashSet { + bundle_files_map + .keys() + .filter(|key| { + key.starts_with("m:") + && !hidden_dependency_keys.contains(*key) + && !hidden_explicit_keys.contains(*key) + }) + .cloned() + .collect() +} + +fn infer_bundle_names_from_unique_installed_keys( + candidate_keys_by_bundle: &HashMap>, + all_installed_managed_keys: &HashSet, +) -> HashSet { + let mut key_occurrence_count: HashMap = HashMap::new(); + for candidate_keys in candidate_keys_by_bundle.values() { + for key in candidate_keys { + *key_occurrence_count.entry(key.clone()).or_default() += 1; + } + } + + candidate_keys_by_bundle + .iter() + .filter_map(|(bundle_name, candidate_keys)| { + let has_unique = candidate_keys.iter().any(|key| { + all_installed_managed_keys.contains(key) + && key_occurrence_count.get(key).copied().unwrap_or_default() == 1 + }); + if has_unique { + Some(bundle_name.clone()) + } else { + None + } + }) + .collect() +} + +fn infer_subscribed_from_archives( + archives: &[BundleArchive], + managed_keys: &HashSet, +) -> HashSet { + let mut candidate_keys_by_bundle = HashMap::new(); + for archive in archives { + let mut keys = HashSet::new(); + for file in &archive.manifest.files { + if !file.enabled { + continue; + } + if let BundleFileKind::Managed { + provider, + project_id, + .. + } = &file.kind + && !file.hidden + { + keys.insert(managed_bundle_key(*provider, project_id)); + } + } + candidate_keys_by_bundle.insert(archive.manifest.name.clone(), keys); + } + infer_bundle_names_from_unique_installed_keys(&candidate_keys_by_bundle, managed_keys) +} + +fn compare_version_segment(left: &str, right: &str) -> Ordering { + match (left.parse::(), right.parse::()) { + (Ok(left_num), Ok(right_num)) => left_num.cmp(&right_num), + (Ok(_), Err(_)) => Ordering::Greater, + (Err(_), Ok(_)) => Ordering::Less, + (Err(_), Err(_)) => left.cmp(right), + } +} + +fn compare_version_like(left: &str, right: &str) -> Ordering { + let left_segments: Vec = left + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) + .collect(); + let right_segments: Vec = right + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) + .collect(); + + for index in 0..left_segments.len().max(right_segments.len()) { + match (left_segments.get(index), right_segments.get(index)) { + (Some(left_segment), Some(right_segment)) => { + let cmp = compare_version_segment(left_segment, right_segment); + if cmp != Ordering::Equal { + return cmp; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => break, + } + } + Ordering::Equal +} + +fn select_highest_loader_version<'a>( + versions: impl IntoIterator, +) -> Option { + versions + .into_iter() + .map(str::trim) + .filter(|value| !value.is_empty()) + .max_by(|left, right| compare_version_like(left, right)) + .map(str::to_string) +} diff --git a/packages/oneclient_content/src/ctx.rs b/packages/oneclient_content/src/ctx.rs new file mode 100644 index 00000000..1045ec72 --- /dev/null +++ b/packages/oneclient_content/src/ctx.rs @@ -0,0 +1,33 @@ +use oneclient_db::DbPool; +use oneclient_events::EventBus; +use oneclient_net::RequestClient; + +use crate::packages::provider::PackageProviderRegistry; + +/// `db` is taken directly not behind a port +/// these tables are shared relational state with cross-entity joins used from +/// four subsystems +#[derive(Clone)] +pub struct ContentCtx { + pub db: DbPool, + pub net: RequestClient, + pub events: EventBus, + pub providers: PackageProviderRegistry, +} + +impl ContentCtx { + #[must_use] + pub fn new( + db: DbPool, + net: RequestClient, + events: EventBus, + providers: PackageProviderRegistry, + ) -> Self { + Self { + db, + net, + events, + providers, + } + } +} diff --git a/packages/oneclient_content/src/error.rs b/packages/oneclient_content/src/error.rs new file mode 100644 index 00000000..481a41e1 --- /dev/null +++ b/packages/oneclient_content/src/error.rs @@ -0,0 +1,66 @@ +use crate::bundles::BundleError; +use crate::packages::PackageError; + +pub type ContentResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum ContentError { + #[error(transparent)] + Package(#[from] PackageError), + + #[error(transparent)] + Bundle(#[from] BundleError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Db(#[from] oneclient_db::DbError), + + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error(transparent)] + Events(#[from] oneclient_events::EventError), + + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), + + #[error("invalid content data: {reason}")] + InvalidData { reason: String }, +} + +impl ContentError { + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } +} diff --git a/packages/oneclient_content/src/lib.rs b/packages/oneclient_content/src/lib.rs new file mode 100644 index 00000000..962c04c0 --- /dev/null +++ b/packages/oneclient_content/src/lib.rs @@ -0,0 +1,8 @@ +pub mod bundles; +pub mod packages; + +mod ctx; +mod error; + +pub use ctx::ContentCtx; +pub use error::{ContentError, ContentResult}; diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs new file mode 100644 index 00000000..f7d3f93b --- /dev/null +++ b/packages/oneclient_content/src/packages/activity.rs @@ -0,0 +1,249 @@ +//! `cluster_artifacts` is unique on `(cluster_id, hash)` so duplicate versions +//! of one package are legal rows +//! the newest stays enabled the rest are switched off rather than unlinked + +use std::collections::HashMap; + +use oneclient_common::domain::ProviderId; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::store::PackageStore; +use crate::packages::types::LinkedArtifactInfo; + +struct Copy { + hash: String, + enabled: bool, + published_at: Option, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn reconcile_duplicate_activity( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let mut switched_off = Vec::new(); + + for hash in duplicates_to_disable(&linked) { + tracing::info!( + cluster_id, + %hash, + "resolving duplicate versions of a package to the newest live one" + ); + + PackageStore::set_artifact_enabled_to(cluster_id, &hash, false, ctx).await?; + switched_off.push(hash); + } + + Ok(switched_off) +} + +fn duplicates_to_disable(linked: &[LinkedArtifactInfo]) -> Vec { + let mut out = Vec::new(); + + for (_, copies) in group_duplicates(linked) { + let live: Vec<&Copy> = copies.iter().filter(|copy| copy.enabled).collect(); + if live.len() < 2 { + continue; + } + + let Some(winner) = newest(live.iter().copied()) else { + continue; + }; + + out.extend( + live.iter() + .filter(|copy| copy.hash != winner) + .map(|copy| copy.hash.clone()), + ); + } + + out +} + +/// Local files and a bundle's external files have no project to group on so +/// they are left out +/// two of those are two packages not two copies +fn group_duplicates(linked: &[LinkedArtifactInfo]) -> Vec<((ProviderId, String), Vec)> { + let mut by_project: HashMap<(ProviderId, String), Vec> = HashMap::new(); + + for info in linked { + let (Some(provider), Some(project_id)) = (info.provider, info.project_id.clone()) else { + continue; + }; + + by_project + .entry((provider, project_id)) + .or_default() + .push(Copy { + hash: info.hash.clone(), + enabled: info.enabled, + published_at: info.published_at.clone(), + }); + } + + by_project.retain(|_, copies| copies.len() > 1); + by_project.into_iter().collect() +} + +/// `published_at` is RFC 3339 so string order matches time order +/// undated copies lose to dated ones and an all-undated group still picks one +fn newest<'a>(copies: impl IntoIterator) -> Option { + copies + .into_iter() + .max_by(|a, b| a.published_at.cmp(&b.published_at)) + .map(|copy| copy.hash.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ContentType; + + fn info( + project_id: Option<&str>, + hash: &str, + enabled: bool, + published_at: Option<&str>, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.into(), + cluster_file_name: format!("{hash}.jar"), + enabled, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: project_id.map(Into::into), + version_id: Some(format!("v-{hash}")), + display_name: None, + display_version: None, + provider: project_id.map(|_| ProviderId::Modrinth), + published_at: published_at.map(Into::into), + } + } + + fn copies(specs: &[(&str, bool, Option<&str>)]) -> Vec { + specs + .iter() + .map(|(hash, enabled, published)| Copy { + hash: (*hash).into(), + enabled: *enabled, + published_at: published.map(Into::into), + }) + .collect() + } + + #[test] + fn the_newest_publication_wins() { + let picked = newest(&copies(&[ + ("old", true, Some("2026-01-01T00:00:00Z")), + ("new", false, Some("2026-06-01T00:00:00Z")), + ("middle", false, Some("2026-03-01T00:00:00Z")), + ])); + + assert_eq!( + picked.as_deref(), + Some("new"), + "being enabled does not win it" + ); + } + + #[test] + fn a_dated_copy_beats_an_undated_one() { + let picked = newest(&copies(&[ + ("undated", true, None), + ("dated", false, Some("2026-01-01T00:00:00Z")), + ])); + + assert_eq!(picked.as_deref(), Some("dated")); + } + + #[test] + fn an_undated_group_still_picks_one() { + let picked = newest(&copies(&[("a", false, None), ("b", false, None)])); + + assert!( + picked.is_some(), + "a group with no dates must not go unresolved" + ); + } + + #[test] + fn only_projects_with_several_copies_are_reconciled() { + let groups = group_duplicates(&[ + info(Some("sodium"), "s1", true, None), + info(Some("sodium"), "s2", true, None), + info(Some("lithium"), "l1", true, None), + ]); + + assert_eq!(groups.len(), 1, "a single copy is nothing to resolve"); + assert_eq!(groups[0].0.1, "sodium"); + assert_eq!(groups[0].1.len(), 2); + } + + #[test] + fn the_newest_live_copy_survives_and_the_rest_go_quiet() { + let mut disable = duplicates_to_disable(&[ + info(Some("sodium"), "old", true, Some("2026-01-01T00:00:00Z")), + info(Some("sodium"), "new", true, Some("2026-06-01T00:00:00Z")), + info(Some("sodium"), "middle", true, Some("2026-03-01T00:00:00Z")), + ]); + disable.sort(); + assert_eq!(disable, vec!["middle".to_string(), "old".to_string()]); + } + + #[test] + fn a_copy_that_is_already_off_is_not_counted_as_a_conflict() { + let disable = duplicates_to_disable(&[ + info(Some("sodium"), "live", true, Some("2026-01-01T00:00:00Z")), + info(Some("sodium"), "off", false, Some("2026-06-01T00:00:00Z")), + ]); + + assert!( + disable.is_empty(), + "one live copy is not a duplicate, however many switched-off copies sit beside it" + ); + } + + #[test] + fn the_user_disabling_the_newest_copy_is_left_standing() { + let disable = duplicates_to_disable(&[ + info(Some("sodium"), "chosen", true, Some("2026-01-01T00:00:00Z")), + info( + Some("sodium"), + "newest", + false, + Some("2026-06-01T00:00:00Z"), + ), + ]); + + assert!( + disable.is_empty(), + "picking the older version on purpose must not be undone, and the newest must not be \ + switched back on" + ); + } + + #[test] + fn a_project_with_no_live_copy_at_all_is_left_alone() { + let disable = duplicates_to_disable(&[ + info(Some("sodium"), "a", false, Some("2026-01-01T00:00:00Z")), + info(Some("sodium"), "b", false, Some("2026-06-01T00:00:00Z")), + ]); + + assert!(disable.is_empty()); + } + + #[test] + fn content_with_no_project_is_left_alone() { + let groups = group_duplicates(&[ + info(None, "local-1", true, None), + info(None, "local-2", true, None), + ]); + + assert!( + groups.is_empty(), + "two local files are two packages, not two copies of one" + ); + } +} diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs new file mode 100644 index 00000000..b387ab45 --- /dev/null +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -0,0 +1,401 @@ +use std::collections::{HashSet, VecDeque}; + +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_db::models::ClusterRow; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::provider::PackageProvider; +use crate::packages::store::PackageStore; +use crate::packages::types::{ + DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary, +}; + +/// Caps the transitive walk so a provider serving a cycle can't spin forever +const MAX_DEPTH: usize = 4; + +const VERSION_WINDOW: usize = 50; + +#[derive(Debug, Clone)] +pub struct ResolvedDependency { + pub project: ProjectDetail, + pub version: VersionDetail, +} + +#[derive(Debug, Clone, Default)] +pub struct DependencyResolution { + /// Ordered breadth-first from the root + /// already-present ones are filtered out + pub install: Vec, + pub unresolved: Vec, +} + +impl DependencyResolution { + pub fn is_empty(&self) -> bool { + self.install.is_empty() && self.unresolved.is_empty() + } +} + +#[tracing::instrument(level = "debug", skip(root, ctx), fields(project_id = %root.project_id, version_id = %root.version_id))] +pub async fn resolve_required( + provider_id: ProviderId, + root: &VersionDetail, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let mut resolution = DependencyResolution::default(); + if !root.dependencies.iter().any(is_required) { + return Ok(resolution); + } + + let provider = ctx.providers.get(provider_id)?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + + // Seeded with installed projects and the root against refetches and self-referencing graphs + let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?; + seen.insert(root.project_id.clone()); + + let mut queue: VecDeque<(VersionDependency, usize)> = root + .dependencies + .iter() + .filter(|dep| is_required(dep)) + .map(|dep| (dep.clone(), 1)) + .collect(); + + while let Some((dep, depth)) = queue.pop_front() { + if let Some(project_id) = &dep.project_id + && seen.contains(project_id) + { + continue; + } + + let resolved = match resolve_one(provider, &dep, &cluster, ctx).await { + Ok(Some(resolved)) => resolved, + Ok(None) => { + tracing::warn!(?dep, "no compatible version for dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + Err(err) => { + tracing::warn!(?dep, %err, "failed to resolve dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + }; + + // A version-pinned dependency only reveals its project once fetched so dedupe again here + if !seen.insert(resolved.version.project_id.clone()) { + continue; + } + + if depth < MAX_DEPTH { + for next in resolved + .version + .dependencies + .iter() + .filter(|d| is_required(d)) + { + queue.push_back((next.clone(), depth + 1)); + } + } + + resolution.install.push(resolved); + } + + Ok(resolution) +} + +fn is_required(dep: &VersionDependency) -> bool { + dep.kind == DependencyKind::Required +} + +fn dependency_label(dep: &VersionDependency) -> String { + dep.project_id + .clone() + .or_else(|| dep.version_id.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +async fn resolve_one( + provider: &dyn PackageProvider, + dep: &VersionDependency, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + // A pinned version wins even when newer exists + // providers ignore the project id once the version id is known + let version = match &dep.version_id { + Some(version_id) => { + provider + .get_version( + dep.project_id.as_deref().unwrap_or_default(), + version_id, + ctx, + ) + .await? + } + None => { + let Some(project_id) = dep.project_id.as_deref() else { + return Ok(None); + }; + let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else { + return Ok(None); + }; + provider + .get_version(project_id, &pick.version_id, ctx) + .await? + } + }; + + let project = provider.get_project(&version.project_id, ctx).await?; + Ok(Some(ResolvedDependency { project, version })) +} + +/// Falls back to the newest prerelease when no release fits the cluster +pub(crate) async fn pick_version( + provider: &dyn PackageProvider, + project_id: &str, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); + let loader_filter = loader.is_modded().then_some(loader); + + let mut candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + loader_filter, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; + + // Resource packs shaders and datapacks declare no loader so a loader-filtered query is empty + if candidates.is_empty() && loader_filter.is_some() { + candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + None, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; + } + + Ok(choose_version(candidates, cluster, loader)) +} + +fn choose_version( + mut candidates: Vec, + cluster: &ClusterRow, + loader: GameLoader, +) -> Option { + candidates.retain(|version| fits_cluster(version, cluster, loader)); + // Neither provider promises an order so sort rather than trust the page + candidates.sort_by_key(|version| std::cmp::Reverse(version.published)); + + candidates + .iter() + .find(|version| matches!(version.release_type, ReleaseType::Release)) + .or_else(|| candidates.first()) + .cloned() +} + +/// Mirrors the store's install-time compatibility check +/// must stay in sync with it +fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool { + if !version.game_versions.is_empty() + && !version + .game_versions + .iter() + .any(|v| cluster.mc_version.contains(v)) + { + return false; + } + + version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l)) +} + +async fn installed_project_ids( + provider_id: ProviderId, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(PackageStore::list_linked_artifacts(cluster_id, ctx) + .await? + .into_iter() + .filter(|linked| linked.provider == Some(provider_id)) + .filter_map(|linked| linked.project_id) + .collect()) +} + +/// False for modpacks they bring their own file list +pub fn resolves_dependencies(content_type: ContentType) -> bool { + !matches!(content_type, ContentType::Modpack) +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + + use super::*; + + fn summary( + version_id: &str, + release_type: ReleaseType, + day: u32, + loaders: Vec, + game_versions: Vec<&str>, + ) -> VersionSummary { + VersionSummary { + version_id: version_id.into(), + project_id: "p".into(), + name: version_id.into(), + version_number: version_id.into(), + published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(), + release_type, + game_versions: game_versions.into_iter().map(Into::into).collect(), + loaders, + downloads: 0, + file_size: 0, + } + } + + 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 prefers_the_newest_release_over_a_newer_prerelease() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "old", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 3, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "new", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "new"); + } + + #[test] + fn falls_back_to_a_prerelease_when_no_release_fits() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "alpha", + ReleaseType::Alpha, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "beta"); + } + + #[test] + fn drops_versions_the_store_would_reject() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "forge", + ReleaseType::Release, + 3, + vec![GameLoader::Forge], + vec!["1.21.4"], + ), + summary( + "old-mc", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.20.1"], + ), + summary( + "fits", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "fits"); + } + + #[test] + fn keeps_loaderless_content_for_a_modded_cluster() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "pack", + ReleaseType::Release, + 1, + Vec::new(), + vec!["1.21.4"], + )]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "pack"); + } + + #[test] + fn no_compatible_version_resolves_to_nothing() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "forge", + ReleaseType::Release, + 1, + vec![GameLoader::Forge], + vec!["1.21.4"], + )]; + + assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none()); + } +} diff --git a/packages/oneclient_content/src/packages/error.rs b/packages/oneclient_content/src/packages/error.rs new file mode 100644 index 00000000..cbbabd27 --- /dev/null +++ b/packages/oneclient_content/src/packages/error.rs @@ -0,0 +1,38 @@ +use thiserror::Error; + +use super::types::PackageBody; +use oneclient_common::domain::ProviderId; + +#[derive(Debug, Error)] +pub enum PackageError { + #[error("no primary download file on version")] + NoPrimaryFile, + #[error("package is not a modpack")] + NotModpack, + #[error("unsupported modpack format")] + UnsupportedModpackFormat, + #[error("modpack install had {failed} of {total} file failures")] + PartialModpackInstall { failed: u64, total: u64 }, + #[error("missing API key for provider {0:?}")] + MissingApiKey(ProviderId), + #[error("unsupported package body type")] + UnsupportedBody(PackageBody), + #[error("package is not compatible with cluster Minecraft version")] + IncompatibleMcVersion, + #[error("package is not compatible with cluster loader")] + IncompatibleLoader, + #[error("cluster {0} not found")] + ClusterNotFound(i64), + #[error("artifact file missing at {0}")] + ArtifactMissing(String), + #[error("hash mismatch: expected {expected}, got {actual}")] + HashMismatch { expected: String, actual: String }, + #[error("provider {0:?} does not support this operation")] + UnsupportedProvider(ProviderId), + #[error("package provider {0:?} is not registered")] + ProviderNotRegistered(ProviderId), + #[error("invalid local file: {0}")] + InvalidLocalFile(String), +} + +pub type PackageResult = Result; diff --git a/packages/oneclient_content/src/packages/file_identity.rs b/packages/oneclient_content/src/packages/file_identity.rs new file mode 100644 index 00000000..cdf50cec --- /dev/null +++ b/packages/oneclient_content/src/packages/file_identity.rs @@ -0,0 +1,75 @@ +use std::path::Path; + +use crate::error::ContentResult; +use polyio::{normalize_hash, sha1_bytes}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileIdentity { + pub sha1: String, + pub cf_fingerprint: Option, +} + +impl FileIdentity { + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { + sha1: sha1_bytes(bytes), + cf_fingerprint: Some(curseforge_fingerprint(bytes)), + } + } + + #[tracing::instrument(level = "debug", skip(path))] + pub async fn from_path(path: impl AsRef) -> ContentResult { + let bytes = polyio::read(path).await?; + Ok(Self::from_bytes(&bytes)) + } + + pub fn from_sha1(sha1: impl AsRef) -> Self { + Self { + sha1: normalize_hash(sha1.as_ref()), + cf_fingerprint: None, + } + } + + pub fn with_curseforge_fingerprint(mut self, fingerprint: u32) -> Self { + self.cf_fingerprint = Some(fingerprint); + self + } +} + +pub fn curseforge_fingerprint(bytes: &[u8]) -> u32 { + const MULTIPLIER: u32 = 1540483477; + + let normalized_len = bytes.iter().filter(|&&b| !is_cf_whitespace(b)).count(); + let mut hash = 1u32 ^ normalized_len as u32; + let mut chunk = 0u32; + let mut chunk_bits = 0u32; + + for &byte in bytes { + if is_cf_whitespace(byte) { + continue; + } + + chunk |= u32::from(byte) << chunk_bits; + chunk_bits += 8; + + if chunk_bits == 32 { + let mixed = chunk.wrapping_mul(MULTIPLIER); + let folded = (mixed ^ (mixed >> 24)).wrapping_mul(MULTIPLIER); + hash = hash.wrapping_mul(MULTIPLIER) ^ folded; + chunk = 0; + chunk_bits = 0; + } + } + + if chunk_bits > 0 { + hash = (hash ^ chunk).wrapping_mul(MULTIPLIER); + } + + let mixed = (hash ^ (hash >> 13)).wrapping_mul(MULTIPLIER); + mixed ^ (mixed >> 15) +} + +#[inline] +fn is_cf_whitespace(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r') +} diff --git a/packages/oneclient_content/src/packages/metadata_cache.rs b/packages/oneclient_content/src/packages/metadata_cache.rs new file mode 100644 index 00000000..02aadc53 --- /dev/null +++ b/packages/oneclient_content/src/packages/metadata_cache.rs @@ -0,0 +1,236 @@ +use std::collections::HashMap; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::package_metadata as meta_dao; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::ProviderId; +use crate::packages::store::artifact_absolute_path; +use crate::packages::types::{PackageBody, ProjectDetail, VersionDetail, VersionFile}; +use oneclient_common::domain::{ContentType, GameLoader}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedPackageMeta { + pub provider: ProviderId, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, +} + +#[tracing::instrument(level = "debug", skip(ctx, project_ids))] +pub async fn read_cached_package_meta( + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], +) -> HashMap { + if project_ids.is_empty() { + return HashMap::new(); + } + + let rows = meta_dao::get_package_metadata_batch(&ctx.db, provider as i64, project_ids) + .await + .unwrap_or_default(); + + rows.into_iter() + .map(|row| { + ( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ) + }) + .collect() +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn cached_project_detail( + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + content_type: ContentType, +) -> ProjectDetail { + let cached = + read_cached_package_meta(ctx, provider, std::slice::from_ref(&project_id.to_string())) + .await; + let meta = cached.get(project_id); + + ProjectDetail { + id: project_id.to_string(), + slug: String::new(), + provider, + content_type, + name: meta + .map(|m| m.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| project_id.to_string()), + summary: meta.map(|m| m.summary.clone()).unwrap_or_default(), + author: meta.map(|m| m.author.clone()).unwrap_or_default(), + 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: meta.and_then(|m| m.icon_url.clone()), + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn get_version_cached( + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, +) -> ContentResult { + if let Some(cached) = cached_version_detail(ctx, provider, project_id, version_id).await { + return Ok(cached); + } + + ctx.providers + .get(provider)? + .get_version(project_id, version_id, ctx) + .await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +async fn cached_version_detail( + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, +) -> Option { + let release = + artifact_dao::get_provider_release(&ctx.db, provider as i64, project_id, version_id) + .await + .ok() + .flatten()?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, &release.hash) + .await + .ok() + .flatten()?; + + let path = artifact_absolute_path(&artifact.path).ok()?; + if !path.exists() { + return None; + } + + let game_versions: Vec = serde_json::from_str(&release.mc_versions).unwrap_or_default(); + let loaders: Vec = serde_json::from_str(&release.mc_loaders).unwrap_or_default(); + let published = release + .published_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + + Some(VersionDetail { + version_id: version_id.to_string(), + project_id: project_id.to_string(), + name: release.display_name, + version_number: release.display_version, + changelog: None, + game_versions, + loaders, + published, + downloads: 0, + files: vec![VersionFile { + sha1: release.hash, + url: String::new(), + file_name: artifact.file_name, + primary: true, + size: artifact.size_bytes.unwrap_or(0) as u64, + fingerprint: None, + }], + // Not persisted with the release row callers needing the graph go to the provider + dependencies: Vec::new(), + }) +} + +#[tracing::instrument(level = "debug", skip(ctx, project_ids))] +pub async fn fetch_package_meta( + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], +) -> ContentResult> { + if project_ids.is_empty() { + return Ok(HashMap::new()); + } + + let provider_repr = provider as i64; + let mut out: HashMap = HashMap::new(); + + for row in meta_dao::get_package_metadata_batch(&ctx.db, provider_repr, project_ids).await? { + out.insert( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ); + } + + let missing: Vec = project_ids + .iter() + .filter(|id| out.get(*id).is_none_or(|m| m.author.is_empty())) + .cloned() + .collect(); + if missing.is_empty() { + return Ok(out); + } + + tracing::debug!( + count = missing.len(), + "fetching missing package metadata from provider" + ); + + let details = ctx + .providers + .get(provider)? + .get_projects(&missing, ctx) + .await?; + + for detail in details { + let meta = CachedPackageMeta { + provider, + project_id: detail.id, + name: detail.name, + summary: detail.summary, + author: detail.author, + icon_url: detail.icon_url, + }; + meta_dao::upsert_package_metadata( + &ctx.db, + provider_repr, + &meta.project_id, + &meta.name, + &meta.summary, + &meta.author, + meta.icon_url.as_deref(), + ) + .await?; + out.insert(meta.project_id.clone(), meta); + } + + Ok(out) +} diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs new file mode 100644 index 00000000..d9c713ff --- /dev/null +++ b/packages/oneclient_content/src/packages/mod.rs @@ -0,0 +1,32 @@ +pub mod activity; +pub mod dependencies; +pub mod error; +pub mod metadata_cache; +pub mod modpack; +pub mod provider; +pub mod store; +pub mod types; +pub mod updates; + +mod file_identity; + +// Re-exported so `oneclient_core::packages::ContentType` keeps working +pub use activity::reconcile_duplicate_activity; +pub use dependencies::{ + DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, +}; +pub use error::{PackageError, PackageResult}; +pub use file_identity::{FileIdentity, curseforge_fingerprint}; +pub use metadata_cache::{ + CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, + read_cached_package_meta, +}; +pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; +pub use provider::{PackageProvider, PackageProviderRegistry}; +pub use store::PackageStore; +pub use types::*; +pub use updates::{ + BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, +}; diff --git a/packages/oneclient_content/src/packages/modpack/mod.rs b/packages/oneclient_content/src/packages/modpack/mod.rs new file mode 100644 index 00000000..30753904 --- /dev/null +++ b/packages/oneclient_content/src/packages/modpack/mod.rs @@ -0,0 +1,17 @@ +mod mrpack; + +use std::path::Path; + +pub use mrpack::{MrpackInstaller, install_mrpack_to_cluster}; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; + +#[tracing::instrument(level = "debug", skip(archive_path, ctx))] +pub async fn install_modpack_archive( + archive_path: impl AsRef, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + mrpack::install_mrpack_to_cluster(archive_path.as_ref().to_path_buf(), cluster_id, ctx).await +} diff --git a/packages/oneclient_content/src/packages/modpack/mrpack.rs b/packages/oneclient_content/src/packages/modpack/mrpack.rs new file mode 100644 index 00000000..2b71889a --- /dev/null +++ b/packages/oneclient_content/src/packages/modpack/mrpack.rs @@ -0,0 +1,200 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use futures_lite::AsyncReadExt; +use oneclient_db::models::ClusterRow; +use serde::Deserialize; +use uuid::Uuid; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::error::PackageError; +use crate::packages::store::{self, PackageStore}; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::ContentType; + +pub struct MrpackInstaller; + +#[derive(Deserialize)] +#[allow(dead_code)] +struct MrpackManifest { + #[serde(default)] + files: Vec, + #[serde(default)] + dependencies: HashMap, +} + +#[derive(Deserialize)] +struct MrpackFileEntry { + path: String, + hashes: MrpackHashes, + #[serde(default)] + downloads: Vec, + #[serde(rename = "fileSize")] + file_size: u64, +} + +#[derive(Deserialize)] +struct MrpackHashes { + sha1: String, +} + +impl MrpackInstaller { + #[tracing::instrument(skip(ctx))] + pub async fn install_archive( + archive_path: PathBuf, + cluster_id: i64, + ctx: &ContentCtx, + ) -> ContentResult<()> { + tracing::info!("installing mrpack archive to cluster"); + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let cluster_root = oneclient_common::paths::clusters_dir()?.join(&cluster.folder_name); + + let bytes = Arc::new(polyio::read(archive_path).await?); + + let mut manifest_bytes: Option> = None; + + polyio::read_zip_entries_bytes(bytes.to_vec(), async |_, entry, reader| { + let entry_name = entry.filename().as_str().map_err(io_err)?; + + if entry_name == "modrinth.index.json" { + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).await.map_err(io_err)?; + manifest_bytes = Some(buf); + } + Ok(()) + }) + .await?; + + let manifest_bytes = manifest_bytes.ok_or(PackageError::UnsupportedModpackFormat)?; + let manifest: MrpackManifest = serde_json::from_slice(&manifest_bytes)?; + + let mut failed = 0u64; + let total = manifest.files.len() as u64; + let progress_id = Uuid::new_v4(); + + for (index, entry) in manifest.files.into_iter().enumerate() { + ctx.events + .progress(progress_id, "Installing Modpack Files", index as u64, total); + + let content_type = content_type_from_path(&entry.path); + let hash = entry.hashes.sha1.to_ascii_lowercase(); + let file_name = Path::new(&entry.path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&entry.path) + .to_string(); + + let path_str = entry.path.clone(); + + if let Err(err) = + install_mrpack_file(entry, content_type, hash, file_name, &cluster, ctx).await + { + failed += 1; + tracing::warn!(path = %path_str, error = %err, "modpack file install failed"); + } + } + + ctx.events + .progress(progress_id, "Installing Modpack Files", total, total); + + polyio::read_zip_entries_bytes(bytes.to_vec(), async |_, entry, reader| { + let name = entry.filename().as_str().map_err(io_err)?; + + let Some(rest) = name.strip_prefix("overrides/") else { + return Ok(()); + }; + if rest.is_empty() || name.ends_with('/') { + return Ok(()); + } + + let dest = cluster_root.join(rest); + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.map_err(io_err)?; + } + + let mut file_bytes = Vec::new(); + reader.read_to_end(&mut file_bytes).await.map_err(io_err)?; + polyio::write(&dest, &file_bytes).await.map_err(io_err)?; + Ok(()) + }) + .await?; + + if failed > 0 { + tracing::error!(failed, total, "modpack install completed with failures"); + return Err(PackageError::PartialModpackInstall { failed, total }.into()); + } + + tracing::info!(total, "modpack install complete"); + Ok(()) + } +} + +#[tracing::instrument(level = "debug", skip(entry, cluster, ctx))] +async fn install_mrpack_file( + entry: MrpackFileEntry, + content_type: ContentType, + hash: String, + file_name: String, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult<()> { + if let Some(row) = oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &hash).await? { + let path = store::artifact_absolute_path(&row.path)?; + if path.exists() { + PackageStore::link_artifact(&row, cluster, Some(&file_name), ctx).await?; + return Ok(()); + } + } + + if let Some((provider_id, version)) = ctx.providers.lookup_version(&hash, ctx).await? { + let provider = ctx.providers.get(provider_id)?; + let project = provider.get_project(&version.project_id, ctx).await?; + let artifact = + PackageStore::download_and_cache(provider_id, &project, &version, false, None, ctx) + .await?; + PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; + return Ok(()); + } + + let url = entry + .downloads + .first() + .cloned() + .ok_or(PackageError::NoPrimaryFile)?; + + let external = ExternalFile { + name: file_name.clone(), + url, + sha1: hash, + size: entry.file_size, + content_type, + }; + + let artifact = store::download_external(&external, false, None, ctx).await?; + PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; + + Ok(()) +} + +fn content_type_from_path(path: &str) -> ContentType { + let top = path.split('/').next().unwrap_or(""); + ContentType::from_folder_name(top).unwrap_or(ContentType::Mod) +} + +fn io_err(err: impl std::error::Error + Send + Sync + 'static) -> polyio::IOError { + polyio::IOError::PathIOError { + source: std::io::Error::other(err), + path: String::new(), + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn install_mrpack_to_cluster( + archive_path: PathBuf, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + MrpackInstaller::install_archive(archive_path, cluster_id, ctx).await +} diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs new file mode 100644 index 00000000..2d03decc --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -0,0 +1,746 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::Deserialize; +use url::Url; + +use super::PackageProvider; +use super::http::fetch_json; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use crate::packages::types::{ + DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, + ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, + VersionSummary, +}; +use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; + +pub struct CurseForgeProvider; + +#[inline(always)] +fn api_url(path: &str) -> String { + format!("{CURSEFORGE_API_URL}{path}") +} + +#[async_trait::async_trait] +impl PackageProvider for CurseForgeProvider { + fn id(&self) -> ProviderId { + ProviderId::CurseForge + } + + #[tracing::instrument(level = "debug", skip(self, filters, ctx))] + async fn search( + &self, + filters: &SearchFilters, + ctx: &ContentCtx, + ) -> ContentResult> { + tracing::debug!(query = ?filters.query, "searching curseforge"); + let mut url = Url::parse(&api_url("/mods/search"))?; + + { + let mut params = url.query_pairs_mut(); + params.append_pair("gameId", &CURSEFORGE_GAME_ID.to_string()); + let class_id = filters + .content_type + .map(cf_class_id) + .unwrap_or(CfClass::Mod as u32); + params.append_pair("classId", &class_id.to_string()); + params.append_pair( + "pageSize", + &filters + .limit + .unwrap_or(super::super::types::DEFAULT_PAGE_SIZE) + .to_string(), + ); + params.append_pair("index", &filters.offset.unwrap_or(0).to_string()); + params.append_pair("sortField", "6"); + params.append_pair("sortOrder", "desc"); + let query = filters.normalized_query(); + if !query.is_empty() { + params.append_pair("searchFilter", &query); + } + if let Some(v) = filters.game_versions.as_ref().and_then(|v| v.first()) { + params.append_pair("gameVersion", v); + } + if let Some(loader) = filters + .loaders + .as_ref() + .and_then(|l| l.first()) + .and_then(|l| cf_loader_type(*l)) + { + params.append_pair("modLoaderType", &loader.to_string()); + } + } + + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + Ok(Page { + offset: response.pagination.index, + limit: response.pagination.page_size, + total: response.pagination.total_count, + items: response + .data + .into_iter() + .map(ProjectSummary::from) + .collect(), + }) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_project( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let response: CfData = fetch_json( + &ctx.net, + Method::GET, + &api_url(&format!("/mods/{project_id}")), + None, + ) + .await?; + Ok(response.data.into_detail()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_project_with_body( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let mut detail = self.get_project(project_id, ctx).await?; + detail.body = fetch_body(project_id, &detail, ctx).await; + Ok(detail) + } + + #[tracing::instrument(level = "debug", skip(self, project_ids, ctx))] + async fn get_projects( + &self, + project_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + let mod_ids: Vec = project_ids.iter().filter_map(|s| s.parse().ok()).collect(); + let body = serde_json::json!({ "modIds": mod_ids }); + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods"), Some(body)).await?; + Ok(response.data.into_iter().map(CfMod::into_detail).collect()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_versions( + &self, + project_id: &str, + game_version: Option<&str>, + loader: Option, + offset: usize, + limit: usize, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut url = Url::parse(&api_url(&format!("/mods/{project_id}/files")))?; + { + let mut params = url.query_pairs_mut(); + if let Some(v) = game_version { + params.append_pair("gameVersion", v); + } + if let Some(t) = loader.and_then(cf_loader_type) { + params.append_pair("modLoaderType", &t.to_string()); + } + } + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + let total = response.data.len(); + let items = response + .data + .into_iter() + .skip(offset) + .take(limit) + .map(VersionSummary::from) + .collect(); + + Ok(Page { + offset, + limit, + total, + items, + }) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_categories( + &self, + content_type: ContentType, + ctx: &ContentCtx, + ) -> ContentResult> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct CfCategory { + name: String, + } + let mut url = Url::parse(&api_url("/categories"))?; + url.query_pairs_mut() + .append_pair("gameId", &CURSEFORGE_GAME_ID.to_string()) + .append_pair("classId", &cf_class_id(content_type).to_string()); + let response: CfData> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + Ok(response.data.into_iter().map(|c| c.name).collect()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_version( + &self, + project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let response: CfData = fetch_json( + &ctx.net, + Method::GET, + &api_url(&format!("/mods/{project_id}/files/{version_id}")), + None, + ) + .await?; + Ok(response.data.into()) + } + + #[tracing::instrument(level = "debug", skip(self, version_ids, ctx))] + async fn get_versions( + &self, + version_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + let file_ids: Vec = version_ids.iter().filter_map(|s| s.parse().ok()).collect(); + let body = serde_json::json!({ "fileIds": file_ids }); + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods/files"), Some(body)).await?; + Ok(response.data.into_iter().map(Into::into).collect()) + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult { + let mut out = HashMap::new(); + + let with_fingerprint: Vec<(&FileIdentity, u32)> = identities + .iter() + .filter_map(|id| id.cf_fingerprint.map(|fp| (id, fp))) + .collect(); + + if with_fingerprint.is_empty() { + return Ok(out); + } + + let nums: Vec = with_fingerprint.iter().map(|(_, fp)| *fp).collect(); + let body = serde_json::json!({ "fingerprints": nums }); + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Response { + exact_matches: Vec, + } + #[derive(Deserialize)] + struct FingerprintMatch { + file: CfFile, + } + + let response: CfData = fetch_json( + &ctx.net, + Method::POST, + &api_url(&format!("/mods/fingerprints/{CURSEFORGE_GAME_ID}")), + Some(body), + ) + .await?; + + for m in response.data.exact_matches { + let version: VersionDetail = m.file.clone().into(); + let file_sha1 = version + .files + .first() + .map(|f| polyio::normalize_hash(&f.sha1)); + + let fp = m.file.file_fingerprint; + + for (identity, expected_fp) in &with_fingerprint { + if *expected_fp == fp || file_sha1.as_deref() == Some(identity.sha1.as_str()) { + out.insert(identity.sha1.clone(), version.clone()); + } + } + } + + Ok(out) + } +} + +#[derive(Deserialize)] +struct CfData { + data: T, +} + +#[derive(Deserialize)] +struct CfPaged { + data: T, + pagination: CfPagination, +} + +#[tracing::instrument(level = "debug", skip(detail, ctx))] +async fn fetch_body(project_id: &str, detail: &ProjectDetail, ctx: &ContentCtx) -> PackageBody { + let fallback = || { + detail + .links + .iter() + .find(|(label, _)| label == "Website") + .map(|(_, url)| PackageBody::Url(url.clone())) + .unwrap_or_else(|| PackageBody::Raw(String::new())) + }; + + let response: Result, _> = fetch_json( + &ctx.net, + Method::GET, + &api_url(&format!("/mods/{project_id}/description")), + None, + ) + .await; + + match response { + Ok(response) => match html_to_markdown(&response.data) { + Some(markdown) => PackageBody::Raw(markdown), + None => fallback(), + }, + Err(err) => { + tracing::warn!(project_id, %err, "failed to fetch curseforge description"); + fallback() + } + } +} + +fn html_to_markdown(html: &str) -> Option { + let markdown = htmd::convert(html).ok()?; + let markdown = markdown.trim(); + + (!markdown.is_empty()).then(|| absolutize_links(markdown)) +} + +fn absolutize_links(markdown: &str) -> String { + markdown + .replace( + "](/linkout?remoteUrl=", + "](https://www.curseforge.com/linkout?remoteUrl=", + ) + .replace("](/", "](https://www.curseforge.com/") +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfPagination { + index: usize, + page_size: usize, + total_count: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMod { + id: u32, + name: String, + slug: String, + summary: String, + download_count: u64, + date_created: DateTime, + date_modified: DateTime, + #[serde(default)] + logo: Option, + #[serde(default)] + authors: Vec, + #[serde(default)] + links: Option, + #[serde(default)] + screenshots: Vec, + class_id: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfScreenshot { + url: String, + #[serde(default)] + title: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfLogo { + #[serde(default)] + thumbnail_url: Option, + #[serde(default)] + url: Option, +} + +#[derive(Deserialize)] +struct CfAuthor { + name: String, + #[serde(default)] + url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfLinks { + #[serde(default)] + website_url: Option, + #[serde(default)] + source_url: Option, + #[serde(default)] + issues_url: Option, + #[serde(default)] + wiki_url: Option, +} + +fn cf_links(links: &CfLinks) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (label, url) in [ + ("Website", &links.website_url), + ("Source", &links.source_url), + ("Issues", &links.issues_url), + ("Wiki", &links.wiki_url), + ] { + if let Some(url) = url.as_ref().filter(|u| !u.is_empty()) { + out.push((label.to_string(), url.clone())); + } + } + out +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfFile { + id: u32, + mod_id: u32, + display_name: String, + file_name: String, + release_type: u8, + file_date: DateTime, + download_count: u64, + #[serde(default)] + game_versions: Vec, + #[serde(default)] + mod_loaders: Vec, + #[serde(default)] + hashes: Vec, + file_fingerprint: u32, + download_url: String, + file_length: u64, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfDependency { + mod_id: u32, + relation_type: u8, +} + +impl From for VersionDependency { + fn from(d: CfDependency) -> Self { + Self { + project_id: Some(d.mod_id.to_string()), + version_id: None, + // CF relationType 1 embedded 2 optional 3 required 4 tool 5 incompatible 6 include + kind: match d.relation_type { + 3 => DependencyKind::Required, + 5 => DependencyKind::Incompatible, + 1 | 6 => DependencyKind::Embedded, + _ => DependencyKind::Optional, + }, + } + } +} + +#[derive(Deserialize, Clone, Copy)] +#[serde(from = "u8")] +enum CfLoader { + Forge = 1, + Cauldron = 2, + #[allow(dead_code)] + LiteLoader = 3, + Fabric = 4, + Quilt = 5, + NeoForge = 6, +} + +#[derive(Clone, Deserialize)] +struct CfHash { + value: String, + #[serde(rename = "algo")] + algorithm: u8, +} + +#[derive(Deserialize)] +#[serde(from = "u32")] +enum CfClass { + Mod = 6, + ResourcePack = 12, + DataPack = 4472, + Shader = 6552, + Modpack = 4471, +} + +impl From for ProjectSummary { + fn from(m: CfMod) -> Self { + Self { + id: m.id.to_string(), + slug: m.slug, + provider: ProviderId::CurseForge, + content_type: cf_class_to_type(m.class_id), + name: m.name, + summary: m.summary, + author: m + .authors + .first() + .map(|a| a.name.clone()) + .unwrap_or_default(), + icon_url: m + .logo + .and_then(|l| l.thumbnail_url.or(l.url)) + .filter(|s| !s.is_empty()), + downloads: m.download_count, + created: m.date_created, + updated: m.date_modified, + loaders: Vec::new(), + game_versions: Vec::new(), + } + } +} + +impl CfMod { + fn into_detail(self) -> ProjectDetail { + ProjectDetail { + id: self.id.to_string(), + slug: self.slug, + provider: ProviderId::CurseForge, + content_type: cf_class_to_type(self.class_id), + name: self.name.clone(), + summary: self.summary, + author: self + .authors + .first() + .map(|a| a.name.clone()) + .unwrap_or_default(), + members: self + .authors + .iter() + .map(|a| ProjectMember { + name: a.name.clone(), + role: "Author".to_string(), + url: a.url.clone(), + avatar_url: None, + }) + .collect(), + gallery: self + .screenshots + .into_iter() + .map(|s| GalleryImage { + url: s.url, + title: s.title.filter(|t| !t.is_empty()), + }) + .collect(), + license: None, + links: self.links.as_ref().map(cf_links).unwrap_or_default(), + body: PackageBody::Raw(String::new()), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: self + .logo + .and_then(|l| l.thumbnail_url.or(l.url)) + .filter(|s| !s.is_empty()), + created: self.date_created, + updated: self.date_modified, + downloads: self.download_count, + } + } +} + +impl From for VersionSummary { + fn from(f: CfFile) -> Self { + VersionSummary { + version_id: f.id.to_string(), + project_id: f.mod_id.to_string(), + name: f.display_name, + version_number: f.file_name, + published: f.file_date, + release_type: match f.release_type { + 2 => ReleaseType::Beta, + 3 => ReleaseType::Alpha, + _ => ReleaseType::Release, + }, + game_versions: f.game_versions, + loaders: f + .mod_loaders + .iter() + .filter_map(|l| cf_loader_to_game(*l)) + .collect(), + downloads: f.download_count, + file_size: f.file_length, + } + } +} + +impl From for VersionDetail { + fn from(f: CfFile) -> Self { + let sha1 = f + .hashes + .iter() + .find(|h| h.algorithm == 1) + .map(|h| h.value.clone()) + .unwrap_or_default(); + + VersionDetail { + version_id: f.id.to_string(), + project_id: f.mod_id.to_string(), + name: f.display_name, + version_number: f.file_name.clone(), + changelog: None, + game_versions: f.game_versions, + loaders: f + .mod_loaders + .iter() + .filter_map(|l| cf_loader_to_game(*l)) + .collect(), + published: f.file_date, + downloads: f.download_count, + files: vec![VersionFile { + sha1, + url: f.download_url, + file_name: f.file_name, + primary: true, + size: f.file_length, + fingerprint: Some(f.file_fingerprint.to_string()), + }], + dependencies: f.dependencies.into_iter().map(Into::into).collect(), + } + } +} + +fn cf_class_id(t: ContentType) -> u32 { + match t { + ContentType::Mod => 6, + ContentType::ResourcePack => 12, + ContentType::DataPack => 4472, + ContentType::Shader => 6552, + ContentType::Modpack => 4471, + ContentType::World => 6, + } +} + +fn cf_class_to_type(class_id: u32) -> ContentType { + match CfClass::from(class_id) { + CfClass::ResourcePack => ContentType::ResourcePack, + CfClass::DataPack => ContentType::DataPack, + CfClass::Shader => ContentType::Shader, + CfClass::Modpack => ContentType::Modpack, + CfClass::Mod => ContentType::Mod, + } +} + +impl From for CfLoader { + fn from(value: u8) -> Self { + match value { + 1 => Self::Forge, + 2 => Self::Cauldron, + 3 => Self::LiteLoader, + 4 => Self::Fabric, + 5 => Self::Quilt, + 6 => Self::NeoForge, + _ => Self::Cauldron, + } + } +} + +impl From for CfClass { + fn from(value: u32) -> Self { + match value { + 6 => Self::Mod, + 12 => Self::ResourcePack, + 4472 => Self::DataPack, + 6552 => Self::Shader, + 4471 => Self::Modpack, + _ => Self::Mod, + } + } +} + +fn cf_loader_to_game(loader: CfLoader) -> Option { + match loader { + CfLoader::Forge => Some(GameLoader::Forge), + CfLoader::Fabric => Some(GameLoader::Fabric), + CfLoader::Quilt => Some(GameLoader::Quilt), + CfLoader::NeoForge => Some(GameLoader::NeoForge), + _ => None, + } +} + +fn cf_loader_type(loader: GameLoader) -> Option { + match loader { + GameLoader::Forge => Some(1), + GameLoader::Fabric => Some(4), + GameLoader::Quilt => Some(5), + GameLoader::NeoForge => Some(6), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// relationType is a bare number + /// a mismap quietly turns required libraries into optional ones + #[test] + fn file_carries_its_dependencies() { + let raw = serde_json::json!({ + "id": 1, + "modId": 2, + "displayName": "Sodium", + "fileName": "sodium.jar", + "releaseType": 1, + "fileDate": "2025-01-01T00:00:00Z", + "downloadCount": 10, + "gameVersions": ["1.21.4"], + "modLoaders": [], + "hashes": [], + "fileFingerprint": 123, + "downloadUrl": "https://example.invalid/sodium.jar", + "fileLength": 100, + "dependencies": [ + {"modId": 10, "relationType": 3}, + {"modId": 11, "relationType": 2}, + {"modId": 12, "relationType": 5}, + {"modId": 13, "relationType": 1} + ] + }); + + let version: VersionDetail = serde_json::from_value::(raw) + .expect("curseforge file") + .into(); + + assert_eq!( + version.dependencies[0], + VersionDependency { + project_id: Some("10".into()), + version_id: None, + kind: DependencyKind::Required, + } + ); + assert_eq!(version.dependencies[1].kind, DependencyKind::Optional); + assert_eq!(version.dependencies[2].kind, DependencyKind::Incompatible); + assert_eq!(version.dependencies[3].kind, DependencyKind::Embedded); + } +} diff --git a/packages/oneclient_content/src/packages/provider/http.rs b/packages/oneclient_content/src/packages/provider/http.rs new file mode 100644 index 00000000..5b67bc78 --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/http.rs @@ -0,0 +1,50 @@ +use reqwest::Method; +use serde::de::DeserializeOwned; + +use crate::ctx::ContentCtx; +use oneclient_events::GroupedProgressChild; +use oneclient_net::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; + +#[tracing::instrument(level = "debug", skip(client, url, body))] +pub async fn fetch_json( + client: &RequestClient, + method: Method, + url: impl reqwest::IntoUrl, + body: Option, +) -> Result { + let url = url.into_url()?; + client.send_json(method, url, body, &[]).await +} + +#[tracing::instrument(level = "debug", skip(client, url, body, headers))] +pub async fn fetch_json_with_headers( + client: &RequestClient, + method: Method, + url: impl reqwest::IntoUrl, + body: Option, + headers: &[(String, String)], +) -> Result { + let url = url.into_url()?; + let borrowed: Vec<(&str, &str)> = headers + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect(); + client.send_json(method, url, body, &borrowed).await +} + +#[tracing::instrument(level = "debug", skip(client, dest, child, ctx))] +pub async fn download_url( + client: &RequestClient, + url: &str, + dest: impl AsRef + Send, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> Result<(), RequestError> { + let request = reqwest::Request::new(Method::GET, url.parse()?); + let options = ResponseOptions { + notify: child.map(|c| ResponseNotifyOptions::grouped(c.clone())), + }; + client + .download_file(request, dest, options, &ctx.events) + .await +} diff --git a/packages/oneclient_content/src/packages/provider/mod.rs b/packages/oneclient_content/src/packages/provider/mod.rs new file mode 100644 index 00000000..48367712 --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/mod.rs @@ -0,0 +1,85 @@ +mod curseforge; +pub mod http; +mod modrinth; +mod registry; + +pub use curseforge::CurseForgeProvider; +pub use modrinth::ModrinthProvider; +pub use registry::PackageProviderRegistry; + +use super::types::{ + Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, VersionLookup, + VersionSummary, +}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use oneclient_common::domain::ProviderId; + +#[async_trait::async_trait] +pub trait PackageProvider: Send + Sync { + fn id(&self) -> ProviderId; + + async fn search( + &self, + filters: &SearchFilters, + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn get_project(&self, project_id: &str, ctx: &ContentCtx) + -> ContentResult; + + /// Populates [`ProjectDetail::body`] + /// Costs an extra request on providers that serve descriptions separately + /// so install paths should stay on `get_project` + async fn get_project_with_body( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + self.get_project(project_id, ctx).await + } + + async fn get_projects( + &self, + project_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn list_versions( + &self, + project_id: &str, + game_version: Option<&str>, + loader: Option, + offset: usize, + limit: usize, + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn get_version( + &self, + project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult; + + async fn get_versions( + &self, + version_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult; + + async fn list_categories( + &self, + _content_type: oneclient_common::domain::ContentType, + _ctx: &ContentCtx, + ) -> ContentResult> { + Ok(Vec::new()) + } +} diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs new file mode 100644 index 00000000..b2bb82f9 --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -0,0 +1,744 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::Deserialize; +use url::Url; + +use serde::de::DeserializeOwned; + +use super::PackageProvider; +use super::http::fetch_json_with_headers; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use crate::packages::types::{ + DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, + ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, + VersionSummary, +}; +use oneclient_common::constants::MODRINTH_API_URL; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_net::{RequestClient, RequestError}; + +pub struct ModrinthProvider; + +fn v2(path: &str) -> String { + format!("{MODRINTH_API_URL}/v2{path}") +} + +async fn fetch_json( + client: &RequestClient, + method: Method, + url: impl reqwest::IntoUrl, + body: Option, +) -> Result { + fetch_json_with_headers( + client, + method, + url, + body, + &client.config().modrinth_headers(), + ) + .await +} + +#[async_trait::async_trait] +impl PackageProvider for ModrinthProvider { + fn id(&self) -> ProviderId { + ProviderId::Modrinth + } + + #[tracing::instrument(level = "debug", skip(self, filters, ctx))] + async fn search( + &self, + filters: &SearchFilters, + ctx: &ContentCtx, + ) -> ContentResult> { + tracing::debug!(query = ?filters.query, "searching modrinth"); + let mut url = Url::parse(&v2("/search"))?; + { + let limit = filters + .limit + .unwrap_or(super::super::types::DEFAULT_PAGE_SIZE); + + let offset = filters.offset.unwrap_or(0); + let mut params = url.query_pairs_mut(); + params.append_pair("limit", &limit.to_string()); + params.append_pair("offset", &offset.to_string()); + params.append_pair("query", &filters.normalized_query()); + + let mut groups: Vec> = Vec::new(); + if let Some(content_type) = filters.content_type { + groups.push(vec![format!( + "project_type:{}", + content_type.modrinth_type() + )]); + } + if let Some(versions) = &filters.game_versions { + let group: Vec = versions.iter().map(|v| format!("versions:{v}")).collect(); + if !group.is_empty() { + groups.push(group); + } + } + if let Some(loaders) = &filters.loaders { + let group: Vec = loaders + .iter() + .map(|l| format!("categories:{}", l.modrinth_name())) + .collect(); + if !group.is_empty() { + groups.push(group); + } + } + if let Some(categories) = &filters.categories { + for category in categories { + groups.push(vec![format!("categories:{category}")]); + } + } + if !groups.is_empty() { + params.append_pair( + "facets", + &serde_json::to_string(&groups).unwrap_or_default(), + ); + } + + if let Some(sort) = filters.sort { + params.append_pair("index", sort.modrinth_index()); + } + } + + #[derive(Deserialize)] + struct Response { + hits: Vec, + offset: usize, + limit: usize, + total_hits: usize, + } + + #[derive(Deserialize)] + struct Hit { + project_id: String, + project_type: String, + slug: String, + author: String, + title: String, + description: String, + #[serde(default)] + categories: Vec, + #[serde(default, alias = "versions")] + game_versions: Vec, + downloads: u64, + icon_url: Option, + date_created: DateTime, + date_modified: DateTime, + } + + let response: Response = fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + Ok(Page { + offset: response.offset, + limit: response.limit, + total: response.total_hits, + items: response + .hits + .into_iter() + .map(|h| ProjectSummary { + id: h.project_id, + slug: h.slug, + provider: ProviderId::Modrinth, + content_type: parse_content_type(&h.project_type), + name: h.title, + summary: h.description, + author: h.author, + icon_url: h.icon_url, + downloads: h.downloads, + created: h.date_created, + updated: h.date_modified, + loaders: h + .categories + .iter() + .filter_map(|c| GameLoader::from_str(c).ok()) + .collect(), + game_versions: h.game_versions, + }) + .collect(), + }) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_project( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let project_url = v2(&format!("/project/{project_id}")); + let members_url = v2(&format!("/project/{project_id}/members")); + let project_fut = fetch_json::(&ctx.net, Method::GET, &project_url, None); + let members_fut = + fetch_json::>(&ctx.net, Method::GET, &members_url, None); + let (raw, members) = tokio::join!(project_fut, members_fut); + + let mut detail = raw?.into_detail(); + if let Ok(members) = members { + apply_modrinth_members(&mut detail, members); + } + Ok(detail) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_categories( + &self, + content_type: ContentType, + ctx: &ContentCtx, + ) -> ContentResult> { + #[derive(Deserialize)] + struct Tag { + name: String, + project_type: String, + } + let tags: Vec = fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; + let want = content_type.modrinth_type(); + Ok(tags + .into_iter() + .filter(|t| t.project_type == want) + .map(|t| t.name) + .collect()) + } + + #[tracing::instrument(level = "debug", skip(self, project_ids, ctx))] + async fn get_projects( + &self, + project_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + if project_ids.is_empty() { + return Ok(Vec::new()); + } + let ids = project_ids + .iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(","); + + let raw: Vec = fetch_json( + &ctx.net, + Method::GET, + &v2(&format!("/projects?ids=[{ids}]")), + None, + ) + .await?; + + let team_ids: Vec = raw + .iter() + .map(|p| p.team.clone()) + .collect::>() + .into_iter() + .collect(); + + let mut members_by_team = fetch_modrinth_team_members(&ctx.net, &team_ids) + .await + .unwrap_or_default(); + + Ok(raw + .into_iter() + .map(|project| { + let team_id = project.team.clone(); + let mut detail = project.into_detail(); + if let Some(members) = members_by_team.remove(&team_id) { + apply_modrinth_members(&mut detail, members); + } + detail + }) + .collect()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_versions( + &self, + project_id: &str, + game_version: Option<&str>, + loader: Option, + offset: usize, + limit: usize, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut url = Url::parse(&v2(&format!("/project/{project_id}/version")))?; + if let Some(v) = game_version { + url.query_pairs_mut() + .append_pair("game_versions", &format!("[\"{v}\"]")); + } + if let Some(loader) = loader { + url.query_pairs_mut() + .append_pair("loaders", &format!("[\"{}\"]", loader.modrinth_name())); + } + + let versions: Vec = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + let total = versions.len(); + let items = versions + .into_iter() + .skip(offset) + .take(limit) + .map(VersionSummary::from) + .collect(); + + Ok(Page { + offset, + limit, + total, + items, + }) + } + + #[tracing::instrument(level = "debug", skip(self, _project_id, ctx))] + async fn get_version( + &self, + _project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let raw: ModrinthVersion = fetch_json( + &ctx.net, + Method::GET, + &v2(&format!("/version/{version_id}")), + None, + ) + .await?; + Ok(raw.into()) + } + + #[tracing::instrument(level = "debug", skip(self, version_ids, ctx))] + async fn get_versions( + &self, + version_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + if version_ids.is_empty() { + return Ok(Vec::new()); + } + let ids = version_ids + .iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(","); + let raw: Vec = fetch_json( + &ctx.net, + Method::GET, + &v2(&format!("/versions?ids=[{ids}]")), + None, + ) + .await?; + Ok(raw.into_iter().map(Into::into).collect()) + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult { + let sha1_hashes: Vec = identities + .iter() + .map(|id| id.sha1.clone()) + .collect::>() + .into_iter() + .collect(); + + if sha1_hashes.is_empty() { + return Ok(HashMap::new()); + } + + let body = serde_json::json!({ + "hashes": sha1_hashes, + "algorithm": "sha1" + }); + + let fetched: HashMap = + fetch_json(&ctx.net, Method::POST, &v2("/version_files"), Some(body)).await?; + + let mut out = HashMap::new(); + for identity in identities { + if let Some(version) = fetched.get(&identity.sha1) { + out.insert(identity.sha1.clone(), version.clone().into()); + } + } + + Ok(out) + } +} + +#[tracing::instrument(level = "debug", skip(client, team_ids))] +async fn fetch_modrinth_team_members( + client: &RequestClient, + team_ids: &[String], +) -> Result>, RequestError> { + if team_ids.is_empty() { + return Ok(HashMap::new()); + } + let ids = team_ids + .iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(","); + let teams: Vec> = fetch_json( + client, + Method::GET, + &v2(&format!("/teams?ids=[{ids}]")), + None, + ) + .await?; + Ok(teams + .into_iter() + .filter_map(|members| { + let team_id = members.first()?.team_id.clone(); + Some((team_id, members)) + }) + .collect()) +} + +#[derive(Deserialize)] +struct ModrinthProject { + id: String, + slug: String, + team: String, + project_type: String, + title: String, + description: String, + body: Option, + body_url: Option, + #[serde(default)] + game_versions: Vec, + #[serde(default)] + loaders: Vec, + #[serde(default)] + icon_url: Option, + published: DateTime, + updated: DateTime, + downloads: u64, + license: Option, + source_url: Option, + issues_url: Option, + wiki_url: Option, + discord_url: Option, + #[serde(default)] + gallery: Vec, +} + +#[derive(Deserialize)] +struct ModrinthGalleryItem { + url: String, + /// Original upload + /// `url` is Modrinth's re-encoded copy and looks soft when scaled up + #[serde(default)] + raw_url: Option, + #[serde(default)] + title: Option, +} + +#[derive(Deserialize)] +struct ModrinthMember { + #[serde(default)] + team_id: String, + role: String, + user: ModrinthUser, +} + +#[derive(Deserialize)] +struct ModrinthUser { + username: String, + #[serde(default)] + avatar_url: Option, +} + +impl ModrinthMember { + fn into_member(self) -> ProjectMember { + ProjectMember { + url: Some(format!("https://modrinth.com/user/{}", self.user.username)), + name: self.user.username, + role: self.role, + avatar_url: self.user.avatar_url, + } + } +} + +fn apply_modrinth_members(detail: &mut ProjectDetail, members: Vec) { + detail.members = members + .into_iter() + .map(ModrinthMember::into_member) + .collect(); + if detail.author.is_empty() { + detail.author = detail + .members + .iter() + .find(|m| m.role.eq_ignore_ascii_case("owner")) + .or_else(|| detail.members.first()) + .map(|m| m.name.clone()) + .unwrap_or_default(); + } +} + +#[derive(Deserialize)] +struct ModrinthLicense { + #[serde(default)] + name: Option, + id: Option, +} + +impl ModrinthProject { + fn into_detail(self) -> ProjectDetail { + let body = if let Some(raw) = self.body { + PackageBody::Raw(raw) + } else if let Some(url) = self.body_url { + PackageBody::Url(url) + } else { + PackageBody::Raw(String::new()) + }; + + let license = self + .license + .and_then(|l| l.name.or(l.id)) + .filter(|s| !s.is_empty()); + + let mut links = Vec::new(); + for (label, url) in [ + ("Source", self.source_url), + ("Issues", self.issues_url), + ("Wiki", self.wiki_url), + ("Discord", self.discord_url), + ] { + if let Some(url) = url.filter(|u| !u.is_empty()) { + links.push((label.to_string(), url)); + } + } + + ProjectDetail { + id: self.id.clone(), + slug: self.slug, + provider: ProviderId::Modrinth, + content_type: parse_content_type(&self.project_type), + name: self.title, + summary: self.description, + author: String::new(), + members: Vec::new(), + gallery: self + .gallery + .into_iter() + .map(|g| GalleryImage { + url: g.raw_url.filter(|url| !url.is_empty()).unwrap_or(g.url), + title: g.title.filter(|t| !t.is_empty()), + }) + .collect(), + license, + links, + body, + version_ids: Vec::new(), + game_versions: self.game_versions, + loaders: self + .loaders + .iter() + .filter_map(|l| GameLoader::from_str(l).ok()) + .collect(), + icon_url: self.icon_url, + created: self.published, + updated: self.updated, + downloads: self.downloads, + } + } +} + +#[derive(Clone, Deserialize)] +struct ModrinthVersion { + id: String, + project_id: String, + name: String, + version_number: String, + changelog: Option, + game_versions: Vec, + loaders: Vec, + date_published: DateTime, + downloads: u64, + #[serde(default)] + files: Vec, + #[serde(default)] + version_type: String, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Clone, Deserialize)] +struct ModrinthDependency { + #[serde(default)] + project_id: Option, + #[serde(default)] + version_id: Option, + #[serde(default)] + dependency_type: String, +} + +impl From for VersionDependency { + fn from(d: ModrinthDependency) -> Self { + Self { + project_id: d.project_id.filter(|id| !id.is_empty()), + version_id: d.version_id.filter(|id| !id.is_empty()), + kind: match d.dependency_type.as_str() { + "required" => DependencyKind::Required, + "incompatible" => DependencyKind::Incompatible, + "embedded" => DependencyKind::Embedded, + _ => DependencyKind::Optional, + }, + } + } +} + +#[derive(Clone, Deserialize)] +struct ModrinthFile { + hashes: ModrinthHashes, + url: String, + filename: String, + #[serde(default)] + primary: bool, + #[serde(default)] + size: u64, +} + +#[derive(Clone, Deserialize)] +struct ModrinthHashes { + sha1: String, +} + +impl From for VersionDetail { + fn from(v: ModrinthVersion) -> Self { + Self { + version_id: v.id, + project_id: v.project_id, + name: v.name.clone(), + version_number: v.version_number, + changelog: v.changelog, + game_versions: v.game_versions, + loaders: v + .loaders + .iter() + .filter_map(|l| GameLoader::from_str(l).ok()) + .collect(), + published: v.date_published, + downloads: v.downloads, + files: v.files.into_iter().map(Into::into).collect(), + dependencies: v.dependencies.into_iter().map(Into::into).collect(), + } + } +} + +impl From for VersionSummary { + fn from(v: ModrinthVersion) -> Self { + let file_size = v + .files + .iter() + .find(|f| f.primary) + .or_else(|| v.files.first()) + .map(|f| f.size) + .unwrap_or(0); + VersionSummary { + version_id: v.id, + project_id: v.project_id, + name: v.name, + version_number: v.version_number, + published: v.date_published, + release_type: parse_release_type(&v.version_type), + game_versions: v.game_versions, + loaders: v + .loaders + .iter() + .filter_map(|l| GameLoader::from_str(l).ok()) + .collect(), + downloads: v.downloads, + file_size, + } + } +} + +impl From for VersionFile { + fn from(f: ModrinthFile) -> Self { + Self { + sha1: f.hashes.sha1, + url: f.url, + file_name: f.filename, + primary: f.primary, + size: f.size, + fingerprint: None, + } + } +} + +fn parse_content_type(s: &str) -> ContentType { + match s { + "mod" => ContentType::Mod, + "resourcepack" => ContentType::ResourcePack, + "shader" => ContentType::Shader, + "datapack" => ContentType::DataPack, + "modpack" => ContentType::Modpack, + _ => ContentType::Mod, + } +} + +fn parse_release_type(s: &str) -> ReleaseType { + match s { + "beta" => ReleaseType::Beta, + "alpha" => ReleaseType::Alpha, + _ => ReleaseType::Release, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A field rename here silently empties the dependency list + #[test] + fn version_carries_its_dependencies() { + let raw = serde_json::json!({ + "id": "abc", + "project_id": "sodium", + "name": "Sodium 0.6", + "version_number": "0.6", + "changelog": null, + "game_versions": ["1.21.4"], + "loaders": ["fabric"], + "date_published": "2025-01-01T00:00:00Z", + "downloads": 10, + "version_type": "release", + "files": [], + "dependencies": [ + {"project_id": "fabric-api", "version_id": null, "dependency_type": "required"}, + {"project_id": null, "version_id": "pinned", "dependency_type": "required"}, + {"project_id": "sodium-extra", "version_id": null, "dependency_type": "optional"}, + {"project_id": "optifine", "version_id": null, "dependency_type": "incompatible"}, + ], + }); + + let version: VersionDetail = serde_json::from_value::(raw) + .expect("modrinth version") + .into(); + + assert_eq!(version.dependencies.len(), 4); + assert_eq!( + version.dependencies[0], + VersionDependency { + project_id: Some("fabric-api".into()), + version_id: None, + kind: DependencyKind::Required, + } + ); + assert_eq!( + version.dependencies[1], + VersionDependency { + project_id: None, + version_id: Some("pinned".into()), + kind: DependencyKind::Required, + } + ); + assert_eq!(version.dependencies[2].kind, DependencyKind::Optional); + assert_eq!(version.dependencies[3].kind, DependencyKind::Incompatible); + } +} diff --git a/packages/oneclient_content/src/packages/provider/registry.rs b/packages/oneclient_content/src/packages/provider/registry.rs new file mode 100644 index 00000000..cc7dadcd --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/registry.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use super::{CurseForgeProvider, ModrinthProvider, PackageProvider}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::error::PackageError; +use crate::packages::file_identity::FileIdentity; +use crate::packages::store::artifact_absolute_path; +use crate::packages::types::{VersionDetail, VersionLookup}; +use oneclient_common::domain::ProviderId; + +#[derive(Clone)] +pub struct PackageProviderRegistry { + providers: HashMap>, +} + +impl PackageProviderRegistry { + pub fn new() -> Self { + let mut registry = Self { + providers: HashMap::new(), + }; + registry.register(Arc::new(ModrinthProvider)); + registry.register(Arc::new(CurseForgeProvider)); + registry + } + + pub fn register(&mut self, provider: Arc) { + self.providers.insert(provider.id(), provider); + } + + pub fn get(&self, id: ProviderId) -> ContentResult<&dyn PackageProvider> { + self.providers + .get(&id) + .map(|p| p.as_ref()) + .ok_or(PackageError::ProviderNotRegistered(id).into()) + } + + pub fn remote_ids(&self) -> Vec { + ProviderId::remote_providers() + .iter() + .copied() + .filter(|id| self.providers.contains_key(id)) + .collect() + } + + pub fn remote(&self) -> Vec<&dyn PackageProvider> { + self.remote_ids() + .into_iter() + .filter_map(|id| self.providers.get(&id).map(|p| p.as_ref())) + .collect() + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult { + if identities.is_empty() { + return Ok(HashMap::new()); + } + + let mut enriched: Vec = identities.to_vec(); + for identity in &mut enriched { + enrich_curseforge_fingerprint(identity, ctx).await?; + } + + let mut merged = HashMap::new(); + for id in self.remote_ids() { + let provider = self.get(id)?; + let found = provider.lookup_versions(&enriched, ctx).await?; + for (sha1, version) in found { + merged.entry(sha1).or_insert(version); + } + } + Ok(merged) + } + + #[tracing::instrument(level = "debug", skip(self, sha1, ctx))] + pub async fn lookup_version( + &self, + sha1: impl AsRef, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut identity = FileIdentity::from_sha1(sha1); + enrich_curseforge_fingerprint(&mut identity, ctx).await?; + + for id in self.remote_ids() { + let provider = self.get(id)?; + let mut found = provider + .lookup_versions(std::slice::from_ref(&identity), ctx) + .await?; + if let Some(version) = found.remove(&identity.sha1) { + return Ok(Some((id, version))); + } + } + Ok(None) + } + + #[tracing::instrument(level = "debug", skip(self, ctx), fields(sha1 = %identity.sha1))] + pub async fn lookup_version_identity( + &self, + identity: &FileIdentity, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut identity = identity.clone(); + enrich_curseforge_fingerprint(&mut identity, ctx).await?; + + for id in self.remote_ids() { + let provider = self.get(id)?; + let mut found = provider + .lookup_versions(std::slice::from_ref(&identity), ctx) + .await?; + if let Some(version) = found.remove(&identity.sha1) { + return Ok(Some((id, version))); + } + } + Ok(None) + } +} + +impl Default for PackageProviderRegistry { + fn default() -> Self { + Self::new() + } +} + +#[tracing::instrument(level = "debug", skip(identity, ctx), fields(sha1 = %identity.sha1))] +async fn enrich_curseforge_fingerprint( + identity: &mut FileIdentity, + ctx: &ContentCtx, +) -> ContentResult<()> { + if identity.cf_fingerprint.is_some() { + return Ok(()); + } + + let Some(row) = + oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &identity.sha1).await? + else { + return Ok(()); + }; + + let path = artifact_absolute_path(&row.path)?; + if !path.exists() { + return Ok(()); + } + + let bytes = polyio::read(&path).await?; + identity.cf_fingerprint = Some(crate::packages::file_identity::curseforge_fingerprint( + &bytes, + )); + Ok(()) +} diff --git a/packages/oneclient_content/src/packages/store/download.rs b/packages/oneclient_content/src/packages/store/download.rs new file mode 100644 index 00000000..8c61ebac --- /dev/null +++ b/packages/oneclient_content/src/packages/store/download.rs @@ -0,0 +1,141 @@ +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::models::ArtifactRow; + +use super::paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_events::GroupedProgressChild; +use polyio::{normalize_hash, sha1_file}; + +#[tracing::instrument(level = "debug", skip(child, ctx))] +pub async fn ensure_artifact_file( + hash: &str, + url: &str, + dest: &std::path::Path, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + if oneclient_net::matches_on_disk(dest, hash).await { + return Ok(polyio::stat(dest).await?.len()); + } + + tracing::debug!("downloading artifact file"); + // Modrinth and CurseForge both index by SHA-1 + let expected = polyio::Checksum::sha1(hash); + oneclient_net::download_verified( + &ctx.net, + &ctx.events, + url, + dest, + Some(&expected), + 0, + child + .cloned() + .map(oneclient_net::ResponseNotifyOptions::grouped), + ) + .await?; + + Ok(polyio::stat(dest).await?.len()) +} + +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "debug", skip(version, file, child, ctx))] +pub async fn download_version_file( + provider: ProviderId, + project_id: &str, + version: &VersionDetail, + content_type: ContentType, + file: &VersionFile, + force: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let hash = normalize_hash(&file.sha1); + if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { + let path = artifact_absolute_path(&row.path)?; + if path.exists() { + let disk = sha1_file(&path).await?; + if disk == hash { + return Ok(row); + } + } + } + + let dest = cache_file_path( + content_type, + provider, + project_id, + &version.version_id, + &file.file_name, + )?; + + let size = ensure_artifact_file(&hash, &file.url, &dest, child, ctx).await?; + let stored_path = relative_cache_path(&dest)?; + + let published_at = version.published.to_rfc3339(); + + let row = artifact_dao::insert_artifact( + &ctx.db, + &hash, + content_type as i64, + &stored_path, + &file.file_name, + Some(size as i64), + ) + .await?; + + artifact_dao::upsert_provider_release( + &ctx.db, + provider as i64, + project_id, + &version.version_id, + &hash, + &version.name, + &version.version_number, + Some(published_at.as_str()), + &serde_json::to_string(&version.game_versions)?, + &serde_json::to_string(&version.loaders)?, + ) + .await?; + + Ok(row) +} + +#[tracing::instrument(level = "debug", skip(file, child, ctx), fields(name = %file.name))] +pub async fn download_external( + file: &ExternalFile, + force: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let hash = normalize_hash(&file.sha1); + if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { + let path = artifact_absolute_path(&row.path)?; + if path.exists() { + return Ok(row); + } + } + + let dest = cache_file_path( + file.content_type, + ProviderId::Local, + "external", + &hash[..hash.len().min(16)], + &file.name, + )?; + let size = ensure_artifact_file(&hash, &file.url, &dest, child, ctx).await?; + let stored_path = relative_cache_path(&dest)?; + + artifact_dao::insert_artifact( + &ctx.db, + &hash, + file.content_type as i64, + &stored_path, + &file.name, + Some(size as i64), + ) + .await + .map_err(Into::into) +} diff --git a/packages/oneclient_content/src/packages/store/gc.rs b/packages/oneclient_content/src/packages/store/gc.rs new file mode 100644 index 00000000..f3b73a76 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/gc.rs @@ -0,0 +1,284 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use oneclient_common::paths; + +use super::paths::{artifact_absolute_path, relative_cache_path}; + +/// A download lands in the cache before its row is inserted so an in-flight fetch looks exactly like an orphan +const RECENT_GRACE: Duration = Duration::from_secs(60 * 60); + +#[derive(Debug, Default, Clone, Copy)] +pub struct GcReport { + pub artifacts_removed: usize, + pub files_removed: usize, + pub bytes_freed: u64, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn evict_if_unused(hash: &str, ctx: &ContentCtx) -> ContentResult { + // Read the row first once it is deleted there is no way back to the file + let row = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await?; + + if !artifact_dao::delete_artifact_if_unused(&ctx.db, hash).await? { + return Ok(false); + } + + if let Some(row) = row + && let Ok(path) = artifact_absolute_path(&row.path) + { + remove_cached_file(&path).await; + } + + Ok(true) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn collect_unused_artifacts(ctx: &ContentCtx) -> ContentResult { + let mut report = GcReport::default(); + + for row in artifact_dao::list_unused_artifacts(&ctx.db).await? { + let path = artifact_absolute_path(&row.path).ok(); + + // An install inserts the row then links it + // in between it looks unused + if let Some(path) = &path + && is_recent(path).await + { + continue; + } + + if !artifact_dao::delete_artifact_if_unused(&ctx.db, &row.hash).await? { + continue; + } + report.artifacts_removed += 1; + + if let Some(path) = path { + report.bytes_freed += file_len(&path).await; + remove_cached_file(&path).await; + } + } + + if report.artifacts_removed > 0 { + tracing::info!( + artifacts = report.artifacts_removed, + mb_freed = report.bytes_freed / 1_000_000, + "reclaimed unused packages" + ); + } + + Ok(report) +} + +pub async fn find_unreferenced_files(ctx: &ContentCtx) -> ContentResult> { + let cache_root = paths::packages_cache_dir()?; + if !polyio::try_exists(&cache_root).await.unwrap_or(false) { + return Ok(Vec::new()); + } + + let known: HashSet = artifact_dao::list_artifact_paths(&ctx.db) + .await? + .into_iter() + .collect(); + + // An empty artifacts table means a lost index not a cache full of garbage + // the other reading would offer to wipe every package the user has + if known.is_empty() { + tracing::debug!("no artifacts indexed; treating the package cache as fully referenced"); + return Ok(Vec::new()); + } + + let mut found = Vec::new(); + for file in walk_files(&cache_root).await { + let Ok(relative) = relative_cache_path(&file) else { + continue; + }; + if known.contains(&relative) || is_recent(&file).await { + continue; + } + + let size = file_len(&file).await; + found.push((file, size)); + } + + Ok(found) +} + +/// Reasons from the filesystem not the database so it can misread an install whose database was reset +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn remove_unreferenced_files(ctx: &ContentCtx) -> ContentResult { + let cache_root = paths::packages_cache_dir()?; + let mut report = GcReport::default(); + + for (file, size) in find_unreferenced_files(ctx).await? { + if polyio::remove_file(&file).await.is_ok() { + report.files_removed += 1; + report.bytes_freed += size; + prune_empty_parents(&file, &cache_root).await; + } + } + + if report.files_removed > 0 { + tracing::info!( + files = report.files_removed, + mb_freed = report.bytes_freed / 1_000_000, + "removed unreferenced cache files" + ); + } + + Ok(report) +} + +async fn remove_cached_file(path: &Path) { + if polyio::remove_file(path).await.is_err() { + return; + } + + if let Ok(root) = paths::packages_cache_dir() { + prune_empty_parents(path, &root).await; + } +} + +async fn prune_empty_parents(file: &Path, root: &Path) { + let mut dir = file.parent().map(Path::to_path_buf); + + while let Some(current) = dir { + if current == root || !current.starts_with(root) { + return; + } + + // `remove_dir` refuses a non-empty directory which is exactly the stop condition + if polyio::remove_dir(¤t).await.is_err() { + return; + } + + dir = current.parent().map(Path::to_path_buf); + } +} + +async fn is_recent(path: &Path) -> bool { + let Ok(meta) = polyio::stat(path).await else { + // Cannot tell how old it is so leave it be + return true; + }; + + meta.modified() + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_none_or(|age| age < RECENT_GRACE) +} + +async fn file_len(path: &Path) -> u64 { + polyio::stat(path).await.map(|m| m.len()).unwrap_or(0) +} + +async fn walk_files(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if file_type.is_dir() { + stack.push(entry.path()); + } else if file_type.is_file() { + files.push(entry.path()); + } + } + } + + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn empty_parents_go_but_the_root_stays() { + let root = polyio::testing::ScratchDir::new("gc_prune"); + let cache = root.join("packages"); + let nested = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&nested).await.unwrap(); + + let file = nested.join("a.jar"); + polyio::write(&file, b"jar".as_slice()).await.unwrap(); + polyio::remove_file(&file).await.unwrap(); + + prune_empty_parents(&file, &cache).await; + + assert!(!cache.join("mod").exists(), "empty chain should be gone"); + assert!(cache.exists(), "the cache root itself always stays"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn a_sibling_stops_the_prune() { + let root = polyio::testing::ScratchDir::new("gc_prune_sibling"); + let cache = root.join("packages"); + let version = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&version).await.unwrap(); + + let gone = version.join("a.jar"); + polyio::write(&gone, b"jar".as_slice()).await.unwrap(); + polyio::write(version.join("b.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::remove_file(&gone).await.unwrap(); + + prune_empty_parents(&gone, &cache).await; + + assert!(version.exists(), "a directory with another file survives"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn fresh_files_are_spared() { + let root = polyio::testing::ScratchDir::new("gc_recent"); + polyio::create_dir_all(root.path()).await.unwrap(); + + let file = root.join("downloading.jar"); + polyio::write(&file, b"partial".as_slice()).await.unwrap(); + + assert!(is_recent(&file).await, "a just-written file is not garbage"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn walk_finds_files_at_every_depth() { + let root = polyio::testing::ScratchDir::new("gc_walk"); + polyio::create_dir_all(root.join("a").join("b")) + .await + .unwrap(); + polyio::write(root.join("top.jar"), b"1".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("mid.jar"), b"2".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("b").join("deep.jar"), b"3".as_slice()) + .await + .unwrap(); + + let found = walk_files(root.path()).await; + + assert_eq!(found.len(), 3, "found: {found:?}"); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs new file mode 100644 index 00000000..f0a222ac --- /dev/null +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -0,0 +1,114 @@ +use std::path::Path; + +use oneclient_db::models::ClusterRow; + +use crate::error::ContentResult; +use oneclient_common::domain::ContentType; +use oneclient_common::paths; + +use super::manifest; + +#[tracing::instrument(level = "debug")] +pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } + + remove_entry(dest).await?; + + if polyio::symlink_file(src, dest).await.is_ok() { + return Ok(()); + } + + polyio::copy(src, dest).await?; + Ok(()) +} + +/// `Path::exists` resolves symlinks so a link to an evicted artifact reads as absent and survives every unlink +pub async fn remove_entry(path: &Path) -> ContentResult<()> { + if polyio::symlink_metadata(path).await.is_err() { + return Ok(()); + } + + polyio::remove_file(path).await?; + Ok(()) +} + +/// Best-effort only the folder is reconciled at the next launch regardless +/// A running game holds its jars open which on Windows blocks deletion so failure here is expected +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub async fn try_unlink_materialized( + cluster: &ClusterRow, + content_type: ContentType, + file_name: &str, +) -> bool { + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + return false; + }; + + let Some(mut loaded) = manifest::load(&game_dir).await else { + return false; + }; + + // The shared game dir belongs to whichever cluster played last + // touching a file we did not put there would delete another cluster's or + // the user's content + let relative = manifest::entry_path(content_type.folder_name(), file_name); + if !loaded.owns(cluster.id, &relative) { + return false; + } + + let path = game_dir.join(content_type.folder_name()).join(file_name); + if let Err(err) = remove_entry(&path).await { + tracing::debug!( + file = file_name, + error = %err, + "could not drop package from the game folder now; it goes at the next launch" + ); + return false; + } + + loaded.entries.retain(|entry| entry.path != relative); + manifest::save(&game_dir, &loaded).await; + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn remove_entry_clears_a_dangling_link() { + let root = polyio::testing::ScratchDir::new("dangling_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); + + assert!(!link.exists(), "the link resolves to nothing"); + assert!( + polyio::symlink_metadata(&link).await.is_ok(), + "but the link itself is still there" + ); + + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn remove_entry_is_fine_with_nothing_there() { + let root = polyio::testing::ScratchDir::new("remove_missing"); + polyio::create_dir_all(root.path()).await.unwrap(); + + remove_entry(&root.join("never_existed.jar")).await.unwrap(); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs new file mode 100644 index 00000000..44460d0e --- /dev/null +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -0,0 +1,156 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const MANIFEST_NAME: &str = ".oneclient-materialized.json"; + +const MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + /// Slash-separated relative to the game directory e.g. `mods/sodium.jar` + pub path: String, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterializedManifest { + pub version: u32, + /// The shared `.minecraft` is used by every non-dedicated cluster in turn so this often is not us + pub cluster_id: i64, + pub entries: Vec, +} + +impl MaterializedManifest { + #[must_use] + pub fn new(cluster_id: i64, entries: Vec) -> Self { + Self { + version: MANIFEST_VERSION, + cluster_id, + entries, + } + } + + #[must_use] + pub fn paths(&self) -> HashSet<&str> { + self.entries.iter().map(|e| e.path.as_str()).collect() + } + + #[must_use] + pub fn contains(&self, relative_path: &str) -> bool { + self.entries.iter().any(|e| e.path == relative_path) + } + + /// The cluster check is what makes deleting out of the shared game directory safe + #[must_use] + pub fn owns(&self, cluster_id: i64, relative_path: &str) -> bool { + self.cluster_id == cluster_id && self.contains(relative_path) + } +} + +#[must_use] +pub fn manifest_path(game_dir: &Path) -> PathBuf { + game_dir.join(MANIFEST_NAME) +} + +/// An unparseable manifest reads as `None` not an error +/// stashing links as user content is recoverable refusing to launch is not +pub async fn load(game_dir: &Path) -> Option { + let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?; + + match serde_json::from_str::(&raw) { + Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), + Ok(manifest) => { + tracing::warn!( + version = manifest.version, + "ignoring materialized manifest written by a different launcher version" + ); + None + } + Err(err) => { + tracing::warn!(error = %err, "materialized manifest is unreadable; ignoring"); + None + } + } +} + +pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { + let body = match serde_json::to_vec_pretty(manifest) { + Ok(body) => body, + Err(err) => { + tracing::warn!(error = %err, "failed to encode materialized manifest"); + return; + } + }; + + if let Err(err) = polyio::write(manifest_path(game_dir), body).await { + tracing::warn!(error = %err, "failed to write materialized manifest"); + } +} + +pub async fn clear(game_dir: &Path) { + polyio::remove_file(manifest_path(game_dir)).await.ok(); +} + +#[must_use] +pub fn entry_path(content_folder: &str, file_name: &str) -> String { + format!("{content_folder}/{file_name}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> MaterializedManifest { + MaterializedManifest::new( + 7, + vec![ManifestEntry { + path: "mods/sodium.jar".into(), + hash: "abc".into(), + }], + ) + } + + #[test] + fn ownership_is_scoped_to_the_cluster_that_wrote_it() { + let manifest = manifest(); + + assert!(manifest.owns(7, "mods/sodium.jar")); + assert!(!manifest.owns(8, "mods/sodium.jar")); + assert!(!manifest.owns(7, "mods/iris.jar")); + } + + #[tokio::test] + async fn round_trips_through_the_game_dir() { + let root = polyio::testing::ScratchDir::new("manifest_round_trip"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + assert!(load(dir).await.is_none(), "no manifest yet"); + + save(dir, &manifest()).await; + let loaded = load(dir).await.expect("manifest should load"); + assert_eq!(loaded.cluster_id, 7); + assert!(loaded.contains("mods/sodium.jar")); + + clear(dir).await; + assert!(load(dir).await.is_none(), "cleared manifest should be gone"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn garbage_manifest_reads_as_absent() { + let root = polyio::testing::ScratchDir::new("manifest_garbage"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(manifest_path(dir), b"not json".as_slice()) + .await + .unwrap(); + + assert!(load(dir).await.is_none()); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs new file mode 100644 index 00000000..69eb9e65 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -0,0 +1,475 @@ +mod download; +mod gc; +mod link; +pub mod manifest; +mod paths; + +pub use download::{download_external, download_version_file, ensure_artifact_file}; +pub use gc::{ + GcReport, collect_unused_artifacts, evict_if_unused, find_unreferenced_files, + remove_unreferenced_files, +}; +pub use link::{link_or_copy, remove_entry, try_unlink_materialized}; +pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; + +use oneclient_db::dao::{artifact as artifact_dao, cluster as cluster_dao}; +use oneclient_db::models::{ArtifactRow, ClusterRow}; + +use super::error::PackageError; +use super::types::{ + CachedArtifact, LinkedArtifactInfo, ProjectDetail, ProviderReleaseInfo, VersionDetail, +}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_events::GroupedProgressChild; +use polyio::{normalize_hash, sha1_file}; +use std::path::Path; + +pub struct PackageStore; + +impl PackageStore { + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn get_cluster(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { + cluster_dao::get_by_id(&ctx.db, cluster_id) + .await? + .ok_or(PackageError::ClusterNotFound(cluster_id).into()) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn cached_artifact( + hash: &str, + ctx: &ContentCtx, + ) -> ContentResult> { + let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await? else { + return Ok(None); + }; + + Ok(Some(row_to_cached(row, ctx).await?)) + } + + #[tracing::instrument(level = "debug", skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] + pub async fn download_and_cache( + provider_id: ProviderId, + project: &ProjectDetail, + version: &VersionDetail, + force: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, + ) -> ContentResult { + let file = version.primary_file().ok_or(PackageError::NoPrimaryFile)?; + + download::download_version_file( + provider_id, + &project.id, + version, + project.content_type, + file, + force, + child, + ctx, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + #[tracing::instrument(skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] + pub async fn install_to_cluster( + provider_id: ProviderId, + project: &ProjectDetail, + version: &VersionDetail, + cluster_id: i64, + skip_compatibility: bool, + force_download: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, + ) -> ContentResult { + tracing::info!("installing package to cluster"); + let cluster = Self::get_cluster(cluster_id, ctx).await?; + + if !skip_compatibility { + ensure_compatible(project, version, &cluster)?; + } + + let artifact = + Self::download_and_cache(provider_id, project, version, force_download, child, ctx) + .await?; + + Self::link_artifact(&artifact, &cluster, None, ctx).await?; + Ok(artifact) + } + + /// Writes nothing to disk + /// the artifact is materialized into the game directory at launch the only + /// moment no game is holding the files open + #[tracing::instrument(level = "debug", skip(artifact, cluster, ctx))] + pub async fn link_artifact( + artifact: &ArtifactRow, + cluster: &ClusterRow, + cluster_file_name: Option<&str>, + ctx: &ContentCtx, + ) -> ContentResult<()> { + let name = cluster_file_name.unwrap_or(&artifact.file_name); + + artifact_dao::link_cluster_artifact(&ctx.db, cluster.id, &artifact.hash, name).await?; + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn link_by_hash( + hash: &str, + cluster_id: i64, + cluster_file_name: Option<&str>, + ctx: &ContentCtx, + ) -> ContentResult<()> { + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; + + let cluster = Self::get_cluster(cluster_id, ctx).await?; + + Self::link_artifact(&artifact, &cluster, cluster_file_name, ctx).await + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn list_linked_artifacts( + cluster_id: i64, + ctx: &ContentCtx, + ) -> ContentResult> { + let links = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id).await?; + let mut items = Vec::with_capacity(links.len()); + + for link in links { + let Some(artifact) = artifact_dao::get_artifact_by_hash(&ctx.db, &link.hash).await? + else { + continue; + }; + + let content_type = + ContentType::from_repr(artifact.content_type as u8).unwrap_or(ContentType::Mod); + let release = artifact_dao::get_release_by_hash(&ctx.db, &link.hash).await?; + + items.push(LinkedArtifactInfo { + hash: link.hash, + cluster_file_name: link.cluster_file_name, + enabled: link.enabled != 0, + content_type, + file_name: artifact.file_name, + project_id: release.as_ref().map(|r| r.project_id.clone()), + version_id: release.as_ref().map(|r| r.version_id.clone()), + display_name: release.as_ref().map(|r| r.display_name.clone()), + display_version: release.as_ref().map(|r| r.display_version.clone()), + provider: release + .as_ref() + .and_then(|r| ProviderId::from_repr(r.provider as u8)), + published_at: release.as_ref().and_then(|r| r.published_at.clone()), + }); + } + + Ok(items) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + /// Only the flag is recorded + /// the folder is rewritten at the next launch since a running game holds + /// its jars open + /// Storage only bundle override bookkeeping lives in + /// [`crate::bundles::toggle_artifact_enabled`] to avoid mutual recursion + pub async fn set_artifact_enabled( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, + ) -> ContentResult { + Self::write_artifact_enabled(cluster_id, hash, None, ctx).await + } + + /// Returns the state it ended in which is the requested one unless the link was already there + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn set_artifact_enabled_to( + cluster_id: i64, + hash: &str, + enabled: bool, + ctx: &ContentCtx, + ) -> ContentResult { + Self::write_artifact_enabled(cluster_id, hash, Some(enabled), ctx).await + } + + /// `target` of `None` means "the opposite of whatever it is now" + async fn write_artifact_enabled( + cluster_id: i64, + hash: &str, + target: Option, + ctx: &ContentCtx, + ) -> ContentResult { + let cluster = Self::get_cluster(cluster_id, ctx).await?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; + + let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash) + .await? + .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; + + let content_type = + ContentType::from_repr(artifact.content_type as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown content type {}", artifact.content_type), + } + })?; + + let enabled = target.unwrap_or(link.enabled == 0); + let file_name = link + .cluster_file_name + .trim_end_matches(".disabled") + .to_string(); + + artifact_dao::update_cluster_artifact( + &ctx.db, + cluster_id, + hash, + &file_name, + i64::from(enabled), + ) + .await?; + + if !enabled { + link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + if link.cluster_file_name != file_name { + link::try_unlink_materialized(&cluster, content_type, &file_name).await; + } + } + + Ok(enabled) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn import_local_file( + path: &Path, + content_type: ContentType, + cluster_id: i64, + ctx: &ContentCtx, + ) -> ContentResult { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| PackageError::InvalidLocalFile(path.display().to_string()))? + .to_string(); + + let hash = normalize_hash(&sha1_file(path).await?); + + if let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { + let cluster = Self::get_cluster(cluster_id, ctx).await?; + Self::link_artifact(&row, &cluster, None, ctx).await?; + return Ok(row); + } + + let dest = cache_file_path( + content_type, + ProviderId::Local, + "imported", + &hash[..hash.len().min(16)], + &file_name, + )?; + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::copy(path, &dest).await?; + + let size = polyio::stat(&dest).await?.len(); + let stored_path = relative_cache_path(&dest)?; + + let row = artifact_dao::insert_artifact( + &ctx.db, + &hash, + content_type as i64, + &stored_path, + &file_name, + Some(size as i64), + ) + .await?; + + let cluster = Self::get_cluster(cluster_id, ctx).await?; + Self::link_artifact(&row, &cluster, None, ctx).await?; + Ok(row) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn resolve_or_download( + provider_id: ProviderId, + project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let provider = ctx.providers.get(provider_id)?; + + let version = provider.get_version(project_id, version_id, ctx).await?; + + let project = provider.get_project(project_id, ctx).await?; + + Self::download_and_cache(provider_id, &project, &version, false, None, ctx).await + } +} + +#[tracing::instrument(level = "debug", skip(row, ctx), fields(hash = %row.hash))] +async fn row_to_cached(row: ArtifactRow, ctx: &ContentCtx) -> ContentResult { + let path = artifact_absolute_path(&row.path)?; + let release = artifact_dao::get_release_by_hash(&ctx.db, &row.hash) + .await? + .map(|r| ProviderReleaseInfo { + provider: ProviderId::from_repr(r.provider as u8).unwrap_or(ProviderId::Local), + project_id: r.project_id, + version_id: r.version_id, + display_name: r.display_name, + display_version: r.display_version, + mc_versions: serde_json::from_str(&r.mc_versions).unwrap_or_default(), + loaders: serde_json::from_str(&r.mc_loaders).unwrap_or_default(), + }); + + Ok(CachedArtifact { + hash: row.hash, + content_type: ContentType::from_repr(row.content_type as u8).unwrap_or(ContentType::Mod), + path, + file_name: row.file_name, + size_bytes: row.size_bytes.map(|s| s as u64), + release, + }) +} + +fn ensure_compatible( + project: &ProjectDetail, + version: &VersionDetail, + cluster: &ClusterRow, +) -> ContentResult<()> { + if project.provider == ProviderId::Local { + return Ok(()); + } + + 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() + && !version + .game_versions + .iter() + .any(|v| cluster.mc_version.contains(v)) + { + return Err(PackageError::IncompatibleMcVersion.into()); + } + + 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(), + dependencies: 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(ContentError::Package(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(ContentError::Package(PackageError::IncompatibleMcVersion)) + )); + } +} diff --git a/packages/oneclient_content/src/packages/store/paths.rs b/packages/oneclient_content/src/packages/store/paths.rs new file mode 100644 index 00000000..a6ae50a2 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/paths.rs @@ -0,0 +1,31 @@ +use std::path::{Path, PathBuf}; + +use crate::error::ContentResult; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::paths; + +pub fn cache_file_path( + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, + file_name: &str, +) -> ContentResult { + Ok(paths::package_version_dir(content_type, provider, project_id, version_id)?.join(file_name)) +} + +pub fn artifact_absolute_path(stored_path: &str) -> ContentResult { + let root = paths::launcher_dir()?; + Ok(root.join(stored_path)) +} + +pub fn relative_cache_path(abs: &Path) -> ContentResult { + let root = paths::launcher_dir()?; + + let rel = abs + .strip_prefix(root) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| abs.to_path_buf()); + + Ok(rel.to_string_lossy().replace('\\', "/")) +} diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs new file mode 100644 index 00000000..90aeadcd --- /dev/null +++ b/packages/oneclient_content/src/packages/types.rs @@ -0,0 +1,241 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::search::normalize_query; + +pub const DEFAULT_PAGE_SIZE: usize = 24; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SearchFilters { + pub query: Option, + pub offset: Option, + pub limit: Option, + pub content_type: Option, + pub game_versions: Option>, + pub loaders: Option>, + pub categories: Option>, + pub sort: Option, +} + +impl SearchFilters { + /// Providers rank the raw string so " sodium " and "sodium" score differently + #[must_use] + pub fn normalized_query(&self) -> String { + self.query + .as_deref() + .map(normalize_query) + .unwrap_or_default() + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SearchSort { + #[default] + Relevance, + Downloads, + Newest, + Updated, +} + +impl SearchSort { + pub fn modrinth_index(self) -> &'static str { + match self { + Self::Relevance => "relevance", + Self::Downloads => "downloads", + Self::Newest => "newest", + Self::Updated => "updated", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Page { + pub offset: usize, + pub limit: usize, + pub total: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectSummary { + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, + pub downloads: u64, + pub created: DateTime, + pub updated: DateTime, + pub loaders: Vec, + pub game_versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PackageBody { + Url(String), + Raw(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectMember { + pub name: String, + pub role: String, + pub url: Option, + pub avatar_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GalleryImage { + pub url: String, + pub title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectDetail { + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub members: Vec, + pub gallery: Vec, + pub body: PackageBody, + pub license: Option, + pub links: Vec<(String, String)>, + pub version_ids: Vec, + pub game_versions: Vec, + pub loaders: Vec, + pub icon_url: Option, + pub created: DateTime, + pub updated: DateTime, + pub downloads: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionSummary { + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub published: DateTime, + pub release_type: ReleaseType, + pub game_versions: Vec, + pub loaders: Vec, + pub downloads: u64, + pub file_size: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReleaseType { + #[default] + Release, + Beta, + Alpha, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionDetail { + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub changelog: Option, + pub game_versions: Vec, + pub loaders: Vec, + pub published: DateTime, + pub downloads: u64, + pub files: Vec, + /// Only [`DependencyKind::Required`] entries are pulled in automatically + #[serde(default)] + pub dependencies: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyKind { + Required, + Optional, + Incompatible, + Embedded, +} + +/// At least one id is set Modrinth may pin a version CurseForge only names the project +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VersionDependency { + pub project_id: Option, + pub version_id: Option, + pub kind: DependencyKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionFile { + pub sha1: String, + pub url: String, + pub file_name: String, + pub primary: bool, + pub size: u64, + pub fingerprint: Option, +} + +impl VersionDetail { + pub fn primary_file(&self) -> Option<&VersionFile> { + self.files.iter().find(|f| f.primary).or(self.files.first()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalFile { + pub name: String, + pub url: String, + pub sha1: String, + pub size: u64, + pub content_type: ContentType, +} + +#[derive(Debug, Clone)] +pub struct CachedArtifact { + pub hash: String, + pub content_type: ContentType, + pub path: std::path::PathBuf, + pub file_name: String, + pub size_bytes: Option, + pub release: Option, +} + +#[derive(Debug, Clone)] +pub struct ProviderReleaseInfo { + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub display_name: String, + pub display_version: String, + pub mc_versions: Vec, + pub loaders: Vec, +} + +pub type VersionLookup = HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkedArtifactInfo { + pub hash: String, + pub cluster_file_name: String, + pub enabled: bool, + pub content_type: ContentType, + pub file_name: String, + pub project_id: Option, + pub version_id: Option, + pub display_name: Option, + pub display_version: Option, + pub provider: Option, + /// Provider publish time RFC 3339 + pub published_at: Option, +} diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs new file mode 100644 index 00000000..27ac83c4 --- /dev/null +++ b/packages/oneclient_content/src/packages/updates.rs @@ -0,0 +1,552 @@ +//! Bundle-tracked artifacts are skipped +//! [`crate::bundles::updates`] owns those files and the two flows would fight +//! over the same artifact +//! +//! The `browser_package_updates` cache is only narrowed by a check that reached +//! the provider so an offline run is a no-op rather than "all up to date" + +use std::collections::HashSet; + +use futures_util::StreamExt; + +use oneclient_common::domain::ProviderId; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::browser_package_update as update_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::{BrowserPackageUpdateRow, ClusterRow}; +use oneclient_events::GroupedProgressChild; + +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use crate::packages::dependencies::pick_version; +use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::types::LinkedArtifactInfo; + +/// Matches the bundle installer's fan-out +/// the providers are the bottleneck +const UPDATE_CHECK_CONCURRENCY: usize = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserPackageUpdate { + pub cluster_id: i64, + /// The artifact currently linked into the cluster + pub hash: String, + pub provider: ProviderId, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + /// Local fallback for a UI with no meta cache entry + pub display_name: String, + /// The user has already declined this exact newer version + pub skipped: bool, +} + +impl BrowserPackageUpdate { + fn from_row(row: BrowserPackageUpdateRow) -> Self { + Self { + cluster_id: row.cluster_id, + hash: row.hash, + provider: ProviderId::from_repr(row.provider as u8).unwrap_or(ProviderId::Modrinth), + project_id: row.project_id, + installed_version_id: row.installed_version_id, + installed_version_name: row.installed_version_name, + latest_version_id: row.latest_version_id, + latest_version_name: row.latest_version_name, + display_name: row.display_name, + skipped: row.skipped != 0, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct BrowserUpdateCheck { + pub cluster_id: i64, + pub updates: Vec, + /// Unreachable this run + /// their cached rows are preserved not cleared + pub unchecked: Vec, +} + +impl BrowserUpdateCheck { + #[must_use] + pub fn pending(&self) -> Vec { + self.updates + .iter() + .filter(|u| !u.skipped) + .cloned() + .collect() + } +} + +struct Candidate { + hash: String, + provider: ProviderId, + project_id: String, + version_id: String, + display_name: String, + display_version: String, +} + +/// A missing bundle tracking row is the marker for "the user added this themselves" +fn browser_installed( + linked: &[LinkedArtifactInfo], + bundle_hashes: &HashSet, +) -> Vec { + linked + .iter() + .filter(|info| !bundle_hashes.contains(&info.hash)) + .filter_map(|info| { + let provider = info.provider?; + if provider == ProviderId::Local { + return None; + } + Some(Candidate { + hash: info.hash.clone(), + provider, + project_id: info.project_id.clone()?, + version_id: info.version_id.clone()?, + display_name: info + .display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + display_version: info.display_version.clone().unwrap_or_default(), + }) + }) + .collect() +} + +/// An unreachable project lands in [`BrowserUpdateCheck::unchecked`] +/// only an unreadable cluster errors +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn check_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + + let bundle_hashes: HashSet = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) + .await? + .into_iter() + .map(|row| row.hash) + .collect(); + + let candidates = browser_installed(&linked, &bundle_hashes); + if candidates.is_empty() { + return Ok(BrowserUpdateCheck { + cluster_id, + ..Default::default() + }); + } + + let skipped: HashSet = update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.skipped != 0) + .map(|row| format!("{}:{}", row.hash, row.latest_version_id)) + .collect(); + + let results = futures_util::stream::iter(candidates.into_iter().map(|candidate| { + let cluster = &cluster; + async move { + let outcome = latest_for(&candidate, cluster, ctx).await; + (candidate, outcome) + } + })) + .buffer_unordered(UPDATE_CHECK_CONCURRENCY) + .collect::>() + .await; + + let mut check = BrowserUpdateCheck { + cluster_id, + ..Default::default() + }; + + for (candidate, outcome) in results { + match outcome { + Ok(Some((latest_version_id, latest_version_name))) => { + let key = format!("{}:{latest_version_id}", candidate.hash); + check.updates.push(BrowserPackageUpdate { + cluster_id, + hash: candidate.hash, + provider: candidate.provider, + project_id: candidate.project_id, + installed_version_id: candidate.version_id, + installed_version_name: candidate.display_version, + latest_version_id, + latest_version_name, + display_name: candidate.display_name, + skipped: skipped.contains(&key), + }); + } + Ok(None) => {} + Err(err) => { + // Offline is the common case and says nothing about the launcher + if err.is_transient() { + tracing::debug!( + project_id = %candidate.project_id, + error = %err, + "update check skipped, provider unreachable" + ); + } else { + tracing::warn!( + project_id = %candidate.project_id, + error = %err, + "update check failed for package" + ); + } + check.unchecked.push(candidate.hash); + } + } + } + + Ok(check) +} + +/// `None` when the installed version is already the newest compatible one +async fn latest_for( + candidate: &Candidate, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + let provider = ctx.providers.get(candidate.provider)?; + let Some(latest) = pick_version(provider, &candidate.project_id, cluster, ctx).await? else { + return Ok(None); + }; + + if latest.version_id == candidate.version_id { + return Ok(None); + } + + let name = if latest.version_number.is_empty() { + latest.name + } else { + latest.version_number + }; + + Ok(Some((latest.version_id, name))) +} + +/// Unreported rows are dropped except unreachable ones which keep their last +/// known state +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn refresh_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let check = check_browser_package_updates(cluster_id, ctx).await?; + + let mut keep: Vec = check.unchecked.clone(); + let mut stored = Vec::with_capacity(check.updates.len()); + + for update in &check.updates { + let row = update_dao::upsert( + &ctx.db, + cluster_id, + &update.hash, + update.provider as i64, + &update.project_id, + &update.installed_version_id, + &update.installed_version_name, + &update.latest_version_id, + &update.latest_version_name, + &update.display_name, + ) + .await?; + keep.push(row.hash.clone()); + // The row is the authority on `skipped` for this exact version + stored.push(BrowserPackageUpdate::from_row(row)); + } + + update_dao::retain_hashes(&ctx.db, cluster_id, &keep).await?; + + Ok(BrowserUpdateCheck { + cluster_id, + updates: stored, + unchecked: check.unchecked, + }) +} + +/// Never touches the network +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn cached_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .map(BrowserPackageUpdate::from_row) + .collect()) +} + +/// The row stays and the package still reads as out of date +/// only the modal stops asking until a newer version than the declined one +/// appears +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn skip_browser_package_update( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + update_dao::set_skipped(&ctx.db, cluster_id, hash, true).await?; + Ok(()) +} + +/// Download first unlink second so a failed download leaves the cluster as it was +#[tracing::instrument(level = "debug", skip(update, child, ctx), fields(cluster_id = update.cluster_id, project_id = %update.project_id))] +pub async fn apply_browser_package_update( + update: &BrowserPackageUpdate, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + // Never touch anything a bundle owns however this was called + if bundle_dao::get_bundle_tracked(&ctx.db, update.cluster_id, &update.hash) + .await? + .is_some() + { + return Err(ContentError::InvalidData { + reason: format!( + "{} is provided by a bundle and is not updated by the browser flow", + update.display_name + ), + }); + } + + let provider = ctx.providers.get(update.provider)?; + let project = provider.get_project(&update.project_id, ctx).await?; + let version = provider + .get_version(&update.project_id, &update.latest_version_id, ctx) + .await?; + + // Compatibility is not re-checked + // a provider disagreeing at install time would strand the user on a build + // they cannot move off + let installed = PackageStore::install_to_cluster( + update.provider, + &project, + &version, + update.cluster_id, + true, + false, + child, + ctx, + ) + .await?; + + let enabled_before = + artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) + .await? + .map(|link| link.enabled != 0); + + // Everything else of this project goes not just the recorded hash which can + // be stale if the user installed a version by hand since the check ran + unlink_other_versions( + update.cluster_id, + update.provider, + &update.project_id, + &installed.hash, + ctx, + ) + .await?; + + // Provider handed back the already-linked file + // leave the link untouched + if installed.hash == update.hash { + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + return Ok(installed.hash); + } + + // A package switched off stays off + // the fresh link starts enabled so this is a toggle rather than a set + if enabled_before == Some(false) { + PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?; + } + + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + + Ok(installed.hash) +} + +/// Bundle-owned copies are stepped over +/// unlinking one here would have the next bundle sync put it straight back +#[tracing::instrument(level = "debug", skip(ctx))] +async fn unlink_other_versions( + cluster_id: i64, + provider: ProviderId, + project_id: &str, + keep_hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + let others = artifact_dao::list_cluster_artifacts_for_project( + &ctx.db, + cluster_id, + provider as i64, + project_id, + keep_hash, + ) + .await?; + + for link in others { + if bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, &link.hash) + .await? + .is_some() + { + tracing::debug!( + hash = %link.hash, + "leaving a bundle-owned version of the project in place" + ); + continue; + } + + tracing::info!( + cluster_id, + project_id, + hash = %link.hash, + "dropping a superseded version of the project" + ); + unlink_superseded(cluster_id, &link.hash, ctx).await?; + } + + Ok(()) +} + +/// Not `bundles::remove_artifact_from_cluster` +/// `packages` must not depend on `bundles` and its override reconciliation +/// does not apply here +#[tracing::instrument(level = "debug", skip(ctx))] +async fn unlink_superseded(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| { + oneclient_common::domain::ContentType::from_repr(artifact.content_type as u8) + }); + let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + if let (Some(content_type), Some(link)) = (content_type, link) { + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + } + + if let Err(err) = evict_if_unused(hash, ctx).await { + tracing::warn!(hash, error = %err, "failed to evict the superseded artifact"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ContentType; + + fn linked( + hash: &str, + provider: Option, + project_id: Option<&str>, + version_id: Option<&str>, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.into(), + cluster_file_name: format!("{hash}.jar"), + enabled: true, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: project_id.map(Into::into), + version_id: version_id.map(Into::into), + display_name: None, + display_version: None, + provider, + published_at: None, + } + } + + #[test] + fn bundle_provided_packages_are_never_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + let bundles: HashSet = ["a".to_string()].into_iter().collect(); + + assert!( + browser_installed(&linked, &bundles).is_empty(), + "a bundle-tracked artifact must stay out of the browser update flow" + ); + } + + #[test] + fn browser_installed_remote_packages_are_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + + let found = browser_installed(&linked, &HashSet::new()); + assert_eq!(found.len(), 1); + assert_eq!(found[0].project_id, "sodium"); + assert_eq!(found[0].version_id, "v1"); + } + + #[test] + fn local_imports_are_not_candidates() { + let linked = vec![ + linked("a", Some(ProviderId::Local), Some("x"), Some("v1")), + linked("b", None, None, None), + ]; + + assert!( + browser_installed(&linked, &HashSet::new()).is_empty(), + "a file with no provider release has no version to compare" + ); + } + + #[test] + fn a_remote_package_with_no_recorded_version_is_not_a_candidate() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + None, + )]; + + assert!( + browser_installed(&linked, &HashSet::new()).is_empty(), + "without an installed version id there is nothing to compare against" + ); + } + + #[test] + fn pending_hides_versions_the_user_declined() { + let make = |hash: &str, skipped: bool| BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped, + }; + + let check = BrowserUpdateCheck { + cluster_id: 1, + updates: vec![make("a", true), make("b", false)], + unchecked: Vec::new(), + }; + + let pending = check.pending(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].hash, "b"); + } +} diff --git a/packages/oneclient_content/tests/file_identity.rs b/packages/oneclient_content/tests/file_identity.rs new file mode 100644 index 00000000..0e9bfb08 --- /dev/null +++ b/packages/oneclient_content/tests/file_identity.rs @@ -0,0 +1,39 @@ +use oneclient_content::packages::{FileIdentity, curseforge_fingerprint}; + +#[test] +fn curseforge_fingerprint_ignores_padding_whitespace() { + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"\t\n\r hello \n\t\r"); + assert_eq!(a, b); +} + +#[test] +fn curseforge_fingerprint_empty_is_stable() { + assert_eq!(curseforge_fingerprint(&[]), curseforge_fingerprint(&[])); +} + +#[test] +fn curseforge_fingerprint_differs_for_content() { + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"world"); + assert_ne!(a, b); +} + +#[test] +fn file_identity_from_bytes_sets_sha1_and_cf_fingerprint() { + let id = FileIdentity::from_bytes(b"test jar content"); + assert_eq!(id.sha1.len(), 40); + assert!(id.cf_fingerprint.is_some()); +} + +#[test] +fn file_identity_from_sha1_has_no_cf_fingerprint() { + let id = FileIdentity::from_sha1("a".repeat(40)); + assert!(id.cf_fingerprint.is_none()); +} + +#[test] +fn file_identity_with_fingerprint_roundtrip() { + let id = FileIdentity::from_sha1("abc").with_curseforge_fingerprint(12345); + assert_eq!(id.cf_fingerprint, Some(12345)); +} diff --git a/packages/oneclient_core/.gitignore b/packages/oneclient_core/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/oneclient_core/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml new file mode 100644 index 00000000..aea60f7f --- /dev/null +++ b/packages/oneclient_core/Cargo.toml @@ -0,0 +1,95 @@ +[package] +authors.workspace = true +description = "OneClient launcher core" +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "oneclient_core" +readme.workspace = true +repository.workspace = true +version.workspace = true + +[lib] +path = "src/lib.rs" + +[features] +default = [] + +[dev-dependencies] +async-tungstenite = {version = "=0.32.1", features = ["tokio-runtime"]} +polyio = {workspace = true, features = ["testing"]} + +[dependencies] +# core (extra) +discord-rich-presence.workspace = true +interfrost.workspace = true +oneclient_auth.workspace = true +oneclient_cluster.workspace = true +oneclient_common.workspace = true +oneclient_content.workspace = true +oneclient_db.workspace = true +oneclient_discord.workspace = true +oneclient_events.workspace = true +oneclient_java.workspace = true +oneclient_mc.workspace = true +oneclient_net.workspace = true +oneclient_polyplus.workspace = true +polyio.workspace = true + +# logging +tracing-futures.workspace = true +tracing-subscriber.workspace = true +tracing.workspace = true + +sentry-tracing.workspace = true +sentry.workspace = true + +# database +sqlx.workspace = true + +# async +async-compression.workspace = true +async-stream.workspace = true +async-tempfile.workspace = true +async-trait.workspace = true +async_zip.workspace = true +futures-lite.workspace = true +futures-util = {workspace = true, features = ["std", "sink"]} +reqwest-websocket.workspace = true +reqwest.workspace = true +tokio-stream.workspace = true +tokio-util.workspace = true +tokio.workspace = true + +# util (codegen/macro) +regex.workspace = true +serde_repr.workspace = true +strum.workspace = true +thiserror.workspace = true + +# crypto +digest.workspace = true +md-5.workspace = true +sha1.workspace = true +sha2.workspace = true + +# util +base64.workspace = true +bytes.workspace = true +chrono.workspace = true +directories.workspace = true +dunce.workspace = true +htmd.workspace = true +image.workspace = true +indicatif.workspace = true +parking_lot.workspace = true +rand.workspace = true +sanitize-filename.workspace = true +serde.workspace = true +serde_json.workspace = true +sysinfo.workspace = true +trash.workspace = true +url.workspace = true +uuid = { workspace = true, features = ["v3", "serde"] } +md5 = "0.8.1" diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs new file mode 100644 index 00000000..c2093e4f --- /dev/null +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -0,0 +1,52 @@ +use std::process::Command; + +use oneclient_core::{LauncherResult, dev, logger}; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + logger::init_debug()?; + let state = dev::initialize().await?; + + let session = state.auth.begin_microsoft_login().await?; + + println!("Sign in with EITHER method:\n"); + println!(" Browser: opening {}", session.browser.auth_url); + try_open_browser(&session.browser.auth_url); + println!( + " Device code: go to {} and enter {}\n", + session.device.verification_uri, session.device.user_code + ); + + println!("Waiting for you to sign in..."); + let account = state.auth.finish_microsoft_login(session).await?; + + println!( + "Signed in as {} ({})\n token expires: {}", + account.username, account.id, account.expires + ); + println!( + "Account saved to {}", + oneclient_common::paths::auth_file()?.display() + ); + + Ok(()) +} + +fn try_open_browser(url: &str) { + let result = if cfg!(target_os = "windows") { + Command::new("cmd").args(["/C", "start", "", url]).spawn() + } else if cfg!(target_os = "macos") { + Command::new("open").arg(url).spawn() + } else { + Command::new("xdg-open").arg(url).spawn() + }; + + match result { + Ok(_) => println!("Opened verification page in your default browser.\n"), + Err(err) => { + println!( + "Could not open browser automatically ({err}). Open the URL above manually.\n" + ); + } + } +} diff --git a/packages/oneclient_core/examples/bundles_sync.rs b/packages/oneclient_core/examples/bundles_sync.rs new file mode 100644 index 00000000..bf9e3b58 --- /dev/null +++ b/packages/oneclient_core/examples/bundles_sync.rs @@ -0,0 +1,32 @@ +use oneclient_common::domain::GameLoader; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let state = dev::ephemeral_state().await?; + + state.bundles.sync(&state.services.content()).await?; + + let bundles = state + .bundles + .list_for(&state.services.content(), "1.21.11", GameLoader::Fabric) + .await?; + + println!("Visible Fabric bundles for 1.21.11: {}", bundles.len()); + for bundle in bundles { + println!( + "- {} ({}) at {}", + bundle.name, + bundle.version_id, + bundle.path.display() + ); + } + + let all = oneclient_db::dao::bundle::list_all(&state.services.db).await?; + let hidden = all.iter().filter(|row| row.hidden != 0).count(); + println!("Total catalog rows: {} (hidden: {hidden})", all.len()); + + Ok(()) +} diff --git a/packages/oneclient_core/examples/cluster_manage.rs b/packages/oneclient_core/examples/cluster_manage.rs new file mode 100644 index 00000000..ba287d81 --- /dev/null +++ b/packages/oneclient_core/examples/cluster_manage.rs @@ -0,0 +1,47 @@ +use oneclient_cluster::profiles::list_named_profiles; +use oneclient_common::domain::GameLoader; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let state = dev::ephemeral_state().await?; + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric) + .mem_max(3072), + ) + .await?; + + println!( + "Created cluster #{} at folder '{}'", + cluster.id, cluster.folder_name + ); + println!(" setting profile: {:?}", cluster.setting_profile_name); + println!(" created at: {:?}", cluster.created_at); + + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + println!( + " resolved mem_max: {:?}, force_fullscreen: {:?}", + profile.mem_max, profile.force_fullscreen + ); + + for c in state.clusters.list().await? { + println!(" - {} ({}, {})", c.name, c.mc_version, c.mc_loader); + } + + for p in list_named_profiles(&state.services.db).await? { + println!("Profile {} (mem_max: {:?})", p.name, p.mem_max); + } + + state.clusters.delete(cluster.id, true).await?; + println!("Deleted cluster #{}", cluster.id); + + Ok(()) +} diff --git a/packages/oneclient_core/examples/detect_v1.rs b/packages/oneclient_core/examples/detect_v1.rs new file mode 100644 index 00000000..c6eee761 --- /dev/null +++ b/packages/oneclient_core/examples/detect_v1.rs @@ -0,0 +1,27 @@ +#[tokio::main] +async fn main() { + match oneclient_core::detect_migration().await { + Ok(Some(detection)) => { + println!( + "source: {} ({})", + detection.source.display_name(), + detection.source.id() + ); + println!("root: {}", detection.root.display()); + println!("instances: {}", detection.instances.len()); + for i in &detection.instances { + println!( + " [{}] {} ({} {:?}) game_dir={} categories={:?}", + i.instance_id, + i.folder_name, + i.mc_version, + i.mc_loader, + i.has_game_dir, + i.categories, + ); + } + } + Ok(None) => println!("no migratable launcher detected"), + Err(err) => eprintln!("detection error: {err}"), + } +} diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs new file mode 100644 index 00000000..aceeee8b --- /dev/null +++ b/packages/oneclient_core/examples/download_game.rs @@ -0,0 +1,48 @@ +#![recursion_limit = "256"] +use oneclient_common::domain::GameLoader; +use oneclient_core::LauncherResult; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + dev::initialize().await?; + let state = dev::ephemeral_state().await?; + + let args: Vec = std::env::args().skip(1).collect(); + let mc_version = args.first().map(String::as_str).unwrap_or("26.1"); + let loader = args + .get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(GameLoader::Vanilla); + let loader_version = args.get(2).map(String::as_str); + + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions { + name: format!("download-{mc_version}"), + mc_version: mc_version.to_string(), + mc_loader: loader, + mc_loader_version: loader_version.map(str::to_string), + mem_max: None, + }, + ) + .await?; + + println!( + "Preparing cluster {} ({} {:?})...", + cluster.name, cluster.mc_version, cluster.mc_loader + ); + + let ready = oneclient_core::clusters::prepare_cluster_locked( + &state, cluster.id, false, true, true, None, + ) + .await?; + println!("Cluster ready at {}", ready.dir()?.display()); + + Ok(()) +} diff --git a/packages/oneclient_core/examples/launch_game.rs b/packages/oneclient_core/examples/launch_game.rs new file mode 100644 index 00000000..2c435b24 --- /dev/null +++ b/packages/oneclient_core/examples/launch_game.rs @@ -0,0 +1,48 @@ +#![recursion_limit = "256"] + +use oneclient_common::domain::GameLoader; +use oneclient_core::LauncherResult; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + dev::initialize().await?; + let state = dev::ephemeral_state().await?; + + let args: Vec = std::env::args().skip(1).collect(); + let mc_version = args.first().map(String::as_str).unwrap_or("1.21.4"); + let loader = args + .get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(GameLoader::Vanilla); + + let account = state + .auth + .add_offline_account("Example".to_string()) + .await?; + println!( + "Using offline account {} ({})", + account.username, account.id + ); + + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new(format!("launch-{mc_version}"), mc_version, loader), + ) + .await?; + + println!("Launching {} ({mc_version} {loader:?})...", cluster.name); + let game = oneclient_core::launch_cluster(&state, cluster.id, &account, true).await?; + println!( + "Launched cluster #{} (pid {:?}). Waiting 10s before exit...", + game.cluster_id, game.pid + ); + + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + Ok(()) +} diff --git a/packages/oneclient_core/examples/notifications.rs b/packages/oneclient_core/examples/notifications.rs new file mode 100644 index 00000000..432cbaf9 --- /dev/null +++ b/packages/oneclient_core/examples/notifications.rs @@ -0,0 +1,30 @@ +use oneclient_events::{EventBus, GroupedProgressSession}; +use tokio::sync::mpsc; +use uuid::Uuid; + +#[tokio::main] +async fn main() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let events = EventBus::new(tx); + + events.notify("Welcome").body("Launcher started").send(); + events + .notify("Heads up") + .body("Example error notification") + .error() + .send(); + + let download = Uuid::new_v4(); + for current in (0..=100).step_by(25) { + events.progress(download, "Downloading assets", current, 100); + } + + let session = GroupedProgressSession::start(&events, "Preparing cluster".to_string()); + session.finish(); + + drop(events); + + while let Some(notification) = rx.recv().await { + println!("{notification:?}"); + } +} diff --git a/packages/oneclient_core/examples/package_project.rs b/packages/oneclient_core/examples/package_project.rs new file mode 100644 index 00000000..857e4e34 --- /dev/null +++ b/packages/oneclient_core/examples/package_project.rs @@ -0,0 +1,59 @@ +use std::env; + +use oneclient_common::domain::ProviderId; +use oneclient_core::{LauncherResult, dev, logger}; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + logger::init_debug()?; + + let mut args = env::args().skip(1); + let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); + let project_ref = args.next().unwrap_or_else(|| "sodium".into()); + let mc_version = args.next(); + + let provider_id = parse_provider(&provider_name)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; + + let project = provider.get_project(&project_ref, &env).await?; + + println!( + "{:?} project: {} ({})", + project.provider, project.name, project.id + ); + println!(" {}\n", project.summary); + + let versions = provider + .list_versions(&project.id, mc_version.as_deref(), None, 0, 5, &env) + .await?; + + println!("Versions (up to {}):", versions.items.len()); + for v in &versions.items { + println!(" {} - {} ({})", v.name, v.version_number, v.version_id); + } + + if let Some(version) = versions.items.first() { + let detail = provider + .get_version(&project.id, &version.version_id, &env) + .await?; + if let Some(file) = detail.primary_file() { + println!( + "\nPrimary file: {} sha1={} cf_fp={:?}", + file.file_name, file.sha1, file.fingerprint + ); + } + } + + Ok(()) +} + +fn parse_provider(name: &str) -> LauncherResult { + match name.to_lowercase().as_str() { + "modrinth" | "mr" => Ok(ProviderId::Modrinth), + "curseforge" | "cf" => Ok(ProviderId::CurseForge), + other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { + reason: format!("unknown provider {other:?}; use modrinth or curseforge"), + }), + } +} diff --git a/packages/oneclient_core/examples/package_search.rs b/packages/oneclient_core/examples/package_search.rs new file mode 100644 index 00000000..ba89859c --- /dev/null +++ b/packages/oneclient_core/examples/package_search.rs @@ -0,0 +1,56 @@ +use std::env; + +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_content::packages::types::SearchFilters; +use oneclient_core::{LauncherResult, dev, logger}; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + logger::init_debug()?; + + let mut args = env::args().skip(1); + let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); + let query = args.next().unwrap_or_else(|| "sodium".into()); + + let provider_id = parse_provider(&provider_name)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; + + let page = provider + .search( + &SearchFilters { + query: Some(query.clone()), + limit: Some(8), + content_type: Some(ContentType::Mod), + ..Default::default() + }, + &env, + ) + .await?; + + println!( + "Search {:?} for {query:?} - {} hit(s) (showing {}):\n", + provider_id, + page.total, + page.items.len() + ); + + for hit in &page.items { + println!( + " {} ({}) - {} downloads\n {}", + hit.name, hit.id, hit.downloads, hit.summary + ); + } + + Ok(()) +} + +fn parse_provider(name: &str) -> LauncherResult { + match name.to_lowercase().as_str() { + "modrinth" | "mr" => Ok(ProviderId::Modrinth), + "curseforge" | "cf" => Ok(ProviderId::CurseForge), + other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { + reason: format!("unknown provider {other:?}; use modrinth or curseforge"), + }), + } +} diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs new file mode 100644 index 00000000..e30678a6 --- /dev/null +++ b/packages/oneclient_core/src/auth/mod.rs @@ -0,0 +1,13 @@ +pub mod microsoft; +pub mod offline; + +pub use offline::{OfflineAccount, generate_offline_uuid}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AccountType { + Microsoft { refresh_token_key: String }, + Offline, +} diff --git a/packages/oneclient_core/src/auth/offline.rs b/packages/oneclient_core/src/auth/offline.rs new file mode 100644 index 00000000..5c3c507f --- /dev/null +++ b/packages/oneclient_core/src/auth/offline.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use uuid::{Builder, Uuid, Variant, Version}; +use md5::{Md5, Digest}; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfflineAccount { + pub username: String, + pub uuid: Uuid, + pub access_token: String, + pub user_type: String, +} + +impl OfflineAccount { + pub fn new(username: &str) -> Result { + let trimmed = username.trim(); + if trimmed.is_empty() { return Err(OfflineAuthError::EmptyUsername); } + if trimmed.len() > 16 { return Err(OfflineAuthError::UsernameTooLong); } + if !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(OfflineAuthError::InvalidCharacters); + } + + let uuid = generate_offline_uuid(trimmed); + Ok(Self { + username: trimmed.to_string(), + uuid, + access_token: "offline".to_string(), + user_type: "offline".to_string(), + }) + } +} + +pub fn generate_offline_uuid(username: &str) -> Uuid { + let mut hasher = Md5::new(); + hasher.update(format!("OfflinePlayer:{}", username).as_bytes()); + let result = hasher.finalize(); + + let mut builder = Builder::from_slice(&result).expect("MD5 is 16 bytes"); + builder.set_version(Version::Md5); + builder.set_variant(Variant::RFC4122); + builder.into_uuid() +} + +#[derive(Debug, Error)] +pub enum OfflineAuthError { + #[error("Username cannot be empty")] + EmptyUsername, + #[error("Username cannot exceed 16 characters")] + UsernameTooLong, + #[error("Invalid characters in username")] + InvalidCharacters, +} diff --git a/packages/oneclient_core/src/changelog.rs b/packages/oneclient_core/src/changelog.rs new file mode 100644 index 00000000..9d55a9de --- /dev/null +++ b/packages/oneclient_core/src/changelog.rs @@ -0,0 +1,62 @@ +use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::{EtagPolicy, fetch_cached}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangelogGroup { + pub version: String, + pub changes: Vec, +} + +pub fn parse_changelog(data: &str) -> Vec { + let mut groups = Vec::new(); + + for line in data.lines() { + if let Some(version) = line.strip_prefix("# ") { + groups.push(ChangelogGroup { + version: version.trim().to_string(), + changes: Vec::new(), + }); + } else if let Some(change) = line.strip_prefix("- ") { + if let Some(group) = groups.last_mut() { + group.changes.push(change.trim().to_string()); + } + } else if line.trim() == "###" { + break; + } + } + + groups +} + +#[tracing::instrument(level = "debug", skip(net))] +pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult { + let url = format!("{}/oneclient/CHANGE_LOG.md", net.config().meta_url_base); + let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md"); + + let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + .await? + .ok_or_else(|| LauncherError::InvalidSettingsProfile { + reason: "changelog is unavailable and not cached".to_string(), + })?; + + Ok(fetched.text()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_changelog_groups_versions_and_bullets() { + let data = "# 2.0.0\n\n- Faster UI\n- Bug fixes\n# 1.9.0\n- Older change\n###\nignored\n"; + let groups = parse_changelog(data); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].version, "2.0.0"); + assert_eq!(groups[0].changes, vec!["Faster UI", "Bug fixes"]); + assert_eq!(groups[1].version, "1.9.0"); + assert_eq!(groups[1].changes, vec!["Older change"]); + } +} diff --git a/packages/oneclient_core/src/clusters/migrate.rs b/packages/oneclient_core/src/clusters/migrate.rs new file mode 100644 index 00000000..fd9a07f3 --- /dev/null +++ b/packages/oneclient_core/src/clusters/migrate.rs @@ -0,0 +1,272 @@ +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_dao}; +use oneclient_db::models::ClusterRow; + +use crate::LauncherResult; +use crate::state::LauncherState; +use crate::versions::RemoteMigration; +use oneclient_common::domain::GameLoader; + +#[tracing::instrument(skip(state))] +pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult { + let rules = state.versions.migrations().await; + if rules.is_empty() { + return Ok(0); + } + + let mut migrated = 0; + + for rule in rules { + match apply_rule(state, &rule).await { + Ok(true) => migrated += 1, + Ok(false) => {} + Err(err) => { + tracing::warn!( + migration_id = %rule.id, + error = %err, + "cluster migration failed; leaving install untouched" + ); + } + } + } + + if migrated > 0 { + tracing::info!(migrated, "applied cluster migrations"); + } + + Ok(migrated) +} + +async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherResult { + let db = &state.services.db; + + if migration_dao::is_applied(db, &rule.id).await? { + return Ok(false); + } + + let Ok(loader) = GameLoader::from_str(&rule.from.loader) else { + tracing::warn!( + migration_id = %rule.id, + loader = %rule.from.loader, + "unknown loader in migration rule; retiring rule" + ); + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + }; + + if rule.from.mc_version == rule.to.mc_version { + tracing::warn!(migration_id = %rule.id, "migration is a no-op; retiring rule"); + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + } + + let Some(source) = + cluster_dao::find_by_version_loader(db, &rule.from.mc_version, loader as i64).await? + else { + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + }; + + if cluster_dao::find_by_version_loader(db, &rule.to.mc_version, loader as i64) + .await? + .is_some() + { + tracing::warn!( + migration_id = %rule.id, + from = %rule.from.mc_version, + to = %rule.to.mc_version, + "target cluster already exists; skipping migration" + ); + return Ok(false); + } + + if crate::game::is_running(state, source.id) { + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + "cluster is running; deferring migration" + ); + return Ok(false); + } + + migrate_cluster(state, rule, &source).await?; + + migration_dao::mark_applied(db, &rule.id).await?; + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + from = %rule.from.mc_version, + to = %rule.to.mc_version, + "migrated cluster" + ); + + Ok(true) +} + +async fn migrate_cluster( + state: &LauncherState, + rule: &RemoteMigration, + source: &ClusterRow, +) -> LauncherResult<()> { + let from = &rule.from.mc_version; + let to = &rule.to.mc_version; + + let clusters_dir = oneclient_common::paths::clusters_dir()?; + let old_dir = clusters_dir.join(&source.folder_name); + + let new_folder = resolve_new_folder(&clusters_dir, &source.folder_name, from, to).await?; + let new_name = retarget_version_prefix(&source.name, from, to); + + let mut renamed: Option<(PathBuf, PathBuf)> = None; + if let Some(folder) = &new_folder { + let new_dir = clusters_dir.join(folder); + if polyio::try_exists(&old_dir).await? { + polyio::rename(&old_dir, &new_dir).await?; + renamed = Some((old_dir.clone(), new_dir)); + } else { + tracing::warn!( + folder = %source.folder_name, + "cluster directory is missing on disk; migrating the row only" + ); + } + } + + let folder_for_db = folder_for_db( + &source.folder_name, + new_folder.as_deref(), + renamed.is_some(), + ); + + match cluster_dao::migrate_version( + &state.services.db, + source.id, + to, + new_name.as_deref(), + folder_for_db, + ) + .await + { + Ok(_) => Ok(()), + Err(err) => { + if let Some((old_dir, new_dir)) = renamed + && let Err(rollback) = polyio::rename(&new_dir, &old_dir).await + { + tracing::error!( + migration_id = %rule.id, + cluster_id = source.id, + from = ?new_dir, + to = ?old_dir, + error = %rollback, + "failed to roll back cluster directory rename after database error" + ); + } + Err(err.into()) + } + } +} + +fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: bool) -> &'a str { + match (renamed, new_folder) { + (true, Some(folder)) => folder, + _ => current, + } +} + +async fn resolve_new_folder( + clusters_dir: &Path, + folder_name: &str, + from: &str, + to: &str, +) -> LauncherResult> { + let Some(candidate) = retarget_version_prefix(folder_name, from, to) else { + tracing::debug!( + folder = %folder_name, + "folder name is not in generated form; leaving it in place" + ); + return Ok(None); + }; + + if polyio::try_exists(&clusters_dir.join(&candidate)).await? { + tracing::warn!( + folder = %folder_name, + candidate = %candidate, + "target folder already exists; leaving directory in place" + ); + return Ok(None); + } + + Ok(Some(candidate)) +} + +fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option { + let rest = value.strip_prefix(from)?; + if !rest.starts_with(' ') { + return None; + } + Some(format!("{to}{rest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retargets_generated_folder() { + assert_eq!( + retarget_version_prefix("26.1 fabric", "26.1", "26.1.2").as_deref(), + Some("26.1.2 fabric") + ); + } + + #[test] + fn retargets_deduplicated_folder() { + assert_eq!( + retarget_version_prefix("26.1 fabric (1)", "26.1", "26.1.2").as_deref(), + Some("26.1.2 fabric (1)") + ); + } + + #[test] + fn does_not_match_adjacent_minor() { + assert_eq!( + retarget_version_prefix("26.10 fabric", "26.1", "26.1.2"), + None + ); + } + + #[test] + fn leaves_custom_names_alone() { + assert_eq!( + retarget_version_prefix("my cool pack", "26.1", "26.1.2"), + None + ); + assert_eq!( + retarget_version_prefix("26.1fabric", "26.1", "26.1.2"), + None + ); + } + + #[test] + fn exact_version_with_no_suffix_is_not_retargeted() { + assert_eq!(retarget_version_prefix("26.1", "26.1", "26.1.2"), None); + } + + #[test] + fn row_claims_new_folder_only_when_the_directory_moved() { + assert_eq!( + folder_for_db("26.1 fabric", Some("26.1.2 fabric"), true), + "26.1.2 fabric" + ); + } + + #[test] + fn row_keeps_its_folder_when_the_rename_was_skipped() { + assert_eq!( + folder_for_db("26.1 fabric", Some("26.1.2 fabric"), false), + "26.1 fabric" + ); + assert_eq!(folder_for_db("my cool pack", None, false), "my cool pack"); + } +} diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs new file mode 100644 index 00000000..8c484704 --- /dev/null +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -0,0 +1,14 @@ +mod migrate; +pub(crate) mod prepare; +mod provision; +mod unlink_legacy; + +pub use migrate::apply_remote_migrations; +pub use prepare::{estimate_cluster_download, prepare_cluster, prepare_cluster_locked}; +pub use provision::{ensure_from_bundles, ensure_from_versions}; +pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; + +pub use oneclient_cluster::{ + Cluster, ClusterError, ClusterLinkTarget, ClusterManager, ClusterStage, ClusterUpdate, + CreateClusterOptions, +}; diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs new file mode 100644 index 00000000..52235603 --- /dev/null +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -0,0 +1,389 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use interfrost::api::minecraft::{DownloadType, VersionInfo}; +use interfrost::api::modded::SidedDataEntry; +use tokio::process::Command; + +use crate::game::{ + self, download_minecraft, download_version_info, get_loader_version, resolve_minecraft_version, +}; +use crate::state::{LauncherServices, LauncherState}; +use crate::{GameError, LauncherResult}; +use oneclient_cluster::Cluster; +use oneclient_cluster::ClusterError; +use oneclient_cluster::ClusterStage; +use oneclient_common::paths; +use oneclient_events::GroupedProgressSession; +use oneclient_java::JavaRuntime; +use oneclient_mc::MetadataStore; + +#[tracing::instrument(skip(state, shared_progress))] +pub async fn prepare_cluster_locked( + state: &Arc, + cluster_id: i64, + force: bool, + search_for_java: bool, + auto_install_java: bool, + shared_progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let mut metadata = state.metadata.lock().await; + prepare_cluster( + state, + &mut metadata, + cluster_id, + force, + search_for_java, + auto_install_java, + shared_progress, + ) + .await +} + +pub async fn prepare_cluster( + state: &Arc, + metadata: &mut MetadataStore, + cluster_id: i64, + force: bool, + search_for_java: bool, + auto_install_java: bool, + shared_progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + let continuing = cluster.stage == ClusterStage::Downloading; + + tracing::info!( + cluster_id, + mc_version = %cluster.mc_version, + force, + continuing, + "preparing cluster" + ); + + if !continuing { + state + .clusters + .set_stage(cluster_id, ClusterStage::Downloading) + .await?; + } + + let owned = shared_progress.is_none().then(|| { + GroupedProgressSession::start( + &state.services.events, + format!("Downloading game - {}", cluster.mc_version), + ) + }); + let progress = shared_progress.or(owned.as_ref()).expect("session present"); + + let result = install_cluster( + state, + metadata, + &cluster, + progress, + force, + search_for_java, + auto_install_java, + ) + .await; + + if let Some(owned) = owned { + owned.finish(); + } + + if let Err(err) = result { + if err.is_cancelled() { + tracing::info!( + cluster_id, + sentry = false, + error = %err, + "cluster preparation cancelled by the user" + ); + } else { + tracing::error!(cluster_id, error = %err, "cluster preparation failed"); + } + if !continuing { + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::NotReady) + .await; + } + return Err(err); + } + + let cluster = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await?; + tracing::debug!(cluster_id, "cluster stage set to Ready"); + Ok(cluster) +} + +const JRE_ESTIMATE_BYTES: u64 = 45_000_000; + +/// Bytes the game install still needs +/// Falls back to the manifest's full size +/// when the asset index isn't cached which is also when nothing is on disk +async fn game_download_bytes(services: &LauncherServices, info: &VersionInfo) -> u64 { + let Some(assets_index) = cached_assets_index(info).await else { + let client = info + .downloads + .get(&DownloadType::Client) + .map_or(0, |d| d.size as u64); + let libraries: u64 = info + .libraries + .iter() + .filter_map(|lib| lib.downloads.as_ref()) + .filter_map(|dl| dl.artifact.as_ref()) + .map(|artifact| artifact.size as u64) + .sum(); + return client + info.asset_index.total_size as u64 + libraries; + }; + + let _ = services; + // Runs before a Java runtime is resolved so rules are checked against the + // host architecture only natives-related edge cases shift + match game::plan_downloads(info, assets_index, std::env::consts::ARCH, false, false).await { + Ok(plan) => plan.total_bytes(), + Err(err) => { + tracing::warn!("download plan failed, estimating full size: {err}"); + info.asset_index.total_size as u64 + } + } +} + +async fn cached_assets_index( + info: &VersionInfo, +) -> Option { + let path = paths::assets_index_dir() + .ok()? + .join(format!("{}.json", info.asset_index.id)); + polyio::read_json(&path).await.ok() +} + +#[tracing::instrument(level = "debug", skip(state, bundles))] +pub async fn estimate_cluster_download( + state: &Arc, + cluster_id: i64, + bundles: &oneclient_content::bundles::BundlesManager, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let info = { + let mut metadata = state.metadata.lock().await; + let (version, _index, _updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + download_version_info( + &state.services.mc(), + None, + &version, + loader_version.as_ref(), + false, + ) + .await? + }; + + let mut total = game_download_bytes(&state.services, &info).await; + + if let Some(java) = &info.java_version { + let installed = state.java.list_runtimes().await.unwrap_or_default(); + if !installed.iter().any(|rt| rt.major == java.major_version) { + total += JRE_ESTIMATE_BYTES; + } + } + + total += oneclient_content::bundles::enabled_bundle_bytes( + cluster_id, + bundles, + &state.services.content(), + ) + .await + .unwrap_or(0); + + Ok(total) +} + +#[tracing::instrument(skip(state, metadata, cluster, progress), fields(cluster_id = cluster.id))] +async fn install_cluster( + state: &Arc, + metadata: &mut MetadataStore, + cluster: &Cluster, + progress: &GroupedProgressSession, + force: bool, + search_for_java: bool, + auto_install_java: bool, +) -> LauncherResult<()> { + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, cluster).await?; + + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let (version, _version_index, minecraft_updated) = + resolve_minecraft_version(metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + + let loader_version = get_loader_version( + metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + + let mut version_info = download_version_info( + &state.services.mc(), + Some(progress), + &version, + loader_version.as_ref(), + force, + ) + .await?; + + let java_major = version_info + .java_version + .as_ref() + .map(|v| v.major_version) + .ok_or(ClusterError::MissingJavaVersion)?; + + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? + { + runtime + } else { + state + .java + .prepare( + java_major, + search_for_java, + auto_install_java, + Some(progress), + ) + .await? + }; + + download_minecraft( + &state.services.mc(), + progress, + &version_info, + &java.os_arch, + minecraft_updated, + force, + ) + .await?; + + run_forge_processors(cluster, &mut version_info, &java).await?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(cluster, version_info, java), fields(cluster_id = cluster.id))] +async fn run_forge_processors( + cluster: &Cluster, + version_info: &mut VersionInfo, + java: &JavaRuntime, +) -> LauncherResult<()> { + let Some(processors) = &version_info.processors else { + return Ok(()); + }; + + let client = paths::versions_dir()? + .join(&version_info.id) + .join(format!("{}.jar", version_info.id)); + let libraries = paths::libraries_dir()?; + let cluster_dir = cluster.game_dir()?; + + let Some(data) = &mut version_info.data else { + return Ok(()); + }; + + macro_rules! data_entry { + ($dest:expr; $($name:literal: client => $client:expr, server => $server:expr;)+) => { + $(HashMap::insert( + $dest, + String::from($name), + SidedDataEntry { + client: String::from($client), + server: String::from($server), + }, + );)+ + }; + } + + data_entry! { + data; + "SIDE": + client => "client", + server => ""; + "MINECRAFT_JAR": + client => client.to_string_lossy(), + server => ""; + "MINECRAFT_VERSION": + client => cluster.mc_version.clone(), + server => ""; + "ROOT": + client => cluster_dir.to_string_lossy(), + server => ""; + "LIBRARY_DIR": + client => libraries.to_string_lossy(), + server => ""; + } + + let total = processors.len(); + for (index, processor) in processors.iter().enumerate() { + if let Some(sides) = &processor.sides + && !sides.contains(&String::from("client")) + { + continue; + } + + let mut cp = processor.classpath.clone(); + cp.push(processor.jar.clone()); + + let processor_jar = game::get_library(&libraries, &processor.jar, false)?; + let main = game::main_class(&processor_jar) + .await? + .ok_or_else(|| GameError::ProcessorMainClass(processor.jar.clone()))?; + + let output = Command::new(&java.absolute_path) + .arg("-cp") + .arg(game::get_classpath_library(&libraries, &cp)?) + .arg(&main) + .args(game::processor_arguments( + &libraries, + &processor.args, + data, + )?) + .output() + .await?; + + if !output.status.success() { + return Err(GameError::ProcessorFailed( + String::from_utf8_lossy(&output.stderr).into_owned(), + ) + .into()); + } + + tracing::debug!( + "ran forge processor {}/{} for {}", + index + 1, + total, + version_info.id + ); + } + + Ok(()) +} diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs new file mode 100644 index 00000000..a4fde317 --- /dev/null +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -0,0 +1,140 @@ +use std::str::FromStr; + +use oneclient_db::dao::{bundle as bundle_dao, cluster as cluster_dao}; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::GameLoader; +use oneclient_common::version::format_mc_version; + +use oneclient_cluster::Cluster; +use oneclient_cluster::CreateClusterOptions; + +#[tracing::instrument(skip(state))] +pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult> { + let groups = bundle_dao::list_distinct_version_loaders(&state.services.db).await?; + let mut created = Vec::new(); + + for group in groups { + let Some(loader) = GameLoader::from_repr(group.mc_loader as u8) else { + tracing::warn!( + mc_version = %group.mc_version, + mc_loader = group.mc_loader, + "skipping bundle group with unknown loader" + ); + continue; + }; + + if cluster_dao::find_by_version_loader( + &state.services.db, + &group.mc_version, + group.mc_loader, + ) + .await? + .is_some() + { + continue; + } + + let mc_version = group.mc_version.clone(); + let name = format!("{mc_version} {loader}"); + let global = state.settings.read().global_game_settings.clone(); + match state + .clusters + .create_provisioned( + &global, + CreateClusterOptions::new(name, mc_version.clone(), loader), + ) + .await + { + Ok(Some(cluster)) => { + tracing::info!( + cluster_id = cluster.id, + mc_version = %cluster.mc_version, + loader = %cluster.mc_loader, + "created cluster from bundle catalog" + ); + created.push(cluster); + } + Ok(None) => {} + Err(err) => { + tracing::warn!( + mc_version = %mc_version, + loader = %loader, + error = %err, + "failed to create cluster for bundle group" + ); + } + } + } + + Ok(created) +} + +#[tracing::instrument(skip(state))] +pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult> { + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; + let mut created = Vec::new(); + + for entry in metadata { + let Some(minor) = entry.minor_version else { + continue; + }; + let Some(loader_str) = entry.loader.as_deref() else { + continue; + }; + let Ok(loader) = GameLoader::from_str(loader_str) else { + tracing::warn!( + major = entry.major_version, + minor, + loader = loader_str, + "skipping versions entry with unknown loader" + ); + continue; + }; + + let mc_version = format_mc_version(entry.major_version, minor, entry.patch_version); + + if cluster_dao::find_by_version_loader(&state.services.db, &mc_version, loader as i64) + .await? + .is_some() + { + continue; + } + + let name = format!("{mc_version} {loader}"); + let global = state.settings.read().global_game_settings.clone(); + match state + .clusters + .create_provisioned( + &global, + CreateClusterOptions::new(name, mc_version.clone(), loader), + ) + .await + { + Ok(Some(cluster)) => { + tracing::info!( + cluster_id = cluster.id, + mc_version = %cluster.mc_version, + loader = %cluster.mc_loader, + "created cluster from versions manifest" + ); + created.push(cluster); + } + Ok(None) => {} + Err(err) => { + tracing::warn!( + mc_version = %mc_version, + loader = %loader, + error = %err, + "failed to create cluster for versions entry" + ); + } + } + } + + Ok(created) +} diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs new file mode 100644 index 00000000..655516c4 --- /dev/null +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -0,0 +1,182 @@ +use std::path::Path; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; + +const SWEPT_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +#[derive(Debug, Default, Clone, Copy)] +pub struct SweepReport { + pub removed: usize, + pub adopted: usize, + pub skipped: usize, +} + +/// User-triggered only the "hash matches a cached artifact so it is ours" rule +/// is true during the transition and false once cluster folders hold user content +#[tracing::instrument(skip(state))] +pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { + let mut report = SweepReport::default(); + + for cluster in state.clusters.list().await? { + let dedicated = cluster.uses_dedicated_dir(); + let Ok(cluster_root) = cluster.dir() else { + continue; + }; + + if dedicated { + adopt_dedicated(state, &cluster, &cluster_root, &mut report).await; + } else { + sweep_shared(state, &cluster_root, &mut report).await; + } + } + + if report.removed > 0 || report.adopted > 0 { + tracing::info!( + removed = report.removed, + adopted = report.adopted, + skipped = report.skipped, + "cleaned up legacy cluster content links" + ); + } + + Ok(report) +} + +async fn sweep_shared(state: &LauncherState, cluster_root: &Path, report: &mut SweepReport) { + for content_type in SWEPT_TYPES { + let dir = cluster_root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + // A symlink here is unambiguously ours nothing else writes links here + if file_type.is_symlink() { + polyio::remove_file(&path).await.ok(); + report.removed += 1; + continue; + } + + if !file_type.is_file() { + continue; + } + + match cached_artifact_for(state, &path).await { + Some(CacheMatch::Elsewhere) => { + if polyio::remove_file(&path).await.is_ok() { + report.removed += 1; + } + } + // Cache row points at this very file (older recovery pass stored it + // in place) deleting it would destroy the only copy + Some(CacheMatch::IsTheArtifact) => { + tracing::debug!( + path = %path.display(), + "leaving cluster file in place; it is the artifact's only copy" + ); + report.skipped += 1; + } + None => report.skipped += 1, + } + } + } +} + +/// Writes a manifest so later launches can tell adopted content from user files +async fn adopt_dedicated( + state: &LauncherState, + cluster: &crate::clusters::Cluster, + cluster_root: &Path, + report: &mut SweepReport, +) { + if manifest::load(cluster_root).await.is_some() { + return; + } + + let linked = match oneclient_content::packages::PackageStore::list_linked_artifacts( + cluster.id, + &state.services.content(), + ) + .await + { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(cluster_id = cluster.id, error = %err, "cannot adopt dedicated content"); + return; + } + }; + + let mut entries = Vec::new(); + for link in linked { + if !link.enabled || !SWEPT_TYPES.contains(&link.content_type) { + continue; + } + + let relative = + manifest::entry_path(link.content_type.folder_name(), &link.cluster_file_name); + if polyio::symlink_metadata(cluster_root.join(&relative)) + .await + .is_err() + { + continue; + } + + entries.push(ManifestEntry { + path: relative, + hash: link.hash, + }); + } + + report.adopted += entries.len(); + manifest::save( + cluster_root, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; +} + +enum CacheMatch { + /// Cache holds this content at its own path the cluster copy is a link + Elsewhere, + /// The artifact row points at this exact file + IsTheArtifact, +} + +async fn cached_artifact_for(state: &LauncherState, path: &Path) -> Option { + let hash = polyio::sha1_file(path).await.ok()?; + let row = + artifact_dao::get_artifact_by_hash(&state.services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten()?; + + let canonical = artifact_absolute_path(&row.path).ok()?; + if same_file(&canonical, path) { + Some(CacheMatch::IsTheArtifact) + } else { + Some(CacheMatch::Elsewhere) + } +} + +fn same_file(a: &Path, b: &Path) -> bool { + match (polyio::canonicalize(a), polyio::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs new file mode 100644 index 00000000..be7d8d26 --- /dev/null +++ b/packages/oneclient_core/src/dev.rs @@ -0,0 +1,324 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_events::{ + Event, EventBus, GameEvent, GroupedProgressEvent, Level, Notification, ProgressEvent, +}; +use oneclient_net::RequestClient; + +use crate::{LauncherResult, LauncherServices, LauncherState}; + +struct GroupedSessionUi { + title: String, + parent: ProgressBar, + children: HashMap, + child_bytes: HashMap, +} + +fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { + tokio::spawn(async move { + let mp = MultiProgress::new(); + let mut progress_bars: HashMap = HashMap::new(); + let mut grouped_sessions: HashMap = HashMap::new(); + + let flat_style = ProgressStyle::with_template( + "{spinner:.green} {msg:<20} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})", + ) + .unwrap() + .progress_chars("#>-"); + + let parent_style = ProgressStyle::with_template( + "{spinner:.green} {msg:<30} [{elapsed_precise}] [{wide_bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})", + ) + .unwrap() + .progress_chars("#>-"); + + let child_style = ProgressStyle::with_template( + " {spinner:.cyan} {msg:<38} [{bar:32.cyan/blue}] {bytes}/{total_bytes}", + ) + .unwrap() + .progress_chars("#>-"); + + while let Some(event) = rx.recv().await { + match event { + Event::Notification(Notification::Message(message)) => { + let (title, body) = (message.title, message.body); + mp.suspend(|| match message.level { + Level::Info => tracing::info!(%title, %body), + Level::Error => tracing::error!(%title, %body), + }); + } + Event::Progress(ProgressEvent::Update { + id, + label, + current, + total, + }) => { + if current >= total { + if let Some(pb) = progress_bars.remove(&id) { + pb.finish_with_message(format!("{label} Done!")); + } + } else { + let pb = progress_bars.entry(id).or_insert_with(|| { + let pb = mp.add(ProgressBar::new(total)); + pb.set_style(flat_style.clone()); + pb.set_message(label.clone()); + pb + }); + + pb.set_length(total); + pb.set_position(current); + } + } + Event::Progress(ProgressEvent::Complete { id, title, body }) => { + if let Some(pb) = progress_bars.remove(&id) { + pb.finish_with_message(format!("{title}: {body}")); + } else { + mp.suspend(|| tracing::info!(%title, %body)); + } + } + Event::Progress(ProgressEvent::Grouped(event)) => match event { + GroupedProgressEvent::Start { session_id, title } => { + let parent = mp.add(ProgressBar::new(1)); + parent.set_style(parent_style.clone()); + parent.set_message(title.clone()); + + grouped_sessions.insert( + session_id, + GroupedSessionUi { + title, + parent, + children: HashMap::new(), + child_bytes: HashMap::new(), + }, + ); + } + GroupedProgressEvent::Expect { .. } => {} + GroupedProgressEvent::AddChild { + session_id, + child_id, + label, + total, + .. + } => { + let Some(session) = grouped_sessions.get_mut(&session_id) else { + continue; + }; + + let child = ProgressBar::new(total); + child.set_style(child_style.clone()); + child.set_message(label.clone()); + mp.add(child.clone()); + + session.children.insert(child_id, child); + session.child_bytes.insert(child_id, (0, total)); + refresh_grouped_parent(session); + } + GroupedProgressEvent::UpdateChild { + session_id, + child_id, + current, + total, + } => { + let Some(session) = grouped_sessions.get_mut(&session_id) else { + continue; + }; + + if let Some(child) = session.children.get(&child_id) { + child.set_length(total); + child.set_position(current); + } + session.child_bytes.insert(child_id, (current, total)); + refresh_grouped_parent(session); + } + GroupedProgressEvent::SetChildPhase { + session_id, + child_id, + phase, + } => { + if let Some(session) = grouped_sessions.get(&session_id) + && let Some(child) = session.children.get(&child_id) + { + child.set_prefix(phase.label()); + } + } + GroupedProgressEvent::FinishChild { + session_id, + child_id, + } => { + let Some(session) = grouped_sessions.get_mut(&session_id) else { + continue; + }; + + if let Some(child) = session.children.remove(&child_id) { + child.finish_and_clear(); + } + session.child_bytes.remove(&child_id); + refresh_grouped_parent(session); + } + GroupedProgressEvent::End { session_id } => { + if let Some(session) = grouped_sessions.remove(&session_id) { + session + .parent + .finish_with_message(format!("{} - complete", session.title)); + } + } + }, + Event::Signal(_) => {} + Event::Game(GameEvent::Stage { cluster_id, stage }) => { + mp.suspend(|| tracing::info!(cluster_id, ?stage, "game stage")); + } + Event::Game(GameEvent::Log { line, .. }) => { + mp.suspend(|| tracing::info!("[game] {line}")); + } + Event::Game(GameEvent::Failed { + cluster_id, + message, + }) => { + mp.suspend(|| tracing::error!(cluster_id, "launch failed: {message}")); + } + // No TTY dialog here answer `None` so the waiting task fails + // cleanly instead of hanging on a prompt nobody can answer + Event::Notification(Notification::Prompt(request)) => { + let choices: Vec<&str> = request.choices.iter().map(|c| c.id).collect(); + mp.suspend(|| { + tracing::warn!( + "{}: {} | choices: {choices:?}", + request.title, + request.body + ); + tracing::warn!("prompts are not interactive in dev; dismissing"); + }); + let _ = request.reply.send(None); + } + } + } + }); +} + +fn refresh_grouped_parent(session: &mut GroupedSessionUi) { + let (current, total) = session + .child_bytes + .values() + .fold((0u64, 0u64), |(cur, tot), (c, t)| (cur + c, tot + t)); + + let total = total.max(1); + session.parent.set_length(total); + session.parent.set_position(current.min(total)); +} + +pub async fn initialize() -> LauncherResult> { + let (tx, rx) = mpsc::unbounded_channel(); + + spawn_notification_handler(rx); + + LauncherState::new(EventBus::new(tx)).await +} + +async fn ephemeral_root() -> LauncherResult { + let path = std::env::current_dir() + .map_err(crate::LauncherError::StdIoError)? + .join("target") + .join(format!("ephemeral-{}", Uuid::new_v4())); + + polyio::create_dir_all(&path).await?; + + Ok(path) +} + +pub async fn ephemeral_state() -> LauncherResult> { + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); + + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); + + let db = oneclient_db::connect(root.join("example.db")).await?; + let settings = crate::settings::store::load_settings(None).await; + + let services = LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }; + let auth = Arc::new(oneclient_auth::AuthService::with_store( + Default::default(), + services.requester.clone(), + services.events.clone(), + )); + + let clusters = crate::clusters::ClusterManager::new(services.db.clone()); + + let java = oneclient_java::JavaService::new( + Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); + + Ok(Arc::new(LauncherState { + services, + auth, + java, + clusters, + settings: parking_lot::RwLock::new(settings), + metadata: tokio::sync::Mutex::new(oneclient_mc::MetadataStore::new()), + bundles: Arc::new(oneclient_content::bundles::BundlesManager::new()), + versions: Arc::new(crate::versions::VersionsManager::new()), + images: crate::images::ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord: oneclient_discord::DiscordRpc::spawn(false), + })) +} + +pub async fn ephemeral_services() -> LauncherResult { + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); + let db = oneclient_db::connect(root.join("example.db")).await?; + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); + + Ok(LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }) +} + +pub async fn seed_bundle_archive( + state: &LauncherState, + manifest: oneclient_content::bundles::BundleManifest, +) -> LauncherResult<()> { + let disk_path = format!("bundles/{}.mrpack", manifest.name); + let loader = manifest.loader as i64; + + oneclient_db::dao::bundle::upsert_bundle( + &state.services.db, + oneclient_db::models::NewBundle { + remote_path: &disk_path, + mc_version: &manifest.mc_version, + mc_loader: loader, + file_name: &format!("{}.mrpack", manifest.name), + name: Some(&manifest.name), + version_id: Some(&manifest.version_id), + category: Some(&manifest.category), + loader_version: Some(&manifest.loader_version), + disk_path: &disk_path, + hidden: false, + etag: None, + synced_at: None, + }, + ) + .await?; + + let path = oneclient_common::paths::launcher_dir()?.join(&disk_path); + state.bundles.cache_archive_manifest(path, manifest).await; + + Ok(()) +} diff --git a/packages/oneclient_core/src/error.rs b/packages/oneclient_core/src/error.rs new file mode 100644 index 00000000..32b61259 --- /dev/null +++ b/packages/oneclient_core/src/error.rs @@ -0,0 +1,272 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LauncherError { + #[error("Unable to parse JSON: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("Database execution failed: {0}")] + SqlError(#[from] sqlx::Error), + + #[error(transparent)] + IoError(#[from] polyio::IOError), + + #[error(transparent)] + StdIoError(#[from] std::io::Error), + + #[error(transparent)] + RequestError(#[from] oneclient_net::RequestError), + + #[error(transparent)] + PathsError(#[from] oneclient_common::PathsError), + + #[error(transparent)] + DbError(#[from] oneclient_db::DbError), + + #[error("launcher core is not initialized")] + NotInitialized, + + #[error("launcher core is already initialized")] + AlreadyInitialized, + + #[error("invalid settings profile: {reason}")] + InvalidSettingsProfile { reason: String }, + + #[error(transparent)] + JavaError(#[from] oneclient_java::JavaError), + + #[error(transparent)] + UrlError(#[from] url::ParseError), + + #[error(transparent)] + EventError(#[from] oneclient_events::EventError), + + #[error(transparent)] + PackageError(#[from] oneclient_content::packages::PackageError), + + #[error(transparent)] + AuthError(#[from] oneclient_auth::AuthError), + + #[error(transparent)] + ClusterError(#[from] crate::clusters::ClusterError), + + #[error(transparent)] + McError(#[from] oneclient_mc::McError), + + #[error(transparent)] + ContentError(#[from] oneclient_content::ContentError), + + #[error(transparent)] + BundleError(#[from] oneclient_content::bundles::BundleError), + + #[error(transparent)] + GameError(#[from] crate::game::GameError), + + #[error(transparent)] + LogsError(#[from] oneclient_cluster::logs::LogsError), + + #[error(transparent)] + ScreenshotsError(#[from] oneclient_cluster::screenshots::ScreenshotsError), + + #[error("minecraft: {0}")] + Minecraft(String), +} + +impl LauncherError { + #[must_use] + pub fn auth_guidance(&self) -> Option { + match self { + LauncherError::AuthError(oneclient_auth::AuthError::Minecraft(err)) => { + oneclient_auth::diagnose_auth_error(err) + } + _ => None, + } + } + + /// Not something to report the dialog is already gone by the time it lands + #[must_use] + pub fn is_login_cancelled(&self) -> bool { + matches!( + self, + LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled) + ) + } + + /// A dialog the user backed out of rather than anything that went wrong + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.is_login_cancelled() + || matches!( + self, + LauncherError::JavaError(oneclient_java::JavaError::Cancelled) + ) + } + + /// Whether the install is missing pieces and so is worth repairing + #[must_use] + pub fn indicates_missing_files(&self) -> bool { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self); + + // Walk the chain the `io::Error` carrying the kind is usually wrapped + // two or three layers deep by the time this surfaces + while let Some(err) = source { + if let Some(io) = err.downcast_ref::() + && io.kind() == std::io::ErrorKind::NotFound + { + return true; + } + source = err.source(); + } + + false + } +} + +pub trait SentryExclusion { + fn is_sentry_excluded(&self) -> bool { + false + } +} + +impl SentryExclusion for LauncherError { + fn is_sentry_excluded(&self) -> bool { + match self { + LauncherError::StdIoError(e) => e.is_sentry_excluded(), + LauncherError::IoError(e) => e.is_sentry_excluded(), + LauncherError::RequestError(e) => e.is_transient(), + LauncherError::JavaError(e) => e.is_transient(), + _ => false, + } + } +} + +impl SentryExclusion for std::io::Error { + fn is_sentry_excluded(&self) -> bool { + use std::io::ErrorKind; + + // `ErrorKind::StorageFull` is still unstable so match raw OS codes + // Platform-gated 112 is the unrelated EHOSTDOWN on Linux + if let Some(code) = self.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + // ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL + return true; + } + let _ = code; + } + + matches!( + self.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } +} + +impl SentryExclusion for reqwest::Error { + fn is_sentry_excluded(&self) -> bool { + // Connectivity problems are the user's network not a launcher bug + self.is_timeout() || self.is_connect() + } +} + +impl SentryExclusion for polyio::IOError { + fn is_sentry_excluded(&self) -> bool { + match self { + polyio::IOError::IOError(source) | polyio::IOError::PathIOError { source, .. } => { + source.is_sentry_excluded() + } + _ => false, + } + } +} + +pub type LauncherResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + /// Raw OS error rather than a synthesized `ErrorKind` same kind but only + /// this one renders the `(os error 2)` text users report + fn not_found(path: &str) -> LauncherError { + LauncherError::IoError(polyio::IOError::PathIOError { + source: std::io::Error::from_raw_os_error(2), + path: path.to_string(), + }) + } + + #[test] + fn a_missing_asset_directory_asks_for_a_repair() { + let path = "/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets"; + let err = not_found(path); + + assert!(err.indicates_missing_files(), "{err}"); + assert!(err.to_string().contains(path), "{err}"); + } + + #[test] + fn a_not_found_nested_several_layers_deep_is_still_found() { + let err = LauncherError::McError(oneclient_mc::McError::Io(polyio::IOError::PathIOError { + source: std::io::Error::from(std::io::ErrorKind::NotFound), + path: "metadata/libraries/foo.jar".to_string(), + })); + + assert!(err.indicates_missing_files(), "{err}"); + } + + #[test] + fn dismissing_a_dialog_is_a_cancellation_not_a_failure() { + assert!(LauncherError::JavaError(oneclient_java::JavaError::Cancelled).is_cancelled()); + assert!(LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled).is_cancelled()); + } + + /// The whole point of the split anything that is not a dismissal keeps + /// reporting at `error` + #[test] + fn real_java_failures_are_not_cancellations() { + for err in [ + oneclient_java::JavaError::MissingJava, + oneclient_java::JavaError::PackageNotFound { major: 21 }, + oneclient_java::JavaError::InvalidJavaPath { + path: "/opt/not-java".to_string(), + }, + oneclient_java::JavaError::VersionMismatch { + expected: 21, + found: 8, + }, + ] { + let err = LauncherError::JavaError(err); + assert!(!err.is_cancelled(), "{err}"); + } + + assert!(!LauncherError::NotInitialized.is_cancelled()); + assert!(!not_found("metadata/assets").is_cancelled()); + } + + #[test] + fn failures_a_repair_could_not_fix_are_left_alone() { + for kind in [ + std::io::ErrorKind::PermissionDenied, + std::io::ErrorKind::ConnectionRefused, + std::io::ErrorKind::TimedOut, + ] { + let err = LauncherError::IoError(polyio::IOError::PathIOError { + source: std::io::Error::from(kind), + path: "metadata/assets".to_string(), + }); + assert!(!err.indicates_missing_files(), "{kind:?} should not repair"); + } + + assert!(!LauncherError::NotInitialized.indicates_missing_files()); + assert!(!LauncherError::Minecraft("bad manifest".into()).indicates_missing_files()); + } +} diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs new file mode 100644 index 00000000..a62bcb00 --- /dev/null +++ b/packages/oneclient_core/src/game/analytics.rs @@ -0,0 +1,348 @@ +use std::collections::HashMap; +use std::net::IpAddr; + +use chrono::{DateTime, Datelike, Local, Timelike}; +use oneclient_db::dao::game_session as session_dao; +use oneclient_db::models::{GameSessionServerRow, SessionSpan}; + +use crate::error::LauncherResult; + +const NIGHT_HOURS: [usize; 8] = [22, 23, 0, 1, 2, 3, 4, 5]; +const NIGHT_OWL_SHARE: f64 = 0.35; +const GAMER_SECS_PER_DAY: f64 = 5.0 * 3600.0; + +#[derive(Debug, Clone, PartialEq)] +pub struct DayPlaytime { + pub date: String, + pub secs: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Persona { + NightOwl, + Gamer, +} + +impl Persona { + pub fn title(self) -> &'static str { + match self { + Persona::NightOwl => "Night Owl", + Persona::Gamer => "Gamer", + } + } + + pub fn description(self) -> &'static str { + match self { + Persona::NightOwl => "Most of your playtime happens after dark.", + Persona::Gamer => "You average over 5 hours on the days you play.", + } + } +} + +pub const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +#[derive(Debug, Clone, PartialEq)] +pub struct PlaytimeStats { + pub total_secs: i64, + pub session_count: usize, + pub per_weekday: [i64; 7], + pub per_hour: [i64; 24], + pub daily: Vec, + pub session_secs: Vec, + pub active_days: usize, + pub avg_secs_per_active_day: f64, + pub peak_hour: Option, + pub peak_weekday: Option, + pub night_share: f64, + pub personas: Vec, +} + +impl PlaytimeStats { + pub fn from_spans(spans: &[SessionSpan]) -> Self { + let mut per_weekday = [0i64; 7]; + let mut per_hour = [0i64; 24]; + let mut total_secs = 0i64; + let mut night_secs = 0i64; + let mut session_count = 0usize; + let mut daily: Vec = Vec::new(); + let mut session_secs: Vec = Vec::new(); + + for span in spans { + let Some(ended) = span.ended_at.as_deref() else { + continue; + }; + let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&span.started_at), + DateTime::parse_from_rfc3339(ended), + ) else { + continue; + }; + + let secs = (end - start).num_seconds(); + if secs <= 0 { + continue; + } + + let local = start.with_timezone(&Local); + let hour = local.hour() as usize; + let weekday = local.weekday().num_days_from_monday() as usize; + + total_secs += secs; + session_count += 1; + session_secs.push(secs); + per_hour[hour] += secs; + per_weekday[weekday] += secs; + if NIGHT_HOURS.contains(&hour) { + night_secs += secs; + } + + let date = local.format("%Y-%m-%d").to_string(); + match daily.last_mut() { + Some(last) if last.date == date => last.secs += secs, + _ => daily.push(DayPlaytime { date, secs }), + } + } + + let active_days = daily.len(); + let avg_secs_per_active_day = if active_days > 0 { + total_secs as f64 / active_days as f64 + } else { + 0.0 + }; + let night_share = if total_secs > 0 { + night_secs as f64 / total_secs as f64 + } else { + 0.0 + }; + + let peak_hour = argmax(&per_hour); + let peak_weekday = argmax(&per_weekday); + + let mut personas = Vec::new(); + if total_secs > 0 && night_share >= NIGHT_OWL_SHARE { + personas.push(Persona::NightOwl); + } + if avg_secs_per_active_day > GAMER_SECS_PER_DAY { + personas.push(Persona::Gamer); + } + + Self { + total_secs, + session_count, + per_weekday, + per_hour, + daily, + session_secs, + active_days, + avg_secs_per_active_day, + peak_hour, + peak_weekday, + night_share, + personas, + } + } +} + +fn argmax(buckets: &[i64]) -> Option { + buckets + .iter() + .enumerate() + .filter(|&(_, &v)| v > 0) + .max_by_key(|&(_, &v)| v) + .map(|(i, _)| i) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerStat { + pub address: String, + pub port: Option, + pub joins: i64, + pub total_secs: i64, + pub last_played: Option, + pub is_ip: bool, +} + +fn address_is_ip(address: &str) -> bool { + address.parse::().is_ok() +} + +pub fn aggregate_servers(rows: &[GameSessionServerRow]) -> Vec { + struct Acc { + port: Option, + joins: i64, + total_secs: i64, + last_played: Option, + } + + let mut map: HashMap = HashMap::new(); + for row in rows { + let acc = map.entry(row.address.clone()).or_insert(Acc { + port: row.port, + joins: 0, + total_secs: 0, + last_played: None, + }); + acc.joins += 1; + + if let Some(ended) = row.disconnected_at.as_deref() + && let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&row.joined_at), + DateTime::parse_from_rfc3339(ended), + ) + { + let secs = (end - start).num_seconds(); + if secs > 0 { + acc.total_secs += secs; + } + } + + if acc + .last_played + .as_deref() + .is_none_or(|prev| row.joined_at.as_str() > prev) + { + acc.last_played = Some(row.joined_at.clone()); + acc.port = row.port; + } + } + + let mut servers: Vec = map + .into_iter() + .map(|(address, acc)| ServerStat { + is_ip: address_is_ip(&address), + address, + port: acc.port, + joins: acc.joins, + total_secs: acc.total_secs, + last_played: acc.last_played, + }) + .collect(); + + servers.sort_by(|a, b| { + b.total_secs + .cmp(&a.total_secs) + .then(b.joins.cmp(&a.joins)) + .then(a.address.cmp(&b.address)) + }); + servers +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Analytics { + pub playtime: PlaytimeStats, + pub servers: Vec, +} + +#[tracing::instrument(level = "debug", skip(db))] +pub async fn global_analytics(db: &oneclient_db::DbPool) -> LauncherResult { + let spans = session_dao::all_session_spans(db).await?; + let servers = session_dao::all_session_servers(db).await?; + Ok(Analytics { + playtime: PlaytimeStats::from_spans(&spans), + servers: aggregate_servers(&servers), + }) +} + +#[tracing::instrument(level = "debug", skip(db))] +pub async fn cluster_analytics( + db: &oneclient_db::DbPool, + cluster_id: i64, +) -> LauncherResult { + let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; + let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; + Ok(Analytics { + playtime: PlaytimeStats::from_spans(&spans), + servers: aggregate_servers(&servers), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn span(start: &str, end: &str) -> SessionSpan { + SessionSpan { + started_at: start.to_string(), + ended_at: Some(end.to_string()), + } + } + + #[test] + fn empty_has_no_personas() { + let stats = PlaytimeStats::from_spans(&[]); + assert_eq!(stats.total_secs, 0); + assert!(stats.personas.is_empty()); + assert_eq!(stats.peak_hour, None); + } + + #[test] + fn skips_open_and_negative_spans() { + let spans = vec![ + SessionSpan { + started_at: "2026-01-01T10:00:00+00:00".into(), + ended_at: None, + }, + span("2026-01-01T10:00:00+00:00", "2026-01-01T09:00:00+00:00"), + ]; + let stats = PlaytimeStats::from_spans(&spans); + assert_eq!(stats.session_count, 0); + assert_eq!(stats.total_secs, 0); + } + + fn server(address: &str, joined: &str, disconnected: Option<&str>) -> GameSessionServerRow { + GameSessionServerRow { + session_started_at: "s".into(), + address: address.into(), + port: Some(25565), + joined_at: joined.into(), + disconnected_at: disconnected.map(str::to_string), + } + } + + #[test] + fn ip_addresses_are_flagged_domains_are_not() { + assert!(address_is_ip("192.168.1.10")); + assert!(address_is_ip("::1")); + assert!(!address_is_ip("mc.hypixel.net")); + assert!(!address_is_ip("play.example.com")); + } + + #[test] + fn aggregate_servers_sums_time_and_counts_joins() { + let rows = vec![ + server( + "mc.hypixel.net", + "2026-01-01T10:00:00+00:00", + Some("2026-01-01T11:00:00+00:00"), + ), + server("mc.hypixel.net", "2026-01-02T10:00:00+00:00", None), + server( + "192.168.1.10", + "2026-01-01T12:00:00+00:00", + Some("2026-01-01T12:30:00+00:00"), + ), + ]; + + let servers = aggregate_servers(&rows); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].address, "mc.hypixel.net"); + assert_eq!(servers[0].joins, 2); + assert_eq!(servers[0].total_secs, 3600); + assert!(!servers[0].is_ip); + assert_eq!(servers[1].address, "192.168.1.10"); + assert_eq!(servers[1].joins, 1); + assert_eq!(servers[1].total_secs, 1800); + assert!(servers[1].is_ip); + } + + #[test] + fn gamer_needs_five_hour_average() { + let spans = vec![span( + "2026-01-01T12:00:00+00:00", + "2026-01-01T18:00:00+00:00", + )]; + let stats = PlaytimeStats::from_spans(&spans); + assert_eq!(stats.active_days, 1); + assert!(stats.personas.contains(&Persona::Gamer)); + } +} diff --git a/packages/oneclient_core/src/game/diagnosis.rs b/packages/oneclient_core/src/game/diagnosis.rs new file mode 100644 index 00000000..ddbf42cb --- /dev/null +++ b/packages/oneclient_core/src/game/diagnosis.rs @@ -0,0 +1,204 @@ +//! Minecraft's exit code says only that it died the log says why and some +//! reasons are ones the launcher can fix + +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CrashDiagnosis { + /// A jar the JVM could not read on the classpath this is nearly always a + /// damaged library or mod which verification repairs + CorruptArchive { + /// Many of these messages carry no path so this is a bonus rather than + /// something to depend on + file: Option, + }, +} + +impl CrashDiagnosis { + #[must_use] + pub fn body(&self) -> String { + match self { + Self::CorruptArchive { file: Some(file) } => format!( + "The game could not read {file} — the file is damaged. \ + Verifying will re-download anything that does not match." + ), + Self::CorruptArchive { file: None } => "The game could not read one of its \ + library or mod files — it is damaged. Verifying will re-download \ + anything that does not match." + .to_string(), + } + } +} + +/// Different sources printing the same underlying damage they share a remedy +/// so they share a diagnosis +const CORRUPT_ARCHIVE_MARKERS: [&str; 4] = [ + "java.util.zip.ZipException", + "java.util.zip.ZipError", + "Invalid or corrupt jarfile", + "zip END header not found", +]; + +/// Runs against every line the game prints so it stays substring scans rather +/// than anything that parses the line +#[must_use] +pub fn diagnose(line: &str) -> Option { + if !CORRUPT_ARCHIVE_MARKERS + .iter() + .any(|marker| line.contains(marker)) + { + return None; + } + + Some(CrashDiagnosis::CorruptArchive { file: jar_in(line) }) +} + +/// Opportunistic some JVMs omit the path entirely and a missing name still +/// leads to the same repair +fn jar_in(line: &str) -> Option { + let token = line + .split(|c: char| c.is_whitespace() || c == '(' || c == ')') + .find(|token| token.trim_end_matches(['.', ',', ':']).ends_with(".jar"))?; + + let token = token.trim_end_matches(['.', ',', ':']); + + // The full path is noise in a notification the file name is what the user + // recognises + let name = token + .rsplit(['/', '\\']) + .next() + .unwrap_or(token) + .to_string(); + + (!name.is_empty()).then_some(name) +} + +/// Keeps the first recognised cause not the last a corrupt jar cascades into +/// unrelated failures so the earliest line is closest to the root cause +#[derive(Clone, Default)] +pub(crate) struct CrashWatch { + found: Arc>>, +} + +impl CrashWatch { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn observe(&self, line: &str) { + // Cheap rejection first this runs on every line the game prints + if self.found.lock().is_ok_and(|found| found.is_some()) { + return; + } + + if let Some(diagnosis) = diagnose(line) + && let Ok(mut found) = self.found.lock() + && found.is_none() + { + tracing::warn!( + ?diagnosis, + "recognised a repairable crash cause in the game log" + ); + *found = Some(diagnosis); + } + } + + pub(crate) fn take(&self) -> Option { + self.found.lock().ok().and_then(|mut found| found.take()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_zip_exception_is_recognised() { + let diagnosis = diagnose("java.util.zip.ZipException: error in opening zip file"); + assert_eq!( + diagnosis, + Some(CrashDiagnosis::CorruptArchive { file: None }) + ); + } + + #[test] + fn a_nested_zip_exception_is_recognised() { + let line = "Caused by: java.util.zip.ZipException: zip END header not found"; + assert!(diagnose(line).is_some()); + } + + #[test] + fn the_jar_is_named_when_the_message_carries_one() { + let line = "java.util.zip.ZipException: error in opening zip file: \ + /Users/someone/metadata/libraries/net/fabricmc/fabric-loader-0.15.jar"; + + assert_eq!( + diagnose(line), + Some(CrashDiagnosis::CorruptArchive { + file: Some("fabric-loader-0.15.jar".to_string()), + }) + ); + } + + #[test] + fn a_windows_path_is_reduced_to_its_file_name() { + let line = + r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar"; + + assert_eq!( + diagnose(line), + Some(CrashDiagnosis::CorruptArchive { + file: Some("asm-9.7.jar".to_string()), + }) + ); + } + + #[test] + fn ordinary_game_output_is_not_a_crash() { + // A false positive would nag the user into re-downloading for nothing + for line in [ + "[Render thread/INFO]: Setting user: Dev", + "[main/INFO]: Loading 42 mods", + "Loaded jar file sodium-0.5.jar", + "[Worker-Main-1/WARN]: Unable to play unknown soundEvent", + "", + ] { + assert_eq!(diagnose(line), None, "{line}"); + } + } + + #[test] + fn the_watch_keeps_the_first_cause_not_the_last() { + let watch = CrashWatch::new(); + + watch.observe("[main/INFO]: Loading mods"); + watch.observe("java.util.zip.ZipException: error in opening zip file: first.jar"); + watch.observe("java.util.zip.ZipException: error in opening zip file: second.jar"); + + assert_eq!( + watch.take(), + Some(CrashDiagnosis::CorruptArchive { + file: Some("first.jar".to_string()), + }) + ); + } + + #[test] + fn a_clean_session_diagnoses_nothing() { + let watch = CrashWatch::new(); + watch.observe("[Render thread/INFO]: Stopping!"); + assert_eq!(watch.take(), None); + } + + #[test] + fn taking_a_diagnosis_consumes_it() { + let watch = CrashWatch::new(); + watch.observe("java.util.zip.ZipException: error in opening zip file"); + + assert!(watch.take().is_some()); + assert!( + watch.take().is_none(), + "a diagnosis must not be reported twice" + ); + } +} diff --git a/packages/oneclient_core/src/game/error.rs b/packages/oneclient_core/src/game/error.rs new file mode 100644 index 00000000..2a2234ad --- /dev/null +++ b/packages/oneclient_core/src/game/error.rs @@ -0,0 +1,43 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GameError { + #[error("no client download exists for version {0}")] + NoClientDownload(String), + + #[error("failed to resolve library artifact path for {0}")] + LibraryPath(String), + + #[error("download hash mismatch for {path}: expected {expected}, got {actual}")] + HashMismatch { + path: String, + expected: String, + actual: String, + }, + + #[error("cluster is missing a required Java version")] + MissingJavaVersion, + + #[error("forge processor failed: {0}")] + ProcessorFailed(String), + + #[error("processor main class not found for {0}")] + ProcessorMainClass(String), + + #[error("invalid game version {0}")] + InvalidVersion(String), + + #[error("cluster {0} is already running")] + AlreadyRunning(i64), + + #[error("another cluster ({0}) is already running in the same directory")] + DirectoryInUse(i64), + + #[error( + "{0} is already running in the shared game directory; close it before launching another cluster" + )] + SharedDirectoryBusy(String), + + #[error("failed to spawn the game process: {0}")] + Spawn(String), +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs new file mode 100644 index 00000000..b5817dff --- /dev/null +++ b/packages/oneclient_core/src/game/launch.rs @@ -0,0 +1,680 @@ +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use interfrost::api::minecraft::ArgumentType; +use tokio::process::Command; + +use crate::ClusterStage; +use crate::LauncherResult; +use crate::clusters::Cluster; +use crate::game::GameError; +use crate::game::session::SessionRecorder; +use crate::game::tail::spawn_log_tail; +use crate::settings::GameSettingsProfile; +use crate::state::LauncherState; +use oneclient_auth::MinecraftAccount; +use oneclient_common::paths; +use oneclient_discord::Presence; +use oneclient_events::{GroupedProgressSession, LaunchStage}; +use oneclient_mc::{ + self as arguments, download_minecraft, download_version_info, game_files_missing, + get_loader_version, resolve_minecraft_version, +}; + +pub fn is_running(state: &LauncherState, cluster_id: i64) -> bool { + state.games.is_running(cluster_id) +} + +#[derive(Debug, Clone)] +pub struct LaunchedGame { + pub cluster_id: i64, + pub pid: Option, +} + +#[tracing::instrument(skip(state, account))] +pub async fn launch_cluster( + state: &Arc, + cluster_id: i64, + account: &MinecraftAccount, + search_for_java: bool, +) -> LauncherResult { + tracing::info!(cluster_id, search_for_java, "launching cluster"); + + let parallel = state.settings.read().allow_parallel_running_clusters; + if !parallel && state.games.is_running(cluster_id) { + tracing::warn!(cluster_id, "cluster already running; refusing launch"); + return Err(GameError::AlreadyRunning(cluster_id).into()); + } + + let events = state.services.events.clone(); + let stage = |s: LaunchStage| { + state.games.set_stage(cluster_id, s); + events.game_stage(cluster_id, s); + }; + + stage(LaunchStage::Checking); + + let existing = state.clusters.get(cluster_id).await?; + + let game_dir = existing.game_dir()?; + let dedicated = existing.uses_dedicated_dir(); + + // Non-dedicated clusters share one directory so a second game there would + // materialize over the running one's + // `dir_in_use_by` waves a cluster past + // its own session which parallel launches make reachable + if !dedicated && let Some(other) = state.games.dir_in_use(&game_dir) { + tracing::warn!(cluster_id, other, "shared game dir busy; refusing launch"); + let name = running_cluster_name(state, other).await; + return Err(GameError::SharedDirectoryBusy(name).into()); + } + + if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) { + return Err(GameError::DirectoryInUse(other).into()); + } + + let progress = GroupedProgressSession::start( + &state.services.events, + format!("Launching {}", existing.name), + ); + + let cluster = if existing.stage == ClusterStage::Ready { + existing + } else { + stage(LaunchStage::Downloading); + match crate::clusters::prepare_cluster_locked( + state, + cluster_id, + false, + search_for_java, + false, + Some(&progress), + ) + .await + { + Ok(cluster) => cluster, + Err(err) => { + progress.finish(); + stage(LaunchStage::Exited); + return Err(err); + } + } + }; + if let Err(err) = oneclient_content::bundles::install_cluster_bundles( + cluster_id, + state.bundles.as_ref(), + Some(&progress), + &state.services.content(), + ) + .await + { + tracing::warn!(cluster_id, error = %err, "failed to install bundle content"); + } + + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let (version, updated, loader_version, version_info) = { + let mut metadata = state.metadata.lock().await; + + let (version, _index, updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| GameError::InvalidVersion(cluster.mc_version.clone()))?; + + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + + let version_info = download_version_info( + &state.services.mc(), + Some(&progress), + &version, + loader_version.as_ref(), + false, + ) + .await; + + (version, updated, loader_version, version_info) + }; + + let version_info = match version_info { + Ok(info) => info, + Err(err) => { + progress.finish(); + stage(LaunchStage::Exited); + return Err(err.into()); + } + }; + + let version_name = loader_version.as_ref().map_or_else( + || version.id.clone(), + |lv| format!("{}-{}", version.id, lv.id), + ); + + tracing::info!( + cluster_id, + mc_version = %version.id, + loader = %cluster.mc_loader, + loader_version = loader_version.as_ref().map(|lv| lv.id.as_str()).unwrap_or("none"), + version_name = %version_name, + libraries = version_info.libraries.len(), + main_class = %version_info.main_class, + "resolved launch metadata" + ); + + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? + { + runtime + } else { + let major = version_info + .java_version + .as_ref() + .map(|v| v.major_version) + .ok_or(GameError::MissingJavaVersion)?; + + state + .java + .prepare(major, search_for_java, false, None) + .await? + }; + + match game_files_missing(&version_info, &java.os_arch, updated) { + Ok(true) => { + tracing::info!(cluster_id, "missing game files; repairing"); + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Repairing) + .await; + stage(LaunchStage::Downloading); + if let Err(err) = download_minecraft( + &state.services.mc(), + &progress, + &version_info, + &java.os_arch, + updated, + false, + ) + .await + { + progress.finish(); + stage(LaunchStage::Exited); + return Err(err.into()); + } + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await; + } + Ok(false) => {} + Err(err) => tracing::warn!(cluster_id, error = %err, "repair check failed"), + } + + progress.finish(); + + stage(LaunchStage::Launching); + + let cwd = game_dir; + polyio::create_dir_all(&cwd).await.ok(); + + if let Err(err) = crate::game::write_allowed_symlinks(&cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); + } + + // The one moment nothing holds the content open so this is where a package + // removed or disabled mid-session actually leaves the folder + if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); + } + + if !dedicated { + // Redirects the shared dir's `logs`/`crash-reports` into this cluster's + // folder so output is attributable unlinked on exit + crate::game::link_cluster_logs(&cluster, &cwd).await; + } + + let client_jar = paths::versions_dir()? + .join(&version_name) + .join(format!("{version_name}.jar")); + let natives = paths::natives_dir()?.join(&version_name); + let libraries = paths::libraries_dir()?; + let assets = paths::assets_dir()?; + + let arg_map = version_info.arguments.clone().unwrap_or_default(); + + let classpaths = arguments::classpaths( + &libraries, + &version_info.libraries, + &client_jar, + &java.os_arch, + updated, + )?; + + let jvm_args = arguments::java_arguments( + updated, + arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), + &natives, + &libraries, + &classpaths, + &version_name, + profile.mem_max.unwrap_or(2048), + profile.launch_args.clone().unwrap_or_default(), + &java.os_arch, + java.major, + )?; + + let mut mc_args = arguments::minecraft_arguments( + updated, + arg_map.get(&ArgumentType::Game).map(Vec::as_slice), + version_info.minecraft_arguments.as_deref(), + &account.access_token, + &account.username, + account.id, + &version.id, + &version_info.asset_index.id, + &cwd, + &assets, + version.type_, + profile.resolution.unwrap_or_default(), + &java.os_arch, + )?; + arguments::append_profile_game_arguments(&mut mc_args, profile.force_fullscreen, None); + + run_hook(profile.hook_pre.as_deref(), &cwd).await; + + tracing::info!( + cluster_id, + java = %java.absolute_path, + jvm_args = jvm_args.len(), + mc_args = mc_args.len(), + cwd = %cwd.display(), + "spawning minecraft process" + ); + tracing::debug!(cluster_id, ?jvm_args, main_class = %version_info.main_class, "jvm arguments"); + + let mut command = base_command(&profile, &java.absolute_path); + apply_env(&mut command, &profile); + command + .args(jvm_args) + .arg(&version_info.main_class) + .args(mc_args) + .current_dir(&cwd); + + let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?; + if let Some(parent) = log_path.parent() { + polyio::create_dir_all(parent).await.ok(); + } + + // A file not a pipe a pipe would die with the launcher and take the game's + // next stdout write down with it + // The cloned handle shares the file offset + // so stdout and stderr interleave instead of overwriting + let handles = match tokio::fs::File::create(&log_path).await { + Ok(out) => match out.try_clone().await { + Ok(err) => Ok((out.into_std().await, err.into_std().await)), + Err(err) => Err(err), + }, + Err(err) => Err(err), + }; + + match handles { + Ok((out, err)) => { + command.stdout(Stdio::from(out)).stderr(Stdio::from(err)); + } + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to open game log; discarding output"); + command.stdout(Stdio::null()).stderr(Stdio::null()); + } + } + command.stdin(Stdio::null()); + detach(&mut command); + + let mut child = command + .spawn() + .map_err(|err| GameError::Spawn(err.to_string()))?; + let pid = child.id(); + + 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 recorder = + SessionRecorder::start(state, cluster_id, profile.mem_max.unwrap_or(2048), &java).await; + + // Pinned to the session row so that if the launcher exits first the next + // start can tell whether the game is still playing + if let (Some(recorder), Some(pid)) = (recorder.as_ref(), pid) { + recorder + .record_process(pid, crate::game::process_start_time(pid)) + .await; + } + + let started_at = recorder + .as_ref() + .and_then(SessionRecorder::started_at) + .unwrap_or_else(Utc::now); + let crash_watch = crate::game::diagnosis::CrashWatch::new(); + let tail = spawn_log_tail( + cluster_id, + log_path, + events.clone(), + recorder.clone(), + crash_watch.clone(), + ); + + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.games.register_kill(cluster_id, kill_tx); + + let state = Arc::clone(state); + let post_hook = profile.hook_post.clone(); + tokio::spawn(async move { + let cluster = cluster; + let status = tokio::select! { + status = child.wait() => status, + _ = kill_rx => { + let _ = child.start_kill(); + child.wait().await + } + }; + + tail.stop().await; + + let outcome = match status { + Ok(status) => Exit::Observed { + code: status.code().map(i64::from), + success: status.success(), + display: status.to_string(), + }, + Err(err) => Exit::Failed(err.to_string()), + }; + + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + recorder, + SessionEnd { + started_at, + ended_at: Utc::now(), + outcome, + owns_slot: true, + diagnosis: crash_watch.take(), + }, + ) + .await; + }); + + Ok(LaunchedGame { cluster_id, pid }) +} + +async fn running_cluster_name(state: &Arc, cluster_id: i64) -> String { + state + .clusters + .get(cluster_id) + .await + .map_or_else(|_| format!("Cluster {cluster_id}"), |cluster| cluster.name) +} + +/// Cuts the game loose from the launcher's process group/console so signals +/// aimed at the launcher (Ctrl-C console close) don't reach the game +fn detach(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command + .as_std_mut() + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } +} + +pub(crate) enum Exit { + Observed { + code: Option, + success: bool, + display: String, + }, + Failed(String), + /// Game exited while the launcher was closed time recovered from the log + /// exit code unrecoverable + Inferred, +} + +pub(crate) struct SessionEnd { + pub started_at: DateTime, + pub ended_at: DateTime, + pub outcome: Exit, + /// False for a stale session recovered from the database whose cluster has a + /// live game book its playtime but clearing the slot would report the live + /// game as exited + pub owns_slot: bool, + /// `None` for a clean exit an unrecognised crash or a session recovered + /// after the fact with no log watched + pub diagnosis: Option, +} + +/// Shared by the live exit path and by recovery of sessions that outlived the +/// launcher +pub(crate) async fn finalize_session( + state: &Arc, + cluster: &Cluster, + cwd: &Path, + dedicated: bool, + post_hook: Option<&str>, + recorder: Option, + end: SessionEnd, +) { + let cluster_id = cluster.id; + let played = (end.ended_at - end.started_at).to_std().unwrap_or_default(); + + if end.owns_slot { + state.games.remove(cluster_id); + state + .services + .events + .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 _ = state.clusters.add_playtime(cluster_id, played).await; + } + + if let Some(recorder) = recorder { + let code = match &end.outcome { + Exit::Observed { code, .. } => *code, + Exit::Failed(_) | Exit::Inferred => None, + }; + recorder.finish_at(&end.ended_at.to_rfc3339(), code).await; + } + + run_hook(post_hook, cwd).await; + + if dedicated { + // The folder stays materialized so it remains a real Minecraft directory + // for external tools adopting drop-ins now keeps the UI right on close + crate::game::import_manual_content(&state.services, cluster, cwd).await; + } else { + if let Err(err) = crate::game::dematerialize_content(&state.services, cluster, cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit"); + } + crate::game::unlink_cluster_logs(cwd).await; + } + + let name = &cluster.name; + let crashed = !matches!(end.outcome, Exit::Observed { success: true, .. }); + + match end.outcome { + Exit::Observed { success: true, .. } => state + .services + .events + .notify("Game closed") + .body(format!("{name} exited")) + .send(), + Exit::Observed { display, .. } => state + .services + .events + .notify("Game crashed") + .body(format!("{name} exited with {display}")) + .error() + .send(), + Exit::Failed(err) => state + .services + .events + .notify("Game error") + .body(format!("{name}: {err}")) + .error() + .send(), + // Nothing was watching so there is no crash to report + Exit::Inferred => {} + } + + // Only when the game actually died a `ZipException` it recovered from is not + // worth interrupting a finished session over + if crashed && let Some(diagnosis) = end.diagnosis { + offer_repair(state, cluster_id, &diagnosis).await; + } +} + +/// Asks rather than acting verification can re-download much of the game too +/// much to start unprompted +/// The offer returns on the next crash +#[tracing::instrument(skip(state, diagnosis), level = "debug")] +pub async fn offer_repair( + state: &Arc, + cluster_id: i64, + diagnosis: &crate::game::diagnosis::CrashDiagnosis, +) { + enum Answer { + Verify, + } + + let answer = state + .services + .events + .ask( + oneclient_events::Prompt::new("A damaged file crashed the game", diagnosis.body()) + .option( + oneclient_events::Choice::primary("verify", "Verify and repair"), + Answer::Verify, + ) + .dismiss("Not now"), + ) + .await; + + match answer { + Ok(Some(_)) => {} + // Dismissed or nobody there to ask neither is consent to re-download + Ok(None) => { + tracing::info!(cluster_id, "user declined the post-crash repair"); + return; + } + Err(err) => { + tracing::warn!(cluster_id, "could not offer a post-crash repair: {err}"); + return; + } + } + + match crate::verify::verify_cluster_files(state, cluster_id).await { + Ok(report) => { + state + .services + .events + .notify("Repair complete") + .body(report.summary()) + .send(); + } + Err(err) => { + tracing::error!(cluster_id, "post-crash repair failed: {err:#}"); + state + .services + .events + .notify("Repair failed") + .body(err.to_string()) + .error() + .send(); + } + } +} + +fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command { + if let Some(wrapper) = profile + .hook_wrapper + .as_deref() + .map(str::trim) + .filter(|hook| !hook.is_empty()) + { + let mut split = wrapper.split_whitespace(); + let mut command = Command::new(split.next().unwrap_or("sh")); + command.args(split); + command.arg(java_path); + command + } else { + Command::new(java_path) + } +} + +fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { + command.env_remove("_JAVA_OPTIONS"); + if let Some(env) = &profile.launch_env { + for pair in env.split_whitespace() { + if let Some((key, value)) = pair.split_once('=') { + command.env(key, value); + } + } + } +} + +#[tracing::instrument(skip(cwd), fields(hook), level = "debug")] +async fn run_hook(hook: Option<&str>, cwd: &Path) { + let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else { + return; + }; + + #[cfg(windows)] + let mut command = { + let mut c = Command::new("cmd"); + c.args(["/C", hook]); + c + }; + #[cfg(not(windows))] + let mut command = { + let mut c = Command::new("sh"); + c.args(["-c", hook]); + c + }; + + command.current_dir(cwd); + if let Err(err) = command.status().await { + tracing::warn!("hook '{hook}' failed: {err}"); + } +} diff --git a/packages/oneclient_core/src/game/log_replay.rs b/packages/oneclient_core/src/game/log_replay.rs new file mode 100644 index 00000000..2eb86497 --- /dev/null +++ b/packages/oneclient_core/src/game/log_replay.rs @@ -0,0 +1,288 @@ +//! Minecraft logs a bare local wall-clock time (`[12:29:00]`) no date no +//! offset Absolute timestamps must be rebuilt by anchoring at the session +//! start and walking forward + +use chrono::{DateTime, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; + +use super::session::{ServerJoin, parse_server_join}; + +/// A backwards jump larger than this means the clock wrapped past midnight +/// rather than lines merely arriving out of order between threads +const ROLLOVER_SLACK_SECS: i64 = 12 * 60 * 60; + +/// Absent any of these the span is closed by the next join or the session end +/// so a missing marker costs accuracy never correctness +const LEAVE_MARKERS: &[&str] = &[ + "Stopping!", + "Stopping worker threads", + "Connection lost", + "Lost connection to server", + "Client disconnected with reason", + "Disconnected from server", +]; + +/// Clean-shutdown marker its timestamp is the truest exit time available +const STOP_MARKER: &str = "Stopping!"; + +pub(crate) fn parse_log_time(line: &str) -> Option { + let stamp = line.strip_prefix('[')?.split(']').next()?.trim(); + + // `%.f` also matches an empty fraction covering both `[12:29:00]` and + // `[12:29:00.123]` + if let Ok(time) = NaiveTime::parse_from_str(stamp, "%H:%M:%S%.f") { + return Some(time); + } + + // Some pack-shipped log4j configs prepend the date + for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%d.%m.%Y %H:%M:%S%.f"] { + if let Ok(dt) = NaiveDateTime::parse_from_str(stamp, fmt) { + return Some(dt.time()); + } + } + + None +} + +pub(crate) fn is_leave_marker(line: &str) -> bool { + LEAVE_MARKERS.iter().any(|marker| line.contains(marker)) +} + +fn local_to_utc(date: NaiveDate, time: NaiveTime) -> DateTime { + let naive = NaiveDateTime::new(date, time); + match Local.from_local_datetime(&naive) { + LocalResult::Single(dt) => dt.with_timezone(&Utc), + // DST end the time happens twice the first fits a forward-walking log + LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), + // DST start this time never existed so step over the gap rather than + // reading it as UTC which would be off by the zone's whole offset + LocalResult::None => Local + .from_local_datetime(&(naive + chrono::Duration::hours(1))) + .earliest() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|| Utc.from_utc_datetime(&naive)), + } +} + +/// Turns bare times into absolute instants advancing the date on wrap +pub(crate) struct LogClock { + date: NaiveDate, + /// High-water mark not the previous line threads interleave so a line + /// may legitimately sit behind the one before it + peak: NaiveTime, +} + +impl LogClock { + pub(crate) fn new(anchor: DateTime) -> Self { + let local = anchor.with_timezone(&Local).naive_local(); + Self { + date: local.date(), + peak: local.time(), + } + } + + pub(crate) fn absolute(&mut self, time: NaiveTime) -> DateTime { + let behind = self.peak.signed_duration_since(time).num_seconds(); + if behind > ROLLOVER_SLACK_SECS { + self.date = self.date.succ_opt().unwrap_or(self.date); + self.peak = time; + } else if time > self.peak { + self.peak = time; + } + local_to_utc(self.date, time) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServerSpan { + pub host: String, + pub port: Option, + pub joined_at: DateTime, + pub disconnected_at: Option>, +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct LogReplay { + /// Timestamp of the last line carrying one a lower bound on the exit + pub last_activity: Option>, + pub stopped_at: Option>, + pub servers: Vec, +} + +pub(crate) fn replay(content: &str, started_at: DateTime) -> LogReplay { + let mut clock = LogClock::new(started_at); + let mut out = LogReplay::default(); + // Timestamps appear only on the first line of a multi-line entry so carry + // the last one forward + let mut now = started_at; + + for line in content.lines() { + if let Some(time) = parse_log_time(line) { + now = clock.absolute(time); + out.last_activity = Some(now); + } + + if let Some(ServerJoin { host, port }) = parse_server_join(line) { + close_open_span(&mut out, now); + out.servers.push(ServerSpan { + host, + port, + joined_at: now, + disconnected_at: None, + }); + continue; + } + + if is_leave_marker(line) { + close_open_span(&mut out, now); + } + + if line.contains(STOP_MARKER) { + out.stopped_at = Some(now); + } + } + + out +} + +fn close_open_span(replay: &mut LogReplay, at: DateTime) { + if let Some(open) = replay + .servers + .iter_mut() + .rev() + .find(|span| span.disconnected_at.is_none()) + { + open.disconnected_at = Some(at.max(open.joined_at)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn anchor() -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, 12, 0, 0) + .unwrap() + .with_timezone(&Utc) + } + + fn local_at(h: u32, m: u32, s: u32) -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, h, m, s) + .unwrap() + .with_timezone(&Utc) + } + + #[test] + fn parses_plain_and_fractional_times() { + assert_eq!( + parse_log_time("[12:29:00] [Client thread/INFO]: Setting user: Lynith"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + assert_eq!( + parse_log_time("[12:29:00.123] [Render thread/INFO]: hi"), + NaiveTime::from_hms_milli_opt(12, 29, 0, 123) + ); + } + + #[test] + fn parses_dated_stamp() { + assert_eq!( + parse_log_time("[2026-07-16 12:29:00] [Render thread/INFO]: hi"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + } + + #[test] + fn ignores_untimestamped_lines() { + assert!(parse_log_time("\tat net.minecraft.Foo.bar(Foo.java:12)").is_none()); + assert!(parse_log_time("[Render thread/INFO]: no time here").is_none()); + } + + #[test] + fn clock_keeps_same_day_while_moving_forward() { + let mut clock = LogClock::new(anchor()); + let at = clock.absolute(NaiveTime::from_hms_opt(12, 30, 0).unwrap()); + assert_eq!(at, local_at(12, 30, 0)); + } + + #[test] + fn clock_rolls_over_midnight() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(23, 59, 0).unwrap()); + let at = clock.absolute(NaiveTime::from_hms_opt(0, 1, 0).unwrap()); + assert_eq!( + at, + local_at(12, 0, 0) + Duration::hours(12) + Duration::minutes(1) + ); + } + + #[test] + fn clock_tolerates_out_of_order_threads() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(12, 29, 8).unwrap()); + // A second thread's line lands a second late this must not add a day + let at = clock.absolute(NaiveTime::from_hms_opt(12, 29, 7).unwrap()); + assert_eq!(at, local_at(12, 29, 7)); + } + + #[test] + fn replays_server_span_closed_by_quit() { + let log = "\ +[12:29:00] [Client thread/INFO]: Setting user: Lynith +[12:30:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565 +[12:45:00] [Client thread/INFO]: Stopping! +"; + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 1); + let span = &out.servers[0]; + assert_eq!(span.host, "mc.hypixel.net"); + assert_eq!(span.port, Some(25565)); + assert_eq!(span.joined_at, local_at(12, 30, 0)); + assert_eq!(span.disconnected_at, Some(local_at(12, 45, 0))); + assert_eq!(out.stopped_at, Some(local_at(12, 45, 0))); + assert_eq!(out.last_activity, Some(local_at(12, 45, 0))); + } + + #[test] + fn consecutive_joins_close_previous_span() { + let log = "\ +[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 +[12:40:00] [Render thread/INFO]: Connecting to b.example.com, 25566 +"; + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 2); + assert_eq!(out.servers[0].disconnected_at, Some(local_at(12, 40, 0))); + assert_eq!(out.servers[1].disconnected_at, None); + } + + #[test] + fn span_left_open_when_log_just_stops() { + let log = "[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565\n"; + let out = replay(log, anchor()); + assert_eq!(out.servers[0].disconnected_at, None); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 30, 0))); + } + + #[test] + fn crash_without_stop_marker_reports_last_activity() { + let log = "\ +[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 +[12:31:00] [Render thread/ERROR]: Encountered an unexpected exception +\tat net.minecraft.Foo.bar(Foo.java:12) +"; + let out = replay(log, anchor()); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 31, 0))); + } + + #[test] + fn empty_log_yields_nothing() { + let out = replay("", anchor()); + assert!(out.servers.is_empty()); + assert!(out.last_activity.is_none()); + assert!(out.stopped_at.is_none()); + } +} diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs new file mode 100644 index 00000000..77b3917b --- /dev/null +++ b/packages/oneclient_core/src/game/mod.rs @@ -0,0 +1,38 @@ +mod analytics; +mod error; +mod launch; +mod log_replay; +mod process; +mod reattach; +mod session; +mod shared_dir; +mod tail; + +pub use analytics::{ + Analytics, DayPlaytime, Persona, PlaytimeStats, ServerStat, WEEKDAY_LABELS, aggregate_servers, + cluster_analytics, global_analytics, +}; +pub use oneclient_mc::{ + append_profile_game_arguments, classpaths, download_to_path, fetch_bytes_verified, + get_classpath_library, get_library, java_arguments, main_class, minecraft_arguments, + processor_arguments, +}; +pub mod diagnosis; + +pub use diagnosis::{CrashDiagnosis, diagnose}; +pub use error::GameError; +pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair}; +pub use oneclient_mc::{ + DownloadPlan, confirm_incomplete_install, download_minecraft, download_version_info, + game_files_missing, get_game_versions, get_loader_version, get_loader_versions, + get_loaders_for_version, is_version_updated, libraries_missing, plan_downloads, + resolve_minecraft_version, validate_rules, verify_game_files, +}; +pub use process::{ + GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, +}; +pub use reattach::recover_sessions; +pub use shared_dir::{ + dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, + unlink_cluster_logs, write_allowed_symlinks, +}; diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs new file mode 100644 index 00000000..fdd8c45e --- /dev/null +++ b/packages/oneclient_core/src/game/process.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Instant; + +use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; +use tokio::sync::oneshot; + +use oneclient_events::LaunchStage; + +fn probe(pid: u32) -> Option<(System, Pid)> { + let pid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + sys.process(pid)?; + Some((sys, pid)) +} + +/// Unix seconds Pids get recycled so this is what pins process identity +pub fn process_start_time(pid: u32) -> Option { + let (sys, pid) = probe(pid)?; + Some(sys.process(pid)?.start_time()) +} + +/// A matching pid whose start time differs is a recycled pid belonging to +/// someone else never ours +pub fn is_process_alive(pid: u32, started_at: Option) -> bool { + let Some(actual) = process_start_time(pid) else { + return false; + }; + match started_at { + Some(expected) => actual == expected, + // Pre-migration sessions have no recorded start time assuming running + // is the safer error as the exit time is recovered on a later start + None => true, + } +} + +/// For games re-adopted after a launcher restart where no `Child` handle +/// survives to kill through +pub fn kill_process(pid: u32) -> bool { + let Some((sys, pid)) = probe(pid) else { + return false; + }; + let Some(process) = sys.process(pid) else { + return false; + }; + // Prefer a graceful terminate so the game can save before shutting down + process + .kill_with(Signal::Term) + .unwrap_or_else(|| process.kill()) +} + +#[derive(Debug, Clone)] +pub struct GameProcess { + pub pid: Option, + pub stage: LaunchStage, + pub started: Instant, +} + +#[derive(Default)] +pub struct GameProcessManager { + inner: Mutex>, + kills: Mutex>>, + dirs: Mutex>, +} + +impl GameProcessManager { + pub fn new() -> Self { + Self::default() + } + + pub fn register_kill(&self, cluster_id: i64, tx: oneshot::Sender<()>) { + self.kills.lock().unwrap().insert(cluster_id, tx); + } + + #[tracing::instrument(skip(self), level = "debug")] + pub fn kill(&self, cluster_id: i64) -> bool { + match self.kills.lock().unwrap().remove(&cluster_id) { + Some(tx) => { + tracing::debug!(cluster_id, "signalling kill to running game"); + tx.send(()).is_ok() + } + None => false, + } + } + + pub fn set_stage(&self, cluster_id: i64, stage: LaunchStage) { + let mut map = self.inner.lock().unwrap(); + if stage == LaunchStage::Exited { + map.remove(&cluster_id); + return; + } + map.entry(cluster_id) + .and_modify(|p| p.stage = stage) + .or_insert_with(|| GameProcess { + pid: None, + stage, + started: Instant::now(), + }); + } + + pub fn set_pid(&self, cluster_id: i64, pid: Option) { + if let Some(p) = self.inner.lock().unwrap().get_mut(&cluster_id) { + p.pid = pid; + } + } + + pub fn remove(&self, cluster_id: i64) { + self.inner.lock().unwrap().remove(&cluster_id); + self.kills.lock().unwrap().remove(&cluster_id); + self.dirs.lock().unwrap().remove(&cluster_id); + } + + pub fn set_dir(&self, cluster_id: i64, dir: PathBuf) { + self.dirs.lock().unwrap().insert(cluster_id, dir); + } + + /// Like [`Self::dir_in_use_by`] but counts a cluster's own session too the + /// shared game directory holds one game at a time whoever it belongs to + pub fn dir_in_use(&self, dir: &Path) -> Option { + self.dirs + .lock() + .unwrap() + .iter() + .find(|(_, d)| d.as_path() == dir) + .map(|(id, _)| *id) + } + + pub fn dir_in_use_by(&self, dir: &Path, exclude: i64) -> Option { + self.dirs + .lock() + .unwrap() + .iter() + .find(|(id, d)| **id != exclude && d.as_path() == dir) + .map(|(id, _)| *id) + } + + pub fn pid(&self, cluster_id: i64) -> Option { + self.inner + .lock() + .unwrap() + .get(&cluster_id) + .and_then(|p| p.pid) + } + + pub fn is_running(&self, cluster_id: i64) -> bool { + self.inner + .lock() + .unwrap() + .get(&cluster_id) + .is_some_and(|p| p.stage == LaunchStage::Running) + } + + pub fn is_active(&self, cluster_id: i64) -> bool { + self.inner.lock().unwrap().contains_key(&cluster_id) + } + + pub fn stage(&self, cluster_id: i64) -> Option { + self.inner.lock().unwrap().get(&cluster_id).map(|p| p.stage) + } + + pub fn running_ids(&self) -> Vec { + self.inner + .lock() + .unwrap() + .iter() + .filter(|(_, p)| p.stage == LaunchStage::Running) + .map(|(id, _)| *id) + .collect() + } +} diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs new file mode 100644 index 00000000..92e97289 --- /dev/null +++ b/packages/oneclient_core/src/game/reattach.rs @@ -0,0 +1,410 @@ +//! Games are spawned detached so sessions can outlive the launcher they are +//! settled on the next start (rows with no `ended_at`) + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use oneclient_db::dao::game_session as session_dao; +use oneclient_db::models::UnfinishedSession; + +use crate::clusters::Cluster; +use crate::game::launch::{Exit, SessionEnd, finalize_session}; +use crate::game::log_replay::{self, ServerSpan}; +use crate::game::process::{is_process_alive, kill_process}; +use crate::game::session::SessionRecorder; +use crate::game::tail::spawn_log_tail; +use crate::state::LauncherState; +use oneclient_discord::Presence; +use oneclient_events::LaunchStage; + +/// Only a fallback for a process we don't own so a slow poll is plenty +const LIVENESS_POLL: Duration = Duration::from_secs(3); + +/// Call once at startup before anything can launch a game +#[tracing::instrument(skip(state), level = "debug")] +pub async fn recover_sessions(state: &Arc) { + let sessions = match session_dao::unfinished_sessions(&state.services.db).await { + Ok(sessions) => sessions, + Err(err) => { + tracing::warn!(error = %err, "failed to list unfinished game sessions"); + return; + } + }; + + if sessions.is_empty() { + return; + } + + tracing::info!( + count = sessions.len(), + "recovering unfinished game sessions" + ); + + let mut live = Vec::new(); + let mut dead = Vec::new(); + for session in sessions { + match classify(state, session).await { + Some((cluster, session, started_at, Some(pid))) => { + live.push((cluster, session, started_at, pid)); + } + Some((cluster, session, started_at, None)) => { + dead.push((cluster, session, started_at)); + } + None => {} + } + } + + // Live games first reconciling an exited session tears down a shared game + // dir and that check reads the live registrations + for (cluster, session, started_at, pid) in live { + readopt(state, cluster, session, started_at, pid).await; + } + + for (cluster, session, started_at) in dead { + reconcile(state, cluster, session, started_at).await; + } +} + +/// `None` means the session was closed out here and needs no further work +async fn classify( + state: &Arc, + session: UnfinishedSession, +) -> Option<(Cluster, UnfinishedSession, DateTime, Option)> { + let cluster_id = session.cluster_id; + + if state.games.is_active(cluster_id) { + return None; + } + + let started_at = match DateTime::parse_from_rfc3339(&session.started_at) { + Ok(at) => at.with_timezone(&Utc), + Err(err) => { + tracing::warn!(cluster_id, session = %session.started_at, error = %err, "unparseable session start; closing it"); + close_untraceable(state, &session).await; + return None; + } + }; + + // No cluster means no log and no playtime worth attributing close the row + // so it stops being reconsidered forever + let Ok(cluster) = state.clusters.get(cluster_id).await else { + tracing::warn!( + cluster_id, + "cluster missing for unfinished session; closing it" + ); + close_untraceable(state, &session).await; + return None; + }; + + let alive = session + .pid + .and_then(|pid| u32::try_from(pid).ok()) + .filter(|pid| is_process_alive(*pid, session.pid_started_at.map(|t| t as u64))); + + Some((cluster, session, started_at, alive)) +} + +/// Ends at the start time which books no playtime rather than inventing some +async fn close_untraceable(state: &Arc, session: &UnfinishedSession) { + if let Err(err) = session_dao::finish_session_at( + &state.services.db, + &session.started_at, + &session.started_at, + None, + ) + .await + { + tracing::warn!(error = %err, "failed to close untraceable session"); + } +} + +#[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id, pid), level = "debug")] +async fn readopt( + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, + pid: u32, +) { + let cluster_id = cluster.id; + let Ok(cwd) = cluster.game_dir() else { + return; + }; + + tracing::info!(cluster_id, pid, "re-adopting running game"); + + let open_server = open_server_of(state, &session.started_at).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); + + state.games.set_stage(cluster_id, LaunchStage::Running); + state.games.set_pid(cluster_id, Some(pid)); + state.games.set_dir(cluster_id, cwd.clone()); + state + .services + .events + .game_stage(cluster_id, LaunchStage::Running); + state.discord.set_presence(Presence::Playing { + cluster: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + }); + + let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else { + return; + }; + // A re-adopted game still writes its log so crashes stay readable even + // though the exit code went with the launcher that spawned it + let crash_watch = crate::game::diagnosis::CrashWatch::new(); + let tail = spawn_log_tail( + cluster_id, + log_path, + state.services.events.clone(), + Some(recorder.clone()), + crash_watch.clone(), + ); + + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.games.register_kill(cluster_id, kill_tx); + + // Resolved up front so the watcher task doesn't reach back into the state + let global = state.settings.read().global_game_settings.clone(); + let post_hook = state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok() + .and_then(|profile| profile.hook_post); + let dedicated = cluster.uses_dedicated_dir(); + let pid_started_at = session.pid_started_at.map(|t| t as u64); + let state = Arc::clone(state); + + tokio::spawn(async move { + tokio::select! { + () = wait_for_exit(pid, pid_started_at) => {} + _ = kill_rx => { + kill_process(pid); + wait_for_exit(pid, pid_started_at).await; + } + } + + tail.stop().await; + + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + Some(recorder), + SessionEnd { + started_at, + ended_at: Utc::now(), + outcome: Exit::Inferred, + // With parallel clusters a newer game may already own this + // cluster's slot only tear down if it is still ours + owns_slot: state.games.pid(cluster_id) == Some(pid), + diagnosis: crash_watch.take(), + }, + ) + .await; + }); +} + +async fn wait_for_exit(pid: u32, pid_started_at: Option) { + while is_process_alive(pid, pid_started_at) { + tokio::time::sleep(LIVENESS_POLL).await; + } +} + +#[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id), level = "debug")] +async fn reconcile( + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, +) { + let cluster_id = cluster.id; + let log = read_session_log(&cluster).await; + + let replay = log + .as_ref() + .map(|log| log_replay::replay(&log.content, started_at)) + .unwrap_or_default(); + + let now = Utc::now().max(started_at); + + // Prefer the log's own timestamps over mtime mtime only helps when nothing + // in the log carries a time + let ended_at = replay + .stopped_at + .or(replay.last_activity) + .or_else(|| log.as_ref().and_then(|log| log.modified)) + .unwrap_or(started_at) + .clamp(started_at, now); + + tracing::info!( + cluster_id, + session = %session.started_at, + %ended_at, + clean_exit = replay.stopped_at.is_some(), + servers = replay.servers.len(), + "recovered session that outlived the launcher" + ); + + let open_server = apply_spans(state, &session.started_at, &replay.servers).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); + + let Ok(cwd) = cluster.game_dir() else { + recorder.finish_at(&ended_at.to_rfc3339(), None).await; + return; + }; + + // A cluster re-adopted moments ago may share this directory clearing it + // mid-session would pull that game apart underneath it + let shared_dir_busy = state.games.dir_in_use_by(&cwd, cluster_id).is_some(); + if shared_dir_busy { + tracing::debug!( + cluster_id, + "shared game dir still in use; skipping exit cleanup" + ); + } + + finalize_session( + state, + &cluster, + &cwd, + cluster.uses_dedicated_dir() || shared_dir_busy, + // Post hook skipped on purpose the exit already happened possibly days + // ago so firing it during startup would surprise the user + None, + Some(recorder), + SessionEnd { + started_at, + ended_at, + outcome: Exit::Inferred, + // The cluster may have a newer session playing right now so only + // claim the slot if nothing else holds it + owns_slot: !state.games.is_active(cluster_id), + // Nothing watched this log while it ran so no crash was recognised + diagnosis: None, + }, + ) + .await; +} + +/// Returns the `joined_at` of a span still left open for the caller to close +async fn apply_spans( + state: &Arc, + session_id: &str, + spans: &[ServerSpan], +) -> Option { + let db = &state.services.db; + let existing = session_dao::list_session_servers(db, session_id) + .await + .unwrap_or_default(); + + // Fewer replayed spans than recorded means the log was rotated or truncated + // so the live rows are the better record keep them + if spans.len() < existing.len() { + tracing::debug!( + session = %session_id, + replayed = spans.len(), + existing = existing.len(), + "log replay is thinner than recorded spans; keeping recorded ones" + ); + return existing + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at); + } + + if let Err(err) = session_dao::delete_session_servers(db, session_id).await { + tracing::warn!(session = %session_id, error = %err, "failed to clear server spans for replay"); + return None; + } + + let mut open = None; + for span in spans { + let joined_at = span.joined_at.to_rfc3339(); + if let Err(err) = session_dao::insert_server_join_at( + db, + session_id, + &span.host, + span.port.map(i64::from), + &joined_at, + ) + .await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server join"); + continue; + } + + match span.disconnected_at { + Some(at) => { + if let Err(err) = + session_dao::finish_server_at(db, &joined_at, &at.to_rfc3339()).await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server leave"); + } + } + None => open = Some(joined_at), + } + } + + open +} + +async fn open_server_of(state: &Arc, session_id: &str) -> Option { + session_dao::list_session_servers(&state.services.db, session_id) + .await + .unwrap_or_default() + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at) +} + +struct SessionLog { + content: String, + modified: Option>, +} + +/// `cluster-output.log` is preferred it is truncated at every launch so it +/// belongs to one session whereas `latest.log` may already have rotated +async fn read_session_log(cluster: &Cluster) -> Option { + let mut candidates: Vec = Vec::new(); + if let Ok(path) = oneclient_cluster::logs::cluster_output_log(cluster) { + candidates.push(path); + } + if let Ok(dir) = cluster.dir() { + candidates.push(dir.join("logs").join("latest.log")); + } + + for path in candidates { + match read_log(&path).await { + Some(log) if !log.content.trim().is_empty() => return Some(log), + _ => continue, + } + } + + None +} + +async fn read_log(path: &Path) -> Option { + let content = polyio::read_to_string(path).await.ok()?; + let modified = polyio::stat(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(DateTime::::from); + + Some(SessionLog { content, modified }) +} diff --git a/packages/oneclient_core/src/game/session.rs b/packages/oneclient_core/src/game/session.rs new file mode 100644 index 00000000..795552ac --- /dev/null +++ b/packages/oneclient_core/src/game/session.rs @@ -0,0 +1,244 @@ +use std::sync::Arc; + +use oneclient_db::DbPool; +use oneclient_db::dao::game_session as session_dao; +use oneclient_db::models::NewGameSession; +use tokio::sync::Mutex; + +use crate::state::LauncherState; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_java::JavaRuntime; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServerJoin { + pub host: String, + pub port: Option, +} + +/// The session row's `started_at` which is its primary key +pub(crate) type SessionId = String; + +pub(crate) fn parse_server_join(line: &str) -> Option { + const MARKER: &str = "Connecting to "; + let idx = line.find(MARKER)?; + let rest = line[idx + MARKER.len()..].trim(); + if rest.is_empty() { + return None; + } + + let (host, port) = match rest.split_once(", ") { + Some((host, port)) => { + let port = port + .split_whitespace() + .next() + .and_then(|p| p.parse::().ok()); + (host.trim(), port) + } + None => (rest, None), + }; + + let host = host.trim(); + if host.is_empty() || host.chars().any(char::is_whitespace) { + return None; + } + + Some(ServerJoin { + host: host.to_string(), + port, + }) +} + +#[derive(Clone)] +pub(crate) struct SessionRecorder { + session_started_at: String, + db: DbPool, + open_server: Arc>>, +} + +impl SessionRecorder { + #[tracing::instrument( + skip(state, java), + fields(cluster_id, ram_allocated_mb), + level = "debug" + )] + pub(crate) async fn start( + state: &Arc, + cluster_id: i64, + ram_allocated_mb: u32, + java: &JavaRuntime, + ) -> Option { + let mods_enabled = count_enabled_mods(state, cluster_id).await; + + let java_vendor = java.vendor.to_string(); + let started_at = chrono::Utc::now().to_rfc3339(); + let new = NewGameSession { + cluster_id, + started_at: &started_at, + ram_allocated_mb: i64::from(ram_allocated_mb), + mods_enabled: mods_enabled as i64, + java_vendor: Some(java_vendor.as_str()), + java_version: Some(java.version.as_str()), + }; + + let session = match session_dao::insert_session(&state.services.db, &new).await { + Ok(row) => row, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to record game session"); + return None; + } + }; + + Some(Self { + session_started_at: session.started_at, + db: state.services.db.clone(), + open_server: Arc::new(Mutex::new(None)), + }) + } + + /// Re-attaches to a row left open by a previous launcher run so a game that + /// outlived the launcher keeps recording into the same session + pub(crate) fn resume( + db: DbPool, + session_started_at: SessionId, + open_server: Option, + ) -> Self { + Self { + session_started_at, + db, + open_server: Arc::new(Mutex::new(open_server)), + } + } + + /// Playtime is measured against this so it agrees with the session span + /// analytics reads back out of the row + pub(crate) fn started_at(&self) -> Option> { + chrono::DateTime::parse_from_rfc3339(&self.session_started_at) + .ok() + .map(|at| at.with_timezone(&chrono::Utc)) + } + + /// Without this a launcher restart cannot tell a live game from one that + /// exited unobserved + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) async fn record_process(&self, pid: u32, pid_started_at: Option) { + if let Err(err) = session_dao::set_session_process( + &self.db, + &self.session_started_at, + Some(i64::from(pid)), + pid_started_at.map(|t| t as i64), + ) + .await + { + tracing::warn!(pid, error = %err, "failed to record session process"); + } + } + + pub(crate) async fn observe(&self, line: &str) { + if let Some(join) = parse_server_join(line) { + self.open(join).await; + } else if super::log_replay::is_leave_marker(line) { + self.close_open().await; + } + } + + async fn open(&self, join: ServerJoin) { + let mut open = self.open_server.lock().await; + if let Some(prev) = open.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close previous server row"); + } + + let port = join.port.map(i64::from); + match session_dao::insert_server_join(&self.db, &self.session_started_at, &join.host, port) + .await + { + Ok(joined_at) => *open = Some(joined_at), + Err(err) => tracing::warn!(error = %err, "failed to record server join"), + } + } + + async fn close_open(&self) { + if let Some(prev) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close server row"); + } + } + + /// The time is explicit because an exit is not always observed as it happens + /// one recovered from a log ended in the past + #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] + pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { + if let Some(open) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server_at(&self.db, &open, ended_at).await + { + tracing::warn!(error = %err, "failed to close open server on exit"); + } + + if let Err(err) = + session_dao::finish_session_at(&self.db, &self.session_started_at, ended_at, exit_code) + .await + { + tracing::warn!(session = %self.session_started_at, error = %err, "failed to finish game session"); + } + } +} + +#[tracing::instrument(skip(state), fields(cluster_id), level = "debug")] +async fn count_enabled_mods(state: &Arc, cluster_id: i64) -> usize { + match PackageStore::list_linked_artifacts(cluster_id, &state.services.content()).await { + Ok(linked) => linked + .into_iter() + .filter(|a| a.enabled && a.content_type == ContentType::Mod) + .count(), + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to count mods for session"); + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_host_and_port() { + let join = parse_server_join( + "[12:00:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565", + ) + .expect("should parse"); + assert_eq!(join.host, "mc.hypixel.net"); + assert_eq!(join.port, Some(25565)); + } + + #[test] + fn parses_ip_and_port() { + let join = parse_server_join("[Render thread/INFO]: Connecting to 192.168.1.10, 25577") + .expect("should parse"); + assert_eq!(join.host, "192.168.1.10"); + assert_eq!(join.port, Some(25577)); + } + + #[test] + fn parses_host_without_port() { + let join = parse_server_join("Connecting to play.example.com").expect("should parse"); + assert_eq!(join.host, "play.example.com"); + assert_eq!(join.port, None); + } + + #[test] + fn ignores_unrelated_lines() { + assert!(parse_server_join("[Render thread/INFO]: Loading world").is_none()); + assert!(parse_server_join("Connecting to ").is_none()); + } + + #[test] + fn rejects_addresses_with_spaces() { + assert!( + parse_server_join("[INFO]: Connecting to voice chat server: 'vc.example.com:24454'") + .is_none() + ); + } +} diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs new file mode 100644 index 00000000..edda2874 --- /dev/null +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -0,0 +1,941 @@ +use std::collections::HashSet; +use std::fs::FileType; +use std::path::Path; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::LauncherResult; +use crate::clusters::Cluster; +use crate::state::LauncherServices; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; +use oneclient_content::packages::store::{artifact_absolute_path, link_or_copy, remove_entry}; + +const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; + +const SWAP_TYPES: [ContentType; 3] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, +]; + +const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; + +struct Desired { + content_type: ContentType, + file_name: String, + hash: String, + /// Where it lives in the artifact cache + src: std::path::PathBuf, +} + +impl Desired { + fn relative_path(&self) -> String { + manifest::entry_path(self.content_type.folder_name(), &self.file_name) + } +} + +/// Safe to run over a directory left by a crashed session another cluster or +/// a launcher version predating the manifest +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] +pub async fn materialize_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) -> LauncherResult<()> { + let dedicated = cluster.uses_dedicated_dir(); + polyio::create_dir_all(game_dir).await.ok(); + + // In the shared directory this often belongs to another cluster so every + // use of it checks the id + let previous = manifest::load(game_dir).await; + + import_manual_content(services, cluster, game_dir).await; + + // Before the folder is built not after handing the game several enabled + // versions of one mod is a classloader conflict + if let Err(err) = + oneclient_content::bundles::reconcile_duplicate_activity(cluster.id, &services.content()) + .await + { + // Not worth blocking a launch the duplicates were already there + tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); + } + + let desired = desired_content(services, cluster).await?; + let desired_paths: HashSet = desired.iter().map(Desired::relative_path).collect(); + + // While the game is still closed this is what lands a package removed + // mid-session and clears another cluster's content from the shared dir + prune_previous(game_dir, previous.as_ref(), &desired_paths).await; + + if !dedicated { + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + let stash = cluster.dir()?.join(content_type.folder_name()); + polyio::create_dir_all(&dir).await.ok(); + + // Whatever is still here belongs to whoever played (or crashed) + // last so take it into this cluster rather than deleting it + let ours = ours_in_folder(content_type, &linked, previous.as_ref()); + stash_content_files(&dir, &stash, &ours).await; + ensure_note(&dir, content_type).await; + restore_stashed(&stash, &dir, content_type, &ours).await; + } + } + + let entries = link_desired(game_dir, &desired).await; + manifest::save(game_dir, &MaterializedManifest::new(cluster.id, entries)).await; + + sync_fabric_dep_overrides(cluster, game_dir).await?; + + Ok(()) +} + +/// Only for shared directories a dedicated cluster's content stays put between +/// sessions and [`materialize_content`] reconciles it at the next launch +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] +pub async fn dematerialize_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) -> LauncherResult<()> { + // Runs first so anything dropped in during the session is a tracked artifact + // by now and gets dropped rather than stashed as a loose file + import_manual_content(services, cluster, game_dir).await; + + let current = manifest::load(game_dir).await; + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + let stash = cluster.dir()?.join(content_type.folder_name()); + polyio::create_dir_all(&dir).await.ok(); + + let ours = ours_in_folder(content_type, &linked, current.as_ref()); + stash_content_files(&dir, &stash, &ours).await; + ensure_note(&dir, content_type).await; + } + + manifest::clear(game_dir).await; + Ok(()) +} + +async fn desired_content( + services: &LauncherServices, + cluster: &Cluster, +) -> LauncherResult> { + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?; + let mut desired = Vec::with_capacity(linked.len()); + + for link in linked { + if !link.enabled || !SWAP_TYPES.contains(&link.content_type) { + continue; + } + + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await? + else { + continue; + }; + + let src = artifact_absolute_path(&artifact.path)?; + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %link.hash, "cached artifact missing; skipping"); + continue; + } + + desired.push(Desired { + content_type: link.content_type, + file_name: link.cluster_file_name, + hash: link.hash, + src, + }); + } + + Ok(desired) +} + +/// Only files that made it are recorded so a failed link is never later +/// mistaken for ours and deleted out from under the user +async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec { + let mut entries = Vec::with_capacity(desired.len()); + + for item in desired { + let dest = game_dir + .join(item.content_type.folder_name()) + .join(&item.file_name); + + match link_or_copy(&item.src, &dest).await { + Ok(()) => entries.push(ManifestEntry { + path: item.relative_path(), + hash: item.hash.clone(), + }), + Err(err) => tracing::warn!( + file = %item.file_name, + error = %err, + "failed to materialize content into the game directory" + ), + } + } + + entries +} + +/// Entries are keyed by path so a package whose file name is unchanged is left +/// in place rather than being deleted and relinked on every launch +async fn prune_previous( + game_dir: &Path, + previous: Option<&MaterializedManifest>, + keep: &HashSet, +) { + let Some(previous) = previous else { + return; + }; + + for entry in &previous.entries { + if keep.contains(&entry.path) { + continue; + } + + let path = game_dir.join(&entry.path); + if let Err(err) = remove_entry(&path).await { + tracing::warn!( + file = %entry.path, + error = %err, + "failed to clear stale materialized content" + ); + } + } +} + +/// Names in one folder that are the launcher's not the user's what we +/// materialized last plus what the database tracks (covering files just +/// adopted by [`import_manual_content`]) +fn ours_in_folder( + content_type: ContentType, + linked: &[oneclient_content::packages::LinkedArtifactInfo], + manifest: Option<&MaterializedManifest>, +) -> HashSet { + let mut names: HashSet = linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.clone()) + .collect(); + + let folder = content_type.folder_name(); + if let Some(manifest) = manifest { + names.extend( + manifest + .entries + .iter() + .filter_map(|entry| entry.path.strip_prefix(&format!("{folder}/"))) + .map(str::to_owned), + ); + } + + names +} + +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] +pub async fn import_manual_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) { + let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(error = %err, "failed to list links; skipping manual-content import"); + return; + } + }; + + let manifest = manifest::load(game_dir).await; + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + let known: HashSet<&str> = linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.as_str()) + .collect(); + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(content_type, name) { + continue; + } + if known.contains(name) { + continue; + } + + // Ours from a previous launch `prune_previous` handles it + // Taking it as a user drop-in would reinstall a just-deleted package + let relative = manifest::entry_path(content_type.folder_name(), name); + if manifest.as_ref().is_some_and(|m| m.contains(&relative)) { + continue; + } + + // No manifest a directory from a launcher version predating it + // If the cache holds this exact file the launcher put it here + if manifest.is_none() && is_cached_artifact(services, &path).await { + tracing::debug!(file = name, "discarding stale launcher content in game dir"); + if let Err(err) = polyio::remove_file(&path).await { + tracing::warn!(file = name, error = %err, "failed to discard stale content"); + } + continue; + } + + match PackageStore::import_local_file( + &path, + content_type, + cluster.id, + &services.content(), + ) + .await + { + Ok(_) => { + tracing::debug!(file = name, "registered manually-added content") + } + Err(err) => tracing::warn!( + file = name, + error = %err, + "failed to register manually-added content" + ), + } + } + } +} + +/// Already in the artifact cache i.e. the launcher put it in the game dir +/// rather than the user dropping it there +async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { + let Ok(hash) = polyio::sha1_file(path).await else { + return false; + }; + + artifact_dao::get_artifact_by_hash(&services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten() + .is_some() +} + +fn has_content_extension(content_type: ContentType, name: &str) -> bool { + let lower = name.to_lowercase(); + match content_type { + ContentType::Mod => lower.ends_with(".jar"), + ContentType::ResourcePack | ContentType::Shader => lower.ends_with(".zip"), + _ => false, + } +} + +const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; + +#[tracing::instrument(level = "debug")] +pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { + let root = oneclient_common::paths::launcher_dir()?; + let base = polyio::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let sep = std::path::MAIN_SEPARATOR; + + let body = format!("[prefix]{}{}", base.to_string_lossy(), sep); + + polyio::write(game_dir.join(ALLOWED_SYMLINKS_NAME), body).await?; + Ok(()) +} + +const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; + +/// Launcher-owned content is dropped (the cache has it) everything else +/// (sidecars unzipped packs stray configs) is *moved* into the cluster folder +/// so it stays attached to that cluster and [`restore_stashed`] links it back +async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if name == EMPTY_NOTE_NAME || name.starts_with('.') { + continue; + } + + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + // Ours and the cache has it covered + // On Windows `symlink_file` hard-links so ours is not always a symlink + if file_type.is_symlink() || ours.contains(&name) { + remove_dir_or_file(&path, file_type).await; + continue; + } + + let dest = stash.join(&name); + if let Err(err) = move_entry(&path, &dest).await { + tracing::warn!( + file = %name, + error = %err, + "failed to stash content into cluster; leaving it in place" + ); + } + } +} + +/// Links stashed leftovers back so the game writes straight through into the +/// cluster folder even if we never get to run on exit +async fn restore_stashed( + stash: &Path, + dir: &Path, + content_type: ContentType, + ours: &HashSet, +) { + let Ok(mut entries) = polyio::read_dir(stash).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if name == EMPTY_NOTE_NAME || name.starts_with('.') || ours.contains(&name) { + continue; + } + + let Ok(file_type) = entry.file_type().await else { + continue; + }; + // A link into the artifact cache managed content is materialized from + // the database not from here + if file_type.is_symlink() { + continue; + } + + // A bare jar in the stash is a leftover from the old scheme often with + // its package already removed restoring it would put a deleted mod back + // every launch + // Left on disk for the Storage settings page to clear + if file_type.is_file() && has_content_extension(content_type, &name) { + tracing::debug!( + file = %name, + "ignoring stray content in cluster folder; use the Storage settings page to clear it" + ); + continue; + } + + let dest = dir.join(&name); + let result = if file_type.is_dir() { + polyio::symlink_dir(&path, &dest).await.map_err(Into::into) + } else { + link_or_copy(&path, &dest).await + }; + + if let Err(err) = result { + tracing::warn!( + file = %name, + error = %err, + "failed to restore stashed content into game dir" + ); + } + } +} + +async fn remove_dir_or_file(path: &Path, file_type: FileType) { + if file_type.is_dir() { + polyio::remove_dir_all(path).await.ok(); + } else if polyio::remove_file(path).await.is_err() { + // A Windows junction has to go through `remove_dir` + polyio::remove_symlink_dir(path).await.ok(); + } +} + +async fn move_entry(src: &Path, dest: &Path) -> LauncherResult<()> { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + + // A stash from an earlier session is older than what the game just wrote + // so it loses + if let Ok(meta) = polyio::symlink_metadata(dest).await { + remove_dir_or_file(dest, meta.file_type()).await; + } + + if polyio::rename(src, dest).await.is_ok() { + return Ok(()); + } + + // `rename` cannot cross devices fall back to a copy for plain files + polyio::copy(src, dest).await?; + polyio::remove_file(src).await.ok(); + Ok(()) +} + +async fn ensure_note(dir: &Path, content_type: ContentType) { + let note = dir.join(EMPTY_NOTE_NAME); + + if polyio::try_exists(¬e).await.unwrap_or(false) { + return; + } + + polyio::write(¬e, { + let noun = content_type.folder_name(); + + format!( + "It's empty here, but nothing is broken!\n\ + \n\ + OneClient keeps your {noun} safe somewhere else (specifically in the global launcher cache)\ + and only puts them here while you play. When you close the game, it tidies them away again.\n\ + \n\ + Want to add {noun}? The best way is to do it right inside OneClient. Or you can drop \ + files in this folder, and OneClient will pick them up the next time you play.\n" + ) + }) + .await + .ok(); +} + +#[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")] +pub async fn link_cluster_logs(cluster: &Cluster, game_dir: &Path) { + let cluster_dir = match cluster.dir() { + Ok(dir) => dir, + Err(err) => { + tracing::warn!(error = %err, "cannot resolve cluster dir; skipping log redirect"); + return; + } + }; + + for name in REDIRECTED_DIRS { + let target = cluster_dir.join(name); + let shared = game_dir.join(name); + + if let Err(err) = redirect_dir(&shared, &target).await { + tracing::warn!( + dir = name, + error = %err, + "failed to redirect shared game dir into cluster; logs may pool in shared dir" + ); + } + } +} + +async fn redirect_dir(shared: &Path, target: &Path) -> LauncherResult<()> { + polyio::create_dir_all(target).await.ok(); + + match polyio::symlink_metadata(shared).await { + Ok(meta) if meta.file_type().is_symlink() => { + polyio::remove_symlink_dir(shared).await?; + } + + // A real directory likely the user's so salvage its contents into the + // cluster folder instead of deleting it + Ok(meta) if meta.is_dir() => { + move_dir_contents(shared, target).await; + polyio::remove_dir_all(shared).await.ok(); + } + + Ok(_) => { + polyio::remove_file(shared).await.ok(); + } + + Err(_) => {} + } + + if let Some(parent) = shared.parent() { + polyio::create_dir_all(parent).await.ok(); + } + polyio::symlink_dir(target, shared).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug")] +pub async fn unlink_cluster_logs(game_dir: &Path) { + for name in REDIRECTED_DIRS { + let shared = game_dir.join(name); + + match polyio::symlink_metadata(&shared).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&shared).await { + tracing::warn!(dir = name, error = %err, "failed to unlink shared log dir"); + } + } + Ok(_) => { + tracing::warn!( + dir = name, + "shared log dir is not our link; leaving as-is (next launch will salvage it)" + ); + } + Err(_) => {} + } + } +} + +async fn move_dir_contents(from: &Path, to: &Path) { + let Ok(mut entries) = polyio::read_dir(from).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let src = entry.path(); + let Some(name) = src.file_name() else { + continue; + }; + + let dest = to.join(name); + + if polyio::rename(&src, &dest).await.is_err() { + tracing::warn!(file = %src.display(), "failed to salvage leaked log file"); + } + } +} + +async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> LauncherResult<()> { + let src = cluster.dir()?.join(FABRIC_DEP_OVERRIDES); + let dest = game_dir.join(FABRIC_DEP_OVERRIDES); + + if src == dest { + return Ok(()); + } + + if polyio::try_exists(&src).await.unwrap_or(false) { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + polyio::copy(&src, &dest).await?; + } else if polyio::try_exists(&dest).await.unwrap_or(false) { + polyio::remove_file(&dest).await.ok(); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn names(names: &[&str]) -> HashSet { + names.iter().map(|n| (*n).to_string()).collect() + } + + fn manifest_of(cluster_id: i64, paths: &[&str]) -> MaterializedManifest { + MaterializedManifest::new( + cluster_id, + paths + .iter() + .map(|p| ManifestEntry { + path: (*p).to_string(), + hash: "hash".into(), + }) + .collect(), + ) + } + + /// A shaderpack's settings sidecar has to survive a launch/exit cycle and + /// end up in the cluster rather than the shared dir + #[tokio::test] + async fn shader_settings_survive_a_session() { + let root = polyio::testing::ScratchDir::new("shader_settings"); + let shared = root.join("shared").join("shaderpacks"); + let stash = root.join("cluster").join("shaderpacks"); + polyio::create_dir_all(&shared).await.unwrap(); + + let ours = names(&["bsl.zip"]); + + polyio::write(shared.join("bsl.zip"), b"pack".as_slice()) + .await + .unwrap(); + polyio::write(shared.join("bsl.zip.txt"), b"BLOOM=off".as_slice()) + .await + .unwrap(); + + stash_content_files(&shared, &stash, &ours).await; + assert!(!shared.join("bsl.zip").exists(), "managed pack left behind"); + assert!(!shared.join("bsl.zip.txt").exists(), "sidecar left behind"); + assert_eq!( + polyio::read_to_string(stash.join("bsl.zip.txt")) + .await + .unwrap(), + "BLOOM=off" + ); + + restore_stashed(&stash, &shared, ContentType::Shader, &ours).await; + assert_eq!( + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), + "BLOOM=off" + ); + + polyio::write(stash.join("bsl.zip.txt"), b"BLOOM=on".as_slice()) + .await + .unwrap(); + assert_eq!( + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), + "BLOOM=on" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn unpacked_dirs_move_into_the_cluster() { + let root = polyio::testing::ScratchDir::new("unpacked"); + let shared = root.join("shared").join("shaderpacks"); + let stash = root.join("cluster").join("shaderpacks"); + polyio::create_dir_all(shared.join("Loose/shaders")) + .await + .unwrap(); + polyio::write( + shared.join("Loose/shaders/final.fsh"), + b"void main".as_slice(), + ) + .await + .unwrap(); + + stash_content_files(&shared, &stash, &HashSet::new()).await; + assert!(!shared.join("Loose").exists()); + assert!(stash.join("Loose/shaders/final.fsh").exists()); + + restore_stashed(&stash, &shared, ContentType::Shader, &HashSet::new()).await; + assert!(shared.join("Loose/shaders/final.fsh").exists()); + + // Only the link goes never the stashed original + stash_content_files(&shared, &stash, &HashSet::new()).await; + assert!(!shared.join("Loose").exists()); + assert!(stash.join("Loose/shaders/final.fsh").exists()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn note_is_never_stashed() { + let root = polyio::testing::ScratchDir::new("note"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::write(shared.join(EMPTY_NOTE_NAME), b"hi".as_slice()) + .await + .unwrap(); + + stash_content_files(&shared, &stash, &HashSet::new()).await; + assert!(shared.join(EMPTY_NOTE_NAME).exists()); + assert!(!stash.join(EMPTY_NOTE_NAME).exists()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// The reported bug a package removed while the game held it open stays in + /// the folder so the next launch must clear it + /// An ordinary file is used + /// because that is what a managed file looks like on Windows + #[tokio::test] + async fn removed_package_is_pruned_at_the_next_launch() { + let root = polyio::testing::ScratchDir::new("prune_removed"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let jar = game_dir.join("mods").join("removed.jar"); + polyio::write(&jar, b"jar".as_slice()).await.unwrap(); + + let previous = manifest_of(1, &["mods/removed.jar", "mods/kept.jar"]); + let keep: HashSet = ["mods/kept.jar".to_string()].into_iter().collect(); + + prune_previous(game_dir, Some(&previous), &keep).await; + + assert!( + polyio::symlink_metadata(&jar).await.is_err(), + "a removed package must not survive into the next session" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// Whatever a previous cluster materialized in the shared dir is ours to + /// clear not the user's to keep + #[tokio::test] + async fn another_clusters_content_is_cleared_not_stashed() { + let root = polyio::testing::ScratchDir::new("cross_cluster"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let jar = game_dir.join("mods").join("theirs.jar"); + polyio::write(&jar, b"jar".as_slice()).await.unwrap(); + + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/theirs.jar"])), + &HashSet::new(), + ) + .await; + + assert!(polyio::symlink_metadata(&jar).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// A hand-dropped file is in no manifest so it is stashed into the cluster + /// rather than deleted + #[tokio::test] + async fn user_files_are_never_pruned() { + let root = polyio::testing::ScratchDir::new("user_file"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let mine = game_dir.join("mods").join("handmade.jar"); + polyio::write(&mine, b"jar".as_slice()).await.unwrap(); + + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/ours.jar"])), + &HashSet::new(), + ) + .await; + + assert!( + mine.exists(), + "a file we never materialized is not ours to delete" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// A jar left in a cluster folder by an older launcher whose package has + /// since been removed must not be linked back into the game + #[tokio::test] + async fn stray_content_in_the_stash_is_not_restored() { + let root = polyio::testing::ScratchDir::new("stray_content"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::create_dir_all(&stash).await.unwrap(); + + // A leftover from the old scheme an ordinary file (which is what a + // Windows hard link looks like) with no database row to explain it + polyio::write(stash.join("removed.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::write(stash.join("options.txt"), b"k=v".as_slice()) + .await + .unwrap(); + + restore_stashed(&stash, &shared, ContentType::Mod, &HashSet::new()).await; + + assert!( + !shared.join("removed.jar").exists(), + "a removed package must not be restored into the game" + ); + assert!( + stash.join("removed.jar").exists(), + "and it must not be silently deleted either" + ); + assert!( + shared.join("options.txt").exists(), + "sidecars still restore" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// Pinned because the halves are easy to swap `stash` reads the game dir + /// and writes the cluster `restore` the reverse and both take two + /// same-typed `&Path`s a swap would quietly delete a user's files + #[tokio::test] + async fn a_session_never_removes_anything_from_the_cluster_folder() { + let root = polyio::testing::ScratchDir::new("stash_is_sacred"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::create_dir_all(&stash).await.unwrap(); + + polyio::write(stash.join("options.txt"), b"k=v".as_slice()) + .await + .unwrap(); + polyio::write(stash.join("leftover.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::create_dir_all(stash.join("unpacked")) + .await + .unwrap(); + polyio::write(stash.join("unpacked").join("inner.txt"), b"x".as_slice()) + .await + .unwrap(); + + polyio::write(shared.join("managed.jar"), b"jar".as_slice()) + .await + .unwrap(); + + let ours = names(&["managed.jar"]); + + let before = dir_entries(&stash).await; + + stash_content_files(&shared, &stash, &ours).await; + restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; + stash_content_files(&shared, &stash, &ours).await; + restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; + + let after = dir_entries(&stash).await; + + assert_eq!( + before, after, + "a session must not add to or remove from the cluster folder" + ); + assert!( + stash.join("unpacked").join("inner.txt").exists(), + "the contents of a stashed directory survive too" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + async fn dir_entries(dir: &Path) -> Vec { + let mut names = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return names; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + names.sort(); + names + } + + #[test] + fn ownership_spans_the_manifest_and_the_database() { + let manifest = manifest_of(1, &["mods/from_manifest.jar", "shaderpacks/bsl.zip"]); + let linked = Vec::new(); + + let ours = ours_in_folder(ContentType::Mod, &linked, Some(&manifest)); + + assert!(ours.contains("from_manifest.jar")); + // Scoped to the folder a shaderpack entry must not make a mod of the + // same name look managed + assert!(!ours.contains("bsl.zip")); + } +} diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs new file mode 100644 index 00000000..e63493fb --- /dev/null +++ b/packages/oneclient_core/src/game/tail.rs @@ -0,0 +1,303 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; + +use crate::game::diagnosis::CrashWatch; +use crate::game::session::SessionRecorder; +use oneclient_events::EventBus; + +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +pub(crate) struct LogTail { + stop: Arc, + handle: tokio::task::JoinHandle<()>, +} + +impl LogTail { + /// Stops only after one last pass the lines explaining why the game + /// exited are written moments before it does + pub(crate) async fn stop(self) { + self.stop.store(true, Ordering::Relaxed); + let _ = self.handle.await; + } +} + +pub(crate) fn spawn_log_tail( + cluster_id: i64, + path: PathBuf, + events: EventBus, + recorder: Option, + crash_watch: CrashWatch, +) -> LogTail { + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); + + let handle = tokio::spawn(async move { + if let Err(err) = tail( + cluster_id, + &path, + &events, + recorder.as_ref(), + &crash_watch, + &flag, + ) + .await + { + tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); + } + }); + + LogTail { stop, handle } +} + +async fn tail( + cluster_id: i64, + path: &PathBuf, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, + stop: &AtomicBool, +) -> std::io::Result<()> { + // The game may not have created the file yet on a re-adopt so wait for it + // rather than giving up + let file = loop { + match tokio::fs::File::open(path).await { + Ok(file) => break file, + Err(_) if !stop.load(Ordering::Relaxed) => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => return Err(err), + } + }; + + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut offset = 0u64; + + loop { + line.clear(); + let read = reader.read_line(&mut line).await?; + + if read == 0 { + // The read above returned EOF on a drained file so the final pass + // has already run by the time the flag is seen here + if stop.load(Ordering::Relaxed) { + return Ok(()); + } + // A relaunch truncates the log if the file shrank our offset is + // past the end and we would never read again + let len = polyio::stat(path).await.map(|m| m.len()).unwrap_or(offset); + if len < offset { + reader.seek(std::io::SeekFrom::Start(0)).await?; + offset = 0; + } + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + + offset += read as u64; + + // A partial line means the game is mid-write wait for the newline + // rather than emitting half a line and re-emitting the rest later + if !line.ends_with('\n') { + // Unless the game is gone a process killed mid-write leaves a + // newline that never arrives hanging the caller of `stop()` + if stop.load(Ordering::Relaxed) { + emit(cluster_id, &line, events, recorder, crash_watch).await; + return Ok(()); + } + reader + .seek(std::io::SeekFrom::Start(offset - read as u64)) + .await?; + offset -= read as u64; + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + + emit(cluster_id, &line, events, recorder, crash_watch).await; + } +} + +async fn emit( + cluster_id: i64, + line: &str, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, +) { + // Blank lines are kept so the console reads the way the log does + let text = line.trim_end_matches(['\n', '\r']).to_string(); + if let Some(recorder) = recorder { + recorder.observe(&text).await; + } + // Read on the way past rather than re-opening the log afterwards a crash + // can take the log with it + crash_watch.observe(&text); + events.game_log(cluster_id, text); +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + use tokio::sync::mpsc; + + struct Harness { + dir: async_tempfile::TempDir, + rx: oneclient_events::EventReceiver, + events: EventBus, + } + + impl Harness { + async fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let dir = polyio::tempdir().await.expect("temp dir"); + Self { + dir, + rx, + events: EventBus::new(tx), + } + } + + fn path(&self) -> PathBuf { + self.dir.dir_path().join("cluster-output.log") + } + + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(oneclient_events::Event::Game(oneclient_events::GameEvent::Log { + line, + .. + })) = self.rx.try_recv() + { + out.push(line); + } + out + } + } + + async fn settle() { + tokio::time::sleep(POLL_INTERVAL * 4).await; + } + + #[tokio::test] + async fn follows_lines_appended_after_start() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] first\n").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b"[12:00:01] second\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] first", "[12:00:01] second"]); + } + + #[tokio::test] + async fn waits_for_a_file_that_does_not_exist_yet() { + let mut h = Harness::new().await; + let path = h.path(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + polyio::write(&path, "[12:00:00] late\n").await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] late"]); + } + + #[tokio::test] + async fn holds_back_a_partial_line_until_its_newline_lands() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] half").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + // A line mid-write must not be emitted or it would arrive twice + assert!(h.drain().is_empty()); + + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b" and half\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] half and half"]); + } + + #[tokio::test] + async fn recovers_when_the_log_is_truncated() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] old session line\n") + .await + .unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); + + // A relaunch truncates the log the tail would otherwise sit forever at + // an offset past the new end + polyio::write(&path, "[13:00:00] new\n").await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[13:00:00] new"]); + } + + #[tokio::test] + async fn stops_even_when_the_last_line_has_no_newline() { + let mut h = Harness::new().await; + let path = h.path(); + // A process killed mid-write leaves a newline that never arrives + polyio::write(&path, "[12:00:00] cut off mid-writ") + .await + .unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + + // Waiting for that newline would hang the exit path forever + let stopped = tokio::time::timeout(Duration::from_secs(5), tail.stop()).await; + assert!( + stopped.is_ok(), + "tail.stop() hung on an unterminated final line" + ); + assert_eq!(h.drain(), vec!["[12:00:00] cut off mid-writ"]); + } + + #[tokio::test] + async fn emits_the_final_lines_written_just_before_exit() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] Stopping!\n") + .await + .unwrap(); + + // Stopping immediately as the exit path does must still flush the + // reason the game exited + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); + } +} diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs new file mode 100644 index 00000000..2fa595b1 --- /dev/null +++ b/packages/oneclient_core/src/images/mod.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use bytes::Bytes; +use reqwest::Method; +use tokio::sync::Mutex; + +use crate::LauncherResult; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::RequestError; +use polyio::sha1_bytes; + +pub const DEFAULT_IMAGE_EDGE: u32 = 1600; + +#[derive(Debug, Default)] +pub struct ImageCacheStore { + memory: Mutex>, +} + +impl ImageCacheStore { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn get( + &self, + net: &RequestClient, + url: &str, + max_edge: u32, + ) -> LauncherResult { + let mem_key = Self::mem_key(url, max_edge); + if let Some(hit) = self.memory.lock().await.get(&mem_key).cloned() { + return Ok(hit); + } + + let path = Self::disk_path(url, max_edge)?; + + if let Ok(bytes) = polyio::read(&path).await { + let bytes = Bytes::from(bytes); + self.memory.lock().await.insert(mem_key, bytes.clone()); + return Ok(bytes); + } + + let raw = download(net, url).await?; + let bytes = downscale_if_oversized(raw, max_edge).await; + + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + if let Err(err) = polyio::write(&path, &bytes).await { + tracing::warn!("failed to persist cached image for {url}: {err}"); + } + + self.memory.lock().await.insert(mem_key, bytes.clone()); + + Ok(bytes) + } + + fn mem_key(url: &str, max_edge: u32) -> String { + format!("{max_edge}|{url}") + } + + fn disk_path(url: &str, max_edge: u32) -> LauncherResult { + let mut name = sha1_bytes(url.as_bytes()); + name.push('_'); + name.push_str(&max_edge.to_string()); + if let Some(ext) = extension_from_url(url) { + name.push('.'); + name.push_str(&ext); + } + Ok(paths::images_cache_dir()?.join(name)) + } +} + +static DECODE_LIMIT: std::sync::LazyLock = + std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(2)); + +async fn downscale_if_oversized(bytes: Bytes, max_edge: u32) -> Bytes { + let _permit = DECODE_LIMIT.acquire().await; + let candidate = bytes.clone(); + match tokio::task::spawn_blocking(move || downscale(&candidate, max_edge)).await { + Ok(Some(smaller)) => smaller, + _ => bytes, + } +} + +fn downscale(bytes: &[u8], max_edge: u32) -> Option { + let img = image::load_from_memory(bytes).ok()?; + if img.width().max(img.height()) <= max_edge { + return None; + } + + let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3); + + let mut out = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut out); + if resized.color().has_alpha() { + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; + } else { + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut cursor, 85) + .encode_image(&resized) + .ok()?; + } + + Some(Bytes::from(out)) +} + +#[tracing::instrument(level = "debug", skip(net))] +async fn download(net: &RequestClient, url: &str) -> LauncherResult { + let parsed = url.parse().map_err(RequestError::from)?; + let request = reqwest::Request::new(Method::GET, parsed); + let res = net.send(request).await?; + let bytes = res.bytes().await.map_err(RequestError::from)?; + Ok(bytes) +} + +fn extension_from_url(url: &str) -> Option { + let path = url.split(['?', '#']).next().unwrap_or(url); + let name = path.rsplit('/').next().unwrap_or(path); + let ext = name.rsplit_once('.')?.1; + if ext.is_empty() || ext.len() > 5 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + Some(ext.to_ascii_lowercase()) +} diff --git a/packages/oneclient_core/src/java_store.rs b/packages/oneclient_core/src/java_store.rs new file mode 100644 index 00000000..31f2f0c5 --- /dev/null +++ b/packages/oneclient_core/src/java_store.rs @@ -0,0 +1,79 @@ +//! The adapter lives here rather than in `oneclient_java` so that crate builds +//! without sqlx and can test against `MemoryJavaStore` + +use std::str::FromStr; + +use oneclient_db::{DbPool, dao, models::JavaVersionRow}; +use oneclient_java::{JavaRuntime, JavaStore, JavaVendor, StoreError, StoreResult}; + +pub struct SqlJavaStore { + pool: DbPool, +} + +impl SqlJavaStore { + #[must_use] + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} + +fn row_to_runtime(row: JavaVersionRow) -> JavaRuntime { + JavaRuntime { + absolute_path: row.absolute_path, + major: row.major as u32, + version: row.version, + vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), + os_arch: row.os_arch, + is_jdk: row.is_jdk, + probe_version: row.probe_version.try_into().unwrap_or(0), + } +} + +#[async_trait::async_trait] +impl JavaStore for SqlJavaStore { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let row = dao::java::insert( + &self.pool, + &runtime.absolute_path, + runtime.major, + &runtime.version, + &runtime.vendor.to_string(), + &runtime.os_arch, + runtime.is_jdk, + runtime.probe_version, + ) + .await + .map_err(StoreError::new)?; + + Ok(row_to_runtime(row)) + } + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + Ok(dao::java::get_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } + + async fn latest_by_major(&self, major: u32) -> StoreResult> { + Ok(dao::java::get_latest_by_major(&self.pool, major) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + dao::java::delete_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new) + } + + async fn list(&self) -> StoreResult> { + Ok(dao::java::list_all(&self.pool) + .await + .map_err(StoreError::new)? + .into_iter() + .map(row_to_runtime) + .collect()) + } +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs new file mode 100644 index 00000000..51a948d5 --- /dev/null +++ b/packages/oneclient_core/src/lib.rs @@ -0,0 +1,68 @@ +#![recursion_limit = "256"] + +#[cfg(debug_assertions)] +pub mod dev; + +pub mod changelog; +pub mod clusters; +mod error; +pub mod game; +pub mod images; +mod java_store; +pub mod logger; +pub mod migration; +pub mod recovery; +pub mod reporting; +pub mod settings; +pub mod simulate; +mod state; +pub mod storage; +pub mod tos; +pub mod verify; +pub mod versions; + +pub use changelog::{ChangelogGroup, fetch_changelog, parse_changelog}; +pub use clusters::{ + Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, + ensure_from_bundles, ensure_from_versions, estimate_cluster_download, +}; +pub use error::{LauncherError, LauncherResult, SentryExclusion}; +pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; +pub use images::ImageCacheStore; +pub use migration::{ + ImportTarget, MigrationDetection, MigrationSource, SourceInstance, detect as detect_migration, + import_game_dir as import_migration_game_dir, +}; +pub use oneclient_cluster::logs::{ + LogFileInfo, LogKind, LogLevel, LogLine, LogsError, MclogsUploadResponse, ReadOptions, + delete_log_at, list_cluster_logs, read_log_at, upload_log_at, +}; +pub use oneclient_cluster::screenshots::{ + ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, +}; +pub use oneclient_content::bundles::{ + ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, + BundleManifest, BundleUpdateCheckResult, BundleWithUpdateStatus, BundlesManager, + FileUpdateStatus, apply_bundle_updates, check_bundle_updates, effective_enabled, + get_bundles_with_update_status, install_bundle, install_cluster_bundles, + install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides, + remove_artifact_from_cluster, set_bundle_package_enabled, set_bundle_package_opt_in, + set_bundle_package_override, set_bundle_package_overrides, toggle_artifact_enabled, +}; +pub use oneclient_content::packages::LinkedArtifactInfo; +pub use oneclient_content::packages::updates::{ + BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, +}; +pub use oneclient_discord::{DiscordRpc, Presence}; +pub use oneclient_mc::{McError as MetadataError, MetadataStore}; +pub use settings::ProfileUpdate; +pub use state::LauncherServices; +pub use state::LauncherState; +pub use state::run_startup_tasks; +pub use tos::{TermsDocument, fetch_terms}; +pub use verify::{ClusterVerifyReport, verify_cluster_files}; +pub use versions::{ + RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest, resolve_migration_chain, +}; diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs new file mode 100644 index 00000000..7f47f774 --- /dev/null +++ b/packages/oneclient_core/src/logger.rs @@ -0,0 +1,219 @@ +use std::io; +use std::sync::OnceLock; + +use parking_lot::Mutex; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::Registry; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, reload}; + +use crate::LauncherResult; + +pub mod console; + +/// The launcher's own crates given an explicit directive rather than falling +/// back to the base level +const APP_TARGETS: &[&str] = &[ + "oneclient_app", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", + "oneclient_core", + "oneclient_db", + "oneclient_discord", + "oneclient_events", + "oneclient_java", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", + "polyio", +]; + +/// Pinned regardless of the base level because the debug filter raises the base +/// to `info` and that would otherwise let this lot flood the log +const NOISY_TARGETS: &[&str] = &[ + "calloop", + "freya_core", + "freya_winit", + "h2", + "hickory_proto", + "hickory_resolver", + "html5ever", + "hyper", + "hyper_util", + "mio", + "quinn", + "reqwest", + "rustls", + "smithay_client_toolkit", + // Only `sqlx::query` `sqlx` proper reports pool and migration problems we want + "sqlx::query", + "tokio_util", + "tower", + "want", + "winit", + "zbus", +]; + +/// Whether `target` belongs to one of [`APP_TARGETS`] rather than a dependency +fn is_app_target(target: &str) -> bool { + APP_TARGETS.iter().any(|app| { + target + .strip_prefix(app) + .is_some_and(|rest| rest.is_empty() || rest.starts_with("::")) + }) +} + +fn sentry_event_filter(metadata: &tracing::Metadata<'_>) -> sentry_tracing::EventFilter { + dependency_filter(*metadata.level(), metadata.target()) + .unwrap_or_else(|| sentry_tracing::default_event_filter(metadata)) +} + +/// `None` when the target is one of ours, meaning the default filter decides +fn dependency_filter(level: tracing::Level, target: &str) -> Option { + if is_app_target(target) { + return None; + } + + Some(match level { + // Kept as context for a report of ours, but never a report of its own + tracing::Level::ERROR => sentry_tracing::EventFilter::Breadcrumb, + _ => sentry_tracing::EventFilter::Ignore, + }) +} + +pub fn default_directives() -> String { + directives("info", "warn") +} + +/// Raises the base level too so an unlisted dependency's `info` still reaches +/// the log +pub fn debug_directives() -> String { + directives("debug", "info") +} + +fn directives(app_level: &str, base_level: &str) -> String { + let mut out = String::from(base_level); + for target in NOISY_TARGETS { + out.push_str(&format!(",{target}=warn")); + } + for target in APP_TARGETS { + out.push_str(&format!(",{target}={app_level}")); + } + out +} + +/// The filter layer sits directly on the [`Registry`] so that is the subscriber +/// type the reload handle is parameterised over +type FilterHandle = reload::Handle; + +static FILTER: OnceLock = OnceLock::new(); +static ACTIVE_DIRECTIVES: Mutex = Mutex::new(String::new()); + +#[derive(Debug, thiserror::Error)] +pub enum FilterError { + #[error("invalid log filter directives: {0}")] + Invalid(String), + + #[error("the logger has not been initialized")] + NotInitialized, +} + +pub fn active_directives() -> String { + ACTIVE_DIRECTIVES.lock().clone() +} + +/// Swaps the active filter without restarting +/// Unparseable directives leave the previous filter in place rather than +/// blanking the log +pub fn set_filter(directives: &str) -> Result<(), FilterError> { + let filter = + EnvFilter::try_new(directives).map_err(|err| FilterError::Invalid(err.to_string()))?; + + FILTER + .get() + .ok_or(FilterError::NotInitialized)? + .reload(filter) + .map_err(|err| FilterError::Invalid(err.to_string()))?; + + *ACTIVE_DIRECTIVES.lock() = directives.to_string(); + tracing::info!(%directives, "log filter reloaded"); + + Ok(()) +} + +pub fn init_debug() -> LauncherResult<()> { + init_filtered(debug_directives) +} + +pub fn init() -> LauncherResult<()> { + init_filtered(default_directives) +} + +pub fn init_filtered(filter: impl FnOnce() -> String) -> LauncherResult<()> { + // `RUST_LOG` wins outright rather than merging appending ours would quietly + // override the targets the user asked for + let directives = std::env::var(EnvFilter::DEFAULT_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(filter); + + let env_filter = EnvFilter::try_new(&directives).unwrap_or_else(|_| { + eprintln!( + "ignoring unparseable {} filter: {directives}", + EnvFilter::DEFAULT_ENV + ); + EnvFilter::new(default_directives()) + }); + + let (filter, handle) = reload::Layer::new(env_filter); + let _ = FILTER.set(handle); + *ACTIVE_DIRECTIVES.lock() = directives; + + let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout); + + let sentry_layer = sentry_tracing::layer().event_filter(sentry_event_filter); + + #[cfg(debug_assertions)] + { + tracing_subscriber::registry() + .with(filter) + .with(stdout_layer) + .with(console::layer()) + .with(sentry_layer) + .init(); + } + + #[cfg(not(debug_assertions))] + { + let logs_dir = oneclient_common::paths::logs_dir()?; + std::fs::create_dir_all(&logs_dir)?; + + let log_path = logs_dir.join(format!( + "{}.log", + chrono::Local::now().to_rfc3339().replace(':', "-") + )); + + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + + let file_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(file); + + tracing_subscriber::registry() + .with(filter) + .with(stdout_layer) + .with(file_layer) + .with(console::layer()) + .with(sentry_layer) + .init(); + + tracing::info!(path = %log_path.display(), "writing logs to file"); + } + + Ok(()) +} diff --git a/packages/oneclient_core/src/logger/console.rs b/packages/oneclient_core/src/logger/console.rs new file mode 100644 index 00000000..1435f737 --- /dev/null +++ b/packages/oneclient_core/src/logger/console.rs @@ -0,0 +1,121 @@ +//! Capture is gated on the broadcast channel's receiver count rather than a flag +//! we maintain so a dropped receiver can never leave capture switched on + +use std::sync::{Arc, OnceLock}; + +use tokio::sync::broadcast; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; + +/// A console falling further behind than this loses its oldest lines which +/// beats blocking the thread that emitted the log +const CAPACITY: usize = 4096; + +static CHANNEL: OnceLock> = OnceLock::new(); + +#[derive(Clone, Debug)] +pub struct ConsoleLine { + pub level: Level, + /// Pre-formatted as `[HH:MM:SS.mmm] [target/LEVEL]: message` the shape the + /// log viewer already parses for Minecraft logs + pub text: Arc, +} + +/// Capture runs for exactly as long as this is alive +pub struct ConsoleSubscription(broadcast::Receiver); + +impl ConsoleSubscription { + pub async fn recv(&mut self) -> Result { + self.0.recv().await + } +} + +/// Nothing is buffered before this call so the console opens empty +pub fn subscribe() -> ConsoleSubscription { + ConsoleSubscription(channel().subscribe()) +} + +pub fn is_capturing() -> bool { + CHANNEL.get().is_some_and(|tx| tx.receiver_count() > 0) +} + +pub(super) fn layer() -> impl Layer { + ConsoleLayer +} + +fn channel() -> &'static broadcast::Sender { + CHANNEL.get_or_init(|| broadcast::channel(CAPACITY).0) +} + +struct ConsoleLayer; + +impl Layer for ConsoleLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + // Not `Layer::enabled` that is ANDed into the whole subscriber's + // decision so returning false there would silence stdout and the file too + if !is_capturing() { + return; + } + + let meta = event.metadata(); + let mut visitor = LineVisitor::default(); + event.record(&mut visitor); + + let prefix = format!( + "[{}] [{}/{}]: ", + chrono::Local::now().format("%H:%M:%S%.3f"), + meta.target(), + meta.level().as_str(), + ); + + let level = *meta.level(); + let tx = channel(); + + // Embedded newlines (a panic backtrace say) are sent line by line so the + // viewer can select them only the first carries the prefix + let mut body = visitor.message; + body.push_str(&visitor.fields); + + let mut rest = body.lines(); + let first = rest.next().unwrap_or_default(); + let _ = tx.send(ConsoleLine { + level, + text: format!("{prefix}{first}").into(), + }); + + for line in rest { + let _ = tx.send(ConsoleLine { + level, + text: line.into(), + }); + } + } +} + +/// Renders non-message fields as `key=value` matching what `fmt` writes to stdout +#[derive(Default)] +struct LineVisitor { + message: String, + fields: String, +} + +impl Visit for LineVisitor { + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message.push_str(value); + } else { + self.fields.push_str(&format!(" {}={value}", field.name())); + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message.push_str(&format!("{value:?}")); + } else { + self.fields + .push_str(&format!(" {}={value:?}", field.name())); + } + } +} diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs new file mode 100644 index 00000000..72f3673b --- /dev/null +++ b/packages/oneclient_core/src/migration/mod.rs @@ -0,0 +1,144 @@ +pub mod oneclient_v1; +pub mod vanilla; + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::LauncherResult; +use oneclient_common::domain::GameLoader; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MigrationSource { + OneClientV1, + Vanilla, +} + +impl MigrationSource { + pub const ALL: &'static [MigrationSource] = + &[MigrationSource::OneClientV1, MigrationSource::Vanilla]; + + pub fn id(self) -> &'static str { + match self { + MigrationSource::OneClientV1 => "oneclient_v1", + MigrationSource::Vanilla => "vanilla", + } + } + + pub fn display_name(self) -> &'static str { + match self { + MigrationSource::OneClientV1 => "OneClient", + MigrationSource::Vanilla => "Minecraft", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.iter().copied().find(|s| s.id() == id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceInstance { + /// Source-local identifier (v1 the cluster row id) + /// Purely informational + pub instance_id: i64, + pub folder_name: String, + pub mc_version: String, + /// Set for "migrated" clusters (e.g. 26.1 fabric -> 26.1.2 fabric) + pub target_mc_version: Option, + pub mc_loader: GameLoader, + /// Empty when the source has no category concept + pub categories: Vec, + pub has_game_dir: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MigrationDetection { + pub source: MigrationSource, + pub root: PathBuf, + pub instances: Vec, +} + +impl SourceInstance { + #[must_use] + pub fn import_version(&self) -> &str { + self.target_mc_version + .as_deref() + .unwrap_or(&self.mc_version) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImportTarget { + /// Copy into the shared `/.minecraft` + Shared, + /// Copy into the new cluster's own folder and mark it dedicated so + /// `Cluster::game_dir()` resolves there instead of the shared dir + Dedicated { new_cluster_id: i64 }, +} + +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { + for source in MigrationSource::ALL.iter().copied() { + let detection = match source { + MigrationSource::OneClientV1 => oneclient_v1::detect().await?, + MigrationSource::Vanilla => vanilla::detect().await?, + }; + + if let Some(detection) = detection + && !detection.instances.is_empty() + { + return Ok(Some(detection)); + } + } + Ok(None) +} + +#[tracing::instrument(skip(state))] +pub async fn import_game_dir( + state: &std::sync::Arc, + source: MigrationSource, + folder_name: &str, + target: ImportTarget, +) -> LauncherResult<()> { + match source { + MigrationSource::OneClientV1 => { + oneclient_v1::import_game_dir(state, folder_name, target).await + } + MigrationSource::Vanilla => vanilla::import_game_dir(state, target).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vanilla_is_the_last_resort() { + assert_eq!(MigrationSource::ALL.last(), Some(&MigrationSource::Vanilla)); + } + + #[test] + fn import_version_prefers_resolved_target() { + let mut instance = SourceInstance { + instance_id: 1, + folder_name: "26.1 fabric".to_string(), + mc_version: "26.1".to_string(), + target_mc_version: None, + mc_loader: GameLoader::Fabric, + categories: Vec::new(), + has_game_dir: true, + }; + assert_eq!(instance.import_version(), "26.1"); + + instance.target_mc_version = Some("26.1.2".to_string()); + assert_eq!(instance.import_version(), "26.1.2"); + } + + #[test] + fn ids_round_trip() { + for source in MigrationSource::ALL.iter().copied() { + assert_eq!(MigrationSource::from_id(source.id()), Some(source)); + } + } +} diff --git a/packages/oneclient_core/src/migration/oneclient_v1.rs b/packages/oneclient_core/src/migration/oneclient_v1.rs new file mode 100644 index 00000000..4da642d6 --- /dev/null +++ b/packages/oneclient_core/src/migration/oneclient_v1.rs @@ -0,0 +1,195 @@ +use std::path::{Path, PathBuf}; + +use sqlx::Row; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + +use crate::LauncherResult; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; + +const IMPORT_EXCLUDE_TOP: &[&str] = &["mods", "logs"]; + +pub fn old_root() -> Option { + let base = directories::BaseDirs::new().map(|d| d.data_dir().join("OneClient")); + + let root = match std::env::var("ONECLIENT_V1_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => base?, + }; + + let exists = root.is_dir(); + tracing::debug!(root = %root.display(), exists, "v1 migration: resolved old root"); + exists.then_some(root) +} + +pub fn category_from_bundle_name(name: &str) -> Option { + let start = name.find('[')?; + let end = name[start + 1..].find(']')? + start + 1; + let inner = name[start + 1..end].trim(); + (!inner.is_empty()).then(|| inner.to_string()) +} + +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { + let Some(root) = old_root() else { + return Ok(None); + }; + + let db_path = root.join("user_data.db"); + if !db_path.exists() { + tracing::debug!(db = %db_path.display(), "v1 migration: no user_data.db, skipping"); + return Ok(None); + } + + match detect_inner(&root, &db_path).await { + Ok(detection) => { + tracing::info!( + instances = detection.instances.len(), + "v1 migration: detected old install" + ); + Ok(Some(detection)) + } + Err(err) => { + tracing::warn!(error = %err, "failed to read old launcher database; skipping v1 migration"); + Ok(None) + } + } +} + +#[tracing::instrument(level = "debug")] +async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult { + let opts = SqliteConnectOptions::new() + .filename(db_path) + .read_only(true) + .immutable(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await?; + + let cluster_rows = sqlx::query("SELECT id, folder_name, mc_version, mc_loader FROM clusters") + .fetch_all(&pool) + .await?; + + let mut instances = Vec::with_capacity(cluster_rows.len()); + for row in cluster_rows { + let cluster_id: i64 = row.try_get("id")?; + let folder_name: String = row.try_get("folder_name")?; + let mc_version: String = row.try_get("mc_version")?; + let mc_loader_raw: i64 = row.try_get("mc_loader")?; + + let Some(mc_loader) = GameLoader::from_repr(mc_loader_raw as u8) else { + tracing::warn!( + cluster_id, + mc_loader_raw, + "old cluster has unknown loader; skipping" + ); + continue; + }; + + let categories = fetch_categories(&pool, cluster_id).await?; + + let has_game_dir = polyio::dir_has_content(&root.join("clusters").join(&folder_name)).await; + + instances.push(SourceInstance { + instance_id: cluster_id, + folder_name, + mc_version, + target_mc_version: None, + mc_loader, + categories, + has_game_dir, + }); + } + + pool.close().await; + + Ok(MigrationDetection { + source: MigrationSource::OneClientV1, + root: root.to_path_buf(), + instances, + }) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn fetch_categories(pool: &sqlx::SqlitePool, cluster_id: i64) -> LauncherResult> { + let rows = sqlx::query( + "SELECT DISTINCT bundle_name FROM cluster_packages \ + WHERE cluster_id = ? AND bundle_name IS NOT NULL AND bundle_name <> ''", + ) + .bind(cluster_id) + .fetch_all(pool) + .await?; + + let mut categories: Vec = Vec::new(); + for row in rows { + let bundle_name: String = row.try_get("bundle_name")?; + let Some(category) = category_from_bundle_name(&bundle_name) else { + continue; + }; + + if !categories.iter().any(|c| c.eq_ignore_ascii_case(&category)) { + categories.push(category); + } + } + + Ok(categories) +} + +#[tracing::instrument(skip(state, target))] +pub async fn import_game_dir( + state: &std::sync::Arc, + folder_name: &str, + target: ImportTarget, +) -> LauncherResult<()> { + let Some(root) = old_root() else { + return Ok(()); + }; + let src = root.join("clusters").join(folder_name); + if !src.is_dir() { + tracing::warn!(folder_name, "old cluster folder missing; nothing to import"); + return Ok(()); + } + + let dest = match &target { + ImportTarget::Shared => paths::shared_minecraft_dir()?, + ImportTarget::Dedicated { new_cluster_id } => { + let cluster = state.clusters.get(*new_cluster_id).await?; + let dir = cluster.dir()?; + polyio::create_dir_all(&dir).await?; + // Mark dedicated so game_dir() resolves to this cluster's own dir + polyio::write(cluster.dedicated_marker()?, Vec::new()).await?; + dir + } + }; + + polyio::create_dir_all(&dest).await?; + polyio::copy_dir(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn category_extraction() { + assert_eq!( + category_from_bundle_name("OneClient 1.21.1 Fabric [HUD]"), + Some("HUD".to_string()) + ); + assert_eq!( + category_from_bundle_name("OneClient 1.21.11 Fabric [QoL]"), + Some("QoL".to_string()) + ); + assert_eq!(category_from_bundle_name("no brackets here"), None); + assert_eq!(category_from_bundle_name("empty []"), None); + assert_eq!( + category_from_bundle_name("trims [ Performance ]"), + Some("Performance".to_string()) + ); + } +} diff --git a/packages/oneclient_core/src/migration/vanilla.rs b/packages/oneclient_core/src/migration/vanilla.rs new file mode 100644 index 00000000..fcb8fc72 --- /dev/null +++ b/packages/oneclient_core/src/migration/vanilla.rs @@ -0,0 +1,243 @@ +use std::path::PathBuf; + +use crate::LauncherResult; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; + +const ROOT_INSTANCE_NAME: &str = ".minecraft"; + +const IMPORT_EXCLUDE_TOP: &[&str] = &[ + "mods", + "resourcepacks", + "shaderpacks", + "datapacks", + "versions", + "libraries", + "assets", + "bin", + "jre", + "logs", + "crash-reports", + "launcher_accounts.json", + "launcher_accounts_microsoft_store.json", + "launcher_msa_credentials.bin", + "launcher_profiles.json", + "launcher_settings.json", + "usercache.json", + "webcache", + "webcache2", + ".mojang", + "cheatbreaker_accounts.json", + "feather", + "feather-mods", + "feather-versions", + "optionsLC.txt", + "servers.dat_old", + "cache", + "downloads", + "jfr", + "skyblock-repo-cache", + "meowdding-repo-cache", +]; + +pub fn old_root() -> Option { + let base = directories::BaseDirs::new().map(|d| { + // Windows `%APPDATA%\.minecraft` (data_dir is Roaming) + #[cfg(target_os = "windows")] + { + d.data_dir().join(".minecraft") + } + // macOS `~/Library/Application Support/minecraft` no leading dot + #[cfg(target_os = "macos")] + { + d.data_dir().join("minecraft") + } + // Linux `~/.minecraft` not data_dir() (`~/.local/share`) + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + d.home_dir().join(".minecraft") + } + }); + + let root = match std::env::var("VANILLA_MC_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => base?, + }; + + let exists = root.is_dir(); + tracing::debug!(root = %root.display(), exists, "vanilla migration: resolved old root"); + exists.then_some(root) +} + +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { + let Some(root) = old_root() else { + return Ok(None); + }; + + if !polyio::dir_has_content(&root).await { + tracing::debug!(root = %root.display(), "vanilla migration: empty install, skipping"); + return Ok(None); + } + + tracing::info!(root = %root.display(), "vanilla migration: detected install"); + + Ok(Some(MigrationDetection { + source: MigrationSource::Vanilla, + instances: vec![SourceInstance { + instance_id: 0, + folder_name: ROOT_INSTANCE_NAME.to_string(), + mc_version: String::new(), + target_mc_version: None, + mc_loader: GameLoader::Vanilla, + categories: Vec::new(), + has_game_dir: true, + }], + root, + })) +} + +#[tracing::instrument(skip(state, target))] +pub async fn import_game_dir( + state: &std::sync::Arc, + target: ImportTarget, +) -> LauncherResult<()> { + let Some(src) = old_root() else { + return Ok(()); + }; + + let dest = match &target { + ImportTarget::Shared => paths::shared_minecraft_dir()?, + ImportTarget::Dedicated { new_cluster_id } => { + let cluster = state.clusters.get(*new_cluster_id).await?; + let dir = cluster.dir()?; + polyio::create_dir_all(&dir).await?; + polyio::write(cluster.dedicated_marker()?, Vec::new()).await?; + dir + } + }; + + polyio::create_dir_all(&dest).await?; + polyio::copy_dir(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn excluded(name: &str) -> bool { + IMPORT_EXCLUDE_TOP + .iter() + .any(|e| e.eq_ignore_ascii_case(name)) + } + + #[test] + fn excludes_package_and_launcher_managed_dirs() { + for name in [ + "mods", + "resourcepacks", + "shaderpacks", + "datapacks", + "versions", + "libraries", + "assets", + "logs", + ] { + assert!(excluded(name), "{name} should be excluded"); + } + } + + #[test] + fn excludes_mojang_credentials() { + assert!(excluded("launcher_accounts.json")); + assert!(excluded("launcher_msa_credentials.bin")); + } + + #[test] + fn excludes_third_party_launcher_data() { + for name in ["cheatbreaker_accounts.json", "feather-mods", "jre"] { + assert!(excluded(name), "{name} should be excluded"); + } + } + + #[test] + fn keeps_user_data() { + for name in [ + "saves", + "config", + "options.txt", + "screenshots", + "servers.dat", + ] { + assert!(!excluded(name), "{name} must not be excluded"); + } + } + + #[tokio::test] + async fn import_copies_user_data_and_skips_the_rest() { + let src = polyio::tempdir().await.expect("src dir"); + let dst = polyio::tempdir().await.expect("dst dir"); + let (src, dst) = (src.dir_path(), dst.dir_path()); + + for file in [ + "options.txt", + "servers.dat", + "launcher_accounts.json", + "launcher_msa_credentials.bin", + ] { + polyio::write(src.join(file), b"x".to_vec()).await.unwrap(); + } + for (dir, file) in [ + ("saves", "world/level.dat"), + ("config", "sodium.json"), + ("screenshots", "shot.png"), + ("mods", "sodium.jar"), + ("resourcepacks", "faithful.zip"), + ("shaderpacks", "bsl.zip"), + ("versions", "1.21.1/1.21.1.jar"), + ("libraries", "com/foo/foo.jar"), + ("assets", "objects/ab/abcdef"), + ("logs", "latest.log"), + ] { + let path = src.join(dir).join(file); + polyio::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + polyio::write(path, b"x".to_vec()).await.unwrap(); + } + + polyio::copy_dir(src, dst, IMPORT_EXCLUDE_TOP) + .await + .unwrap(); + + for kept in [ + "options.txt", + "servers.dat", + "saves/world/level.dat", + "config/sodium.json", + "screenshots/shot.png", + ] { + assert!(dst.join(kept).exists(), "{kept} should have been copied"); + } + for skipped in [ + "mods", + "resourcepacks", + "shaderpacks", + "versions", + "libraries", + "assets", + "logs", + "launcher_accounts.json", + "launcher_msa_credentials.bin", + ] { + assert!( + !dst.join(skipped).exists(), + "{skipped} should not have been copied" + ); + } + } +} diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs new file mode 100644 index 00000000..c6b9cff5 --- /dev/null +++ b/packages/oneclient_core/src/recovery.rs @@ -0,0 +1,426 @@ +use std::path::{Path, PathBuf}; + +use oneclient_db::DbPool; +use oneclient_db::dao::{ + artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as cluster_bundle_dao, +}; +use oneclient_db::models::NewCluster; +use strum::IntoEnumIterator; + +use crate::LauncherResult; +use crate::clusters::ClusterStage; +use crate::state::LauncherState; +use oneclient_cluster::profiles::create_profile_from_global; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::paths; +use oneclient_common::version::parse_mc_version; +use oneclient_content::packages::PackageStore; +use polyio::sha1_file; + +const FILE_CONTENT_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +#[derive(Debug, Default, Clone, Copy)] +pub struct RecoveryReport { + pub adopted_clusters: usize, + pub indexed_artifacts: usize, + pub relinked_files: usize, +} + +impl RecoveryReport { + pub fn did_recover(&self) -> bool { + self.adopted_clusters > 0 + } +} + +#[tracing::instrument(skip(state))] +pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult { + let mut report = RecoveryReport::default(); + + // Held across the whole scan+adopt so a concurrent cluster create (folder + // written before its DB row commits) isn't misclassified as an orphan + let _guard = state.clusters.provisioning_guard().await; + + let clusters_dir = paths::clusters_dir()?; + let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; + if orphans.is_empty() { + return Ok(report); + } + + tracing::info!( + orphan_folders = orphans.len(), + "user_data.db appears reset; reconstructing from disk" + ); + + if let Err(err) = state.java.rescan().await { + tracing::warn!("java rescan during recovery failed: {err:#}"); + } + + report.indexed_artifacts = rebuild_artifact_cache(&state.services.db).await?; + + for folder in orphans { + match adopt_cluster(state, &folder, &mut report).await { + Ok(()) => {} + Err(err) => { + tracing::warn!(folder = %folder, "failed to adopt cluster folder: {err:#}"); + } + } + } + + tracing::info!( + adopted = report.adopted_clusters, + artifacts = report.indexed_artifacts, + files = report.relinked_files, + "database reconstruction complete" + ); + + Ok(report) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn orphan_cluster_folders(pool: &DbPool, clusters_dir: &Path) -> LauncherResult> { + let mut orphans = Vec::new(); + for name in list_dir_names(clusters_dir).await? { + if cluster_dao::get_by_folder_name(pool, &name) + .await? + .is_none() + { + orphans.push(name); + } + } + Ok(orphans) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult { + let cache_root = paths::packages_cache_dir()?; + if !cache_root.exists() { + return Ok(0); + } + + let launcher_dir = paths::launcher_dir()?; + let mut indexed = 0; + + for content_name in list_dir_names(&cache_root).await? { + let Some(content_type) = ContentType::from_folder_name(&content_name) else { + continue; + }; + let content_dir = cache_root.join(&content_name); + + for provider_name in list_dir_names(&content_dir).await? { + let Some(provider) = provider_from_dir_name(&provider_name) else { + continue; + }; + let provider_dir = content_dir.join(&provider_name); + + for project_id in list_dir_names(&provider_dir).await? { + let project_dir = provider_dir.join(&project_id); + + for version_id in list_dir_names(&project_dir).await? { + let version_dir = project_dir.join(&version_id); + + for file in list_files(&version_dir).await? { + if let Err(err) = index_cache_file( + pool, + launcher_dir, + content_type, + provider, + &project_id, + &version_id, + &file, + ) + .await + { + tracing::warn!(path = %file.display(), "failed to index cached file: {err:#}"); + continue; + } + indexed += 1; + } + } + } + } + } + + Ok(indexed) +} + +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "debug", skip(pool, launcher_dir, file))] +async fn index_cache_file( + pool: &DbPool, + launcher_dir: &Path, + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, + file: &Path, +) -> LauncherResult<()> { + let hash = sha1_file(file).await?; + let stored_path = relative_launcher_path(launcher_dir, file); + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let size = polyio::stat(file).await.ok().map(|m| m.len() as i64); + + artifact_dao::insert_artifact( + pool, + &hash, + content_type as i64, + &stored_path, + &file_name, + size, + ) + .await?; + + if matches!(provider, ProviderId::Modrinth | ProviderId::CurseForge) { + artifact_dao::upsert_provider_release( + pool, + provider as i64, + project_id, + version_id, + &hash, + &file_name, + version_id, + None, + "[]", + "[]", + ) + .await?; + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(state, report))] +async fn adopt_cluster( + state: &LauncherState, + folder_name: &str, + report: &mut RecoveryReport, +) -> LauncherResult<()> { + let (name, mc_version, loader) = parse_folder_identity(folder_name); + + let global = state.settings.read().global_game_settings.clone(); + let profile = + create_profile_from_global(&state.services.db, &global, &name, None, None).await?; + + let row = cluster_dao::insert( + &state.services.db, + &NewCluster { + name: &name, + folder_name, + mc_version: &mc_version, + mc_loader: loader as i64, + mc_loader_version: None, + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + }, + ) + .await?; + + report.adopted_clusters += 1; + tracing::info!( + cluster_id = row.id, + folder = folder_name, + mc_version = %mc_version, + loader = %loader, + "adopted cluster folder from disk" + ); + + relink_cluster_files(state, row.id, folder_name, report).await?; + Ok(()) +} + +/// Files are taken into the artifact cache not indexed in place the cache is +/// the only place content is materialized from so in-place rows break on tidy-up +#[tracing::instrument(level = "debug", skip(state, report))] +async fn relink_cluster_files( + state: &LauncherState, + cluster_id: i64, + folder_name: &str, + report: &mut RecoveryReport, +) -> LauncherResult<()> { + let pool = &state.services.db; + let content = state.services.content(); + let cluster_root = paths::cluster_dir(folder_name)?; + + for content_type in FILE_CONTENT_TYPES { + let dir = cluster_root.join(content_type.folder_name()); + let files = match list_files(&dir).await { + Ok(files) => files, + Err(_) => continue, + }; + + for file in files { + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let enabled = !file_name.ends_with(".disabled"); + let base_name = file_name.trim_end_matches(".disabled").to_string(); + + let row = match PackageStore::import_local_file( + &file, + content_type, + cluster_id, + &content, + ) + .await + { + Ok(row) => row, + Err(err) => { + tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}"); + continue; + } + }; + + // `import_local_file` links under the artifact's own name which is + // the `.disabled` spelling for a file the user had switched off + if !enabled { + artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0) + .await?; + } + + report.relinked_files += 1; + } + } + + Ok(()) +} + +#[tracing::instrument(skip(state))] +pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<()> { + let clusters = state.clusters.list().await?; + + for cluster in clusters { + let archives = match state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await + { + Ok(archives) => archives, + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + "failed to load bundle archives during recovery: {err:#}" + ); + continue; + } + }; + + for archive in archives { + let bundle_name = &archive.manifest.name; + for file in &archive.manifest.files { + use oneclient_content::bundles::BundleFileKind; + let BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } = &file.kind + else { + continue; + }; + + let Some(release) = artifact_dao::get_provider_release( + &state.services.db, + *provider as i64, + project_id, + version_id, + ) + .await? + else { + continue; + }; + + if artifact_dao::is_cluster_linked(&state.services.db, cluster.id, &release.hash) + .await? + { + cluster_bundle_dao::track_bundle_artifact( + &state.services.db, + cluster.id, + &release.hash, + bundle_name, + version_id, + project_id, + ) + .await?; + } + } + } + } + + Ok(()) +} + +fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) { + let tokens: Vec<&str> = folder_name.split_whitespace().collect(); + + for take in [2usize, 1] { + if tokens.len() > take { + let split = tokens.len() - take; + let loader_str = tokens[split..].join(" "); + if let Ok(loader) = loader_str.parse::() { + let version = tokens[..split].join(" "); + if parse_mc_version(&version).is_some() { + return (folder_name.to_string(), version, loader); + } + } + } + } + + let mc_version = parse_mc_version(folder_name) + .map(|_| folder_name.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + (folder_name.to_string(), mc_version, GameLoader::Vanilla) +} + +fn provider_from_dir_name(name: &str) -> Option { + ProviderId::iter().find(|p| p.dir_name() == name) +} + +fn relative_launcher_path(launcher_dir: &Path, abs: &Path) -> String { + abs.strip_prefix(launcher_dir) + .unwrap_or(abs) + .to_string_lossy() + .replace('\\', "/") +} + +async fn list_dir_names(dir: &Path) -> LauncherResult> { + let mut names = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(names); + }; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(names) +} + +async fn list_files(dir: &Path) -> LauncherResult> { + let mut files = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(files); + }; + while let Some(entry) = entries.next_entry().await? { + if entry + .file_type() + .await + .map(|t| t.is_file()) + .unwrap_or(false) + { + files.push(entry.path()); + } + } + Ok(files) +} diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs new file mode 100644 index 00000000..fbca1d5e --- /dev/null +++ b/packages/oneclient_core/src/reporting.rs @@ -0,0 +1,89 @@ +use std::panic; +use std::sync::Arc; +use std::time::Duration; + +use sentry::protocol::{Breadcrumb, Context, Event}; +use sentry::{ClientInitGuard, ClientOptions}; + +use oneclient_common::constants::SENTRY_DSN; + +const PANIC_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); +const TRACING_FIELDS_CONTEXT: &str = "Rust Tracing Fields"; + +const ENVIRONMENT: &str = if cfg!(debug_assertions) { + "development" +} else { + "production" +}; + +/// `before_send` hook drops events whose log call set `sentry = false` +/// +/// ```ignore +/// tracing::error!(sentry = false, "handled MSA failure, don't report as a crash"); +/// ``` +fn drop_opted_out_events(event: Event<'static>) -> Option> { + let opted_out = matches!( + event.contexts.get(TRACING_FIELDS_CONTEXT), + Some(Context::Other(fields)) + if fields.get("sentry").and_then(serde_json::Value::as_bool) == Some(false) + ); + + if opted_out { None } else { Some(event) } +} + +fn drop_opted_out_breadcrumbs(breadcrumb: Breadcrumb) -> Option { + let opted_out = breadcrumb + .data + .get("sentry") + .and_then(serde_json::Value::as_bool) + == Some(false); + + if opted_out { None } else { Some(breadcrumb) } +} + +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, + before_send: Some(Arc::new(drop_opted_out_events)), + before_breadcrumb: Some(Arc::new(drop_opted_out_breadcrumbs)), + ..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)); + } + })); +} diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs new file mode 100644 index 00000000..12ff3ed1 --- /dev/null +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -0,0 +1,85 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use oneclient_cluster::GameSettingsProfile; + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ViewLayout { + #[default] + Grid, + List, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +#[serde(default)] +pub struct ViewState { + pub layout: ViewLayout, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(default)] +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, + pub global_game_settings: GameSettingsProfile, + pub allow_parallel_running_clusters: bool, + pub dynamic_background_enabled: bool, + pub animations_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 seen_changelog_version: Option, + pub modrinth_api_key: Option, + pub curseforge_api_key: Option, + pub custom_api_endpoint: Option, + pub custom_meta_url_base: Option, +} + +impl LauncherSettings { + pub fn view_state(&self, key: &str) -> ViewState { + self.view_states.get(key).cloned().unwrap_or_default() + } + + pub fn set_view_state(&mut self, key: impl Into, state: ViewState) { + self.view_states.insert(key.into(), state); + } +} + +impl Default for LauncherSettings { + fn default() -> Self { + Self { + settings_version: 1, + log_debug: false, + auto_update: true, + crash_reporting: true, + discord_enabled: true, + enable_gamemode: false, + max_concurrent_requests: 25, + global_game_settings: GameSettingsProfile::default_global_profile(), + allow_parallel_running_clusters: false, + dynamic_background_enabled: true, + animations_enabled: true, + view_states: BTreeMap::new(), + seen_onboarding: false, + accepted_tos_version: 0, + accepted_privacy_version: 0, + seen_versions: Vec::new(), + seen_changelog_version: None, + modrinth_api_key: None, + curseforge_api_key: None, + custom_api_endpoint: None, + custom_meta_url_base: None, + } + } +} diff --git a/packages/oneclient_core/src/settings/mod.rs b/packages/oneclient_core/src/settings/mod.rs new file mode 100644 index 00000000..f98bdf7f --- /dev/null +++ b/packages/oneclient_core/src/settings/mod.rs @@ -0,0 +1,21 @@ +mod launcher; + +pub use launcher::{LauncherSettings, ViewLayout, ViewState}; +pub use oneclient_cluster::{ + GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, +}; +pub use oneclient_common::Resolution; + +pub mod store; + +/// Pushed down by the composition layer on every settings change so the HTTP +/// client never reaches back into global settings (and thus into its own owner) +#[must_use] +pub fn net_config(settings: &LauncherSettings) -> oneclient_net::NetConfig { + oneclient_net::NetConfig::default().with_overrides( + settings.curseforge_api_key.as_deref(), + settings.modrinth_api_key.as_deref(), + settings.custom_api_endpoint.as_deref(), + settings.custom_meta_url_base.as_deref(), + ) +} diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs new file mode 100644 index 00000000..9f472187 --- /dev/null +++ b/packages/oneclient_core/src/settings/store.rs @@ -0,0 +1,75 @@ +use parking_lot::RwLock; + +use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_events::EventBus; + +use super::launcher::LauncherSettings; +use oneclient_cluster::GameSettingsProfile; + +#[tracing::instrument(level = "debug", skip(notify))] +pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { + match async { + let path = paths::settings_file()?; + let exists = polyio::try_exists(&path).await?; + + Ok::(if !exists { + LauncherSettings::default() + } else { + let data = polyio::read(&path).await?; + serde_json::from_slice(&data)? + }) + } + .await + { + Ok(settings) => settings, + Err(err) => { + tracing::warn!("failed to read settings file: {err}"); + + if let Some(notify) = notify { + notify + .notify("Settings") + .body("Failed to load settings") + .error() + .send(); + } + + LauncherSettings::default() + } + } +} + +/// Prefer [`save_settings_and_apply`] this leaves the HTTP client on its old +/// endpoints/keys so those changes only take effect on the next launch +#[tracing::instrument(level = "debug", skip(settings))] +pub async fn save_settings(settings: &LauncherSettings) -> LauncherResult<()> { + let path = paths::settings_file()?; + + let data = serde_json::to_string_pretty(settings)?; + polyio::write_atomic(path, data).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(services, settings))] +pub async fn save_settings_and_apply( + services: &crate::LauncherServices, + settings: &LauncherSettings, +) -> LauncherResult<()> { + save_settings(settings).await?; + services.requester.set_config(super::net_config(settings)); + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn save_global_profile( + settings: &RwLock, + global: GameSettingsProfile, +) -> LauncherResult<()> { + { + let mut lock = settings.write(); + lock.global_game_settings = global; + } + + let snapshot = settings.read().clone(); + save_settings(&snapshot).await +} diff --git a/packages/oneclient_core/src/simulate.rs b/packages/oneclient_core/src/simulate.rs new file mode 100644 index 00000000..c25c6750 --- /dev/null +++ b/packages/oneclient_core/src/simulate.rs @@ -0,0 +1,365 @@ +//! Deliberately damaging an installation so repair paths can be exercised +//! Each mode breaks files differently because the launcher catches different +//! damage at different layers + +use std::path::{Path, PathBuf}; + +use oneclient_common::paths; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_db::dao::artifact as artifact_dao; + +use crate::LauncherResult; +use crate::state::LauncherState; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct SimulationReport { + pub affected: usize, + /// Capped sample not the full list 50 damaged assets must not become a + /// 50-line toast + pub samples: Vec, +} + +impl SimulationReport { + #[must_use] + pub fn summary(&self, verb: &str) -> String { + if self.affected == 0 { + return "Nothing to affect — is the game installed?".to_string(); + } + + let mut text = format!("{verb} {} file(s)", self.affected); + if let Some(first) = self.samples.first() { + text.push_str(&format!(": {first}")); + if self.affected > 1 { + text.push_str(&format!(" and {} more", self.affected - 1)); + } + } + text + } + + fn push(&mut self, path: &Path) { + self.affected += 1; + if self.samples.len() < 3 { + self.samples.push( + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Damage { + /// Same length wrong bytes passes a size check fails a hash check + Corrupt, + /// Half length as an interrupted write would leave it + Truncate, + Delete, +} + +impl Damage { + #[must_use] + pub fn verb(self) -> &'static str { + match self { + Self::Corrupt => "Corrupted", + Self::Truncate => "Truncated", + Self::Delete => "Deleted", + } + } + + fn apply(self, path: &Path) -> std::io::Result<()> { + match self { + Self::Delete => std::fs::remove_file(path), + Self::Truncate => { + let len = std::fs::metadata(path)?.len(); + let file = std::fs::OpenOptions::new().write(true).open(path)?; + file.set_len(len / 2) + } + Self::Corrupt => { + let mut bytes = std::fs::read(path)?; + if bytes.is_empty() { + return Ok(()); + } + // Mid-file so the damage lands past any header a reader + // validates first keeping the file superficially plausible + let start = bytes.len() / 2; + let end = (start + 8).min(bytes.len()); + for byte in &mut bytes[start..end] { + *byte = !*byte; + } + std::fs::write(path, &bytes) + } + } + } +} + +/// Spread across shards rather than taken from one so the verify pass has to +/// walk the whole tree +#[tracing::instrument(level = "debug")] +pub async fn damage_assets(count: usize, damage: Damage) -> LauncherResult { + let dir = paths::assets_object_dir()?; + + tokio::task::spawn_blocking(move || { + let mut report = SimulationReport::default(); + let Ok(shards) = std::fs::read_dir(&dir) else { + return report; + }; + + // One file per shard before a second from any so a small count never + // all lands in `00/` + let mut shard_dirs: Vec = shards + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + shard_dirs.sort(); + + let mut round = 0; + while report.affected < count { + let mut progressed = false; + + for shard in &shard_dirs { + if report.affected >= count { + break; + } + + let Ok(entries) = std::fs::read_dir(shard) else { + continue; + }; + let Some(path) = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .nth(round) + else { + continue; + }; + + progressed = true; + match damage.apply(&path) { + Ok(()) => report.push(&path), + Err(err) => { + tracing::warn!(path = %path.display(), "could not damage: {err}"); + } + } + } + + // Every shard exhausted asking for more than exists is not an error + if !progressed { + break; + } + round += 1; + } + + tracing::warn!( + affected = report.affected, + ?damage, + "simulated asset damage" + ); + report + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +/// Libraries are on the classpath so damage stops the game outright +#[tracing::instrument(level = "debug")] +pub async fn damage_libraries(count: usize, damage: Damage) -> LauncherResult { + let dir = paths::libraries_dir()?; + + tokio::task::spawn_blocking(move || { + let mut report = SimulationReport::default(); + let mut stack = vec![dir]; + + while let Some(current) = stack.pop() { + if report.affected >= count { + break; + } + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().is_none_or(|ext| ext != "jar") { + continue; + } + if report.affected >= count { + break; + } + match damage.apply(&path) { + Ok(()) => report.push(&path), + Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"), + } + } + } + + tracing::warn!( + affected = report.affected, + ?damage, + "simulated library damage" + ); + report + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +/// Reproduces the reported failure a cluster marked `Ready` skips preparation +/// on later launches so nothing notices the missing tree until a read fails +#[tracing::instrument(level = "debug")] +pub async fn delete_assets_tree() -> LauncherResult { + let dir = paths::assets_dir()?; + + let existed = polyio::try_exists(&dir).await.unwrap_or(false); + if existed { + tokio::fs::remove_dir_all(&dir) + .await + .map_err(std::io::Error::other)?; + } + + tracing::warn!(path = %dir.display(), existed, "simulated a missing assets tree"); + + Ok(SimulationReport { + affected: usize::from(existed), + samples: vec![dir.display().to_string()], + }) +} + +/// Content is cached content-addressed so a mod that no longer hashes to its +/// key must be refetched from the provider a different path from game files +#[tracing::instrument(level = "debug", skip(state))] +pub async fn damage_cluster_content( + state: &std::sync::Arc, + cluster_id: i64, + count: usize, + damage: Damage, +) -> LauncherResult { + let content = state.services.content(); + let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?; + + let mut paths = Vec::new(); + for link in linked { + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await? + else { + continue; + }; + if let Ok(path) = artifact_absolute_path(&artifact.path) { + paths.push(path); + } + if paths.len() >= count { + break; + } + } + + tokio::task::spawn_blocking(move || { + let mut report = SimulationReport::default(); + for path in paths { + if !path.is_file() { + continue; + } + match damage.apply(&path) { + Ok(()) => report.push(&path), + Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"), + } + } + + tracing::warn!( + affected = report.affected, + ?damage, + "simulated content damage" + ); + report + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("oneclient-simulate-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn corrupting_keeps_the_length_and_changes_the_bytes() { + // Must survive a size check so only the hashing pass can catch it + let dir = scratch("corrupt"); + let file = dir.join("object.bin"); + let original: Vec = (0..=255u8).collect(); + std::fs::write(&file, &original).unwrap(); + + Damage::Corrupt.apply(&file).unwrap(); + + let after = std::fs::read(&file).unwrap(); + assert_eq!(after.len(), original.len(), "length must not change"); + assert_ne!(after, original, "bytes must change"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn corrupting_an_empty_file_is_a_no_op_rather_than_a_panic() { + let dir = scratch("corrupt-empty"); + let file = dir.join("empty.bin"); + std::fs::write(&file, []).unwrap(); + + Damage::Corrupt.apply(&file).unwrap(); + + assert!(std::fs::read(&file).unwrap().is_empty()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn truncating_halves_the_file() { + let dir = scratch("truncate"); + let file = dir.join("object.bin"); + std::fs::write(&file, vec![7u8; 100]).unwrap(); + + Damage::Truncate.apply(&file).unwrap(); + + assert_eq!(std::fs::metadata(&file).unwrap().len(), 50); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_report_over_nothing_says_so() { + let report = SimulationReport::default(); + assert_eq!( + report.summary("Corrupted"), + "Nothing to affect — is the game installed?" + ); + } + + #[test] + fn a_report_names_a_sample_and_counts_the_rest() { + let mut report = SimulationReport::default(); + report.push(Path::new("/tmp/assets/objects/00/aaaa")); + report.push(Path::new("/tmp/assets/objects/01/bbbb")); + report.push(Path::new("/tmp/assets/objects/02/cccc")); + report.push(Path::new("/tmp/assets/objects/03/dddd")); + + assert_eq!(report.affected, 4); + assert_eq!(report.samples.len(), 3); + assert_eq!( + report.summary("Corrupted"), + "Corrupted 4 file(s): aaaa and 3 more" + ); + } +} diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs new file mode 100644 index 00000000..3e55c495 --- /dev/null +++ b/packages/oneclient_core/src/state.rs @@ -0,0 +1,166 @@ +use std::sync::Arc; + +use oneclient_db::DbPool; +use tokio::sync::Mutex; + +use crate::LauncherResult; +use crate::clusters::ClusterManager; +use crate::images::ImageCacheStore; +use crate::settings::{LauncherSettings, store}; +use crate::versions::VersionsManager; +use oneclient_auth::AuthService; +use oneclient_common::paths; +use oneclient_content::bundles::BundlesManager; +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_discord::DiscordRpc; +use oneclient_events::EventBus; +use oneclient_java::JavaService; +use oneclient_mc::MetadataStore; +use oneclient_net::RequestClient; + +#[derive(Clone)] +pub struct LauncherServices { + pub events: EventBus, + pub requester: RequestClient, + pub db: DbPool, + pub packages: PackageProviderRegistry, +} + +pub struct LauncherState { + pub services: LauncherServices, + pub settings: parking_lot::RwLock, + pub auth: Arc, + pub java: JavaService, + pub clusters: ClusterManager, + pub metadata: Mutex, + pub bundles: Arc, + pub versions: Arc, + pub images: ImageCacheStore, + pub games: crate::game::GameProcessManager, + pub discord: DiscordRpc, +} + +impl LauncherServices { + #[must_use] + pub fn content(&self) -> oneclient_content::ContentCtx { + oneclient_content::ContentCtx::new( + self.db.clone(), + self.requester.clone(), + self.events.clone(), + self.packages.clone(), + ) + } + + /// Both fields are refcounted handles so building this per call is cheap + #[must_use] + pub fn mc(&self) -> oneclient_mc::McCtx { + oneclient_mc::McCtx::new(self.requester.clone(), self.events.clone()) + } +} + +impl LauncherState { + /// No side effects beyond opening the database and reading settings the + /// background startup work is [`run_startup_tasks`] which the caller runs + #[tracing::instrument(skip(events))] + pub async fn new(events: EventBus) -> LauncherResult> { + let services = LauncherServices { + events, + db: oneclient_db::connect(paths::database_file()?).await?, + // Defaults because loading settings needs the event bus already inside + // `services` Real config is pushed in below before any request + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + packages: PackageProviderRegistry::new(), + }; + + let settings = store::load_settings(Some(&services.events)).await; + services + .requester + .set_config(crate::settings::net_config(&settings)); + let auth = + Arc::new(AuthService::load(services.requester.clone(), services.events.clone()).await?); + let java = JavaService::new( + std::sync::Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); + let discord = DiscordRpc::spawn(settings.discord_enabled); + + let clusters = ClusterManager::new(services.db.clone()); + + let state = Arc::new(Self { + services, + settings: parking_lot::RwLock::new(settings), + auth, + java, + clusters, + metadata: Mutex::new(MetadataStore::new()), + bundles: Arc::new(BundlesManager::new()), + versions: Arc::new(VersionsManager::from_cache().await), + images: ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord, + }); + + Ok(state) + } +} + +pub fn run_startup_tasks(state: &Arc) { + let background = Arc::clone(state); + tokio::spawn(async move { + let recovery = match crate::recovery::reconstruct_from_disk(&background).await { + Ok(report) => report, + Err(err) => { + tracing::error!("disk recovery failed: {err:#}"); + crate::recovery::RecoveryReport::default() + } + }; + + crate::game::recover_sessions(&background).await; + + let content = background.services.content(); + let (versions_res, bundles_res) = tokio::join!( + background.versions.sync(&background.services), + background.bundles.sync(&content), + ); + if let Err(err) = versions_res { + tracing::error!("versions manifest sync failed: {err:#}"); + } + if let Err(err) = bundles_res { + tracing::error!("bundle catalog sync failed: {err:#}"); + } + + if recovery.did_recover() + && let Err(err) = crate::recovery::restore_bundle_tracking(&background).await + { + tracing::warn!("bundle tracking restore failed: {err:#}"); + } + + if let Err(err) = crate::clusters::apply_remote_migrations(&background).await { + tracing::error!("cluster migrations failed: {err:#}"); + } + + if let Err(err) = crate::clusters::ensure_from_versions(&background).await { + tracing::error!("versions cluster provisioning failed: {err:#}"); + } else { + background + .services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } + + // Must run last judging an artifact unused before recovery bundle + // tracking and provisioning have restored their rows would evict + // content still in use Only the row-driven half runs unattended + if let Err(err) = + oneclient_content::packages::store::collect_unused_artifacts(&content).await + { + tracing::warn!("package cache cleanup failed: {err:#}"); + } + + background + .services + .events + .signal(oneclient_events::Signal::SyncComplete); + }); +} diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs new file mode 100644 index 00000000..070cec00 --- /dev/null +++ b/packages/oneclient_core/src/storage.rs @@ -0,0 +1,380 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::ContentType; +use oneclient_common::paths; +use oneclient_content::packages::store::{find_unreferenced_files, remove_unreferenced_files}; + +const LEGACY_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageEntry { + pub label: String, + pub bytes: u64, + pub path: PathBuf, + pub files: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReclaimableEntry { + pub bytes: u64, + pub files: usize, +} + +impl ReclaimableEntry { + #[must_use] + pub fn is_empty(&self) -> bool { + self.files == 0 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageReport { + pub total_bytes: u64, + pub categories: Vec, + pub clusters: Vec, + pub unreferenced_cache: ReclaimableEntry, + pub legacy_cluster_content: ReclaimableEntry, +} + +#[tracing::instrument(skip(state))] +pub async fn storage_report(state: &LauncherState) -> LauncherResult { + #[cfg(debug_assertions)] + if let Some(report) = fixture_report() { + return Ok(report); + } + + let launcher = paths::launcher_dir()?; + + let mut categories = vec![ + entry("Packages", paths::packages_cache_dir()?).await, + entry("Minecraft versions", paths::versions_dir()?).await, + entry("Libraries", paths::libraries_dir()?).await, + entry("Assets", paths::assets_dir()?).await, + entry("Natives", paths::natives_dir()?).await, + entry("Java runtimes", paths::java_dir()?).await, + entry("Shared game directory", paths::shared_minecraft_dir()?).await, + entry("Logs", paths::logs_dir()?).await, + entry("Image cache", paths::images_cache_dir()?).await, + entry("Bundles", paths::bundles_dir()?).await, + ]; + categories.sort_by_key(|entry| std::cmp::Reverse(entry.bytes)); + + let mut clusters = Vec::new(); + for cluster in state.clusters.list().await? { + let Ok(dir) = cluster.dir() else { + continue; + }; + clusters.push(entry(&cluster.name, dir).await); + } + clusters.sort_by_key(|entry| std::cmp::Reverse(entry.bytes)); + + let unreferenced = find_unreferenced_files(&state.services.content()).await?; + let unreferenced_cache = ReclaimableEntry { + bytes: unreferenced.iter().map(|(_, size)| size).sum(), + files: unreferenced.len(), + }; + + Ok(StorageReport { + total_bytes: dir_size(launcher).await, + categories, + clusters, + unreferenced_cache, + legacy_cluster_content: legacy_cluster_content(state).await?, + }) +} + +/// Content is materialized from the cache now so anything in a cluster's own +/// folder is an inert leftover from an older launcher space not correctness +async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult { + let mut found = ReclaimableEntry::default(); + + for cluster in state.clusters.list().await? { + // A dedicated cluster's folder *is* its game directory so content there belongs + if cluster.uses_dedicated_dir() { + continue; + } + let Ok(root) = cluster.dir() else { + continue; + }; + + for content_type in LEGACY_TYPES { + let dir = root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + if file_type.is_symlink() { + found.files += 1; + continue; + } + + if file_type.is_file() && is_content_file(content_type, name) { + found.files += 1; + found.bytes += polyio::stat(&path).await.map(|m| m.len()).unwrap_or(0); + } + } + } + } + + Ok(found) +} + +fn is_content_file(content_type: ContentType, name: &str) -> bool { + let lower = name.to_lowercase(); + let lower = lower.trim_end_matches(".disabled"); + + match content_type { + ContentType::Mod => lower.ends_with(".jar"), + ContentType::ResourcePack | ContentType::Shader | ContentType::DataPack => { + lower.ends_with(".zip") + } + _ => false, + } +} + +async fn entry(label: &str, path: PathBuf) -> StorageEntry { + StorageEntry { + label: label.to_string(), + bytes: dir_size(&path).await, + path, + files: None, + } +} + +/// Links are not followed so a materialized game directory does not appear to +/// double the size of the package cache +pub async fn dir_size(root: impl AsRef) -> u64 { + let mut total = 0; + let mut stack = vec![root.as_ref().to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if file_type.is_symlink() { + continue; + } + + if file_type.is_dir() { + stack.push(entry.path()); + } else if let Ok(meta) = entry.metadata().await { + total += meta.len(); + } + } + } + + total +} + +/// Cleanup refuses to run while fixture numbers are shown the report bears no +/// relation to disk so the only thing it could delete is the user's real data +fn showing_fixture() -> bool { + #[cfg(debug_assertions)] + { + std::env::var("ONECLIENT_FAKE_STORAGE").is_ok() + } + + #[cfg(not(debug_assertions))] + false +} + +pub async fn clean_unreferenced_cache(state: &LauncherState) -> LauncherResult { + if showing_fixture() { + tracing::info!("fixture storage report is active; skipping cache cleanup"); + return Ok(0); + } + + let report = remove_unreferenced_files(&state.services.content()).await?; + Ok(report.bytes_freed) +} + +pub async fn clean_legacy_cluster_content(state: &LauncherState) -> LauncherResult { + if showing_fixture() { + tracing::info!("fixture storage report is active; skipping leftover cleanup"); + return Ok(0); + } + + let report = crate::clusters::unlink_legacy_cluster_content(state).await?; + Ok(report.removed) +} + +/// Set `ONECLIENT_FAKE_STORAGE` to `empty` `clean` or `full` to return a fixture +/// instead of scanning disk +/// Read on every refresh so no rebuild is needed +#[cfg(debug_assertions)] +fn fixture_report() -> Option { + let scenario = std::env::var("ONECLIENT_FAKE_STORAGE").ok()?; + + let fake = |label: &str, bytes: u64| StorageEntry { + label: label.to_string(), + bytes, + path: PathBuf::from("/tmp/oneclient-fake").join(label.to_lowercase().replace(' ', "-")), + files: None, + }; + + let report = match scenario.trim().to_lowercase().as_str() { + "empty" => StorageReport { + total_bytes: 0, + categories: Vec::new(), + clusters: Vec::new(), + unreferenced_cache: ReclaimableEntry::default(), + legacy_cluster_content: ReclaimableEntry::default(), + }, + "clean" => StorageReport { + total_bytes: 1_284_000_000, + categories: vec![ + fake("Minecraft versions", 512_000_000), + fake("Packages", 368_000_000), + fake("Libraries", 240_000_000), + fake("Assets", 96_000_000), + fake("Java runtimes", 61_000_000), + fake("Shared game directory", 5_400_000), + fake("Logs", 1_200_000), + fake("Image cache", 400_000), + ], + clusters: vec![ + fake("26.1.2 Fabric", 42_000_000), + fake("26.2 Fabric", 18_500_000), + fake("1.21.11 Fabric", 9_100_000), + ], + unreferenced_cache: ReclaimableEntry::default(), + legacy_cluster_content: ReclaimableEntry::default(), + }, + "full" => StorageReport { + total_bytes: 4_930_000_000, + categories: vec![ + fake("Packages", 2_100_000_000), + fake("Minecraft versions", 1_400_000_000), + fake("Libraries", 890_000_000), + fake("Assets", 412_000_000), + fake("Java runtimes", 118_000_000), + fake("Natives", 6_800_000), + fake("Shared game directory", 2_100_000), + fake("Logs", 940_000), + fake("Bundles", 120_000), + ], + clusters: vec![ + fake("26.1.2 Fabric", 310_000_000), + fake("26.2 Fabric", 148_000_000), + fake("1.21.11 Fabric", 96_000_000), + fake("1.21.10 Fabric", 12_000_000), + fake("1.21.1 Fabric", 0), + ], + unreferenced_cache: ReclaimableEntry { + bytes: 743_000_000, + files: 214, + }, + legacy_cluster_content: ReclaimableEntry { + bytes: 261_000_000, + files: 439, + }, + }, + other => { + tracing::warn!( + scenario = other, + "unknown ONECLIENT_FAKE_STORAGE value; expected empty, clean or full" + ); + return None; + } + }; + + tracing::info!(scenario = %scenario, "serving fixture storage report"); + Some(report) +} + +#[must_use] +pub fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"]; + + if bytes < 1000 { + return format!("{bytes} B"); + } + + let mut value = bytes as f64 / 1000.0; + let mut unit = 0; + + while value >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + + if value >= 100.0 { + format!("{value:.0} {}", UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_read_the_way_a_person_would_say_them() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(999), "999 B"); + assert_eq!(format_bytes(1_500), "1.5 KB"); + assert_eq!(format_bytes(628_000_000), "628 MB"); + assert_eq!(format_bytes(2_400_000_000), "2.4 GB"); + } + + #[test] + fn disabled_content_still_counts_as_content() { + assert!(is_content_file(ContentType::Mod, "sodium.jar")); + assert!(is_content_file(ContentType::Mod, "sodium.jar.disabled")); + assert!(is_content_file(ContentType::Shader, "BSL.zip")); + assert!(!is_content_file(ContentType::Shader, "BSL.zip.txt")); + assert!(!is_content_file(ContentType::Mod, "options.txt")); + } + + #[tokio::test] + async fn size_ignores_what_links_point_at() { + let root = polyio::testing::ScratchDir::new("dir_size"); + let dir = root.path(); + polyio::create_dir_all(dir.join("nested")).await.unwrap(); + + polyio::write(dir.join("a.bin"), vec![0u8; 1000]) + .await + .unwrap(); + polyio::write(dir.join("nested").join("b.bin"), vec![0u8; 500]) + .await + .unwrap(); + + assert_eq!(dir_size(dir).await, 1500); + + polyio::symlink_file(dir.join("a.bin"), dir.join("link.bin")) + .await + .unwrap(); + assert_eq!( + dir_size(dir).await, + 1500, + "a link is not another copy of its target" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs new file mode 100644 index 00000000..f58e6ba7 --- /dev/null +++ b/packages/oneclient_core/src/tos.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +use crate::{LauncherError, LauncherResult}; +use oneclient_common::constants; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::{EtagPolicy, fetch_cached}; + +#[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(net))] +pub async fn fetch_terms(net: &RequestClient) -> LauncherResult { + let url = format!("{}/oneclient/tos.json", net.config().meta_url_base); + let cache_path = paths::caches_dir()?.join("TERMS.json"); + + let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + .await? + .ok_or_else(|| LauncherError::InvalidSettingsProfile { + reason: "terms are unavailable and not cached".to_string(), + })?; + + Ok(fetched.json()?) +} + +#[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/packages/oneclient_core/src/verify.rs b/packages/oneclient_core/src/verify.rs new file mode 100644 index 00000000..e542ef3b --- /dev/null +++ b/packages/oneclient_core/src/verify.rs @@ -0,0 +1,403 @@ +use std::sync::Arc; + +use oneclient_cluster::ClusterError; +use oneclient_common::paths; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; +use polyio::{normalize_hash, sha1_file}; + +use crate::LauncherResult; +use crate::clusters::prepare::prepare_cluster_locked; +use crate::game::{ + download_version_info, get_loader_version, resolve_minecraft_version, verify_game_files, +}; +use crate::state::LauncherState; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ClusterVerifyReport { + pub checked: usize, + /// Mismatched files also deleted from disk + pub corrupt: usize, + pub missing: usize, + pub repaired: usize, + /// Corrupt with no source to re-fetch from named so the user can replace them + pub unrepairable: Vec, + /// Game files re-downloaded uncounted because the assets index was gone too + pub reinstalled_game_files: bool, +} + +impl ClusterVerifyReport { + #[must_use] + pub fn is_clean(&self) -> bool { + self.corrupt == 0 + && self.missing == 0 + && self.unrepairable.is_empty() + && !self.reinstalled_game_files + } + + #[must_use] + pub fn summary(&self) -> String { + if self.is_clean() { + return format!("All {} files verified.", self.checked); + } + + if self.reinstalled_game_files && self.corrupt == 0 && self.missing == 0 { + return "Game files were missing and have been re-downloaded.".to_string(); + } + + let mut parts = Vec::new(); + if self.corrupt > 0 { + parts.push(format!("{} corrupt", self.corrupt)); + } + if self.missing > 0 { + parts.push(format!("{} missing", self.missing)); + } + if self.repaired > 0 { + parts.push(format!("{} repaired", self.repaired)); + } + if !self.unrepairable.is_empty() { + parts.push(format!("{} could not be replaced", self.unrepairable.len())); + } + + format!("Checked {} files: {}.", self.checked, parts.join(", ")) + } +} + +/// Deletes bad files and re-prepares rather than overwriting in place to reuse +/// the one well-tested download path instead of maintaining a second +#[tracing::instrument(skip(state), level = "debug")] +pub async fn verify_cluster_files( + state: &Arc, + cluster_id: i64, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + + let progress = GroupedProgressSession::start( + &state.services.events, + format!("Verifying files - {}", cluster.mc_version), + ); + + let result = run_verify(state, cluster_id, &progress).await; + progress.finish(); + + match &result { + Ok(report) => tracing::info!( + cluster_id, + checked = report.checked, + corrupt = report.corrupt, + missing = report.missing, + repaired = report.repaired, + "cluster verification finished" + ), + Err(err) => tracing::error!(cluster_id, "cluster verification failed: {err}"), + } + + result +} + +async fn run_verify( + state: &Arc, + cluster_id: i64, + progress: &GroupedProgressSession, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + // Scoped so the metadata lock is released before the repair pass which takes it too + let (version_info, minecraft_updated) = { + let mut metadata = state.metadata.lock().await; + let (version, _index, minecraft_updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + + let info = download_version_info( + &state.services.mc(), + Some(progress), + &version, + loader_version.as_ref(), + false, + ) + .await?; + + (info, minecraft_updated) + }; + + let mut report = ClusterVerifyReport::default(); + + // Absent or corrupt index means nothing to verify assets against so skip + // rather than fail prepare fetches it and the assets below + let assets_index = cached_assets_index(&version_info).await; + + // Library rules are per-architecture use the runtime this cluster launches + // with or a different library set than the launch path installs is verified + let java_arch = { + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await + .ok() + .flatten() + .map_or_else(|| std::env::consts::ARCH.to_string(), |rt| rt.os_arch) + }; + + // Counted apart from content which repairs itself as it goes one bucket + // would count the content ones twice + let mut game_broken = 0; + + // The index lives in the assets directory so its absence usually means that + // whole directory is gone hand the work to prepare + let reinstalled_game_files = assets_index.is_none(); + + if let Some(assets_index) = assets_index { + let game = verify_game_files( + progress, + &version_info, + assets_index, + &java_arch, + minecraft_updated, + ) + .await?; + + report.checked += game.checked; + report.corrupt += game.corrupt; + report.missing += game.missing; + game_broken = game.corrupt + game.missing; + } else { + tracing::warn!("assets index is missing; re-downloading the game files wholesale"); + } + + verify_cluster_content(state, cluster_id, progress, &mut report).await?; + + // Runs `prepare` directly not the launch path which skips clusters already + // marked `Ready` and so never heals one with deleted files + if game_broken > 0 || reinstalled_game_files { + prepare_cluster_locked(state, cluster_id, false, true, true, Some(progress)).await?; + report.repaired += game_broken; + } + + report.reinstalled_game_files = reinstalled_game_files; + + Ok(report) +} + +/// The content cache is content-addressed so a file that does not hash to its +/// own key is unusable nothing can find it again +async fn verify_cluster_content( + state: &Arc, + cluster_id: i64, + progress: &GroupedProgressSession, + report: &mut ClusterVerifyReport, +) -> LauncherResult<()> { + let content = state.services.content(); + let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?; + if linked.is_empty() { + return Ok(()); + } + + let child = progress.child( + "Verifying content", + linked.len() as u64, + TaskCategory::Packages, + ); + child.set_phase(TaskPhase::Verifying); + + for (index, link) in linked.iter().enumerate() { + child.set_progress(index as u64, Some(linked.len() as u64)); + + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await? + else { + continue; + }; + + let Ok(path) = artifact_absolute_path(&artifact.path) else { + continue; + }; + + if !path.is_file() { + report.missing += 1; + repair_content(state, link, report).await; + continue; + } + + let actual = match sha1_file(&path).await { + Ok(actual) => actual, + Err(err) => { + tracing::warn!(path = %path.display(), "could not hash content: {err}"); + report.corrupt += 1; + let _ = polyio::remove_file(&path).await; + repair_content(state, link, report).await; + continue; + } + }; + + report.checked += 1; + + if normalize_hash(&actual) == normalize_hash(&link.hash) { + continue; + } + + tracing::warn!( + file = %link.file_name, + expected = %link.hash, + %actual, + "corrupt content file; removing" + ); + report.corrupt += 1; + let _ = polyio::remove_file(&path).await; + repair_content(state, link, report).await; + } + + child.finish(); + Ok(()) +} + +/// Failures are recorded not propagated one delisted mod should not abandon +/// verification of everything else +async fn repair_content( + state: &Arc, + link: &oneclient_content::packages::LinkedArtifactInfo, + report: &mut ClusterVerifyReport, +) { + let content = state.services.content(); + + let (Some(provider_id), Some(project_id), Some(version_id)) = ( + link.provider, + link.project_id.as_deref(), + link.version_id.as_deref(), + ) else { + tracing::warn!(file = %link.file_name, "no provider to re-download from"); + report.unrepairable.push(link.file_name.clone()); + return; + }; + + let outcome = async { + let provider = content.providers.get(provider_id)?; + let version = provider + .get_version(project_id, version_id, &content) + .await?; + + // The cluster is pinned to one exact file by hash another file from the + // same version would silently change what is installed + let file = version + .files + .iter() + .find(|file| normalize_hash(&file.sha1) == normalize_hash(&link.hash)) + .ok_or_else(|| oneclient_content::ContentError::InvalidData { + reason: format!("{} is no longer served by {provider_id:?}", link.file_name), + })?; + + oneclient_content::packages::store::download_version_file( + provider_id, + project_id, + &version, + link.content_type, + file, + true, + None, + &content, + ) + .await + } + .await; + + match outcome { + Ok(_) => { + tracing::info!(file = %link.file_name, "re-downloaded corrupt content"); + report.repaired += 1; + } + Err(err) => { + tracing::warn!(file = %link.file_name, "could not re-download: {err}"); + report.unrepairable.push(link.file_name.clone()); + } + } +} + +async fn cached_assets_index( + info: &interfrost::api::minecraft::VersionInfo, +) -> Option { + let path = paths::assets_index_dir() + .ok()? + .join(format!("{}.json", info.asset_index.id)); + polyio::read_json(&path).await.ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_clean_sweep_says_so() { + let report = ClusterVerifyReport { + checked: 5123, + ..Default::default() + }; + + assert!(report.is_clean()); + assert_eq!(report.summary(), "All 5123 files verified."); + } + + #[test] + fn a_repaired_sweep_reports_what_it_did() { + let report = ClusterVerifyReport { + checked: 5123, + corrupt: 3, + missing: 1, + repaired: 4, + unrepairable: Vec::new(), + reinstalled_game_files: false, + }; + + assert!(!report.is_clean()); + assert_eq!( + report.summary(), + "Checked 5123 files: 3 corrupt, 1 missing, 4 repaired." + ); + } + + #[test] + fn a_wholesale_reinstall_is_not_reported_as_a_clean_sweep() { + let report = ClusterVerifyReport { + checked: 2, + reinstalled_game_files: true, + ..Default::default() + }; + + assert!(!report.is_clean()); + assert_eq!( + report.summary(), + "Game files were missing and have been re-downloaded." + ); + } + + #[test] + fn content_that_cannot_be_replaced_is_still_reported() { + let report = ClusterVerifyReport { + checked: 12, + corrupt: 1, + repaired: 0, + unrepairable: vec!["my-local-mod.jar".to_string()], + ..Default::default() + }; + + assert!(!report.is_clean()); + assert_eq!( + report.summary(), + "Checked 12 files: 1 corrupt, 1 could not be replaced." + ); + } +} diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs new file mode 100644 index 00000000..d1ab08be --- /dev/null +++ b/packages/oneclient_core/src/versions/manager.rs @@ -0,0 +1,93 @@ +use tokio::sync::RwLock; + +use crate::LauncherResult; +use crate::state::LauncherServices; +use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest}; +use oneclient_common::paths; +use oneclient_net::{EtagPolicy, fetch_cached}; + +pub struct VersionsManager { + manifest: RwLock, +} + +impl VersionsManager { + #[must_use] + pub fn new() -> Self { + Self { + manifest: RwLock::new(VersionsManifest::default()), + } + } + + /// Seeds from the last synced copy on disk so the UI renders stale metadata + /// immediately instead of blocking on a request that usually 304s + #[must_use] + pub async fn from_cache() -> Self { + let manifest = Self::cached_manifest() + .await + .unwrap_or_else(VersionsManifest::default); + + Self { + manifest: RwLock::new(manifest), + } + } + + async fn cached_manifest() -> Option { + let path = paths::caches_dir().ok()?.join("versions.json"); + let bytes = polyio::read(&path).await.ok()?; + match serde_json::from_slice(&bytes) { + Ok(manifest) => Some(manifest), + Err(err) => { + tracing::warn!("cached versions manifest is unreadable: {err}"); + None + } + } + } + + #[tracing::instrument(level = "debug", skip(self, services))] + pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { + let Some((manifest, changed)) = Self::fetch_manifest(services).await? else { + tracing::debug!("skipping versions sync; no remote or cached manifest available"); + return Ok(false); + }; + *self.manifest.write().await = manifest; + Ok(changed) + } + + pub async fn metadata(&self, meta_url_base: &str) -> Vec { + self.manifest.read().await.metadata(meta_url_base) + } + + pub async fn migrations(&self) -> Vec { + self.manifest.read().await.migrations.clone() + } + + #[tracing::instrument(level = "debug", skip(services))] + async fn fetch_manifest( + services: &LauncherServices, + ) -> LauncherResult> { + let manifest_path = paths::caches_dir()?.join("versions.json"); + let url = format!( + "{}/oneclient/versions/metadata.json", + services.requester.config().meta_url_base + ); + + let Some(fetched) = fetch_cached( + &services.requester, + &url, + &manifest_path, + EtagPolicy::CommitNow, + ) + .await? + else { + return Ok(None); + }; + + Ok(Some((fetched.json()?, fetched.changed))) + } +} + +impl Default for VersionsManager { + fn default() -> Self { + Self::new() + } +} diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs new file mode 100644 index 00000000..33ed2006 --- /dev/null +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -0,0 +1,347 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::GameLoader; +use oneclient_common::version::{VersionKey, format_mc_version}; + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct VersionsManifest { + #[serde(default)] + pub clusters: Vec, + #[serde(default)] + pub migrations: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteCluster { + pub major_version: u32, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub art: Option, + #[serde(default)] + pub long_description: Option, + #[serde(default)] + pub tags: Vec, + /// Default for every entry in this cluster an entry's own key wins + #[serde(default)] + pub predownload: Option, + #[serde(default)] + pub entries: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteEntry { + pub minor_version: u32, + #[serde(default)] + pub patch_version: Option, + #[serde(default)] + pub loader: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub art: Option, + #[serde(default)] + pub long_description: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub predownload: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteMigration { + pub id: String, + pub from: MigrationSource, + pub to: MigrationTarget, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationSource { + pub mc_version: String, + pub loader: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationTarget { + pub mc_version: String, +} + +/// Returns the input unchanged when no rule matches +/// Multi-hop chains are +/// followed the loop is bounded by rule count so cyclic rules can't spin forever +#[must_use] +pub fn resolve_migration_chain( + mc_version: &str, + loader: GameLoader, + rules: &[RemoteMigration], +) -> String { + let mut current = mc_version.to_string(); + + for _ in 0..=rules.len() { + let Some(rule) = rules.iter().find(|rule| { + rule.from.mc_version == current + && GameLoader::from_str(&rule.from.loader).is_ok_and(|l| l == loader) + }) else { + break; + }; + + if rule.to.mc_version == current { + break; + } + + current = rule.to.mc_version.clone(); + } + + current +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VersionMetadata { + pub major_version: u32, + pub minor_version: Option, + pub patch_version: Option, + pub loader: Option, + pub name: String, + pub art_url: Option, + pub long_description: Option, + pub tags: Vec, + /// Fetched up front during onboarding rather than on first launch + pub predownload: bool, +} + +impl VersionMetadata { + #[must_use] + pub fn key(&self) -> Option { + Some((self.minor_version?, self.patch_version)) + } + + /// `None` for the synthetic cluster-level row which has no minor version + #[must_use] + pub fn mc_version(&self) -> Option { + Some(format_mc_version( + self.major_version, + self.minor_version?, + self.patch_version, + )) + } +} + +fn art_url(path: &Option, meta_url_base: &str) -> Option { + path.as_ref().map(|p| format!("{meta_url_base}{p}")) +} + +impl VersionsManifest { + pub fn metadata(&self, meta_url_base: &str) -> Vec { + let mut out = Vec::new(); + + for cluster in &self.clusters { + let cluster_name = cluster + .name + .clone() + .unwrap_or_else(|| format!("1.{}", cluster.major_version)); + + out.push(VersionMetadata { + major_version: cluster.major_version, + minor_version: None, + patch_version: None, + loader: None, + name: cluster_name.clone(), + art_url: art_url(&cluster.art, meta_url_base), + long_description: cluster.long_description.clone(), + tags: cluster.tags.clone(), + predownload: cluster.predownload.unwrap_or(false), + }); + + for entry in &cluster.entries { + out.push(VersionMetadata { + major_version: cluster.major_version, + minor_version: Some(entry.minor_version), + patch_version: entry.patch_version, + loader: entry.loader.clone(), + name: entry.name.clone().unwrap_or_else(|| cluster_name.clone()), + art_url: art_url(&entry.art, meta_url_base) + .or_else(|| art_url(&cluster.art, meta_url_base)), + long_description: entry + .long_description + .clone() + .or_else(|| cluster.long_description.clone()), + tags: entry.tags.clone().unwrap_or_else(|| cluster.tags.clone()), + predownload: entry.predownload.or(cluster.predownload).unwrap_or(false), + }); + } + } + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_without_migrations_key_parses() { + let manifest: VersionsManifest = + serde_json::from_str(r#"{"clusters": []}"#).expect("should parse"); + assert!(manifest.migrations.is_empty()); + } + + #[test] + fn entry_without_patch_key_parses() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[{"minor_version":1}]}]}"#, + ) + .expect("should parse"); + assert_eq!(manifest.clusters[0].entries[0].patch_version, None); + } + + #[test] + fn patch_version_reaches_metadata() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[ + {"minor_version":1,"patch_version":2,"loader":"fabric"} + ]}]}"#, + ) + .expect("should parse"); + + let metadata = manifest.metadata("https://example.test"); + let entry = metadata + .iter() + .find(|m| m.minor_version == Some(1)) + .expect("entry present"); + assert_eq!(entry.patch_version, Some(2)); + assert_eq!(entry.key(), Some((1, Some(2)))); + } + + #[test] + fn predownload_inherits_from_cluster_and_entry_overrides() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[ + {"major_version":26,"predownload":true,"entries":[ + {"minor_version":1}, + {"minor_version":2,"predownload":false} + ]}, + {"major_version":21,"entries":[ + {"minor_version":1}, + {"minor_version":11,"predownload":true} + ]} + ]}"#, + ) + .expect("should parse"); + + let metadata = manifest.metadata("https://example.test"); + let flag = |major: u32, minor: Option| { + metadata + .iter() + .find(|m| m.major_version == major && m.minor_version == minor) + .expect("row present") + .predownload + }; + + assert!(flag(26, None)); + assert!(flag(26, Some(1))); + assert!(!flag(26, Some(2))); + assert!(flag(21, Some(11))); + assert!(!flag(21, None)); + assert!(!flag(21, Some(1))); + } + + #[test] + fn mc_version_skips_the_cluster_row() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[ + {"minor_version":1,"patch_version":2} + ]}]}"#, + ) + .expect("should parse"); + + let metadata = manifest.metadata("https://example.test"); + assert_eq!(metadata[0].mc_version(), None); + assert_eq!(metadata[1].mc_version().as_deref(), Some("26.1.2")); + } + + fn rule(id: &str, from: &str, loader: &str, to: &str) -> RemoteMigration { + RemoteMigration { + id: id.to_string(), + from: MigrationSource { + mc_version: from.to_string(), + loader: loader.to_string(), + }, + to: MigrationTarget { + mc_version: to.to_string(), + }, + } + } + + #[test] + fn chain_resolves_single_hop() { + let rules = [rule("a", "26.1", "fabric", "26.1.2")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1.2" + ); + } + + #[test] + fn chain_follows_multiple_hops() { + let rules = [ + rule("a", "26.1", "fabric", "26.1.2"), + rule("b", "26.1.2", "fabric", "26.1.3"), + ]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1.3" + ); + } + + #[test] + fn chain_requires_matching_loader() { + let rules = [rule("a", "26.1", "forge", "26.1.2")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1" + ); + } + + #[test] + fn chain_returns_input_when_no_rule_matches() { + let rules = [rule("a", "21.1", "fabric", "21.1.2")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1" + ); + } + + #[test] + fn chain_terminates_on_self_and_cyclic_rules() { + let self_rule = [rule("a", "26.1", "fabric", "26.1")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &self_rule), + "26.1" + ); + + let cycle = [ + rule("a", "26.1", "fabric", "26.2"), + rule("b", "26.2", "fabric", "26.1"), + ]; + let out = resolve_migration_chain("26.1", GameLoader::Fabric, &cycle); + assert!(out == "26.1" || out == "26.2"); + } + + #[test] + fn migrations_parse() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[],"migrations":[ + {"id":"x","from":{"mc_version":"26.1","loader":"fabric"}, + "to":{"mc_version":"26.1.2"}} + ]}"#, + ) + .expect("should parse"); + + assert_eq!(manifest.migrations[0].id, "x"); + assert_eq!(manifest.migrations[0].from.mc_version, "26.1"); + assert_eq!(manifest.migrations[0].to.mc_version, "26.1.2"); + } +} diff --git a/packages/oneclient_core/src/versions/mod.rs b/packages/oneclient_core/src/versions/mod.rs new file mode 100644 index 00000000..e9ace6ba --- /dev/null +++ b/packages/oneclient_core/src/versions/mod.rs @@ -0,0 +1,8 @@ +mod manager; +mod manifest; + +pub use manager::VersionsManager; +pub use manifest::{ + MigrationSource, MigrationTarget, RemoteMigration, VersionMetadata, VersionsManifest, + resolve_migration_chain, +}; diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs new file mode 100644 index 00000000..9bd51cc6 --- /dev/null +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -0,0 +1,396 @@ +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_content::bundles::{ + BundleFile, BundleFileKind, BundleManifest, check_bundle_updates, +}; +use oneclient_core::LauncherState; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_db::dao::{artifact as artifact_dao, cluster_bundle as bundle_dao}; +use oneclient_db::models::OverrideType; + +const BUNDLE: &str = "Test Bundle"; +const MC_VERSION: &str = "1.21.1"; +const PROJECT_ID: &str = "sodium"; +const VERSION_ID: &str = "v1"; +const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn managed_file(enabled: bool) -> BundleFile { + BundleFile { + enabled, + hidden: false, + path: "mods/sodium.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: PROJECT_ID.to_string(), + version_id: VERSION_ID.to_string(), + sha1: HASH.to_string(), + }, + } +} + +/// Added by the catalog after the last sync untracked so it has no override +fn newly_shipped_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + path: "mods/newcomer.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "newcomer".to_string(), + version_id: "v1".to_string(), + sha1: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + }, + } +} + +fn manifest(files: Vec) -> BundleManifest { + BundleManifest { + name: BUNDLE.to_string(), + version_id: "1".to_string(), + category: "test".to_string(), + mc_version: MC_VERSION.to_string(), + loader: GameLoader::Fabric, + loader_version: "0.16.0".to_string(), + enabled: true, + files, + } +} + +async fn cluster_with_tracked_mod(state: &LauncherState) -> i64 { + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), + ) + .await + .unwrap(); + + artifact_dao::insert_artifact( + &state.services.db, + HASH, + ContentType::Mod as i64, + "artifacts/sodium.jar", + "sodium.jar", + Some(1), + ) + .await + .unwrap(); + + artifact_dao::link_cluster_artifact(&state.services.db, cluster.id, HASH, "sodium.jar") + .await + .unwrap(); + + artifact_dao::upsert_provider_release( + &state.services.db, + ProviderId::Modrinth as i64, + PROJECT_ID, + VERSION_ID, + HASH, + "Sodium", + "1.0.0", + None, + MC_VERSION, + "fabric", + ) + .await + .unwrap(); + + bundle_dao::track_bundle_artifact( + &state.services.db, + cluster.id, + HASH, + BUNDLE, + VERSION_ID, + PROJECT_ID, + ) + .await + .unwrap(); + + cluster.id +} + +#[tokio::test] +async fn mod_still_in_manifest_is_not_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![managed_file(true)])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.removals_available.is_empty(), + "an unchanged tracked mod was flagged for removal: {:?}", + check.removals_available + ); + assert!(check.updates_available.is_empty()); +} + +#[tokio::test] +async fn mod_dropped_from_manifest_is_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.removals_available.len(), + 1, + "a mod the bundle no longer ships should be removed" + ); + assert_eq!(check.removals_available[0].package_id, PROJECT_ID); +} + +#[tokio::test] +async fn disabled_mod_dropped_from_manifest_is_still_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.removals_available.len(), + 1, + "a disabled mod the bundle no longer ships should still be removed" + ); +} + +#[tokio::test] +async fn user_disabled_mod_is_not_treated_as_a_removal() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![managed_file(true)])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.removals_available.is_empty(), + "a user-disabled mod is still shipped by the bundle and must not be removed: {:?}", + check.removals_available + ); +} + +#[tokio::test] +async fn live_bundle_takes_on_new_catalog_files() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.additions_available.len(), + 1, + "a bundle the user still has enabled should pick up newly shipped files" + ); + assert_eq!( + check.additions_available[0].new_file.kind.package_id(), + "newcomer" + ); +} + +#[tokio::test] +async fn emptied_bundle_does_not_take_on_new_catalog_files() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + // Disabling everything is how you opt out + // The newcomer lacks an override only + // because it did not exist yet that absence must not read as consent + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.additions_available.is_empty(), + "a bundle whose content the user disabled must not reinstate itself: {:?}", + check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect::>() + ); +} + +#[tokio::test] +async fn removed_bundle_content_does_not_take_on_new_catalog_files() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + oneclient_content::bundles::remove_artifact_from_cluster( + cluster_id, + HASH, + true, + &state.services.content(), + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.additions_available.is_empty(), + "a bundle the user emptied by removal must not reinstate itself: {:?}", + check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect::>() + ); +} + +#[tokio::test] +async fn opting_a_single_file_in_keeps_the_bundle_live() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "newcomer", + OverrideType::Enabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.additions_available.len(), + 1, + "an explicit opt-in is consent, even with the rest of the bundle disabled" + ); +} diff --git a/packages/oneclient_core/tests/clusters.rs b/packages/oneclient_core/tests/clusters.rs new file mode 100644 index 00000000..bb5bd7ff --- /dev/null +++ b/packages/oneclient_core/tests/clusters.rs @@ -0,0 +1,147 @@ +use std::time::Duration; + +use oneclient_cluster::profiles::create_settings_profile; +use oneclient_common::domain::GameLoader; +use oneclient_common::patch::Patch; +use oneclient_core::ProfileUpdate; +use oneclient_core::clusters::{ClusterStage, CreateClusterOptions}; + +#[tokio::test] +async fn cluster_lifecycle_with_settings_profile() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let settings = state.settings.read().clone(); + + let global = state.settings.read().global_game_settings.clone(); + + // Deliberately not the global default of 4096 so the assertions distinguish + // "the cluster's own profile was used" from "fell back to global and matched" + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric).mem_max(2048), + ) + .await + .unwrap(); + + assert_eq!(cluster.name, "Test Cluster"); + assert_eq!(cluster.mc_version, "1.21.1"); + assert_eq!(cluster.stage, ClusterStage::NotReady); + assert!(cluster.setting_profile_name.is_some()); + assert!(cluster.created_at.is_some()); + + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(2048)); + + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Set(6144), + ..Default::default() + }, + ) + .await + .unwrap(); + + let updated = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(updated.mem_max, Some(6144)); + + let shared = create_settings_profile( + &state.services.db, + &settings.global_game_settings, + "Shared Profile", + ) + .await + .unwrap(); + + state + .clusters + .update( + cluster.id, + oneclient_core::ClusterUpdate::default().setting_profile(shared.name.clone()), + ) + .await + .unwrap(); + + let reassigned = state.clusters.get(cluster.id).await.unwrap(); + assert_eq!( + reassigned.setting_profile_name.as_deref(), + Some("Shared Profile") + ); + + let after_play = state + .clusters + .add_playtime(cluster.id, Duration::from_secs(120)) + .await + .unwrap(); + assert_eq!(after_play.overall_played, Duration::from_secs(120)); + assert!(after_play.last_played.is_some()); + + state.clusters.delete(cluster.id, true).await.unwrap(); + assert!(state.clusters.get(cluster.id).await.is_err()); +} + +#[tokio::test] +async fn cleared_profile_fields_inherit_from_global() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + + { + let mut settings = state.settings.write(); + settings.global_game_settings.mem_max = Some(8192); + } + + // Read the baseline *after* the write above so the new cluster inherits it + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), + ) + .await + .unwrap(); + + let profile_name = cluster.setting_profile_name.clone().unwrap(); + + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Clear, + force_fullscreen: Patch::Clear, + ..Default::default() + }, + ) + .await + .unwrap(); + + let global = state.settings.read().global_game_settings.clone(); + let stored = oneclient_cluster::profiles::get_profile_or_default( + &state.services.db, + &global, + Some(&profile_name), + ) + .await + .unwrap(); + assert_eq!(stored.mem_max, None); + assert_eq!(stored.force_fullscreen, None); + + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(8192)); + assert_eq!(resolved.force_fullscreen, Some(false)); +} diff --git a/packages/oneclient_core/tests/download_verify.rs b/packages/oneclient_core/tests/download_verify.rs new file mode 100644 index 00000000..6d762017 --- /dev/null +++ b/packages/oneclient_core/tests/download_verify.rs @@ -0,0 +1,364 @@ +use oneclient_core::dev; +use oneclient_core::game::download_to_path; +use oneclient_events::{GroupedProgressSession, TaskCategory}; +use polyio::testing::ScratchDir as Scratch; +use polyio::{Sha1Stream, sha1_bytes, sha1_file}; + +/// Minecraft asset objects are content-addressed and immutable so this +/// URL/hash pair stays valid +const ASSET_SHA1: &str = "af96f55a90eaf11b327f1b5f8834a051027dc506"; +const ASSET_URL: &str = + "https://resources.download.minecraft.net/af/af96f55a90eaf11b327f1b5f8834a051027dc506"; +const ASSET_SIZE: u64 = 2063; + +#[test] +fn sha1_stream_matches_one_shot_hash() { + let data: Vec = (0..70_000u32).map(|i| (i % 251) as u8).collect(); + + let mut hasher = Sha1Stream::new(); + for chunk in data.chunks(4096) { + hasher.update(chunk); + } + + assert_eq!(hasher.finish(), sha1_bytes(&data)); +} + +#[test] +fn sha1_stream_of_empty_input_matches() { + assert_eq!(Sha1Stream::new().finish(), sha1_bytes(&[])); +} + +#[tokio::test] +async fn sha1_file_matches_sha1_bytes() { + let dir = Scratch::new("sha1-file"); + let path = dir.join("payload.bin"); + + // Larger than the hashing buffer so the multi-read path is covered too + let data: Vec = (0..600_000u32).map(|i| (i % 253) as u8).collect(); + std::fs::write(&path, &data).unwrap(); + + assert_eq!(sha1_file(&path).await.unwrap(), sha1_bytes(&data)); +} + +#[tokio::test] +async fn sha1_file_handles_empty_file() { + let dir = Scratch::new("sha1-empty"); + let path = dir.join("empty.bin"); + std::fs::write(&path, []).unwrap(); + + assert_eq!(sha1_file(&path).await.unwrap(), sha1_bytes(&[])); +} + +/// Serves `body` with a 200 declaring `content_length` however long `body` is +/// pass a larger one to imitate a mirror holding the wrong file +async fn serve(body: Vec, content_length: usize) -> String { + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let body = body.clone(); + + tokio::spawn(async move { + let mut request = [0u8; 1024]; + let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut request).await; + + let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {content_length}\r\n\r\n"); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = stream.flush().await; + }); + } + }); + + format!("http://127.0.0.1:{port}/artifact.jar") +} + +/// Answers the first `failures` requests with `status` then serves `ok_body` +/// Returns the URL and a request counter +/// `usize::MAX` never recovers +async fn serve_flaky( + status: u16, + reason: &'static str, + failures: usize, + error_body: Vec, + ok_body: Vec, +) -> (String, std::sync::Arc) { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let served = Arc::new(AtomicUsize::new(0)); + + tokio::spawn({ + let served = Arc::clone(&served); + async move { + while let Ok((mut stream, _)) = listener.accept().await { + let (error_body, ok_body) = (error_body.clone(), ok_body.clone()); + let served = Arc::clone(&served); + + tokio::spawn(async move { + // Draining the request first or the client sees a reset + let mut request = [0u8; 1024]; + let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut request).await; + + let seen = served.fetch_add(1, Ordering::SeqCst); + let (status, reason, body) = if seen < failures { + (status, reason, error_body) + } else { + (200, "OK", ok_body) + }; + + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = stream.flush().await; + }); + } + } + }); + + (format!("http://127.0.0.1:{port}/artifact.jar"), served) +} + +#[tokio::test] +async fn download_to_path_never_writes_an_error_response_to_disk() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-404"); + let dest = dir.join("artifact.jar"); + + // What a CDN answers for a missing object well-formed but XML not a jar + let (url, served) = serve_flaky( + 404, + "Not Found", + usize::MAX, + b"NoSuchKey".to_vec(), + Vec::new(), + ) + .await; + + let err = download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 0, + &url, + &dest, + None, + ) + .await + .expect_err("a 404 must not be saved as the file"); + + assert!(err.to_string().contains("404"), "{err}"); + assert!(err.to_string().contains("NoSuchKey"), "{err}"); + assert!(!dest.exists(), "an error body must never reach disk"); + // A 404 answers the same way every time retrying it only delays the error + assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn download_to_path_retries_a_server_error_and_recovers() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-500"); + let dest = dir.join("artifact.jar"); + + // Unlike a 404 a 500 can succeed on the next attempt + let (url, served) = serve_flaky( + 500, + "Internal Server Error", + 1, + b"boom".to_vec(), + vec![7u8; 128], + ) + .await; + + download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 128, + &url, + &dest, + None, + ) + .await + .expect("a retried server error should recover"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), 128); + assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn download_to_path_rejects_a_body_shorter_than_the_manifest_size() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-short"); + let dest = dir.join("artifact.jar"); + + let url = serve(vec![7u8; 64], 64).await; + + let err = download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 4096, + &url, + &dest, + None, + ) + .await + .expect_err("a short body should fail without a hash to check"); + + assert!(err.to_string().contains("ended after 64 of 4096"), "{err}"); + assert!(!dest.exists(), "truncated download should be removed"); +} + +#[tokio::test] +async fn download_to_path_accepts_an_unhashed_body_of_the_promised_length() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-exact"); + let dest = dir.join("artifact.jar"); + + let url = serve(vec![7u8; 4096], 4096).await; + + download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 4096, + &url, + &dest, + None, + ) + .await + .expect("a body of the promised length should be kept"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), 4096); +} + +#[tokio::test] +async fn download_to_path_keeps_an_unhashed_body_of_unknown_length() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-unknown"); + let dest = dir.join("artifact.jar"); + + let url = serve(vec![7u8; 128], 128).await; + + // Nothing declared a size so there is nothing to check against + download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 0, + &url, + &dest, + None, + ) + .await + .expect("an unmeasurable download should still be written"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), 128); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn download_to_path_accepts_matching_hash() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-ok"); + let dest = dir.join("nested").join("icon.png"); + + download_to_path( + &services.requester, + &services.events, + &progress, + "icon", + TaskCategory::Assets, + ASSET_SIZE, + ASSET_URL, + &dest, + Some(ASSET_SHA1), + ) + .await + .expect("download should verify"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), ASSET_SIZE); + assert_eq!(sha1_file(&dest).await.unwrap(), ASSET_SHA1); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn download_to_path_rejects_mismatched_hash_and_removes_file() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-bad"); + let dest = dir.join("icon.png"); + + let err = download_to_path( + &services.requester, + &services.events, + &progress, + "icon", + TaskCategory::Assets, + ASSET_SIZE, + ASSET_URL, + &dest, + Some("0000000000000000000000000000000000000000"), + ) + .await + .expect_err("mismatched hash should fail"); + + assert!(err.to_string().contains("0000000000000000"), "{err}"); + assert!(!dest.exists(), "corrupt download should be removed"); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn download_to_path_without_expected_hash_still_writes() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-nohash"); + let dest = dir.join("icon.png"); + + download_to_path( + &services.requester, + &services.events, + &progress, + "icon", + TaskCategory::Assets, + 0, + ASSET_URL, + &dest, + None, + ) + .await + .expect("download should succeed"); + + assert_eq!(sha1_file(&dest).await.unwrap(), ASSET_SHA1); +} diff --git a/packages/oneclient_core/tests/patch.rs b/packages/oneclient_core/tests/patch.rs new file mode 100644 index 00000000..e5ba07a6 --- /dev/null +++ b/packages/oneclient_core/tests/patch.rs @@ -0,0 +1,33 @@ +use oneclient_common::patch::Patch; + +#[test] +fn patch_apply_to_option() { + let mut slot = Some(42u32); + + Patch::Unchanged.apply_to_option(&mut slot); + assert_eq!(slot, Some(42)); + + Patch::Set(99).apply_to_option(&mut slot); + assert_eq!(slot, Some(99)); + + Patch::::Clear.apply_to_option(&mut slot); + assert_eq!(slot, None); +} + +#[test] +fn patch_command_empty_string_clears() { + let mut slot: Option = Some("wrapper".into()); + + Patch::Set(" ".to_owned()).apply_to_command_option(&mut slot); + assert_eq!(slot, Some("".to_owned())); +} + +#[test] +fn patch_into_db_patch() { + assert_eq!(Patch::::Unchanged.into_db_patch(), None); + assert_eq!(Patch::::Clear.into_db_patch(), Some(None)); + assert_eq!( + Patch::Set("x".to_string()).into_db_patch(), + Some(Some("x".to_string())) + ); +} diff --git a/packages/oneclient_core/tests/providers.rs b/packages/oneclient_core/tests/providers.rs new file mode 100644 index 00000000..b5d98484 --- /dev/null +++ b/packages/oneclient_core/tests/providers.rs @@ -0,0 +1,163 @@ +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_content::packages::types::{PackageBody, SearchFilters}; +use oneclient_core::dev; + +#[test] +fn registry_get_unknown_provider_errors() { + let registry = oneclient_content::packages::PackageProviderRegistry::new(); + + let err = match registry.get(ProviderId::Local) { + Err(e) => e, + Ok(_) => panic!("expected ProviderId::Local to be unregistered"), + }; + + assert!(err.to_string().contains("not registered")); +} + +#[tokio::test] +async fn registry_providers_match_ids() { + let registry = oneclient_content::packages::PackageProviderRegistry::new(); + + for id in registry.remote_ids() { + assert_eq!(registry.get(id).unwrap().id(), id); + } +} + +#[tokio::test] +#[ignore = "requires network"] +async fn modrinth_search_returns_hits() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); + + let page = provider + .search( + &SearchFilters { + query: Some("sodium".into()), + limit: Some(5), + content_type: Some(ContentType::Mod), + ..Default::default() + }, + &env, + ) + .await + .unwrap(); + + assert!(!page.items.is_empty()); + assert!( + page.items + .iter() + .any(|p| p.name.to_lowercase().contains("sodium")) + ); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn modrinth_get_project_and_versions() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); + + let project = provider.get_project("AANobbMI", &env).await.unwrap(); + assert_eq!(project.slug, "sodium"); + assert_eq!(project.provider, ProviderId::Modrinth); + + let versions = provider + .list_versions(&project.id, Some("1.20.1"), None, 0, 5, &env) + .await + .unwrap(); + assert!(!versions.items.is_empty()); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn curseforge_search_returns_hits() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::CurseForge).unwrap(); + + let page = provider + .search( + &SearchFilters { + query: Some("sodium".into()), + limit: Some(5), + content_type: Some(ContentType::Mod), + ..Default::default() + }, + &env, + ) + .await + .unwrap(); + + assert!(!page.items.is_empty()); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn modrinth_lookup_version_by_sha1() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); + + let versions = provider + .list_versions("AANobbMI", Some("1.20.1"), None, 0, 1, &env) + .await + .unwrap(); + let version = provider + .get_version("AANobbMI", &versions.items[0].version_id, &env) + .await + .unwrap(); + let sha1 = version + .primary_file() + .expect("version has a primary file") + .sha1 + .clone(); + + let found = env.providers.lookup_version(&sha1, &env).await.unwrap(); + assert!(found.is_some()); + + let (provider_id, _) = found.unwrap(); + assert_eq!(provider_id, ProviderId::Modrinth); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn curseforge_get_project_fetches_markdown_body() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::CurseForge).unwrap(); + + // 238222 = Just Enough Items (JEI) + let project = provider + .get_project_with_body("238222", &env) + .await + .unwrap(); + assert_eq!(project.provider, ProviderId::CurseForge); + + let body = match &project.body { + PackageBody::Raw(body) => body, + PackageBody::Url(url) => panic!("expected a fetched body, got a link to {url}"), + }; + + assert!(!body.is_empty(), "body should not be empty"); + assert!( + !body.contains("

    ") && !body.contains(" assert!(!body.is_empty(), "body should not be empty"), + PackageBody::Url(url) => panic!("expected a fetched body, got a link to {url}"), + } +} diff --git a/packages/oneclient_db/.env b/packages/oneclient_db/.env new file mode 100644 index 00000000..721eb5ca --- /dev/null +++ b/packages/oneclient_db/.env @@ -0,0 +1,2 @@ +# Uncomment the line below to develop with sqlx type checking +# DATABASE_URL=sqlite:///tmp/OneClient/user_data.db \ No newline at end of file diff --git a/packages/oneclient_db/.env.template b/packages/oneclient_db/.env.template new file mode 100644 index 00000000..03f1330c --- /dev/null +++ b/packages/oneclient_db/.env.template @@ -0,0 +1,2 @@ +# Uncomment the line below to develop with sqlx type checking +#DATABASE_URL=sqlite:///tmp/OneClient/user_data.db \ No newline at end of file diff --git a/packages/oneclient_db/.gitignore b/packages/oneclient_db/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/oneclient_db/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/oneclient_db/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json b/packages/oneclient_db/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json new file mode 100644 index 00000000..33514fa6 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO java_versions (absolute_path, major, version, vendor, os_arch, is_jdk)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(absolute_path) DO UPDATE SET\n\t\t\tmajor = excluded.major,\n\t\t\tversion = excluded.version,\n\t\t\tvendor = excluded.vendor,\n\t\t\tos_arch = excluded.os_arch,\n\t\t\tis_jdk = excluded.is_jdk\n\t\tRETURNING absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\t", + "describe": { + "columns": [ + { + "name": "absolute_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "absolute_path" + } + } + }, + { + "name": "major", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "major" + } + } + }, + { + "name": "version", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "version" + } + } + }, + { + "name": "vendor", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "vendor" + } + } + }, + { + "name": "os_arch", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "os_arch" + } + } + }, + { + "name": "is_jdk: bool", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "is_jdk" + } + } + } + ], + "parameters": { + "Right": 6 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc" +} diff --git a/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json b/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json new file mode 100644 index 00000000..8a0fed68 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id FROM applied_migrations WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "applied_migrations", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab" +} diff --git a/packages/oneclient_db/.sqlx/query-0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb.json b/packages/oneclient_db/.sqlx/query-0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb.json new file mode 100644 index 00000000..c2543978 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT started_at, ended_at\n\t\tFROM game_sessions\n\t\tWHERE ended_at IS NOT NULL\n\t\tORDER BY started_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true + ] + }, + "hash": "0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb" +} diff --git a/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json b/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json new file mode 100644 index 00000000..5da3a9d7 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n ORDER BY major DESC, version DESC\n ", + "describe": { + "columns": [ + { + "name": "absolute_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "absolute_path" + } + } + }, + { + "name": "major", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "major" + } + } + }, + { + "name": "version", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "version" + } + } + }, + { + "name": "vendor", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "vendor" + } + } + }, + { + "name": "os_arch", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "os_arch" + } + } + }, + { + "name": "is_jdk: bool", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "is_jdk" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba" +} diff --git a/packages/oneclient_db/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json b/packages/oneclient_db/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json new file mode 100644 index 00000000..5adaaa3a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO clusters (\n\t\t\tname, folder_name, mc_version, mc_loader, mc_loader_version,\n\t\t\tsetting_profile_name, stage, created_at\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?)\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 8 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871" +} diff --git a/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json b/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json new file mode 100644 index 00000000..8adcc90a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n cluster_id, hash, cluster_file_name, enabled,\n bundle_name, bundle_version_id, package_id, installed_at\n FROM cluster_artifacts\n WHERE cluster_id = ? AND bundle_name IS NOT NULL\n ", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + }, + { + "name": "bundle_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "bundle_name" + } + } + }, + { + "name": "bundle_version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "bundle_version_id" + } + } + }, + { + "name": "package_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "package_id" + } + } + }, + { + "name": "installed_at", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "installed_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813" +} diff --git a/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json b/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json new file mode 100644 index 00000000..e2104f28 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET mc_version = ?,\n\t\t name = ?,\n\t\t folder_name = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61" +} diff --git a/packages/oneclient_db/.sqlx/query-1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202.json b/packages/oneclient_db/.sqlx/query-1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202.json new file mode 100644 index 00000000..7e904084 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM setting_profiles WHERE name = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202" +} diff --git a/packages/oneclient_db/.sqlx/query-2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319.json b/packages/oneclient_db/.sqlx/query-2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319.json new file mode 100644 index 00000000..c822cc59 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319.json @@ -0,0 +1,146 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tcluster_id, hash, provider as \"provider!: i64\", project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\tFROM browser_package_updates\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "hash" + } + } + }, + { + "name": "provider!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "project_id" + } + } + }, + { + "name": "installed_version_id", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_id" + } + } + }, + { + "name": "installed_version_name", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_name" + } + } + }, + { + "name": "latest_version_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_id" + } + } + }, + { + "name": "latest_version_name", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_name" + } + } + }, + { + "name": "display_name", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "display_name" + } + } + }, + { + "name": "checked_at", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "checked_at" + } + } + }, + { + "name": "skipped", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "skipped" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319" +} diff --git a/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json b/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json new file mode 100644 index 00000000..f01cd4bc --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51" +} diff --git a/packages/oneclient_db/.sqlx/query-26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c.json b/packages/oneclient_db/.sqlx/query-26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c.json new file mode 100644 index 00000000..7a7d4881 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE cluster_artifacts\n SET\n bundle_name = ?,\n bundle_version_id = ?,\n package_id = ?,\n installed_at = ?\n WHERE cluster_id = ? AND hash = ?\n RETURNING\n cluster_id, hash, cluster_file_name, enabled,\n bundle_name, bundle_version_id, package_id, installed_at\n ", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + }, + { + "name": "bundle_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "bundle_name" + } + } + }, + { + "name": "bundle_version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "bundle_version_id" + } + } + }, + { + "name": "package_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "package_id" + } + } + }, + { + "name": "installed_at", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "installed_at" + } + } + } + ], + "parameters": { + "Right": 6 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c" +} diff --git a/packages/oneclient_db/.sqlx/query-271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107.json b/packages/oneclient_db/.sqlx/query-271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107.json new file mode 100644 index 00000000..1d517c25 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107.json @@ -0,0 +1,146 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tcluster_id, hash, provider as \"provider!: i64\", project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\tFROM browser_package_updates\n\t\tWHERE cluster_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "hash" + } + } + }, + { + "name": "provider!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "project_id" + } + } + }, + { + "name": "installed_version_id", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_id" + } + } + }, + { + "name": "installed_version_name", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_name" + } + } + }, + { + "name": "latest_version_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_id" + } + } + }, + { + "name": "latest_version_name", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_name" + } + } + }, + { + "name": "display_name", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "display_name" + } + } + }, + { + "name": "checked_at", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "checked_at" + } + } + }, + { + "name": "skipped", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "skipped" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107" +} diff --git a/packages/oneclient_db/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json b/packages/oneclient_db/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json new file mode 100644 index 00000000..f914a967 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET name = ?,\n\t\t setting_profile_name = ?,\n\t\t mc_loader_version = ?,\n\t\t linked_modpack_hash = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42" +} diff --git a/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json b/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json new file mode 100644 index 00000000..caaad113 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\tFROM java_versions\n\t\tWHERE major = ?\n\t\tORDER BY version DESC\n\t\tLIMIT 1\n\t\t", + "describe": { + "columns": [ + { + "name": "absolute_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "absolute_path" + } + } + }, + { + "name": "major", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "major" + } + } + }, + { + "name": "version", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "version" + } + } + }, + { + "name": "vendor", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "vendor" + } + } + }, + { + "name": "os_arch", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "os_arch" + } + } + }, + { + "name": "is_jdk: bool", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "is_jdk" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b" +} diff --git a/packages/oneclient_db/.sqlx/query-3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77.json b/packages/oneclient_db/.sqlx/query-3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77.json new file mode 100644 index 00000000..8de9a5e2 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT session_started_at, address, port, joined_at, disconnected_at\n\t\tFROM game_session_servers\n\t\tORDER BY joined_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "session_started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "session_started_at" + } + } + }, + { + "name": "address", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "port" + } + } + }, + { + "name": "joined_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "joined_at" + } + } + }, + { + "name": "disconnected_at", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "disconnected_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77" +} diff --git a/packages/oneclient_db/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json b/packages/oneclient_db/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json new file mode 100644 index 00000000..b1ced123 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET overall_played = COALESCE(overall_played, 0) + ?,\n\t\t last_played = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1" +} diff --git a/packages/oneclient_db/.sqlx/query-42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7.json b/packages/oneclient_db/.sqlx/query-42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7.json new file mode 100644 index 00000000..b05cc0d4 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT session_started_at, address, port, joined_at, disconnected_at\n\t\tFROM game_session_servers\n\t\tWHERE session_started_at = ?\n\t\tORDER BY joined_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "session_started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "session_started_at" + } + } + }, + { + "name": "address", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "port" + } + } + }, + { + "name": "joined_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "joined_at" + } + } + }, + { + "name": "disconnected_at", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "disconnected_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7" +} diff --git a/packages/oneclient_db/.sqlx/query-43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a.json b/packages/oneclient_db/.sqlx/query-43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a.json new file mode 100644 index 00000000..6ddb354e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_bundle_overrides\n WHERE cluster_id = ? AND package_id = ? AND override_type IN ('disabled', 'removed')\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a" +} diff --git a/packages/oneclient_db/.sqlx/query-439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919.json b/packages/oneclient_db/.sqlx/query-439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919.json new file mode 100644 index 00000000..f1d3ef92 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT hash, content_type, path, file_name, size_bytes\n\t\tFROM artifacts\n\t\tWHERE hash = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "hash", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "hash" + } + } + }, + { + "name": "content_type", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "content_type" + } + } + }, + { + "name": "path", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "path" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "file_name" + } + } + }, + { + "name": "size_bytes", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "size_bytes" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919" +} diff --git a/packages/oneclient_db/.sqlx/query-44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a.json b/packages/oneclient_db/.sqlx/query-44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a.json new file mode 100644 index 00000000..f16b3da9 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE game_sessions\n\t\tSET ended_at = ?, exit_code = ?\n\t\tWHERE started_at = ? AND ended_at IS NULL\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a" +} diff --git a/packages/oneclient_db/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json b/packages/oneclient_db/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json new file mode 100644 index 00000000..c5405caf --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tORDER BY last_played IS NULL, last_played DESC, name ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22" +} diff --git a/packages/oneclient_db/.sqlx/query-472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9.json b/packages/oneclient_db/.sqlx/query-472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9.json new file mode 100644 index 00000000..cfc5f2d7 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE browser_package_updates\n\t\tSET skipped = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9" +} diff --git a/packages/oneclient_db/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json b/packages/oneclient_db/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json new file mode 100644 index 00000000..7c99e1d0 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tWHERE id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d" +} diff --git a/packages/oneclient_db/.sqlx/query-4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f.json b/packages/oneclient_db/.sqlx/query-4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f.json new file mode 100644 index 00000000..aa9723d9 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM artifacts WHERE hash = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f" +} diff --git a/packages/oneclient_db/.sqlx/query-4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c.json b/packages/oneclient_db/.sqlx/query-4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c.json new file mode 100644 index 00000000..559d63ef --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n cluster_id, hash, cluster_file_name, enabled,\n bundle_name, bundle_version_id, package_id, installed_at\n FROM cluster_artifacts\n WHERE cluster_id = ? AND hash = ? AND bundle_name IS NOT NULL\n ", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + }, + { + "name": "bundle_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "bundle_name" + } + } + }, + { + "name": "bundle_version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "bundle_version_id" + } + } + }, + { + "name": "package_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "package_id" + } + } + }, + { + "name": "installed_at", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "installed_at" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c" +} diff --git a/packages/oneclient_db/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json b/packages/oneclient_db/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json new file mode 100644 index 00000000..5eb036f5 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET cluster_file_name = ?, enabled = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f" +} diff --git a/packages/oneclient_db/.sqlx/query-5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02.json b/packages/oneclient_db/.sqlx/query-5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02.json new file mode 100644 index 00000000..ea400fff --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02.json @@ -0,0 +1,146 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO browser_package_updates (\n\t\t\tcluster_id, hash, provider, project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tprovider = excluded.provider,\n\t\t\tproject_id = excluded.project_id,\n\t\t\tinstalled_version_id = excluded.installed_version_id,\n\t\t\tinstalled_version_name = excluded.installed_version_name,\n\t\t\tlatest_version_name = excluded.latest_version_name,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tchecked_at = excluded.checked_at,\n\t\t\tskipped = CASE\n\t\t\t\tWHEN browser_package_updates.latest_version_id = excluded.latest_version_id\n\t\t\t\tTHEN browser_package_updates.skipped\n\t\t\t\tELSE 0\n\t\t\tEND,\n\t\t\tlatest_version_id = excluded.latest_version_id\n\t\tRETURNING\n\t\t\tcluster_id, hash, provider as \"provider!: i64\", project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "hash" + } + } + }, + { + "name": "provider!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "project_id" + } + } + }, + { + "name": "installed_version_id", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_id" + } + } + }, + { + "name": "installed_version_name", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_name" + } + } + }, + { + "name": "latest_version_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_id" + } + } + }, + { + "name": "latest_version_name", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_name" + } + } + }, + { + "name": "display_name", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "display_name" + } + } + }, + { + "name": "checked_at", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "checked_at" + } + } + }, + { + "name": "skipped", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "skipped" + } + } + } + ], + "parameters": { + "Right": 10 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02" +} diff --git a/packages/oneclient_db/.sqlx/query-53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30.json b/packages/oneclient_db/.sqlx/query-53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30.json new file mode 100644 index 00000000..72420f7a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT s.session_started_at, s.address, s.port, s.joined_at, s.disconnected_at\n\t\tFROM game_session_servers s\n\t\tJOIN game_sessions g ON g.started_at = s.session_started_at\n\t\tWHERE g.cluster_id = ?\n\t\tORDER BY s.joined_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "session_started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "session_started_at" + } + } + }, + { + "name": "address", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "port" + } + } + }, + { + "name": "joined_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "joined_at" + } + } + }, + { + "name": "disconnected_at", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "disconnected_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30" +} diff --git a/packages/oneclient_db/.sqlx/query-54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a.json b/packages/oneclient_db/.sqlx/query-54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a.json new file mode 100644 index 00000000..2dcb5d1b --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM clusters WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a" +} diff --git a/packages/oneclient_db/.sqlx/query-56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549.json b/packages/oneclient_db/.sqlx/query-56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549.json new file mode 100644 index 00000000..141791a5 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO cluster_bundle_overrides (cluster_id, bundle_name, package_id, override_type)\n VALUES (?, ?, ?, ?)\n ON CONFLICT(cluster_id, bundle_name, package_id) DO UPDATE SET\n override_type = excluded.override_type\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549" +} diff --git a/packages/oneclient_db/.sqlx/query-5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1.json b/packages/oneclient_db/.sqlx/query-5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1.json new file mode 100644 index 00000000..e8b5a800 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO bundles (\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(remote_path) DO UPDATE SET\n mc_version = excluded.mc_version,\n mc_loader = excluded.mc_loader,\n file_name = excluded.file_name,\n name = COALESCE(excluded.name, bundles.name),\n version_id = COALESCE(excluded.version_id, bundles.version_id),\n category = COALESCE(excluded.category, bundles.category),\n loader_version = COALESCE(excluded.loader_version, bundles.loader_version),\n disk_path = excluded.disk_path,\n hidden = excluded.hidden,\n etag = COALESCE(excluded.etag, bundles.etag),\n synced_at = COALESCE(excluded.synced_at, bundles.synced_at)\n RETURNING\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 12 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1" +} diff --git a/packages/oneclient_db/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json b/packages/oneclient_db/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json new file mode 100644 index 00000000..9f55b11a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tWHERE mc_version = ? AND mc_loader = ?\n\t\tLIMIT 1\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644" +} diff --git a/packages/oneclient_db/.sqlx/query-628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186.json b/packages/oneclient_db/.sqlx/query-628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186.json new file mode 100644 index 00000000..1df54e88 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n FROM bundles\n WHERE mc_version = ? AND mc_loader = ? AND hidden = 0\n ORDER BY name, file_name\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186" +} diff --git a/packages/oneclient_db/.sqlx/query-6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02.json b/packages/oneclient_db/.sqlx/query-6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02.json new file mode 100644 index 00000000..dc4f2d75 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE game_session_servers\n\t\tSET disconnected_at = ?\n\t\tWHERE joined_at = ? AND disconnected_at IS NULL\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02" +} diff --git a/packages/oneclient_db/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json b/packages/oneclient_db/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json new file mode 100644 index 00000000..976cb60e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET stage = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e" +} diff --git a/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json b/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json new file mode 100644 index 00000000..cb59124e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE game_sessions\n\t\tSET pid = ?, pid_started_at = ?\n\t\tWHERE started_at = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e" +} diff --git a/packages/oneclient_db/.sqlx/query-7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1.json b/packages/oneclient_db/.sqlx/query-7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1.json new file mode 100644 index 00000000..1088f25e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tcluster_id, started_at, ended_at, exit_code,\n\t\t\tram_allocated_mb, mods_enabled, java_vendor, java_version\n\t\tFROM game_sessions\n\t\tWHERE cluster_id = ?\n\t\tORDER BY started_at DESC\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "cluster_id" + } + } + }, + { + "name": "started_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + }, + { + "name": "exit_code", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "exit_code" + } + } + }, + { + "name": "ram_allocated_mb", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ram_allocated_mb" + } + } + }, + { + "name": "mods_enabled", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "mods_enabled" + } + } + }, + { + "name": "java_vendor", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_vendor" + } + } + }, + { + "name": "java_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_version" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1" +} diff --git a/packages/oneclient_db/.sqlx/query-80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b.json b/packages/oneclient_db/.sqlx/query-80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b.json new file mode 100644 index 00000000..6870408b --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE cluster_artifacts\n SET bundle_name = NULL, bundle_version_id = NULL, package_id = NULL, installed_at = NULL\n WHERE cluster_id = ? AND hash = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b" +} diff --git a/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json b/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json new file mode 100644 index 00000000..b445c9cb --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c" +} diff --git a/packages/oneclient_db/.sqlx/query-8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd.json b/packages/oneclient_db/.sqlx/query-8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd.json new file mode 100644 index 00000000..c368c4c2 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO package_metadata (provider, project_id, name, summary, author, icon_url, updated_at)\n\t\tVALUES (?, ?, ?, ?, ?, ?, datetime('now'))\n\t\tON CONFLICT(provider, project_id) DO UPDATE SET\n\t\t\tname = excluded.name,\n\t\t\tsummary = excluded.summary,\n\t\t\tauthor = excluded.author,\n\t\t\ticon_url = excluded.icon_url,\n\t\t\tupdated_at = excluded.updated_at\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd" +} diff --git a/packages/oneclient_db/.sqlx/query-968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f.json b/packages/oneclient_db/.sqlx/query-968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f.json new file mode 100644 index 00000000..82961e5f --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f.json @@ -0,0 +1,122 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tprovider as \"provider!: i64\", project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\tFROM provider_releases\n\t\tWHERE provider = ? AND project_id = ? AND version_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "provider!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "provider_releases", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "project_id" + } + } + }, + { + "name": "version_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "version_id" + } + } + }, + { + "name": "hash", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "hash" + } + } + }, + { + "name": "display_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_name" + } + } + }, + { + "name": "display_version", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_version" + } + } + }, + { + "name": "published_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "published_at" + } + } + }, + { + "name": "mc_versions", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_versions" + } + } + }, + { + "name": "mc_loaders", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_loaders" + } + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f" +} diff --git a/packages/oneclient_db/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json b/packages/oneclient_db/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json new file mode 100644 index 00000000..ad2eb026 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tWHERE folder_name = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782" +} diff --git a/packages/oneclient_db/.sqlx/query-98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700.json b/packages/oneclient_db/.sqlx/query-98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700.json new file mode 100644 index 00000000..e08f44bc --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tWHERE name = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700" +} diff --git a/packages/oneclient_db/.sqlx/query-9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263.json b/packages/oneclient_db/.sqlx/query-9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263.json new file mode 100644 index 00000000..36c33164 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT s.address AS \"address!\", COUNT(*) AS \"joins!: i64\"\n\t\tFROM game_session_servers s\n\t\tJOIN game_sessions g ON g.started_at = s.session_started_at\n\t\tWHERE g.cluster_id = ?\n\t\tGROUP BY s.address\n\t\tORDER BY COUNT(*) DESC\n\t\t", + "describe": { + "columns": [ + { + "name": "address!", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "joins!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263" +} diff --git a/packages/oneclient_db/.sqlx/query-a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e.json b/packages/oneclient_db/.sqlx/query-a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e.json new file mode 100644 index 00000000..5d43cd32 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT started_at, ended_at\n\t\tFROM game_sessions\n\t\tWHERE cluster_id = ? AND ended_at IS NOT NULL\n\t\tORDER BY started_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e" +} diff --git a/packages/oneclient_db/.sqlx/query-a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391.json b/packages/oneclient_db/.sqlx/query-a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391.json new file mode 100644 index 00000000..b27df2e7 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n FROM bundles\n WHERE remote_path = ?\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391" +} diff --git a/packages/oneclient_db/.sqlx/query-ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094.json b/packages/oneclient_db/.sqlx/query-ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094.json new file mode 100644 index 00000000..e564fe71 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT 1 FROM cluster_artifacts\n WHERE cluster_id = ? AND bundle_name IS NOT NULL AND package_id = ?\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "name": "1", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094" +} diff --git a/packages/oneclient_db/.sqlx/query-ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55.json b/packages/oneclient_db/.sqlx/query-ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55.json new file mode 100644 index 00000000..117cee86 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM java_versions WHERE absolute_path = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55" +} diff --git a/packages/oneclient_db/.sqlx/query-b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2.json b/packages/oneclient_db/.sqlx/query-b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2.json new file mode 100644 index 00000000..6ef33182 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO artifacts (hash, content_type, path, file_name, size_bytes)\n\t\tVALUES (?, ?, ?, ?, ?)\n\t\tON CONFLICT(hash) DO UPDATE SET\n\t\t\tcontent_type = excluded.content_type,\n\t\t\tpath = excluded.path,\n\t\t\tfile_name = excluded.file_name,\n\t\t\tsize_bytes = COALESCE(excluded.size_bytes, artifacts.size_bytes)\n\t\tRETURNING hash, content_type, path, file_name, size_bytes\n\t\t", + "describe": { + "columns": [ + { + "name": "hash", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "hash" + } + } + }, + { + "name": "content_type", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "content_type" + } + } + }, + { + "name": "path", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "path" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "file_name" + } + } + }, + { + "name": "size_bytes", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "size_bytes" + } + } + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2" +} diff --git a/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json b/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json new file mode 100644 index 00000000..ebc1b8fd --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tDELETE FROM game_session_servers\n\t\tWHERE session_started_at = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933" +} diff --git a/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json b/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json new file mode 100644 index 00000000..c7ab1753 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af" +} diff --git a/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json b/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json new file mode 100644 index 00000000..b79fa719 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca" +} diff --git a/packages/oneclient_db/.sqlx/query-c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555.json b/packages/oneclient_db/.sqlx/query-c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555.json new file mode 100644 index 00000000..584a28cf --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555" +} diff --git a/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json b/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json new file mode 100644 index 00000000..9d2eae4f --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT cluster_id, started_at, pid, pid_started_at\n\t\tFROM game_sessions\n\t\tWHERE ended_at IS NULL\n\t\tORDER BY started_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "cluster_id" + } + } + }, + { + "name": "started_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "pid", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "pid" + } + } + }, + { + "name": "pid_started_at", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "pid_started_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe" +} diff --git a/packages/oneclient_db/.sqlx/query-c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2.json b/packages/oneclient_db/.sqlx/query-c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2.json new file mode 100644 index 00000000..72745260 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO setting_profiles (\n\t\t\tname, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n\t\t\thook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET\n\t\t\tjava_path = excluded.java_path,\n\t\t\tresolution = excluded.resolution,\n\t\t\tforce_fullscreen = excluded.force_fullscreen,\n\t\t\tmem_max = excluded.mem_max,\n\t\t\tlaunch_args = excluded.launch_args,\n\t\t\tlaunch_env = excluded.launch_env,\n\t\t\thook_pre = excluded.hook_pre,\n\t\t\thook_wrapper = excluded.hook_wrapper,\n\t\t\thook_post = excluded.hook_post,\n\t\t\tos_extra = excluded.os_extra,\n\t\t\tbrowser_update_mode = excluded.browser_update_mode\n\t\tRETURNING name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 12 + }, + "nullable": [ + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2" +} diff --git a/packages/oneclient_db/.sqlx/query-c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38.json b/packages/oneclient_db/.sqlx/query-c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38.json new file mode 100644 index 00000000..6b724ee8 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id, cluster_id, bundle_name, package_id, override_type\n FROM cluster_bundle_overrides\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "id" + } + } + }, + { + "name": "cluster_id", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "cluster_id" + } + } + }, + { + "name": "bundle_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "bundle_name" + } + } + }, + { + "name": "package_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "package_id" + } + } + }, + { + "name": "override_type", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "override_type" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38" +} diff --git a/packages/oneclient_db/.sqlx/query-caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a.json b/packages/oneclient_db/.sqlx/query-caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a.json new file mode 100644 index 00000000..5f455621 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO game_sessions (\n\t\t\tcluster_id, started_at, ram_allocated_mb, mods_enabled, java_vendor, java_version\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tRETURNING\n\t\t\tcluster_id, started_at, ended_at, exit_code,\n\t\t\tram_allocated_mb, mods_enabled, java_vendor, java_version\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "cluster_id" + } + } + }, + { + "name": "started_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + }, + { + "name": "exit_code", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "exit_code" + } + } + }, + { + "name": "ram_allocated_mb", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ram_allocated_mb" + } + } + }, + { + "name": "mods_enabled", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "mods_enabled" + } + } + }, + { + "name": "java_vendor", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_vendor" + } + } + }, + { + "name": "java_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_version" + } + } + } + ], + "parameters": { + "Right": 6 + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a" +} diff --git a/packages/oneclient_db/.sqlx/query-caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892.json b/packages/oneclient_db/.sqlx/query-caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892.json new file mode 100644 index 00000000..db3b7431 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892" +} diff --git a/packages/oneclient_db/.sqlx/query-d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json b/packages/oneclient_db/.sqlx/query-d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json new file mode 100644 index 00000000..d2eadb39 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT DISTINCT mc_version, mc_loader\n FROM bundles\n WHERE hidden = 0\n ORDER BY mc_version, mc_loader\n ", + "describe": { + "columns": [ + { + "name": "mc_version", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854" +} diff --git a/packages/oneclient_db/.sqlx/query-da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c.json b/packages/oneclient_db/.sqlx/query-da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c.json new file mode 100644 index 00000000..abc98452 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c.json @@ -0,0 +1,122 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO provider_releases (\n\t\t\tprovider, project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(provider, project_id, version_id) DO UPDATE SET\n\t\t\thash = excluded.hash,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tdisplay_version = excluded.display_version,\n\t\t\tpublished_at = excluded.published_at,\n\t\t\tmc_versions = excluded.mc_versions,\n\t\t\tmc_loaders = excluded.mc_loaders\n\t\tRETURNING\n\t\t\tprovider as \"provider!: i64\", project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\t", + "describe": { + "columns": [ + { + "name": "provider!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "provider_releases", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "project_id" + } + } + }, + { + "name": "version_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "version_id" + } + } + }, + { + "name": "hash", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "hash" + } + } + }, + { + "name": "display_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_name" + } + } + }, + { + "name": "display_version", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_version" + } + } + }, + { + "name": "published_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "published_at" + } + } + }, + { + "name": "mc_versions", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_versions" + } + } + }, + { + "name": "mc_loaders", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_loaders" + } + } + } + ], + "parameters": { + "Right": 9 + }, + "nullable": [ + null, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c" +} diff --git a/packages/oneclient_db/.sqlx/query-e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee.json b/packages/oneclient_db/.sqlx/query-e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee.json new file mode 100644 index 00000000..c354f764 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO game_session_servers (session_started_at, address, port, joined_at)\n\t\tVALUES (?, ?, ?, ?)\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee" +} diff --git a/packages/oneclient_db/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json b/packages/oneclient_db/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json new file mode 100644 index 00000000..0a86cb15 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf" +} diff --git a/packages/oneclient_db/.sqlx/query-e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501.json b/packages/oneclient_db/.sqlx/query-e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501.json new file mode 100644 index 00000000..cd62c77c --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501.json @@ -0,0 +1,122 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tprovider as \"provider!: i64\", project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\tFROM provider_releases\n\t\tWHERE hash = ?\n\t\tLIMIT 1\n\t\t", + "describe": { + "columns": [ + { + "name": "provider!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "provider_releases", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "project_id" + } + } + }, + { + "name": "version_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "version_id" + } + } + }, + { + "name": "hash", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "hash" + } + } + }, + { + "name": "display_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_name" + } + } + }, + { + "name": "display_version", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_version" + } + } + }, + { + "name": "published_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "published_at" + } + } + }, + { + "name": "mc_versions", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_versions" + } + } + }, + { + "name": "mc_loaders", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_loaders" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501" +} diff --git a/packages/oneclient_db/.sqlx/query-f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec.json b/packages/oneclient_db/.sqlx/query-f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec.json new file mode 100644 index 00000000..b3eb3cf9 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM browser_package_updates WHERE cluster_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec" +} diff --git a/packages/oneclient_db/.sqlx/query-f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed.json b/packages/oneclient_db/.sqlx/query-f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed.json new file mode 100644 index 00000000..3743afe5 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_bundle_overrides\n WHERE cluster_id = ? AND bundle_name = ? AND package_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed" +} diff --git a/packages/oneclient_db/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json b/packages/oneclient_db/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json new file mode 100644 index 00000000..27530fbf --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n WHERE absolute_path = ?\n ", + "describe": { + "columns": [ + { + "name": "absolute_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "absolute_path" + } + } + }, + { + "name": "major", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "major" + } + } + }, + { + "name": "version", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "version" + } + } + }, + { + "name": "vendor", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "vendor" + } + } + }, + { + "name": "os_arch", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "os_arch" + } + } + }, + { + "name": "is_jdk: bool", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "is_jdk" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2" +} diff --git a/packages/oneclient_db/.sqlx/query-f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628.json b/packages/oneclient_db/.sqlx/query-f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628.json new file mode 100644 index 00000000..c1c2145d --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tORDER BY name ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628" +} diff --git a/packages/oneclient_db/.sqlx/query-f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073.json b/packages/oneclient_db/.sqlx/query-f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073.json new file mode 100644 index 00000000..22f62585 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n FROM bundles\n ORDER BY mc_version, mc_loader, name, file_name\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073" +} diff --git a/packages/oneclient_db/Cargo.toml b/packages/oneclient_db/Cargo.toml new file mode 100644 index 00000000..73e03cb4 --- /dev/null +++ b/packages/oneclient_db/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "oneclient_db" +description = "OneClient SQLx database layer" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +tokio.workspace = true +async-tempfile.workspace = true + +[dependencies] +sqlx.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +chrono.workspace = true +tracing.workspace = true +dunce.workspace = true diff --git a/packages/oneclient_db/migrations/20260525104022_initial_migration.sql b/packages/oneclient_db/migrations/20260525104022_initial_migration.sql new file mode 100644 index 00000000..5c5cb2d1 --- /dev/null +++ b/packages/oneclient_db/migrations/20260525104022_initial_migration.sql @@ -0,0 +1,153 @@ +CREATE TABLE `java_versions` ( + `absolute_path` TEXT PRIMARY KEY NOT NULL, + `major` INT NOT NULL, + `version` TEXT NOT NULL, + `vendor` TEXT NOT NULL, + `os_arch` TEXT NOT NULL +); +CREATE INDEX `java_versions_major_idx` ON `java_versions` (`major`); + +CREATE TABLE `setting_profiles` ( + `name` TEXT PRIMARY KEY NOT NULL, + `java_path` TEXT, + `resolution` TEXT, + `force_fullscreen` INTEGER, + `mem_max` INTEGER, + `launch_args` TEXT, + `launch_env` TEXT, + `hook_pre` TEXT, + `hook_wrapper` TEXT, + `hook_post` TEXT, + `os_extra` TEXT, + FOREIGN KEY (`java_path`) REFERENCES `java_versions` (`absolute_path`) +); + +CREATE TABLE `clusters` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` TEXT NOT NULL, + `folder_name` TEXT NOT NULL, + `setting_profile_name` TEXT, + `mc_version` TEXT NOT NULL DEFAULT 'unknown', + `mc_loader` INTEGER NOT NULL DEFAULT 0, + `stage` INTEGER NOT NULL DEFAULT 0, + `mc_loader_version` TEXT, + `created_at` TEXT, + `last_played` TEXT, + `overall_played` INTEGER, + `linked_modpack_hash` TEXT, + FOREIGN KEY (`setting_profile_name`) REFERENCES `setting_profiles` (`name`) +); +CREATE INDEX `clusters_folder_name_idx` ON `clusters` (`folder_name`); + +-- Content-addressed artifact cache (mods, packs, worlds, etc. - modpacks included) +CREATE TABLE artifacts ( + hash TEXT PRIMARY KEY NOT NULL, + content_type INTEGER NOT NULL, + path TEXT NOT NULL, + file_name TEXT NOT NULL, + size_bytes INTEGER +); +CREATE INDEX artifacts_content_type_idx ON artifacts (content_type); + +-- Provider metadata for a specific project version (optional for local-only files) +CREATE TABLE provider_releases ( + provider INTEGER NOT NULL, + project_id TEXT NOT NULL, + version_id TEXT NOT NULL, + hash TEXT NOT NULL, + display_name TEXT NOT NULL, + display_version TEXT NOT NULL, + published_at TEXT, + mc_versions TEXT NOT NULL DEFAULT '[]', + mc_loaders TEXT NOT NULL DEFAULT '[]', + PRIMARY KEY (provider, project_id, version_id), + FOREIGN KEY (hash) REFERENCES artifacts (hash) ON DELETE CASCADE +); +CREATE INDEX provider_releases_hash_idx ON provider_releases (hash); + +-- Links installed artifacts into a cluster instance folder +CREATE TABLE cluster_artifacts ( + cluster_id INTEGER NOT NULL, + hash TEXT NOT NULL, + cluster_file_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + bundle_name TEXT, + bundle_version_id TEXT, + package_id TEXT, + installed_at TEXT, + PRIMARY KEY (cluster_id, hash), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE, + FOREIGN KEY (hash) REFERENCES artifacts (hash) ON DELETE CASCADE +); +CREATE INDEX cluster_artifacts_cluster_id_idx ON cluster_artifacts (cluster_id); +CREATE INDEX cluster_artifacts_bundle_name_idx ON cluster_artifacts (cluster_id, bundle_name); + +CREATE TABLE bundles ( + remote_path TEXT PRIMARY KEY NOT NULL, + mc_version TEXT NOT NULL, + mc_loader INTEGER NOT NULL, + file_name TEXT NOT NULL, + name TEXT, + version_id TEXT, + category TEXT, + loader_version TEXT, + disk_path TEXT NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0, + etag TEXT, + synced_at TEXT +); +CREATE INDEX bundles_mc_version_loader_idx ON bundles (mc_version, mc_loader); +CREATE INDEX bundles_hidden_idx ON bundles (hidden); + +CREATE TABLE cluster_bundle_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + cluster_id INTEGER NOT NULL, + bundle_name TEXT NOT NULL, + package_id TEXT NOT NULL, + override_type TEXT NOT NULL, + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE, + UNIQUE (cluster_id, bundle_name, package_id) +); +CREATE INDEX cluster_bundle_overrides_cluster_id_idx ON cluster_bundle_overrides (cluster_id); + +-- Local cache of remote provider project metadata, keyed by package id. +-- Lets package lists render names/icons/descriptions offline and without +-- refetching from the provider on every session. Icon bytes themselves live in +-- the on-disk image cache (keyed by url); here we persist the url -> package id +-- mapping plus the display fields. +CREATE TABLE package_metadata ( + provider INTEGER NOT NULL, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + icon_url TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider, project_id) +); + +CREATE TABLE game_sessions ( + cluster_id INTEGER NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + exit_code INTEGER, + ram_allocated_mb INTEGER NOT NULL, + mods_enabled INTEGER NOT NULL DEFAULT 0, + java_vendor TEXT, + java_version TEXT, + PRIMARY KEY (started_at, ended_at), + UNIQUE (started_at), + FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE +); +CREATE INDEX game_sessions_cluster_id_idx ON game_sessions(cluster_id); + +CREATE TABLE game_session_servers ( + session_started_at TEXT NOT NULL, + address TEXT NOT NULL, + port INTEGER, + joined_at TEXT NOT NULL, + disconnected_at TEXT, + PRIMARY KEY (joined_at, disconnected_at), + FOREIGN KEY (session_started_at) REFERENCES game_sessions(started_at) ON DELETE CASCADE +); +CREATE INDEX game_session_servers_session_started_at_idx ON game_session_servers(session_started_at); \ No newline at end of file diff --git a/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql b/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql new file mode 100644 index 00000000..8fe785eb --- /dev/null +++ b/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql @@ -0,0 +1,4 @@ +CREATE TABLE applied_migrations ( + id TEXT PRIMARY KEY NOT NULL, + applied_at TEXT NOT NULL +); diff --git a/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql b/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql new file mode 100644 index 00000000..499251b1 --- /dev/null +++ b/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql @@ -0,0 +1,6 @@ +-- Track the OS process behind a session so a restarted launcher can tell a +-- still-running game from one that exited while the launcher was closed. +-- `pid_started_at` is the process start time (unix seconds), used to reject +-- a recycled pid that now belongs to an unrelated process. +ALTER TABLE game_sessions ADD COLUMN pid INTEGER; +ALTER TABLE game_sessions ADD COLUMN pid_started_at INTEGER; diff --git a/packages/oneclient_db/migrations/20260801120000_browser_package_updates.sql b/packages/oneclient_db/migrations/20260801120000_browser_package_updates.sql new file mode 100644 index 00000000..0d329e24 --- /dev/null +++ b/packages/oneclient_db/migrations/20260801120000_browser_package_updates.sql @@ -0,0 +1,37 @@ +-- Auto-updates for packages the user installed from the browser, i.e. remote +-- provider content that no bundle provides. Bundles keep their own update flow +-- and are deliberately out of scope here. +-- +-- `browser_update_mode` is the per-profile override of the launcher-wide +-- setting; NULL means "inherit the global value", like every other column on +-- this table. Values are the strings in `PackageUpdateMode::as_str`. +ALTER TABLE setting_profiles ADD COLUMN browser_update_mode TEXT; + +-- Cache of the launch-time update check, so the package manager can mark a +-- package out of date without going back to the provider on every render, and +-- still knows about it after a restart. +-- +-- Keyed by the artifact hash actually installed in the cluster: that is what +-- the package rows are keyed by, and it changes when the update is applied, +-- which is what makes a stale row impossible to mistake for a live one. +-- +-- `skipped` records that the user was asked about *this* newer version and said +-- no. The row stays either way, because a skipped package is still out of date +-- and should still say so in the package manager; it just stops re-opening the +-- modal for the same version. +CREATE TABLE browser_package_updates ( + cluster_id INTEGER NOT NULL, + hash TEXT NOT NULL, + provider INTEGER NOT NULL, + project_id TEXT NOT NULL, + installed_version_id TEXT NOT NULL, + installed_version_name TEXT NOT NULL DEFAULT '', + latest_version_id TEXT NOT NULL, + latest_version_name TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + checked_at TEXT NOT NULL, + skipped INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (cluster_id, hash), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE +); +CREATE INDEX browser_package_updates_cluster_id_idx ON browser_package_updates (cluster_id); diff --git a/packages/oneclient_db/migrations/20260802120000_java_is_jdk.sql b/packages/oneclient_db/migrations/20260802120000_java_is_jdk.sql new file mode 100644 index 00000000..0de5e00b --- /dev/null +++ b/packages/oneclient_db/migrations/20260802120000_java_is_jdk.sql @@ -0,0 +1,5 @@ +-- Whether a recorded runtime is a development kit rather than a bare runtime +-- image. Probing for it means spawning the java process, so it is remembered +-- alongside the version instead. Rows written before this migration default to +-- 0 and are corrected the next time they are probed. +ALTER TABLE `java_versions` ADD COLUMN `is_jdk` INTEGER NOT NULL DEFAULT 0; diff --git a/packages/oneclient_db/migrations/20260806120000_repair_orphaned_bundle_disables.sql b/packages/oneclient_db/migrations/20260806120000_repair_orphaned_bundle_disables.sql new file mode 100644 index 00000000..1f24491e --- /dev/null +++ b/packages/oneclient_db/migrations/20260806120000_repair_orphaned_bundle_disables.sql @@ -0,0 +1,44 @@ +-- Repairs bundle content whose two records of one decision came apart. +-- +-- `cluster_artifacts.enabled` and `cluster_bundle_overrides` both record whether +-- a package is wanted, and the launcher was able to write one without the other: +-- duplicate resolution wrote the flag with no override, and re-resolving a +-- package to a different bundle deleted the override with no flag. Either way +-- the artifact ends up off with nothing recording why, which nothing could then +-- repair -- the update flow decides from the override and the manifest default +-- and never reads `enabled`, so it saw the package as installed and enabled and +-- never touched it again. For a bundle's hidden dependencies that meant a +-- library the pack needs never reaching `mods/`. + +-- First, the objections that are no longer about anything. Declining a bundle at +-- onboarding recorded `removed` for its hidden files, including libraries that +-- an accepted bundle right beside it ships too -- so a cluster ends up claiming +-- to have removed a package it demonstrably installed, under a different bundle. +-- Left in place these answer for the package in every lookup that resolves a +-- user's choice across bundles, which is now how a choice is resolved. +DELETE FROM cluster_bundle_overrides +WHERE override_type = 'removed' + AND EXISTS (SELECT 1 FROM cluster_artifacts a + WHERE a.cluster_id = cluster_bundle_overrides.cluster_id + AND a.bundle_name IS NOT NULL + AND a.bundle_name <> cluster_bundle_overrides.bundle_name + AND a.package_id = cluster_bundle_overrides.package_id); + +-- Then the flag itself. A genuine user disable always leaves a `disabled` or +-- `removed` row behind, so those are matched here and stay off -- under any +-- bundle, not just the one the artifact sits under today, because a package that +-- moved bundles keeps its old row and that row still speaks for the user. +-- +-- Deliberately narrower than the runtime pass in `bundles::heal_bundle_activity`, +-- which also frees hidden files from a `disabled` row on the grounds that a +-- dependency the user was never shown cannot have been turned off on purpose. +-- Whether a file is hidden is a manifest fact and there is no manifest here, so +-- those wait for the next bundle apply rather than being guessed at. +UPDATE cluster_artifacts SET enabled = 1 +WHERE bundle_name IS NOT NULL + AND package_id IS NOT NULL + AND enabled = 0 + AND NOT EXISTS (SELECT 1 FROM cluster_bundle_overrides o + WHERE o.cluster_id = cluster_artifacts.cluster_id + AND o.package_id = cluster_artifacts.package_id + AND o.override_type IN ('disabled', 'removed')); diff --git a/packages/oneclient_db/migrations/20260809120000_java_probe_version.sql b/packages/oneclient_db/migrations/20260809120000_java_probe_version.sql new file mode 100644 index 00000000..0b03f787 --- /dev/null +++ b/packages/oneclient_db/migrations/20260809120000_java_probe_version.sql @@ -0,0 +1 @@ +ALTER TABLE `java_versions` ADD COLUMN `probe_version` INTEGER NOT NULL DEFAULT 0; diff --git a/packages/oneclient_db/src/backup.rs b/packages/oneclient_db/src/backup.rs new file mode 100644 index 00000000..15703703 --- /dev/null +++ b/packages/oneclient_db/src/backup.rs @@ -0,0 +1,271 @@ +//! Snapshots taken before a migration + +use std::path::{Path, PathBuf}; + +use crate::{DbError, DbPool}; + +pub const KEEP: usize = 5; + +const SIDECARS: &[&str] = &["-wal", "-shm", "-journal"]; + +pub fn dir(database_path: &Path) -> PathBuf { + database_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("backups") +} + +fn stem(database_path: &Path) -> String { + database_path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "database".to_owned()) +} + +fn timestamp() -> String { + chrono::Local::now().format("%Y%m%d-%H%M%S").to_string() +} + +/// A consistent copy without blocking on the journal, unlike a file copy +pub async fn snapshot(pool: &DbPool, database_path: &Path) -> Result { + let dir = dir(database_path); + std::fs::create_dir_all(&dir)?; + + let target = dir.join(format!("{}-{}.db", stem(database_path), timestamp())); + + sqlx::query("VACUUM INTO ?") + .bind(target.to_string_lossy().as_ref()) + .execute(pool) + .await?; + + Ok(target) +} + +/// Newest first +pub fn list(database_path: &Path) -> Vec { + let prefix = format!("{}-", stem(database_path)); + + let Ok(entries) = std::fs::read_dir(dir(database_path)) else { + return Vec::new(); + }; + + let mut found: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.ends_with(".db") + && name + .strip_prefix(&prefix) + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) + }) + }) + .collect(); + + found.sort(); + found.reverse(); + found +} + +pub fn prune(database_path: &Path, keep: usize) { + for old in list(database_path).into_iter().skip(keep) { + if let Err(err) = std::fs::remove_file(&old) { + tracing::warn!(path = %old.display(), %err, "could not remove old snapshot"); + } + } +} + +/// Moves the live database into `backups/` and returns where it went. The +/// caller is left with no database at `database_path`, which is what both +/// restore and reset want before they continue. +pub fn set_aside(database_path: &Path) -> Result, DbError> { + if !database_path.exists() { + return Ok(None); + } + + let dir = dir(database_path); + std::fs::create_dir_all(&dir)?; + + let target = dir.join(format!( + "{}-replaced-{}.db", + stem(database_path), + timestamp() + )); + std::fs::rename(database_path, &target)?; + + for suffix in SIDECARS { + let sidecar = sidecar(database_path, suffix); + if sidecar.exists() { + let _ = std::fs::remove_file(sidecar); + } + } + + Ok(Some(target)) +} + +fn sidecar(database_path: &Path, suffix: &str) -> PathBuf { + let mut name = database_path.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) +} + +/// Puts `snapshot` back, keeping the current database in `backups/` first +pub fn restore(database_path: &Path, snapshot: &Path) -> Result, DbError> { + let replaced = set_aside(database_path)?; + std::fs::copy(snapshot, database_path)?; + + tracing::info!( + snapshot = %snapshot.display(), + "restored the database from a snapshot" + ); + + Ok(replaced) +} + +/// Starts over. The old database is moved aside, never deleted, so it is +/// recoverable by hand afterwards. +pub fn reset(database_path: &Path) -> Result, DbError> { + let replaced = set_aside(database_path)?; + + tracing::warn!( + replaced = ?replaced.as_ref().map(|path| path.display().to_string()), + "reset the database" + ); + + Ok(replaced) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_tempfile::TempDir; + use sqlx::SqlitePool; + + async fn pool_at(path: &Path) -> SqlitePool { + SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .expect("open sqlite") + } + + #[tokio::test] + async fn a_snapshot_is_a_usable_database() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + + let snapshot = snapshot(&pool, &db).await.expect("snapshot"); + assert!(snapshot.exists()); + + let restored = pool_at(&snapshot).await; + let found: Option = + sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'demo'") + .fetch_optional(&restored) + .await + .expect("probe"); + assert!(found.is_some()); + } + + #[tokio::test] + async fn restoring_keeps_the_replaced_database() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE before_snapshot (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + let snapshot = snapshot(&pool, &db).await.expect("snapshot"); + + sqlx::query("CREATE TABLE after_snapshot (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + pool.close().await; + + let replaced = restore(&db, &snapshot).expect("restore").expect("moved"); + + let now = pool_at(&db).await; + let after: Option = + sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'after_snapshot'") + .fetch_optional(&now) + .await + .expect("probe"); + assert!(after.is_none(), "the snapshot should be what is live now"); + + let kept = pool_at(&replaced).await; + let after: Option = + sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'after_snapshot'") + .fetch_optional(&kept) + .await + .expect("probe"); + assert!( + after.is_some(), + "the replaced database must still be intact" + ); + } + + #[tokio::test] + async fn a_reset_moves_the_database_rather_than_deleting_it() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + pool.close().await; + + let moved = reset(&db).expect("reset").expect("moved"); + + assert!(!db.exists()); + assert!(moved.exists()); + } + + #[tokio::test] + async fn a_set_aside_file_is_not_offered_as_a_snapshot() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + let snapshot = snapshot(&pool, &db).await.expect("snapshot"); + pool.close().await; + + reset(&db).expect("reset"); + + assert_eq!(list(&db), vec![snapshot]); + } + + #[tokio::test] + async fn pruning_keeps_the_newest() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + let backups = self::dir(&db); + std::fs::create_dir_all(&backups).expect("mkdir"); + + for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] { + std::fs::write(backups.join(format!("user_data-{stamp}.db")), b"").expect("write"); + } + + prune(&db, 2); + + let left = list(&db); + assert_eq!(left.len(), 2); + assert!( + left[0].ends_with("user_data-20260103-000000.db"), + "the newest must survive" + ); + } +} diff --git a/packages/oneclient_db/src/console.rs b/packages/oneclient_db/src/console.rs new file mode 100644 index 00000000..d9c6ecdf --- /dev/null +++ b/packages/oneclient_db/src/console.rs @@ -0,0 +1,172 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +use sqlx::sqlite::SqliteRow; +use sqlx::{Column, Row, ValueRef}; + +use crate::{DbError, DbPool}; + +/// sqlx requires the SQL string to be `'static` interning stops repeated runs +/// of the same text from leaking +fn intern(sql: &str) -> &'static str { + static CACHE: Mutex>> = Mutex::new(None); + + let mut guard = CACHE.lock().unwrap(); + let cache = guard.get_or_insert_with(HashSet::new); + + if let Some(existing) = cache.get(sql) { + return existing; + } + + let leaked: &'static str = Box::leak(sql.to_owned().into_boxed_str()); + cache.insert(leaked); + leaked +} + +#[derive(Debug, Clone, Default)] +pub struct ConsoleQueryResult { + pub columns: Vec, + pub rows: Vec>, + pub rows_affected: u64, + pub is_select: bool, +} + +/// Statements starting with `SELECT` `WITH` `PRAGMA` or `EXPLAIN` are fetched +/// into a table everything else is executed and reports `rows_affected` +pub async fn run_console_query(pool: &DbPool, sql: &str) -> Result { + let sql = intern(sql.trim().trim_end_matches(';').trim()); + + if returns_rows(sql) { + let fetched = sqlx::query(sql).persistent(false).fetch_all(pool).await?; + Ok(rows_to_result(&fetched)) + } else { + let done = sqlx::query(sql).persistent(false).execute(pool).await?; + Ok(ConsoleQueryResult { + rows_affected: done.rows_affected(), + is_select: false, + ..Default::default() + }) + } +} + +fn returns_rows(sql: &str) -> bool { + let head = sql + .trim_start() + .split(|c: char| c.is_whitespace() || c == '(') + .next() + .unwrap_or("") + .to_ascii_uppercase(); + + matches!(head.as_str(), "SELECT" | "WITH" | "PRAGMA" | "EXPLAIN") +} + +fn rows_to_result(fetched: &[SqliteRow]) -> ConsoleQueryResult { + let columns = fetched + .first() + .map(|row| { + row.columns() + .iter() + .map(|c| c.name().to_string()) + .collect::>() + }) + .unwrap_or_default(); + + let rows = fetched + .iter() + .map(|row| { + (0..row.columns().len()) + .map(|idx| cell_to_string(row, idx)) + .collect() + }) + .collect(); + + ConsoleQueryResult { + columns, + rows, + rows_affected: 0, + is_select: true, + } +} + +/// SQLite is dynamically typed so probe the raw value and decode accordingly +fn cell_to_string(row: &SqliteRow, idx: usize) -> String { + match row.try_get_raw(idx) { + Ok(raw) if raw.is_null() => return "NULL".to_string(), + Ok(_) => {} + Err(_) => return "".to_string(), + } + + if let Ok(v) = row.try_get::(idx) { + return v; + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::, _>(idx) { + return format!("", v.len()); + } + + "".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + async fn pool() -> DbPool { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, ratio REAL)") + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn select_returns_columns_and_rows() { + let pool = pool().await; + sqlx::query("INSERT INTO t (name, ratio) VALUES ('a', 1.5), (NULL, 2.0)") + .execute(&pool) + .await + .unwrap(); + + let res = run_console_query(&pool, "SELECT id, name, ratio FROM t ORDER BY id;") + .await + .unwrap(); + + assert!(res.is_select); + assert_eq!(res.columns, vec!["id", "name", "ratio"]); + assert_eq!(res.rows.len(), 2); + assert_eq!(res.rows[0], vec!["1", "a", "1.5"]); + assert_eq!(res.rows[1][1], "NULL"); + } + + #[tokio::test] + async fn non_select_reports_rows_affected() { + let pool = pool().await; + let res = run_console_query(&pool, "INSERT INTO t (name) VALUES ('x'), ('y')") + .await + .unwrap(); + + assert!(!res.is_select); + assert_eq!(res.rows_affected, 2); + assert!(res.rows.is_empty()); + } + + #[tokio::test] + async fn invalid_sql_errors() { + let pool = pool().await; + assert!( + run_console_query(&pool, "SELECT * FROM nope") + .await + .is_err() + ); + } +} diff --git a/packages/oneclient_db/src/dao/applied_migration.rs b/packages/oneclient_db/src/dao/applied_migration.rs new file mode 100644 index 00000000..5d55241e --- /dev/null +++ b/packages/oneclient_db/src/dao/applied_migration.rs @@ -0,0 +1,24 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +pub async fn is_applied(pool: &SqlitePool, id: &str) -> Result { + let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn mark_applied(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { + let applied_at = Utc::now().to_rfc3339(); + + sqlx::query!( + "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + id, + applied_at + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs new file mode 100644 index 00000000..c8a9c168 --- /dev/null +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -0,0 +1,321 @@ +use sqlx::SqlitePool; + +use crate::models::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow}; + +pub async fn get_artifact_by_hash( + pool: &SqlitePool, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ArtifactRow, + r#" + SELECT hash, content_type, path, file_name, size_bytes + FROM artifacts + WHERE hash = ? + "#, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn insert_artifact( + pool: &SqlitePool, + hash: &str, + content_type: i64, + path: &str, + file_name: &str, + size_bytes: Option, +) -> Result { + sqlx::query_as!( + ArtifactRow, + r#" + INSERT INTO artifacts (hash, content_type, path, file_name, size_bytes) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(hash) DO UPDATE SET + content_type = excluded.content_type, + path = excluded.path, + file_name = excluded.file_name, + size_bytes = COALESCE(excluded.size_bytes, artifacts.size_bytes) + RETURNING hash, content_type, path, file_name, size_bytes + "#, + hash, + content_type, + path, + file_name, + size_bytes + ) + .fetch_one(pool) + .await +} + +/// `provider_releases` cascades the cached file is the caller's to delete +/// this layer does not touch the disk +pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result { + let linked: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?") + .bind(hash) + .fetch_one(pool) + .await?; + + if linked.0 > 0 { + return Ok(false); + } + + sqlx::query!("DELETE FROM artifacts WHERE hash = ?", hash) + .execute(pool) + .await?; + + Ok(true) +} + +pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as::<_, ArtifactRow>( + r#" + SELECT hash, content_type, path, file_name, size_bytes + FROM artifacts + WHERE hash NOT IN (SELECT hash FROM cluster_artifacts) + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn list_artifact_paths(pool: &SqlitePool) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = sqlx::query_as("SELECT path FROM artifacts") + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|(path,)| path).collect()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn upsert_provider_release( + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, + hash: &str, + display_name: &str, + display_version: &str, + published_at: Option<&str>, + mc_versions: &str, + mc_loaders: &str, +) -> Result { + sqlx::query_as!( + ProviderReleaseRow, + r#" + INSERT INTO provider_releases ( + provider, project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, project_id, version_id) DO UPDATE SET + hash = excluded.hash, + display_name = excluded.display_name, + display_version = excluded.display_version, + published_at = excluded.published_at, + mc_versions = excluded.mc_versions, + mc_loaders = excluded.mc_loaders + RETURNING + provider as "provider!: i64", project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + "#, + provider_id, + project_id, + version_id, + hash, + display_name, + display_version, + published_at, + mc_versions, + mc_loaders + ) + .fetch_one(pool) + .await +} + +pub async fn get_provider_release( + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ProviderReleaseRow, + r#" + SELECT + provider as "provider!: i64", project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + FROM provider_releases + WHERE provider = ? AND project_id = ? AND version_id = ? + "#, + provider_id, + project_id, + version_id + ) + .fetch_optional(pool) + .await +} + +pub async fn get_release_by_hash( + pool: &SqlitePool, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ProviderReleaseRow, + r#" + SELECT + provider as "provider!: i64", project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + FROM provider_releases + WHERE hash = ? + LIMIT 1 + "#, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn link_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, +) -> Result { + sqlx::query_as!( + ClusterArtifactRow, + r#" + INSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled) + VALUES (?, ?, ?, 1) + ON CONFLICT(cluster_id, hash) DO UPDATE SET + cluster_file_name = excluded.cluster_file_name + RETURNING cluster_id, hash, cluster_file_name, enabled + "#, + cluster_id, + hash, + cluster_file_name + ) + .fetch_one(pool) + .await +} + +/// `DISTINCT` is required `provider_releases` is keyed by version so one +/// artifact joins several rows and would otherwise be reported repeatedly +pub async fn list_cluster_artifacts_for_project( + pool: &SqlitePool, + cluster_id: i64, + provider: i64, + project_id: &str, + exclude_hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterArtifactRow, + r#" + SELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled + FROM cluster_artifacts ca + JOIN provider_releases pr ON pr.hash = ca.hash + WHERE ca.cluster_id = ? + AND pr.provider = ? + AND pr.project_id = ? + AND ca.hash <> ? + "#, + cluster_id, + provider, + project_id, + exclude_hash + ) + .fetch_all(pool) + .await +} + +pub async fn is_cluster_linked( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result { + let row: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? LIMIT 1") + .bind(cluster_id) + .bind(hash) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn unlink_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn list_cluster_artifacts( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterArtifactRow, + r#" + SELECT cluster_id, hash, cluster_file_name, enabled + FROM cluster_artifacts + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn get_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterArtifactRow, + r#" + SELECT cluster_id, hash, cluster_file_name, enabled + FROM cluster_artifacts + WHERE cluster_id = ? AND hash = ? + "#, + cluster_id, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn update_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, + enabled: i64, +) -> Result { + sqlx::query_as!( + ClusterArtifactRow, + r#" + UPDATE cluster_artifacts + SET cluster_file_name = ?, enabled = ? + WHERE cluster_id = ? AND hash = ? + RETURNING cluster_id, hash, cluster_file_name, enabled + "#, + cluster_file_name, + enabled, + cluster_id, + hash + ) + .fetch_one(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/browser_package_update.rs b/packages/oneclient_db/src/dao/browser_package_update.rs new file mode 100644 index 00000000..919f8fad --- /dev/null +++ b/packages/oneclient_db/src/dao/browser_package_update.rs @@ -0,0 +1,246 @@ +use chrono::Utc; +use sqlx::{QueryBuilder, Sqlite, SqlitePool}; + +use crate::models::BrowserPackageUpdateRow; + +pub async fn list_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" + SELECT + cluster_id, hash, provider as "provider!: i64", project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + FROM browser_package_updates + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" + SELECT + cluster_id, hash, provider as "provider!: i64", project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + FROM browser_package_updates + "#, + ) + .fetch_all(pool) + .await +} + +/// Re-upserting the same `latest_version_id` keeps `skipped` a different one +/// resets it since it is a new question for the user +#[allow(clippy::too_many_arguments)] +pub async fn upsert( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + provider: i64, + project_id: &str, + installed_version_id: &str, + installed_version_name: &str, + latest_version_id: &str, + latest_version_name: &str, + display_name: &str, +) -> Result { + let checked_at = Utc::now().to_rfc3339(); + + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" + INSERT INTO browser_package_updates ( + cluster_id, hash, provider, project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(cluster_id, hash) DO UPDATE SET + provider = excluded.provider, + project_id = excluded.project_id, + installed_version_id = excluded.installed_version_id, + installed_version_name = excluded.installed_version_name, + latest_version_name = excluded.latest_version_name, + display_name = excluded.display_name, + checked_at = excluded.checked_at, + skipped = CASE + WHEN browser_package_updates.latest_version_id = excluded.latest_version_id + THEN browser_package_updates.skipped + ELSE 0 + END, + latest_version_id = excluded.latest_version_id + RETURNING + cluster_id, hash, provider as "provider!: i64", project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + "#, + cluster_id, + hash, + provider, + project_id, + installed_version_id, + installed_version_name, + latest_version_id, + latest_version_name, + display_name, + checked_at + ) + .fetch_one(pool) + .await +} + +pub async fn set_skipped( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + skipped: bool, +) -> Result<(), sqlx::Error> { + let skipped = i64::from(skipped); + sqlx::query!( + r#" + UPDATE browser_package_updates + SET skipped = ? + WHERE cluster_id = ? AND hash = ? + "#, + skipped, + cluster_id, + hash + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete(pool: &SqlitePool, cluster_id: i64, hash: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; + + Ok(()) +} + +/// Drops rows for hashes the latest check did not report an empty list clears +/// the cluster +pub async fn retain_hashes( + pool: &SqlitePool, + cluster_id: i64, + hashes: &[String], +) -> Result<(), sqlx::Error> { + if hashes.is_empty() { + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ?", + cluster_id + ) + .execute(pool) + .await?; + return Ok(()); + } + + let mut builder: QueryBuilder = + QueryBuilder::new("DELETE FROM browser_package_updates WHERE cluster_id = "); + builder.push_bind(cluster_id); + builder.push(" AND hash NOT IN ("); + let mut separated = builder.separated(", "); + for hash in hashes { + separated.push_bind(hash); + } + separated.push_unseparated(")"); + + builder.build().execute(pool).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + // Runtime query not the macro the offline cache is library-only + sqlx::query( + r#" + INSERT INTO clusters (id, name, folder_name, mc_version) + VALUES (1, 'test', 'test', '1.21.4') + "#, + ) + .execute(&pool) + .await + .expect("cluster row"); + pool + } + + async fn record(pool: &SqlitePool, hash: &str, latest: &str) -> BrowserPackageUpdateRow { + upsert( + pool, 1, hash, 0, "proj", "v1", "1.0.0", latest, "2.0.0", "Mod", + ) + .await + .expect("upsert") + } + + #[tokio::test] + async fn skip_survives_a_recheck_of_the_same_version() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); + + let row = record(&pool, "abc", "v2").await; + assert_eq!(row.skipped, 1, "re-running the check must not re-ask"); + } + + #[tokio::test] + async fn a_newer_version_asks_again() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); + + let row = record(&pool, "abc", "v3").await; + assert_eq!(row.skipped, 0, "a different version is a new question"); + } + + #[tokio::test] + async fn retain_drops_hashes_the_check_no_longer_reports() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + record(&pool, "def", "v2").await; + + retain_hashes(&pool, 1, &["abc".to_string()]) + .await + .expect("retain"); + + let rows = list_for_cluster(&pool, 1).await.expect("list"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].hash, "abc"); + } + + #[tokio::test] + async fn retain_with_nothing_clears_the_cluster() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + + retain_hashes(&pool, 1, &[]).await.expect("retain"); + + assert!(list_for_cluster(&pool, 1).await.expect("list").is_empty()); + } +} diff --git a/packages/oneclient_db/src/dao/bundle.rs b/packages/oneclient_db/src/dao/bundle.rs new file mode 100644 index 00000000..beeecd1c --- /dev/null +++ b/packages/oneclient_db/src/dao/bundle.rs @@ -0,0 +1,152 @@ +use sqlx::QueryBuilder; +use sqlx::SqlitePool; + +use crate::models::{BundleRow, NewBundle}; + +pub async fn upsert_bundle( + pool: &SqlitePool, + bundle: NewBundle<'_>, +) -> Result { + let hidden = i64::from(bundle.hidden); + sqlx::query_as!( + BundleRow, + r#" + INSERT INTO bundles ( + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(remote_path) DO UPDATE SET + mc_version = excluded.mc_version, + mc_loader = excluded.mc_loader, + file_name = excluded.file_name, + name = COALESCE(excluded.name, bundles.name), + version_id = COALESCE(excluded.version_id, bundles.version_id), + category = COALESCE(excluded.category, bundles.category), + loader_version = COALESCE(excluded.loader_version, bundles.loader_version), + disk_path = excluded.disk_path, + hidden = excluded.hidden, + etag = COALESCE(excluded.etag, bundles.etag), + synced_at = COALESCE(excluded.synced_at, bundles.synced_at) + RETURNING + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + "#, + bundle.remote_path, + bundle.mc_version, + bundle.mc_loader, + bundle.file_name, + bundle.name, + bundle.version_id, + bundle.category, + bundle.loader_version, + bundle.disk_path, + hidden, + bundle.etag, + bundle.synced_at, + ) + .fetch_one(pool) + .await +} + +pub async fn hide_bundles_not_in( + pool: &SqlitePool, + remote_paths: &[String], +) -> Result { + if remote_paths.is_empty() { + let result = sqlx::query("UPDATE bundles SET hidden = 1 WHERE hidden = 0") + .execute(pool) + .await?; + return Ok(result.rows_affected()); + } + + let mut builder = QueryBuilder::new( + "UPDATE bundles SET hidden = 1 WHERE hidden = 0 AND remote_path NOT IN (", + ); + let mut separated = builder.separated(", "); + for path in remote_paths { + separated.push_bind(path); + } + separated.push_unseparated(")"); + + let result = builder.build().execute(pool).await?; + Ok(result.rows_affected()) +} + +pub async fn list_visible_for_version_loader( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleRow, + r#" + SELECT + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + FROM bundles + WHERE mc_version = ? AND mc_loader = ? AND hidden = 0 + ORDER BY name, file_name + "#, + mc_version, + mc_loader, + ) + .fetch_all(pool) + .await +} + +pub async fn get_by_remote_path( + pool: &SqlitePool, + remote_path: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleRow, + r#" + SELECT + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + FROM bundles + WHERE remote_path = ? + "#, + remote_path + ) + .fetch_optional(pool) + .await +} + +#[derive(Debug, Clone)] +pub struct BundleVersionLoaderGroup { + pub mc_version: String, + pub mc_loader: i64, +} + +pub async fn list_distinct_version_loaders( + pool: &SqlitePool, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleVersionLoaderGroup, + r#" + SELECT DISTINCT mc_version, mc_loader + FROM bundles + WHERE hidden = 0 + ORDER BY mc_version, mc_loader + "# + ) + .fetch_all(pool) + .await +} + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleRow, + r#" + SELECT + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + FROM bundles + ORDER BY mc_version, mc_loader, name, file_name + "# + ) + .fetch_all(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/cluster.rs b/packages/oneclient_db/src/dao/cluster.rs new file mode 100644 index 00000000..b2f84eba --- /dev/null +++ b/packages/oneclient_db/src/dao/cluster.rs @@ -0,0 +1,351 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +use crate::models::{ClusterPatch, ClusterRow, NewCluster}; + +pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + WHERE id = ? + "#, + id + ) + .fetch_optional(pool) + .await +} + +pub async fn get_by_folder_name( + pool: &SqlitePool, + folder_name: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + WHERE folder_name = ? + "#, + folder_name + ) + .fetch_optional(pool) + .await +} + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + ORDER BY last_played IS NULL, last_played DESC, name ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn find_by_version_loader( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + WHERE mc_version = ? AND mc_loader = ? + LIMIT 1 + "#, + mc_version, + mc_loader + ) + .fetch_optional(pool) + .await +} + +pub async fn insert(pool: &SqlitePool, new: &NewCluster<'_>) -> Result { + let created_at = Utc::now().to_rfc3339(); + + sqlx::query_as!( + ClusterRow, + r#" + INSERT INTO clusters ( + name, folder_name, mc_version, mc_loader, mc_loader_version, + setting_profile_name, stage, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + new.name, + new.folder_name, + new.mc_version, + new.mc_loader, + new.mc_loader_version, + new.setting_profile_name, + new.stage, + created_at + ) + .fetch_one(pool) + .await +} + +pub async fn update( + pool: &SqlitePool, + id: i64, + patch: &ClusterPatch, +) -> Result { + let existing = get_by_id(pool, id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let name = patch.name.as_deref().unwrap_or(&existing.name); + let setting_profile_name = patch + .setting_profile_name + .clone() + .unwrap_or(existing.setting_profile_name); + let mc_loader_version = patch + .mc_loader_version + .clone() + .unwrap_or(existing.mc_loader_version); + let linked_modpack_hash = patch + .linked_modpack_hash + .clone() + .unwrap_or(existing.linked_modpack_hash); + + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET name = ?, + setting_profile_name = ?, + mc_loader_version = ?, + linked_modpack_hash = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + name, + setting_profile_name, + mc_loader_version, + linked_modpack_hash, + id + ) + .fetch_one(pool) + .await +} + +pub async fn migrate_version( + pool: &SqlitePool, + id: i64, + mc_version: &str, + name: Option<&str>, + folder_name: &str, +) -> Result { + let existing = get_by_id(pool, id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let name = name.unwrap_or(&existing.name); + + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET mc_version = ?, + name = ?, + folder_name = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + mc_version, + name, + folder_name, + id + ) + .fetch_one(pool) + .await +} + +pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result { + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET stage = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + stage, + id + ) + .fetch_one(pool) + .await +} + +pub async fn add_playtime( + pool: &SqlitePool, + id: i64, + seconds: i64, +) -> Result { + let now = Utc::now().to_rfc3339(); + + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET overall_played = COALESCE(overall_played, 0) + ?, + last_played = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + seconds, + now, + id + ) + .fetch_one(pool) + .await +} + +pub async fn delete_by_id(pool: &SqlitePool, id: i64) -> Result { + let result = sqlx::query!("DELETE FROM clusters WHERE id = ?", id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dao::applied_migration; + + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + pool + } + + async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow { + insert( + pool, + &NewCluster { + name: &format!("{mc_version} fabric"), + folder_name: folder, + mc_version, + mc_loader: 1, + mc_loader_version: None, + setting_profile_name: None, + stage: 0, + }, + ) + .await + .expect("insert cluster") + } + + #[tokio::test] + async fn migrate_version_rewrites_identity_and_keeps_history() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "26.1 fabric").await; + add_playtime(&pool, cluster.id, 3600) + .await + .expect("record playtime"); + + let migrated = migrate_version( + &pool, + cluster.id, + "26.1.2", + Some("26.1.2 fabric"), + "26.1.2 fabric", + ) + .await + .expect("migrate"); + + assert_eq!(migrated.id, cluster.id, "must move the row, not replace it"); + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.folder_name, "26.1.2 fabric"); + assert_eq!(migrated.name, "26.1.2 fabric"); + assert_eq!(migrated.overall_played, Some(3600)); + assert_eq!(migrated.created_at, cluster.created_at); + + assert!( + find_by_version_loader(&pool, "26.1", 1) + .await + .unwrap() + .is_none() + ); + assert_eq!( + find_by_version_loader(&pool, "26.1.2", 1) + .await + .unwrap() + .unwrap() + .id, + cluster.id + ); + } + + #[tokio::test] + async fn migrate_version_can_leave_name_and_folder_untouched() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "my cool pack").await; + + let migrated = migrate_version(&pool, cluster.id, "26.1.2", None, "my cool pack") + .await + .expect("migrate"); + + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.name, "26.1 fabric", "custom name must survive"); + assert_eq!(migrated.folder_name, "my cool pack"); + } + + #[tokio::test] + async fn applied_migrations_ledger_is_idempotent() { + let pool = pool().await; + + assert!( + !applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + assert!( + !applied_migration::is_applied(&pool, "rule-b") + .await + .unwrap() + ); + } +} diff --git a/packages/oneclient_db/src/dao/cluster_bundle.rs b/packages/oneclient_db/src/dao/cluster_bundle.rs new file mode 100644 index 00000000..96b44f45 --- /dev/null +++ b/packages/oneclient_db/src/dao/cluster_bundle.rs @@ -0,0 +1,226 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +use crate::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; + +pub async fn track_bundle_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + bundle_name: &str, + bundle_version_id: &str, + package_id: &str, +) -> Result { + let installed_at = Utc::now().to_rfc3339(); + sqlx::query_as!( + BundleTrackedArtifactRow, + r#" + UPDATE cluster_artifacts + SET + bundle_name = ?, + bundle_version_id = ?, + package_id = ?, + installed_at = ? + WHERE cluster_id = ? AND hash = ? + RETURNING + cluster_id, hash, cluster_file_name, enabled, + bundle_name, bundle_version_id, package_id, installed_at + "#, + bundle_name, + bundle_version_id, + package_id, + installed_at, + cluster_id, + hash + ) + .fetch_one(pool) + .await +} + +pub async fn clear_bundle_tracking( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE cluster_artifacts + SET bundle_name = NULL, bundle_version_id = NULL, package_id = NULL, installed_at = NULL + WHERE cluster_id = ? AND hash = ? + "#, + cluster_id, + hash + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_bundle_tracked( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleTrackedArtifactRow, + r#" + SELECT + cluster_id, hash, cluster_file_name, enabled, + bundle_name, bundle_version_id, package_id, installed_at + FROM cluster_artifacts + WHERE cluster_id = ? AND bundle_name IS NOT NULL + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn get_bundle_tracked( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleTrackedArtifactRow, + r#" + SELECT + cluster_id, hash, cluster_file_name, enabled, + bundle_name, bundle_version_id, package_id, installed_at + FROM cluster_artifacts + WHERE cluster_id = ? AND hash = ? AND bundle_name IS NOT NULL + "#, + cluster_id, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn has_bundle_mapping_for_package( + pool: &SqlitePool, + cluster_id: i64, + package_id: &str, +) -> Result { + let row = sqlx::query_scalar!( + r#" + SELECT 1 FROM cluster_artifacts + WHERE cluster_id = ? AND bundle_name IS NOT NULL AND package_id = ? + LIMIT 1 + "#, + cluster_id, + package_id + ) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn save_override( + pool: &SqlitePool, + cluster_id: i64, + bundle_name: &str, + package_id: &str, + override_type: OverrideType, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO cluster_bundle_overrides (cluster_id, bundle_name, package_id, override_type) + VALUES (?, ?, ?, ?) + ON CONFLICT(cluster_id, bundle_name, package_id) DO UPDATE SET + override_type = excluded.override_type + "#, + cluster_id, + bundle_name, + package_id, + override_type.as_str() + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn save_overrides( + pool: &SqlitePool, + cluster_id: i64, + overrides: &[(String, String, OverrideType)], +) -> Result<(), sqlx::Error> { + if overrides.is_empty() { + return Ok(()); + } + + let mut tx = pool.begin().await?; + for (bundle_name, package_id, override_type) in overrides { + sqlx::query!( + r#" + INSERT INTO cluster_bundle_overrides (cluster_id, bundle_name, package_id, override_type) + VALUES (?, ?, ?, ?) + ON CONFLICT(cluster_id, bundle_name, package_id) DO UPDATE SET + override_type = excluded.override_type + "#, + cluster_id, + bundle_name, + package_id, + override_type.as_str() + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +pub async fn remove_override( + pool: &SqlitePool, + cluster_id: i64, + bundle_name: &str, + package_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM cluster_bundle_overrides + WHERE cluster_id = ? AND bundle_name = ? AND package_id = ? + "#, + cluster_id, + bundle_name, + package_id + ) + .execute(pool) + .await?; + Ok(()) +} + +/// Opt-in (`enabled`) rows are left alone they say the opposite thing +pub async fn clear_suppressing_overrides( + pool: &SqlitePool, + cluster_id: i64, + package_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM cluster_bundle_overrides + WHERE cluster_id = ? AND package_id = ? AND override_type IN ('disabled', 'removed') + "#, + cluster_id, + package_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_overrides( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterBundleOverrideRow, + r#" + SELECT id, cluster_id, bundle_name, package_id, override_type + FROM cluster_bundle_overrides + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/game_session.rs b/packages/oneclient_db/src/dao/game_session.rs new file mode 100644 index 00000000..4c179d75 --- /dev/null +++ b/packages/oneclient_db/src/dao/game_session.rs @@ -0,0 +1,300 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +use crate::models::{ + GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, + UnfinishedSession, +}; + +pub async fn insert_session( + pool: &SqlitePool, + new: &NewGameSession<'_>, +) -> Result { + sqlx::query_as!( + GameSessionRow, + r#" + INSERT INTO game_sessions ( + cluster_id, started_at, ram_allocated_mb, mods_enabled, java_vendor, java_version + ) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING + cluster_id, started_at, ended_at, exit_code, + ram_allocated_mb, mods_enabled, java_vendor, java_version + "#, + new.cluster_id, + new.started_at, + new.ram_allocated_mb, + new.mods_enabled, + new.java_vendor, + new.java_version + ) + .fetch_one(pool) + .await +} + +pub async fn finish_session( + pool: &SqlitePool, + started_at: &str, + exit_code: Option, +) -> Result<(), sqlx::Error> { + finish_session_at(pool, started_at, &Utc::now().to_rfc3339(), exit_code).await +} + +/// For exit times inferred from the game's log rather than observed live +pub async fn finish_session_at( + pool: &SqlitePool, + started_at: &str, + ended_at: &str, + exit_code: Option, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE game_sessions + SET ended_at = ?, exit_code = ? + WHERE started_at = ? AND ended_at IS NULL + "#, + ended_at, + exit_code, + started_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn set_session_process( + pool: &SqlitePool, + started_at: &str, + pid: Option, + pid_started_at: Option, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE game_sessions + SET pid = ?, pid_started_at = ? + WHERE started_at = ? + "#, + pid, + pid_started_at, + started_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn unfinished_sessions(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + UnfinishedSession, + r#" + SELECT cluster_id, started_at, pid, pid_started_at + FROM game_sessions + WHERE ended_at IS NULL + ORDER BY started_at ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn insert_server_join( + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, +) -> Result { + insert_server_join_at( + pool, + session_started_at, + address, + port, + &Utc::now().to_rfc3339(), + ) + .await +} + +pub async fn insert_server_join_at( + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, + joined_at: &str, +) -> Result { + sqlx::query!( + r#" + INSERT INTO game_session_servers (session_started_at, address, port, joined_at) + VALUES (?, ?, ?, ?) + "#, + session_started_at, + address, + port, + joined_at + ) + .execute(pool) + .await?; + Ok(joined_at.to_string()) +} + +pub async fn finish_server(pool: &SqlitePool, joined_at: &str) -> Result<(), sqlx::Error> { + finish_server_at(pool, joined_at, &Utc::now().to_rfc3339()).await +} + +pub async fn finish_server_at( + pool: &SqlitePool, + joined_at: &str, + disconnected_at: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE game_session_servers + SET disconnected_at = ? + WHERE joined_at = ? AND disconnected_at IS NULL + "#, + disconnected_at, + joined_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_sessions_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionRow, + r#" + SELECT + cluster_id, started_at, ended_at, exit_code, + ram_allocated_mb, mods_enabled, java_vendor, java_version + FROM game_sessions + WHERE cluster_id = ? + ORDER BY started_at DESC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn list_session_servers( + pool: &SqlitePool, + session_started_at: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionServerRow, + r#" + SELECT session_started_at, address, port, joined_at, disconnected_at + FROM game_session_servers + WHERE session_started_at = ? + ORDER BY joined_at ASC + "#, + session_started_at + ) + .fetch_all(pool) + .await +} + +/// Lets a log replay rewrite a session's server spans from scratch +pub async fn delete_session_servers( + pool: &SqlitePool, + session_started_at: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM game_session_servers + WHERE session_started_at = ? + "#, + session_started_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn server_join_counts( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ServerJoinCount, + r#" + SELECT s.address AS "address!", COUNT(*) AS "joins!: i64" + FROM game_session_servers s + JOIN game_sessions g ON g.started_at = s.session_started_at + WHERE g.cluster_id = ? + GROUP BY s.address + ORDER BY COUNT(*) DESC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn all_session_spans(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + SessionSpan, + r#" + SELECT started_at, ended_at + FROM game_sessions + WHERE ended_at IS NOT NULL + ORDER BY started_at ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn session_spans_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + SessionSpan, + r#" + SELECT started_at, ended_at + FROM game_sessions + WHERE cluster_id = ? AND ended_at IS NOT NULL + ORDER BY started_at ASC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn all_session_servers( + pool: &SqlitePool, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionServerRow, + r#" + SELECT session_started_at, address, port, joined_at, disconnected_at + FROM game_session_servers + ORDER BY joined_at ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn session_servers_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionServerRow, + r#" + SELECT s.session_started_at, s.address, s.port, s.joined_at, s.disconnected_at + FROM game_session_servers s + JOIN game_sessions g ON g.started_at = s.session_started_at + WHERE g.cluster_id = ? + ORDER BY s.joined_at ASC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/java.rs b/packages/oneclient_db/src/dao/java.rs new file mode 100644 index 00000000..4138c1f0 --- /dev/null +++ b/packages/oneclient_db/src/dao/java.rs @@ -0,0 +1,110 @@ +use sqlx::SqlitePool; + +use crate::DbError; +use crate::models::JavaVersionRow; + +#[allow(clippy::too_many_arguments)] +pub async fn insert( + pool: &SqlitePool, + absolute_path: &str, + major: u32, + version: &str, + vendor: &str, + os_arch: &str, + is_jdk: bool, + probe_version: u32, +) -> Result { + sqlx::query_as!( + JavaVersionRow, + r#" + INSERT INTO java_versions (absolute_path, major, version, vendor, os_arch, is_jdk, probe_version) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(absolute_path) DO UPDATE SET + major = excluded.major, + version = excluded.version, + vendor = excluded.vendor, + os_arch = excluded.os_arch, + is_jdk = excluded.is_jdk, + probe_version = excluded.probe_version + RETURNING absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + "#, + absolute_path, + i64::from(major), + version, + vendor, + os_arch, + is_jdk, + i64::from(probe_version), + ) + .fetch_one(pool) + .await + .map_err(Into::into) +} + +pub async fn get_by_path( + pool: &SqlitePool, + absolute_path: &str, +) -> Result, DbError> { + let row = sqlx::query_as!( + JavaVersionRow, + r#" + SELECT absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + FROM java_versions + WHERE absolute_path = ? + "#, + absolute_path, + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn get_latest_by_major( + pool: &SqlitePool, + major: u32, +) -> Result, DbError> { + let row = sqlx::query_as!( + JavaVersionRow, + r#" + SELECT absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + FROM java_versions + WHERE major = ? + ORDER BY version DESC + LIMIT 1 + "#, + i64::from(major) + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn delete_by_path(pool: &SqlitePool, absolute_path: &str) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM java_versions WHERE absolute_path = ? + "#, + absolute_path, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn list_all(pool: &SqlitePool) -> Result, DbError> { + let rows = sqlx::query_as!( + JavaVersionRow, + r#" + SELECT absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + FROM java_versions + ORDER BY major DESC, version DESC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/packages/oneclient_db/src/dao/mod.rs b/packages/oneclient_db/src/dao/mod.rs new file mode 100644 index 00000000..63874833 --- /dev/null +++ b/packages/oneclient_db/src/dao/mod.rs @@ -0,0 +1,10 @@ +pub mod applied_migration; +pub mod artifact; +pub mod browser_package_update; +pub mod bundle; +pub mod cluster; +pub mod cluster_bundle; +pub mod game_session; +pub mod java; +pub mod package_metadata; +pub mod setting_profile; diff --git a/packages/oneclient_db/src/dao/package_metadata.rs b/packages/oneclient_db/src/dao/package_metadata.rs new file mode 100644 index 00000000..34210171 --- /dev/null +++ b/packages/oneclient_db/src/dao/package_metadata.rs @@ -0,0 +1,63 @@ +use sqlx::{QueryBuilder, Sqlite, SqlitePool}; + +use crate::models::PackageMetadataRow; + +pub async fn get_package_metadata_batch( + pool: &SqlitePool, + provider: i64, + project_ids: &[String], +) -> Result, sqlx::Error> { + if project_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut builder: QueryBuilder = QueryBuilder::new( + "SELECT provider, project_id, name, summary, author, icon_url \ + FROM package_metadata WHERE provider = ", + ); + builder.push_bind(provider); + builder.push(" AND project_id IN ("); + let mut separated = builder.separated(", "); + for id in project_ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + + builder + .build_query_as::() + .fetch_all(pool) + .await +} + +pub async fn upsert_package_metadata( + pool: &SqlitePool, + provider: i64, + project_id: &str, + name: &str, + summary: &str, + author: &str, + icon_url: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO package_metadata (provider, project_id, name, summary, author, icon_url, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(provider, project_id) DO UPDATE SET + name = excluded.name, + summary = excluded.summary, + author = excluded.author, + icon_url = excluded.icon_url, + updated_at = excluded.updated_at + "#, + provider, + project_id, + name, + summary, + author, + icon_url + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/packages/oneclient_db/src/dao/setting_profile.rs b/packages/oneclient_db/src/dao/setting_profile.rs new file mode 100644 index 00000000..0511ece1 --- /dev/null +++ b/packages/oneclient_db/src/dao/setting_profile.rs @@ -0,0 +1,108 @@ +use sqlx::SqlitePool; + +use crate::DbError; +use crate::error::DbResult; +use crate::models::SettingProfileRow; + +const GLOBAL_PROFILE_NAME: &str = "Global"; + +pub fn is_reserved_global_name(name: &str) -> bool { + name == GLOBAL_PROFILE_NAME +} + +pub async fn list_all(pool: &SqlitePool) -> DbResult> { + let rows = sqlx::query_as!( + SettingProfileRow, + r#" + SELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + FROM setting_profiles + ORDER BY name ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +pub async fn get_by_name(pool: &SqlitePool, name: &str) -> DbResult> { + let row = sqlx::query_as!( + SettingProfileRow, + r#" + SELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + FROM setting_profiles + WHERE name = ? + "#, + name, + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn upsert( + pool: &SqlitePool, + row: &SettingProfileRow, +) -> Result { + if row.name.is_empty() || is_reserved_global_name(&row.name) { + return Err(DbError::InvalidValue { + field: "name".into(), + value: row.name.clone(), + }); + } + + sqlx::query_as!( + SettingProfileRow, + r#" + INSERT INTO setting_profiles ( + name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + java_path = excluded.java_path, + resolution = excluded.resolution, + force_fullscreen = excluded.force_fullscreen, + mem_max = excluded.mem_max, + launch_args = excluded.launch_args, + launch_env = excluded.launch_env, + hook_pre = excluded.hook_pre, + hook_wrapper = excluded.hook_wrapper, + hook_post = excluded.hook_post, + os_extra = excluded.os_extra, + browser_update_mode = excluded.browser_update_mode + RETURNING name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + "#, + &row.name, + &row.java_path, + &row.resolution, + row.force_fullscreen, + row.mem_max, + &row.launch_args, + &row.launch_env, + &row.hook_pre, + &row.hook_wrapper, + &row.hook_post, + &row.os_extra, + &row.browser_update_mode, + ) + .fetch_one(pool) + .await + .map_err(Into::into) +} + +pub async fn delete_by_name(pool: &SqlitePool, name: &str) -> Result<(), DbError> { + let result = sqlx::query!(r#"DELETE FROM setting_profiles WHERE name = ?"#, name,) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound); + } + + Ok(()) +} diff --git a/packages/oneclient_db/src/error.rs b/packages/oneclient_db/src/error.rs new file mode 100644 index 00000000..19278210 --- /dev/null +++ b/packages/oneclient_db/src/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DbError { + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + Migrate(#[from] sqlx::migrate::MigrateError), + + #[error("record not found")] + NotFound, + + #[error("invalid value for {field}: {value}")] + InvalidValue { field: String, value: String }, +} + +pub(crate) type DbResult = Result; diff --git a/packages/oneclient_db/src/lib.rs b/packages/oneclient_db/src/lib.rs new file mode 100644 index 00000000..ff5e44ab --- /dev/null +++ b/packages/oneclient_db/src/lib.rs @@ -0,0 +1,11 @@ +mod error; +mod migrate; +mod pool; + +pub mod backup; +pub mod console; +pub mod dao; +pub mod models; + +pub use error::DbError; +pub use pool::{DbPool, connect}; diff --git a/packages/oneclient_db/src/migrate.rs b/packages/oneclient_db/src/migrate.rs new file mode 100644 index 00000000..8bb88a43 --- /dev/null +++ b/packages/oneclient_db/src/migrate.rs @@ -0,0 +1,136 @@ +use std::path::Path; + +use sqlx::migrate::{MigrateError, Migrator}; + +use crate::{DbError, DbPool, backup}; + +const MAX_ATTEMPTS: u32 = 3; + +pub async fn run(pool: &DbPool, database_path: &Path) -> Result<(), DbError> { + let mut migrator = sqlx::migrate!(); + migrator.set_ignore_missing(true); + + if has_pending(pool, &migrator).await { + match backup::snapshot(pool, database_path).await { + Ok(path) => { + tracing::info!(path = %path.display(), "snapshotted the database before migrating"); + backup::prune(database_path, backup::KEEP); + } + // A migration that then succeeds is the common case, so a failed + // snapshot is not worth refusing to start over + Err(err) => tracing::warn!(%err, "could not snapshot the database before migrating"), + } + } + + let mut attempt = 1; + loop { + let error = match migrator.run(pool).await { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + if attempt >= MAX_ATTEMPTS || !matches!(error, MigrateError::ExecuteMigration(..)) { + return Err(error.into()); + } + + tracing::warn!(attempt, %error, "migration failed, retrying"); + attempt += 1; + } +} + +/// False on a fresh database, where there is nothing yet worth snapshotting +async fn has_pending(pool: &DbPool, migrator: &Migrator) -> bool { + let applied: Vec = sqlx::query_scalar("SELECT version FROM _sqlx_migrations") + .fetch_all(pool) + .await + .unwrap_or_default(); + + !applied.is_empty() && migrator.iter().any(|m| !applied.contains(&m.version)) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_tempfile::TempDir; + use sqlx::SqlitePool; + + async fn migrated() -> (TempDir, std::path::PathBuf, SqlitePool) { + let dir = TempDir::new().await.expect("tempdir"); + let path = dir.dir_path().join("user_data.db"); + let pool = SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .expect("open sqlite"); + + run(&pool, &path).await.expect("migrations run"); + (dir, path, pool) + } + + #[tokio::test] + async fn migrates_a_fresh_database() { + let (_dir, _path, pool) = migrated().await; + + let column: Option = sqlx::query_scalar( + "SELECT 1 FROM pragma_table_info('setting_profiles') WHERE name = ?", + ) + .bind("browser_update_mode") + .fetch_optional(&pool) + .await + .expect("probe"); + + assert!(column.is_some()); + } + + #[tokio::test] + async fn running_twice_is_a_no_op() { + let (_dir, path, pool) = migrated().await; + run(&pool, &path).await.expect("second run"); + } + + #[tokio::test] + async fn tolerates_a_migration_this_build_does_not_ship() { + let (_dir, path, pool) = migrated().await; + + sqlx::query( + "INSERT INTO _sqlx_migrations + (version, description, success, checksum, execution_time) + VALUES (?, 'from a newer build', TRUE, x'00', -1)", + ) + .bind(20990101120000i64) + .execute(&pool) + .await + .expect("record a migration from the future"); + + run(&pool, &path) + .await + .expect("a downgrade must still start"); + } + + #[tokio::test] + async fn a_fresh_database_is_not_snapshotted() { + let (_dir, path, _pool) = migrated().await; + assert!(backup::list(&path).is_empty()); + } + + #[tokio::test] + async fn nothing_outstanding_once_migrated() { + let (_dir, _path, pool) = migrated().await; + assert!(!has_pending(&pool, &sqlx::migrate!()).await); + } + + #[tokio::test] + async fn an_unapplied_migration_is_outstanding() { + let (_dir, _path, pool) = migrated().await; + + let latest: i64 = sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations") + .fetch_one(&pool) + .await + .expect("latest version"); + sqlx::query("DELETE FROM _sqlx_migrations WHERE version = ?") + .bind(latest) + .execute(&pool) + .await + .expect("forget it"); + + assert!(has_pending(&pool, &sqlx::migrate!()).await); + } +} diff --git a/packages/oneclient_db/src/models/artifact.rs b/packages/oneclient_db/src/models/artifact.rs new file mode 100644 index 00000000..7bc31713 --- /dev/null +++ b/packages/oneclient_db/src/models/artifact.rs @@ -0,0 +1,31 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct ArtifactRow { + pub hash: String, + pub content_type: i64, + pub path: String, + pub file_name: String, + pub size_bytes: Option, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ProviderReleaseRow { + pub provider: i64, + pub project_id: String, + pub version_id: String, + pub hash: String, + pub display_name: String, + pub display_version: String, + pub published_at: Option, + pub mc_versions: String, + pub mc_loaders: String, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ClusterArtifactRow { + pub cluster_id: i64, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, +} diff --git a/packages/oneclient_db/src/models/browser_package_update.rs b/packages/oneclient_db/src/models/browser_package_update.rs new file mode 100644 index 00000000..7fb36a50 --- /dev/null +++ b/packages/oneclient_db/src/models/browser_package_update.rs @@ -0,0 +1,18 @@ +use sqlx::FromRow; + +/// `hash` is the artifact currently linked into the cluster so a row can never +/// be attached to a file the cluster no longer has +#[derive(Debug, Clone, FromRow)] +pub struct BrowserPackageUpdateRow { + pub cluster_id: i64, + pub hash: String, + pub provider: i64, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + pub display_name: String, + pub checked_at: String, + pub skipped: i64, +} diff --git a/packages/oneclient_db/src/models/bundle.rs b/packages/oneclient_db/src/models/bundle.rs new file mode 100644 index 00000000..bf5047fa --- /dev/null +++ b/packages/oneclient_db/src/models/bundle.rs @@ -0,0 +1,32 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct BundleRow { + pub remote_path: String, + pub mc_version: String, + pub mc_loader: i64, + pub file_name: String, + pub name: Option, + pub version_id: Option, + pub category: Option, + pub loader_version: Option, + pub disk_path: String, + pub hidden: i64, + pub etag: Option, + pub synced_at: Option, +} + +pub struct NewBundle<'a> { + pub remote_path: &'a str, + pub mc_version: &'a str, + pub mc_loader: i64, + pub file_name: &'a str, + pub name: Option<&'a str>, + pub version_id: Option<&'a str>, + pub category: Option<&'a str>, + pub loader_version: Option<&'a str>, + pub disk_path: &'a str, + pub hidden: bool, + pub etag: Option<&'a str>, + pub synced_at: Option<&'a str>, +} diff --git a/packages/oneclient_db/src/models/cluster.rs b/packages/oneclient_db/src/models/cluster.rs new file mode 100644 index 00000000..0a7d07c8 --- /dev/null +++ b/packages/oneclient_db/src/models/cluster.rs @@ -0,0 +1,37 @@ +use sqlx::FromRow; + +pub type ClusterId = i64; + +#[derive(Debug, Clone, FromRow)] +pub struct ClusterRow { + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: i64, + pub stage: i64, + pub mc_loader_version: Option, + pub created_at: Option, + pub last_played: Option, + pub overall_played: Option, + pub linked_modpack_hash: Option, +} + +pub struct NewCluster<'a> { + pub name: &'a str, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: i64, + pub mc_loader_version: Option<&'a str>, + pub setting_profile_name: Option<&'a str>, + pub stage: i64, +} + +#[derive(Debug, Clone, Default)] +pub struct ClusterPatch { + pub name: Option, + pub setting_profile_name: Option>, + pub mc_loader_version: Option>, + pub linked_modpack_hash: Option>, +} diff --git a/packages/oneclient_db/src/models/cluster_bundle.rs b/packages/oneclient_db/src/models/cluster_bundle.rs new file mode 100644 index 00000000..3cb80206 --- /dev/null +++ b/packages/oneclient_db/src/models/cluster_bundle.rs @@ -0,0 +1,49 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverrideType { + Removed, + Disabled, + /// Opt-in install a file the bundle manifest ships with `enabled: false` + Enabled, +} + +impl OverrideType { + pub fn as_str(self) -> &'static str { + match self { + Self::Removed => "removed", + Self::Disabled => "disabled", + Self::Enabled => "enabled", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "removed" => Some(Self::Removed), + "disabled" => Some(Self::Disabled), + "enabled" => Some(Self::Enabled), + _ => None, + } + } +} + +#[derive(Debug, Clone, FromRow)] +pub struct ClusterBundleOverrideRow { + pub id: i64, + pub cluster_id: i64, + pub bundle_name: String, + pub package_id: String, + pub override_type: String, +} + +#[derive(Debug, Clone, FromRow)] +pub struct BundleTrackedArtifactRow { + pub cluster_id: i64, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, + pub bundle_name: Option, + pub bundle_version_id: Option, + pub package_id: Option, + pub installed_at: Option, +} diff --git a/packages/oneclient_db/src/models/game_session.rs b/packages/oneclient_db/src/models/game_session.rs new file mode 100644 index 00000000..26beed9b --- /dev/null +++ b/packages/oneclient_db/src/models/game_session.rs @@ -0,0 +1,53 @@ +use sqlx::FromRow; + +pub type GameSessionId = String; + +#[derive(Debug, Clone, FromRow)] +pub struct GameSessionRow { + pub cluster_id: i64, + pub started_at: String, + pub ended_at: Option, + pub exit_code: Option, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option, + pub java_version: Option, +} + +pub struct NewGameSession<'a> { + pub cluster_id: i64, + pub started_at: &'a str, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option<&'a str>, + pub java_version: Option<&'a str>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct UnfinishedSession { + pub cluster_id: i64, + pub started_at: String, + pub pid: Option, + pub pid_started_at: Option, +} + +#[derive(Debug, Clone, FromRow)] +pub struct GameSessionServerRow { + pub session_started_at: String, + pub address: String, + pub port: Option, + pub joined_at: String, + pub disconnected_at: Option, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ServerJoinCount { + pub address: String, + pub joins: i64, +} + +#[derive(Debug, Clone, FromRow)] +pub struct SessionSpan { + pub started_at: String, + pub ended_at: Option, +} diff --git a/packages/oneclient_db/src/models/java.rs b/packages/oneclient_db/src/models/java.rs new file mode 100644 index 00000000..db77db85 --- /dev/null +++ b/packages/oneclient_db/src/models/java.rs @@ -0,0 +1,12 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct JavaVersionRow { + pub absolute_path: String, + pub major: i64, + pub version: String, + pub vendor: String, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: i64, +} diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs new file mode 100644 index 00000000..9bc61da2 --- /dev/null +++ b/packages/oneclient_db/src/models/mod.rs @@ -0,0 +1,22 @@ +mod artifact; +mod browser_package_update; +mod bundle; +mod cluster; +mod cluster_bundle; +mod game_session; +mod java; +mod package_metadata; +mod setting_profile; + +pub use artifact::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow}; +pub use browser_package_update::BrowserPackageUpdateRow; +pub use bundle::{BundleRow, NewBundle}; +pub use cluster::{ClusterId, ClusterPatch, ClusterRow, NewCluster}; +pub use cluster_bundle::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; +pub use game_session::{ + GameSessionId, GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, + SessionSpan, UnfinishedSession, +}; +pub use java::JavaVersionRow; +pub use package_metadata::PackageMetadataRow; +pub use setting_profile::SettingProfileRow; diff --git a/packages/oneclient_db/src/models/package_metadata.rs b/packages/oneclient_db/src/models/package_metadata.rs new file mode 100644 index 00000000..fa922345 --- /dev/null +++ b/packages/oneclient_db/src/models/package_metadata.rs @@ -0,0 +1,11 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct PackageMetadataRow { + pub provider: i64, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, +} diff --git a/packages/oneclient_db/src/models/setting_profile.rs b/packages/oneclient_db/src/models/setting_profile.rs new file mode 100644 index 00000000..66e2d213 --- /dev/null +++ b/packages/oneclient_db/src/models/setting_profile.rs @@ -0,0 +1,18 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct SettingProfileRow { + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// `PackageUpdateMode::as_str` NULL inherits the global setting + pub browser_update_mode: Option, +} diff --git a/packages/oneclient_db/src/pool.rs b/packages/oneclient_db/src/pool.rs new file mode 100644 index 00000000..1cab41c2 --- /dev/null +++ b/packages/oneclient_db/src/pool.rs @@ -0,0 +1,38 @@ +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use std::path::Path; +use std::str::FromStr; + +use crate::DbError; + +pub type DbPool = SqlitePool; + +#[tracing::instrument( + skip(database_path), + fields(database_path = %database_path.as_ref().display()) +)] +pub async fn connect(database_path: impl AsRef) -> Result { + let database_path = database_path.as_ref(); + if let Some(parent) = database_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let options = SqliteConnectOptions::from_str(&format!( + "sqlite://{}?mode=rwc", + dunce::canonicalize(database_path) + .unwrap_or_else(|_| database_path.to_path_buf()) + .display() + ))? + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(4) + .connect_with(options) + .await?; + + crate::migrate::run(&pool, database_path).await?; + + tracing::info!("database ready"); + + Ok(pool) +} diff --git a/packages/oneclient_discord/Cargo.toml b/packages/oneclient_discord/Cargo.toml new file mode 100644 index 00000000..f53dce78 --- /dev/null +++ b/packages/oneclient_discord/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "oneclient_discord" +description = "Discord rich presence for OneClient" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true + +chrono.workspace = true +discord-rich-presence.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tracing-subscriber.workspace = true diff --git a/packages/oneclient_discord/examples/rpc.rs b/packages/oneclient_discord/examples/rpc.rs new file mode 100644 index 00000000..5b542250 --- /dev/null +++ b/packages/oneclient_discord/examples/rpc.rs @@ -0,0 +1,33 @@ +use std::time::Duration; + +use oneclient_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_discord/src/lib.rs b/packages/oneclient_discord/src/lib.rs new file mode 100644 index 00000000..f8b41280 --- /dev/null +++ b/packages/oneclient_discord/src/lib.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 oneclient_common::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_events/Cargo.toml b/packages/oneclient_events/Cargo.toml new file mode 100644 index 00000000..d7acf940 --- /dev/null +++ b/packages/oneclient_events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "oneclient_events" +description = "The OneClient event bus: notifications, progress, prompts and signals" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +thiserror.workspace = true +tokio = { workspace = true } +tracing.workspace = true +uuid.workspace = true diff --git a/packages/oneclient_events/src/bus.rs b/packages/oneclient_events/src/bus.rs new file mode 100644 index 00000000..54be020e --- /dev/null +++ b/packages/oneclient_events/src/bus.rs @@ -0,0 +1,301 @@ +use tokio::sync::{mpsc, oneshot}; +use uuid::Uuid; + +use crate::error::{EventError, EventResult}; +use crate::event::{ + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, +}; +use crate::prompt::{Answer, Chosen, Prompt, PromptRequest}; + +/// Cheap to clone every clone feeds the same channel +/// Hand clones to subsystems rather than wrapping this in an `Arc` +#[derive(Clone, Debug)] +pub struct EventBus { + tx: mpsc::UnboundedSender, +} + +pub type EventReceiver = mpsc::UnboundedReceiver; + +impl EventBus { + #[must_use] + pub fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } + + #[must_use] + pub fn channel() -> (Self, EventReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self::new(tx), rx) + } + + /// A closed bus is logged rather than returned use [`EventBus::ask`] when + /// you need to know whether the other end is alive + pub fn emit(&self, event: impl Into) { + if let Err(err) = self.tx.send(event.into()) { + tracing::debug!("dropping event, bus is closed: {err}"); + } + } + + #[must_use] + pub fn is_open(&self) -> bool { + !self.tx.is_closed() + } + + /// ```ignore + /// events.notify("Install failed").body(err.to_string()).error().send(); + /// ``` + pub fn notify(&self, title: impl Into) -> NotificationBuilder<'_> { + NotificationBuilder { + bus: self, + message: Message { + title: title.into(), + body: String::new(), + level: Level::Info, + }, + } + } + + /// Repeated calls with the same `id` replace one another + pub fn progress(&self, id: Uuid, label: impl Into, current: u64, total: u64) { + self.emit(ProgressEvent::Update { + id, + label: label.into(), + current, + total, + }); + } + + pub fn finish_progress(&self, id: Uuid, title: impl Into, body: impl Into) { + self.emit(ProgressEvent::Complete { + id, + title: title.into(), + body: body.into(), + }); + } + + pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { + self.emit(GameEvent::Stage { cluster_id, stage }); + } + + pub fn game_log(&self, cluster_id: i64, line: impl Into) { + self.emit(GameEvent::Log { + cluster_id, + line: line.into(), + }); + } + + pub fn game_failed(&self, cluster_id: i64, message: impl Into) { + self.emit(GameEvent::Failed { + cluster_id, + message: message.into(), + }); + } + + pub fn signal(&self, signal: Signal) { + self.emit(signal); + } + + /// Returns `Ok(None)` when the prompt was dismissed + #[tracing::instrument(level = "debug", skip_all, fields(title = %prompt.title))] + pub async fn ask(&self, prompt: Prompt) -> EventResult>> { + let Prompt { + title, + body, + options, + dismiss, + } = prompt; + + let (choices, values): (Vec<_>, Vec<_>) = options.into_iter().unzip(); + let (reply, reply_rx) = oneshot::channel(); + + self.tx + .send(Event::Notification(Notification::Prompt(PromptRequest { + title, + body, + choices: choices.clone(), + dismiss, + reply, + }))) + .map_err(|_| EventError::BusClosed)?; + + // A dropped sender means the UI went away without answering + let Some(answer) = reply_rx.await.map_err(|_| EventError::BusClosed)? else { + return Ok(None); + }; + + let Answer { id, input } = answer; + let index = choices + .iter() + .position(|choice| choice.id == id) + .ok_or(EventError::UnknownChoice(id))?; + + let value = values + .into_iter() + .nth(index) + .ok_or(EventError::UnknownChoice(id))?; + + Ok(Some(Chosen { value, input })) + } +} + +#[must_use = "the notification is not emitted until `.send()` is called"] +pub struct NotificationBuilder<'a> { + bus: &'a EventBus, + message: Message, +} + +impl NotificationBuilder<'_> { + pub fn body(mut self, body: impl Into) -> Self { + self.message.body = body.into(); + self + } + + pub fn level(mut self, level: Level) -> Self { + self.message.level = level; + self + } + + pub fn info(self) -> Self { + self.level(Level::Info) + } + + pub fn error(self) -> Self { + self.level(Level::Error) + } + + pub fn send(self) { + self.bus.emit(self.message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::prompt::{Choice, InputValue}; + + #[derive(Debug, PartialEq, Eq)] + enum Answered { + Download, + Folder, + } + + fn prompt() -> Prompt { + Prompt::new("Java required", "No Java 21 runtime was found.") + .option( + Choice::primary("download", "Download").picks_selection("java-vendor"), + Answered::Download, + ) + .option( + Choice::new("folder", "Choose folder").picks_folder("Pick a folder"), + Answered::Folder, + ) + .dismiss("Cancel") + } + + #[tokio::test] + async fn ask_maps_the_answer_back_to_the_callers_value() { + let (bus, mut rx) = EventBus::channel(); + + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + assert_eq!(request.choices.len(), 2); + assert_eq!(request.dismiss.as_deref(), Some("Cancel")); + request + .reply + .send(Some(Answer::new("folder").with_folder("/opt/jdk21"))) + .unwrap(); + + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Folder); + assert_eq!(chosen.folder(), Some(std::path::Path::new("/opt/jdk21"))); + } + + #[tokio::test] + async fn selection_input_round_trips() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request + .reply + .send(Some(Answer::new("download").with_selection("adoptium"))) + .unwrap(); + + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Download); + assert_eq!(chosen.selection(), Some("adoptium")); + assert_eq!( + chosen.input, + Some(InputValue::Selection("adoptium".to_string())) + ); + } + + #[tokio::test] + async fn dismissing_yields_none() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(None).unwrap(); + + assert!(task.await.unwrap().unwrap().is_none()); + } + + #[tokio::test] + async fn unknown_choice_is_an_error_not_a_dismissal() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(Some(Answer::new("nonsense"))).unwrap(); + + assert_eq!( + task.await.unwrap().unwrap_err(), + EventError::UnknownChoice("nonsense") + ); + } + + #[tokio::test] + async fn dropping_the_receiver_unblocks_the_asker() { + let (bus, rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + drop(rx); + + assert_eq!(task.await.unwrap().unwrap_err(), EventError::BusClosed); + } + + #[test] + fn emitting_on_a_closed_bus_does_not_panic() { + let (bus, rx) = EventBus::channel(); + drop(rx); + + assert!(!bus.is_open()); + bus.notify("gone") + .body("nobody is listening") + .error() + .send(); + bus.game_log(1, "still fine"); + } + + #[tokio::test] + async fn notification_builder_defaults_to_info_with_an_empty_body() { + let (bus, mut rx) = EventBus::channel(); + bus.notify("Done").send(); + + let Some(Event::Notification(Notification::Message(message))) = rx.recv().await else { + panic!("expected a message"); + }; + assert_eq!(message.title, "Done"); + assert_eq!(message.body, ""); + assert_eq!(message.level, Level::Info); + } +} diff --git a/packages/oneclient_events/src/error.rs b/packages/oneclient_events/src/error.rs new file mode 100644 index 00000000..048199c8 --- /dev/null +++ b/packages/oneclient_events/src/error.rs @@ -0,0 +1,12 @@ +pub type EventResult = Result; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EventError { + #[error("the event bus is closed")] + BusClosed, + + /// A wrong answer rather than a missing one so it must not be read as + /// "dismissed" + #[error("prompt answered with an unknown choice '{0}'")] + UnknownChoice(&'static str), +} diff --git a/packages/oneclient_events/src/event.rs b/packages/oneclient_events/src/event.rs new file mode 100644 index 00000000..d118a802 --- /dev/null +++ b/packages/oneclient_events/src/event.rs @@ -0,0 +1,131 @@ +use uuid::Uuid; + +use crate::progress::GroupedProgressEvent; +use crate::prompt::PromptRequest; + +/// One channel carries all of it so ordering between a progress update and the +/// notification that follows it is preserved +/// Nothing here names a screen or a surface rendering is entirely the +/// front-end's call +#[derive(Debug)] +pub enum Event { + Notification(Notification), + Progress(ProgressEvent), + Game(GameEvent), + /// State changed elsewhere whoever caches it should refetch + Signal(Signal), +} + +#[derive(Debug)] +pub enum Notification { + Message(Message), + Prompt(PromptRequest), +} + +impl From for Event { + fn from(value: Notification) -> Self { + Self::Notification(value) + } +} + +impl From for Event { + fn from(value: Message) -> Self { + Self::Notification(Notification::Message(value)) + } +} + +impl From for Event { + fn from(value: PromptRequest) -> Self { + Self::Notification(Notification::Prompt(value)) + } +} + +impl From for Event { + fn from(value: ProgressEvent) -> Self { + Self::Progress(value) + } +} + +impl From for Event { + fn from(value: GameEvent) -> Self { + Self::Game(value) + } +} + +impl From for Event { + fn from(value: Signal) -> Self { + Self::Signal(value) + } +} + +impl From for Event { + fn from(value: GroupedProgressEvent) -> Self { + Self::Progress(ProgressEvent::Grouped(value)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub title: String, + pub body: String, + pub level: Level, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub enum Level { + #[default] + Info, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProgressEvent { + /// Keyed by `id` so repeated updates replace one another rather than stack + Update { + id: Uuid, + label: String, + current: u64, + total: u64, + }, + + /// Turns the in-flight [`ProgressEvent::Update`] with this `id` into a + /// finished message in place rather than emitting a second card + Complete { + id: Uuid, + title: String, + body: String, + }, + + Grouped(GroupedProgressEvent), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GameEvent { + Stage { cluster_id: i64, stage: LaunchStage }, + Log { cluster_id: i64, line: String }, + Failed { cluster_id: i64, message: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchStage { + Checking, + Downloading, + Launching, + Running, + Exited, +} + +impl LaunchStage { + #[must_use] + pub fn is_busy(self) -> bool { + matches!(self, Self::Checking | Self::Downloading | Self::Launching) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Signal { + ClustersChanged, + JavaChanged, + /// Initial background sync finished + SyncComplete, +} diff --git a/packages/oneclient_events/src/lib.rs b/packages/oneclient_events/src/lib.rs new file mode 100644 index 00000000..43d79479 --- /dev/null +++ b/packages/oneclient_events/src/lib.rs @@ -0,0 +1,23 @@ +//! This crate is a leaf +//! It must not learn what a Java vendor a package provider or a cluster is +//! prompts carry caller-defined values through [`Prompt`] and +//! [`TaskCategory`] holds display labels rather than types + +mod bus; +mod error; +mod event; + +pub mod progress; +pub mod prompt; + +pub use bus::{EventBus, EventReceiver, NotificationBuilder}; +pub use error::{EventError, EventResult}; +pub use event::{ + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, +}; +pub use progress::{ + GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession, TaskCategory, TaskPhase, +}; +pub use prompt::{ + Answer, Choice, ChoiceInput, ChoiceStyle, Chosen, InputValue, Prompt, PromptRequest, +}; diff --git a/packages/oneclient_events/src/progress.rs b/packages/oneclient_events/src/progress.rs new file mode 100644 index 00000000..f810dd20 --- /dev/null +++ b/packages/oneclient_events/src/progress.rs @@ -0,0 +1,398 @@ +//! Both handles are RAII dropping the last one ends the session or finishes +//! the child so an early return on an error path cannot leave the UI showing a +//! task that will never complete + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use uuid::Uuid; + +use crate::bus::EventBus; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupedProgressEvent { + Start { + session_id: Uuid, + title: String, + }, + /// Pre-announces a category's children so the aggregate total is known + /// up-front instead of climbing during a `buffer_unordered` fan-out + Expect { + session_id: Uuid, + category: TaskCategory, + count: u64, + total: u64, + }, + AddChild { + session_id: Uuid, + child_id: Uuid, + label: String, + total: u64, + category: TaskCategory, + }, + UpdateChild { + session_id: Uuid, + child_id: Uuid, + current: u64, + total: u64, + }, + SetChildPhase { + session_id: Uuid, + child_id: Uuid, + phase: TaskPhase, + }, + FinishChild { + session_id: Uuid, + child_id: Uuid, + }, + End { + session_id: Uuid, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TaskPhase { + #[default] + Downloading, + Verifying, + Extracting, + Installing, +} + +impl TaskPhase { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Downloading => "Downloading", + Self::Verifying => "Verifying", + Self::Extracting => "Extracting", + Self::Installing => "Installing", + } + } +} + +/// Display labels only not a dependency on the subsystems they name +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum TaskCategory { + Client, + Metadata, + Libraries, + Natives, + Assets, + Java, + #[default] + Packages, +} + +impl TaskCategory { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Client => "Client", + Self::Metadata => "Metadata", + Self::Libraries => "Libraries", + Self::Natives => "Natives", + Self::Assets => "Assets", + Self::Java => "Java runtime", + Self::Packages => "Packages", + } + } + + #[must_use] + pub fn is_minecraft(self) -> bool { + !matches!(self, Self::Packages) + } +} + +struct SessionInner { + session_id: Uuid, + events: EventBus, + ended: AtomicBool, +} + +impl SessionInner { + fn end(&self) { + if self.ended.swap(true, Ordering::Relaxed) { + return; + } + + self.events.emit(GroupedProgressEvent::End { + session_id: self.session_id, + }); + } +} + +impl Drop for SessionInner { + fn drop(&mut self) { + self.end(); + } +} + +struct ChildInner { + session_id: Uuid, + child_id: Uuid, + events: EventBus, + label: String, + total: AtomicU64, + finished: Arc, +} + +#[derive(Clone)] +pub struct GroupedProgressSession { + inner: Arc, +} + +#[derive(Clone)] +pub struct GroupedProgressChild { + inner: Arc, +} + +impl GroupedProgressSession { + pub fn start(events: &EventBus, title: impl Into) -> Self { + let session_id = Uuid::new_v4(); + let title = title.into(); + + events.emit(GroupedProgressEvent::Start { session_id, title }); + + Self { + inner: Arc::new(SessionInner { + session_id, + events: events.clone(), + ended: AtomicBool::new(false), + }), + } + } + + pub fn id(&self) -> Uuid { + self.inner.session_id + } + + /// `count` = number of files `total` = sum of their sizes in bytes + pub fn expect(&self, category: TaskCategory, count: u64, total: u64) { + if count == 0 { + return; + } + self.inner.events.emit(GroupedProgressEvent::Expect { + session_id: self.inner.session_id, + category, + count, + total, + }); + } + + pub fn child( + &self, + label: impl Into, + total: u64, + category: TaskCategory, + ) -> GroupedProgressChild { + let child_id = Uuid::new_v4(); + let label = label.into(); + let total = total.max(1); + + self.inner.events.emit(GroupedProgressEvent::AddChild { + session_id: self.inner.session_id, + child_id, + label: label.clone(), + total, + category, + }); + + GroupedProgressChild { + inner: Arc::new(ChildInner { + session_id: self.inner.session_id, + child_id, + events: self.inner.events.clone(), + label, + total: AtomicU64::new(total), + finished: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }), + } + } + + pub async fn run_child( + &self, + label: impl Into, + total: u64, + category: TaskCategory, + f: F, + ) -> Result + where + F: FnOnce(GroupedProgressChild) -> Fut, + Fut: std::future::Future>, + { + let child = self.child(label, total, category); + let result = f(child.clone()).await; + if result.is_ok() { + child.finish(); + } + result + } + + pub fn finish(self) { + self.inner.end(); + } + + /// Consumes the session without emitting an `End` event + /// Use when another actor will take over the session's notification entry + pub fn detach(self) -> Uuid { + self.inner.ended.store(true, Ordering::Relaxed); + self.inner.session_id + } +} + +impl GroupedProgressChild { + pub fn label(&self) -> &str { + &self.inner.label + } + + pub fn set_phase(&self, phase: TaskPhase) { + if self + .inner + .finished + .load(std::sync::atomic::Ordering::Relaxed) + { + return; + } + self.inner.events.emit(GroupedProgressEvent::SetChildPhase { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + phase, + }); + } + + pub fn set_progress(&self, current: u64, total: Option) { + if self + .inner + .finished + .load(std::sync::atomic::Ordering::Relaxed) + { + return; + } + + // Prefer the largest known total Content-Length is often missing (1) for + // chunked responses and would clobber a real manifest size freezing the bar + let stored = self.inner.total.load(Ordering::Relaxed); + let total = total.unwrap_or(0).max(stored).max(1); + if total > stored { + self.inner.total.store(total, Ordering::Relaxed); + } + self.inner.events.emit(GroupedProgressEvent::UpdateChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + current, + total, + }); + } + + pub fn finish(&self) { + if self + .inner + .finished + .swap(true, std::sync::atomic::Ordering::Relaxed) + { + return; + } + + self.inner.events.emit(GroupedProgressEvent::FinishChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + }); + } +} + +impl Drop for GroupedProgressChild { + /// Only the *last* handle finishes the task clones are handed around + /// routinely and finishing on any clone's drop would end the task early + fn drop(&mut self) { + if Arc::strong_count(&self.inner) == 1 { + self.finish(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::{Event, ProgressEvent}; + + fn drain(rx: &mut crate::bus::EventReceiver) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let Event::Progress(ProgressEvent::Grouped(event)) = event { + events.push(event); + } + } + events + } + + fn has_finish(events: &[GroupedProgressEvent]) -> bool { + events + .iter() + .any(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })) + } + + #[test] + fn dropping_a_clone_does_not_finish_the_task() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); + let child = session.child("file", 100, TaskCategory::Assets); + let _ = drain(&mut rx); + + drop(child.clone()); + assert!( + !has_finish(&drain(&mut rx)), + "a cloned handle going out of scope must not finish the task" + ); + + child.set_progress(50, Some(100)); + let events = drain(&mut rx); + assert!( + events + .iter() + .any(|e| matches!(e, GroupedProgressEvent::UpdateChild { current: 50, .. })), + "progress must still be reported after a clone is dropped: {events:?}" + ); + + drop(child); + assert!( + has_finish(&drain(&mut rx)), + "the last handle going out of scope should finish the task" + ); + } + + #[test] + fn explicit_finish_reports_once() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); + let child = session.child("file", 100, TaskCategory::Assets); + let _ = drain(&mut rx); + + child.finish(); + assert_eq!( + drain(&mut rx) + .iter() + .filter(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })) + .count(), + 1 + ); + + drop(child); + assert!(!has_finish(&drain(&mut rx)), "drop must not finish twice"); + } + + #[test] + fn progress_after_finish_is_ignored() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); + let child = session.child("file", 100, TaskCategory::Assets); + child.finish(); + let _ = drain(&mut rx); + + child.set_progress(75, Some(100)); + assert!(drain(&mut rx).is_empty()); + } +} diff --git a/packages/oneclient_events/src/prompt.rs b/packages/oneclient_events/src/prompt.rs new file mode 100644 index 00000000..4491b14f --- /dev/null +++ b/packages/oneclient_events/src/prompt.rs @@ -0,0 +1,190 @@ +//! A prompt carries its own [`Choice`] list +//! The wire format is untyped (a choice id plus optional collected input) but +//! [`Prompt`] pairs each choice with a caller-defined value keeping this +//! crate free of any dependency on the subsystems that raise prompts + +use std::path::{Path, PathBuf}; + +use tokio::sync::oneshot; + +/// Only a hint the event layer has no business knowing what a button looks like +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChoiceStyle { + Primary, + #[default] + Secondary, + Danger, +} + +/// Something the UI must collect before the choice can be answered +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChoiceInput { + Folder { + title: String, + }, + /// `hint` names the kind of thing being picked so the front-end can route to + /// the right picker its meaning belongs to whoever built the prompt + Selection { + hint: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputValue { + Folder(PathBuf), + Selection(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Choice { + /// Stable identifier matched when the answer comes back + pub id: &'static str, + pub label: String, + pub style: ChoiceStyle, + pub input: Option, +} + +impl Choice { + pub fn new(id: &'static str, label: impl Into) -> Self { + Self { + id, + label: label.into(), + style: ChoiceStyle::Secondary, + input: None, + } + } + + #[must_use] + pub fn primary(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Primary) + } + + #[must_use] + pub fn danger(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Danger) + } + + #[must_use] + pub fn style(mut self, style: ChoiceStyle) -> Self { + self.style = style; + self + } + + #[must_use] + pub fn picks_folder(mut self, title: impl Into) -> Self { + self.input = Some(ChoiceInput::Folder { + title: title.into(), + }); + self + } + + #[must_use] + pub fn picks_selection(mut self, hint: impl Into) -> Self { + self.input = Some(ChoiceInput::Selection { hint: hint.into() }); + self + } +} + +/// ```ignore +/// enum JavaAnswer { Download, PickFolder } +/// +/// let answer = events.ask( +/// Prompt::new("Java required", "No Java 21 runtime was found.") +/// .option( +/// Choice::primary("download", "Download").picks_selection("java-vendor"), +/// JavaAnswer::Download, +/// ) +/// .option( +/// Choice::new("folder", "Choose folder").picks_folder("Select a Java 21 folder"), +/// JavaAnswer::PickFolder, +/// ) +/// .dismiss("Cancel"), +/// ).await?; +/// ``` +#[must_use = "a prompt is not shown until it is passed to `EventBus::ask`"] +pub struct Prompt { + pub(crate) title: String, + pub(crate) body: String, + pub(crate) options: Vec<(Choice, T)>, + pub(crate) dismiss: Option, +} + +impl Prompt { + pub fn new(title: impl Into, body: impl Into) -> Self { + Self { + title: title.into(), + body: body.into(), + options: Vec::new(), + dismiss: None, + } + } + + pub fn option(mut self, choice: Choice, value: T) -> Self { + self.options.push((choice, value)); + self + } + + /// A prompt without this can still be dismissed by closing it so callers + /// must always handle `None` + pub fn dismiss(mut self, label: impl Into) -> Self { + self.dismiss = Some(label.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Chosen { + pub value: T, + pub input: Option, +} + +impl Chosen { + pub fn folder(&self) -> Option<&Path> { + match &self.input { + Some(InputValue::Folder(path)) => Some(path), + _ => None, + } + } + + pub fn selection(&self) -> Option<&str> { + match &self.input { + Some(InputValue::Selection(value)) => Some(value), + _ => None, + } + } +} + +/// The untyped prompt as it crosses the bus `T` never leaves the caller's crate +#[derive(Debug)] +pub struct PromptRequest { + pub title: String, + pub body: String, + pub choices: Vec, + pub dismiss: Option, + pub reply: oneshot::Sender>, +} + +/// `None` on the wire means dismissed +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Answer { + pub id: &'static str, + pub input: Option, +} + +impl Answer { + pub fn new(id: &'static str) -> Self { + Self { id, input: None } + } + + #[must_use] + pub fn with_folder(mut self, path: impl Into) -> Self { + self.input = Some(InputValue::Folder(path.into())); + self + } + + #[must_use] + pub fn with_selection(mut self, value: impl Into) -> Self { + self.input = Some(InputValue::Selection(value.into())); + self + } +} diff --git a/packages/oneclient_java/Cargo.toml b/packages/oneclient_java/Cargo.toml new file mode 100644 index 00000000..b677d596 --- /dev/null +++ b/packages/oneclient_java/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "oneclient_java" +description = "Locating, probing and installing Java runtimes" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +async-trait.workspace = true +async-tempfile.workspace = true +async_zip.workspace = true +astral-tokio-tar.workspace = true +futures-lite.workspace = true +tokio.workspace = true + +reqwest.workspace = true +url.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_java/assets/java/JavaInfo.class b/packages/oneclient_java/assets/java/JavaInfo.class new file mode 100644 index 0000000000000000000000000000000000000000..b4d13ae6de0cde5846a4b18dfe3e49ab1cdf651c GIT binary patch literal 3231 zcma)8X?GLX8Gf!c(v0Q7h6w_MApw$5%XlF`++Z657!!=;rIt%-lf)TIV|#3A#*E}G zT?r&@k}m09=#qvm?&2;Xt?+3YPV*%n`k_CeU;7u@bJEb~&PcHg$~irl(cJqk_kExD zdEYyK|L`w&031UxiW-DeC_2K32yC4-7tQvp=}fl|P0m{Bfg>j*Ry8rUV|il-zKbDtNXt-4#VW8dPl6 zu?@zi6H_bsf|V1fo3;wWo;z=Og%yE=P51jq+`m;zx7&5Zu!E7h3k*CKDA{iNFb^*9 zc+1Rnsn{j3^WLb=EZEtM3i(zMN7DxA>J zS^`)u+eTeCJ7*W7tYN#3!+4BQ&6xR~teMY$jK^`A!chsvF@Zfz89w>hcfNok`o@FX|$8cvr} zo6Q6|Jvz=xojRF>o13?@vV!$Er{gJ{7f`Han>ia-zP0c%SHF2 z8lCanC37-sX}HK!+&mG@aq$@e$Q;LI`&<%uqROcihn9M-^1RYkEuO`5DkgM153}5c z3k5sdo^;b@mI)~%LqiP82;5bO2tXPc6_$=EOqXr_u*(#$E4WE_$?|&4JUx|LxY9?J zkTlpN#ggqv|8qLB;4skAu2V2=hYW43%(Z96^itO41SzIh@F=a zMDaa*U&Rk}T*nP|x*r{rYk$f!=V$D6zSmi_J=e)u4m+xmz2dW^s&rquaX!YL^r^v* zbo>}UVR_4KNA{QPHrkSH@l$~v8_g<50Snsec`jT1CVr;k=Q@6YUkdD!TFdPP4!L~0 z9Cf@x7Hz+kV>~3dTrNufa|7nQudhN^af^OR+U1%OpYAkg zqJRHJQ|^M7wq&_zFJqNAZ<~w>K6P@x!XFd`&uA^6`Sp%772e$Aj5sG3L@p*{rN909;tvJuUL2SnyKOJ7i z4!p?=@2`mCZ$$DBwAv5lE&&az_@j{j3D|#eTt%9H|AF{7L5|A1G(eB;LVh8(HirzY zh`x0UJ*})ILu)?LIuA~jf-;CZ7)l-Xad$t%Ip812nhdDaipoo~RFhijMyUOg@oD4f zb&TD@Geu1HHyblW%w9(<{4UaCA;U?HDaL#%xr&Zv!=va1^k$>5NoP^&EK41uWc7mF z`CKY_sIu1ajaq-<2Ew5=zl#4-*7})>WZDW|WRNd#l*r#ooST@+A)?rV1X|IBHeR`h z@eGb)o?o2Hc$`*8ITer7vJ+RSA#pwk?E@qT>b7kkpq@y72-U|sMu_kF=n;Pj3+HJG z?0=BBlBz53;L4bB)p&`}+{Txak$1Tc$<0H?E4T3)6ATt#_ZMNg*pM2p`m~#~T!CWM&!s-9AH}B_PjHIzgbGr_?03GXBY&x_v6%I1_-#52w;W=Qa*{^L?nw zDij`6BCMPxZo!0Vm`II;%eQJ2ypC@wM9P_yi>`?<^zAh=Yni>lm ZzfO%sjH{`)q24=%!i?ijw5`FP{|5pM3>g3b literal 0 HcmV?d00001 diff --git a/packages/oneclient_java/assets/java/JavaInfo.java b/packages/oneclient_java/assets/java/JavaInfo.java new file mode 100644 index 00000000..10d19de5 --- /dev/null +++ b/packages/oneclient_java/assets/java/JavaInfo.java @@ -0,0 +1,98 @@ +import java.io.File; +import java.util.Locale; + +public final class JavaInfo { + private static final String[] CHECKED_PROPERTIES = new String[] {"os.arch", "java.version", "java.vendor", "java.home"}; + + public static void main(String[] args) { + int returnCode = 0; + + for (String key : CHECKED_PROPERTIES) { + String property = System.getProperty(key); + + if (property != null) { + System.out.println(key + "=" + property); + } else { + returnCode = 1; + } + } + + System.out.println("java.awt=" + hasClass("java.awt.Toolkit")); + System.out.println("java.awt.natives=" + hasAwtNatives()); + System.out.println("java.awt.link=" + awtLinks()); + System.out.println("java.jdk=" + hasCompiler()); + + System.exit(returnCode); + } + + /** Loads the class without initialising it, so no display is ever needed. */ + private static boolean hasClass(String name) { + try { + Class.forName(name, false, JavaInfo.class.getClassLoader()); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static boolean hasAwtNatives() { + try { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String[] required; + + if (os.contains("win")) { + required = new String[] {"awt.dll", "fontmanager.dll"}; + } else if (os.contains("mac") || os.contains("darwin")) { + required = new String[] {"libawt_lwawt.dylib", "libfontmanager.dylib"}; + } else { + required = new String[] {"libawt_xawt.so", "libfontmanager.so"}; + } + + for (String name : required) { + if (!findNative(name)) { + return false; + } + } + + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static boolean findNative(String name) { + File home = new File(System.getProperty("java.home", ".")); + File[] roots = new File[] { + new File(home, "lib"), + new File(home, "bin"), + new File(new File(home, "lib"), System.getProperty("os.arch", "")), + }; + + for (File root : roots) { + if (new File(root, name).isFile()) { + return true; + } + } + + return false; + } + + private static boolean awtLinks() { + try { + java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().getClass(); + return true; + } catch (LinkageError broken) { + return false; + } catch (Throwable ignored) { + return true; + } + } + + private static boolean hasCompiler() { + try { + return javax.tools.ToolProvider.getSystemJavaCompiler() != null; + } catch (Throwable ignored) { + return false; + } + } +} diff --git a/packages/oneclient_java/assets/java/README.md b/packages/oneclient_java/assets/java/README.md new file mode 100644 index 00000000..eb9180c4 --- /dev/null +++ b/packages/oneclient_java/assets/java/README.md @@ -0,0 +1,13 @@ +[`JavaInfo`](./JavaInfo.java) compiled by [`LynithDev`](https://github.com/LynithDev) using +``` +os.arch=amd64 +java.version=1.8.0_382 +java.vendor=Azul Systems, Inc. +``` + +> [!NOTE] +> The checked-in `JavaInfo.class` predates the `java.home`, `java.awt` and `java.jdk` +> lines and does not print them yet. Recompiling it against a Java 8 target +> (`javac --release 8 JavaInfo.java`) is what turns the probe-side answers on; until +> then [`checker.rs`](../../src/checker.rs) falls back to inspecting the installation +> on disk, which reaches the same verdict for every image seen in the wild. diff --git a/packages/oneclient_java/examples/install_provider.rs b/packages/oneclient_java/examples/install_provider.rs new file mode 100644 index 00000000..b9ef8b9c --- /dev/null +++ b/packages/oneclient_java/examples/install_provider.rs @@ -0,0 +1,66 @@ +//! Downloads and probes one vendor's JDK then records it in an in-memory store + +use std::env; + +use oneclient_common::paths::java_dir; +use oneclient_events::EventBus; +use oneclient_java::vendors::{ + AdoptiumRuntimeProvider, CorrettoRuntimeProvider, JavaRuntimeProvider, LibericaRuntimeProvider, + ZuluRuntimeProvider, +}; +use oneclient_java::{JavaResult, JavaService, MemoryJavaStore, check_java_runtime}; +use oneclient_net::{NetConfig, RequestClient}; + +#[tokio::main] +async fn main() -> JavaResult<()> { + let mut args = env::args().skip(1); + let vendor = args.next().unwrap_or_else(|| { + eprintln!("usage: install_provider [major]"); + std::process::exit(1); + }); + + let major: u32 = args + .next() + .unwrap_or_else(|| "21".to_string()) + .parse() + .expect("major version must be a number"); + + let net = RequestClient::new(NetConfig::default())?; + // Nothing drains this so events are dropped + let (events, _rx) = EventBus::channel(); + + let provider: Box = match vendor.as_str() { + "zulu" => Box::new(ZuluRuntimeProvider), + "adoptium" => Box::new(AdoptiumRuntimeProvider), + "corretto" => Box::new(CorrettoRuntimeProvider), + "liberica" => Box::new(LibericaRuntimeProvider), + other => { + eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica"); + std::process::exit(1); + } + }; + + let packages = provider.list_packages(Some(major), &net).await?; + let package = packages + .into_iter() + .find(|p| p.java_version.contains(&major)) + .unwrap_or_else(|| panic!("no {vendor} package for Java {major}")); + + println!("Installing package {:#?} to '{:?}'", package, java_dir()?); + + let executable = provider + .install_package(&package, &net, &events, None) + .await?; + println!("Installed: {}", executable.display()); + + let info = check_java_runtime(executable.display().to_string()).await?; + println!(" version: {}", info.version); + println!(" vendor: {}", info.vendor); + println!(" arch: {}", info.os_arch); + + let service = JavaService::new(MemoryJavaStore::new(), net, events); + service.add_custom_runtime(executable).await?; + println!("Registered runtimes: {:?}", service.list_runtimes().await?); + + Ok(()) +} diff --git a/packages/oneclient_java/examples/list_providers.rs b/packages/oneclient_java/examples/list_providers.rs new file mode 100644 index 00000000..c85cb345 --- /dev/null +++ b/packages/oneclient_java/examples/list_providers.rs @@ -0,0 +1,55 @@ +//! Lists the JDK packages each vendor currently publishes + +use std::env; + +use oneclient_java::vendors::{JavaVendor, runtime_providers}; +use oneclient_java::{JavaPackage, JavaResult}; +use oneclient_net::{NetConfig, RequestClient}; + +#[tokio::main] +async fn main() -> JavaResult<()> { + let arg = env::args().nth(1).unwrap_or_else(|| "21".to_string()); + let major: Option = if arg == "all" { + None + } else { + Some(arg.parse().expect("usage: list_providers [major|all]")) + }; + + let net = RequestClient::new(NetConfig::default())?; + + match major { + Some(major) => println!("Listing JDK packages for Java {major} (metadata only):\n"), + None => println!("Listing JDK packages for all majors (metadata only):\n"), + } + + for provider in runtime_providers() { + match provider.list_packages(major, &net).await { + Ok(packages) => print_vendor(provider.vendor(), &packages), + Err(err) => println!("{:?}: ERROR {err}", provider.vendor()), + } + } + + Ok(()) +} + +fn print_vendor(vendor: JavaVendor, packages: &[JavaPackage]) { + let label = format!("{vendor:?}"); + + if packages.is_empty() { + println!("{label}: (none)"); + return; + } + + for package in packages { + println!( + "{label}: {} [{}] {}", + package.name, + package + .java_version + .first() + .map(|v| v.to_string()) + .unwrap_or_default(), + package.download_url + ); + } +} diff --git a/packages/oneclient_java/src/checker.rs b/packages/oneclient_java/src/checker.rs new file mode 100644 index 00000000..825557f6 --- /dev/null +++ b/packages/oneclient_java/src/checker.rs @@ -0,0 +1,182 @@ +use std::{collections::HashMap, path::PathBuf}; + +use async_tempfile::TempDir; +use tokio::sync::OnceCell; + +use crate::error::{JavaError, JavaResult}; + +const JAVA_INFO_CLASS: &[u8] = include_bytes!("../assets/java/JavaInfo.class"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaCheckInfo { + pub version: String, + pub vendor: String, + pub os_arch: String, + /// Mod loaders and game tooling expect a JDK so the locator prefers one + /// when both satisfy the requested major + pub is_jdk: bool, +} + +#[tracing::instrument(level = "debug", skip(absolute_path))] +pub async fn check_java_runtime(absolute_path: String) -> JavaResult { + let temp_dir = get_java_info_dir().await?; + + let mut command = tokio::process::Command::new(&absolute_path); + command + .arg("-cp") + .arg(temp_dir) + .arg("JavaInfo") + .env_remove("_JAVA_OPTIONS") + .env_remove("JAVA_TOOL_OPTIONS") + .env_remove("JDK_JAVA_OPTIONS"); + + let program = command.as_std().get_program().to_string_lossy(); + let args: Vec = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + + tracing::debug!("running command: {} {}", program, args.join(" ")); + + let output = command + .output() + .await + .map_err(|e| JavaError::RuntimeCheckError { + source: e, + path: absolute_path.clone(), + })?; + + let java_info = String::from_utf8_lossy(&output.stdout); + + let info = java_info + .lines() + .filter_map(|line| { + let (key, value) = line.split_once('=')?; + + Some((key.to_string(), value.to_string())) + }) + .collect::>(); + + let Some(version) = info.get("java.version").cloned() else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + path = %absolute_path, + status = ?output.status.code(), + "java probe did not report java.version; stderr: {}", + stderr.trim() + ); + return Err(JavaError::InvalidJavaPath { + path: absolute_path, + }); + }; + + // Minecraft needs AWT a headless image launches fine and then dies mid-game + if !has_usable_awt(&info) { + tracing::warn!( + path = %absolute_path, + classes = probe_flag(&info, "java.awt"), + natives = probe_flag(&info, "java.awt.natives"), + links = probe_flag(&info, "java.awt.link"), + "java installation has no usable java.awt support" + ); + return Err(JavaError::MissingAwtSupport { + path: absolute_path, + }); + } + + Ok(JavaCheckInfo { + os_arch: info + .get("os.arch") + .cloned() + .unwrap_or_else(|| String::from("unknown")), + version, + vendor: info + .get("java.vendor") + .cloned() + .unwrap_or_else(|| String::from("unknown")), + is_jdk: probe_flag(&info, "java.jdk"), + }) +} + +fn has_usable_awt(info: &HashMap) -> bool { + probe_flag(info, "java.awt") + && probe_flag(info, "java.awt.natives") + && probe_flag(info, "java.awt.link") +} + +fn probe_flag(info: &HashMap, key: &str) -> bool { + info.get(key) + .is_some_and(|value| value.eq_ignore_ascii_case("true")) +} + +static TEMP_JAVA_INFO: OnceCell = OnceCell::const_new(); + +#[tracing::instrument(level = "debug")] +async fn get_java_info_dir() -> Result<&'static PathBuf, polyio::IOError> { + let dir: Result<&TempDir, polyio::IOError> = TEMP_JAVA_INFO + .get_or_try_init(async || { + let dir = polyio::tempdir().await?; + let file = dir.dir_path().join("JavaInfo.class"); + + polyio::write(&file, JAVA_INFO_CLASS).await?; + + Ok(dir) + }) + .await; + + Ok(dir?.dir_path()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + } + + #[test] + fn a_flag_the_probe_never_printed_is_false() { + assert!(!probe_flag(&info(&[]), "java.awt")); + } + + #[test] + fn a_flag_is_read_regardless_of_case() { + assert!(probe_flag(&info(&[("java.awt", "TRUE")]), "java.awt")); + assert!(!probe_flag(&info(&[("java.awt", "false")]), "java.awt")); + } + + fn awt(classes: &str, natives: &str, link: &str) -> HashMap { + info(&[ + ("java.awt", classes), + ("java.awt.natives", natives), + ("java.awt.link", link), + ]) + } + + #[test] + fn a_complete_image_is_usable() { + assert!(has_usable_awt(&awt("true", "true", "true"))); + } + + #[test] + fn awt_classes_without_their_natives_are_not_usable() { + assert!(!has_usable_awt(&awt("true", "false", "true"))); + } + + #[test] + fn natives_that_refuse_to_link_are_not_usable() { + assert!(!has_usable_awt(&awt("true", "true", "false"))); + } + + #[test] + fn every_half_is_required() { + assert!(!has_usable_awt(&info(&[("java.awt", "true")]))); + assert!(!has_usable_awt(&info(&[("java.awt.natives", "true")]))); + assert!(!has_usable_awt(&info(&[("java.awt.link", "true")]))); + } +} diff --git a/packages/oneclient_java/src/data.rs b/packages/oneclient_java/src/data.rs new file mode 100644 index 00000000..c4618e60 --- /dev/null +++ b/packages/oneclient_java/src/data.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +use polyio::Checksum; +use serde::{Deserialize, Serialize}; + +use crate::vendors::JavaVendor; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JavaRuntime { + pub absolute_path: String, + pub major: u32, + pub version: String, + pub vendor: JavaVendor, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaPackage { + pub download_url: String, + pub name: String, + pub java_version: Vec, + pub vendor: JavaVendor, + pub archive: PackageArchive, + /// Algorithm varies per vendor + /// `None` when vendor metadata omitted or malformed it + /// installs unverified rather than failing + pub checksum: Option, + /// Bytes when the vendor publishes it + pub size: Option, +} + +impl JavaPackage { + /// Drops malformed vendor checksums which would otherwise make every + /// install fail as a corrupt download + #[must_use] + pub fn with_checksum(mut self, checksum: Option) -> Self { + self.checksum = checksum.filter(|sum| { + let ok = sum.is_well_formed(); + if !ok { + tracing::warn!( + algorithm = sum.algorithm.name(), + hex = %sum.hex, + "vendor published a malformed checksum; installing unverified" + ); + } + ok + }); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageArchive { + Zip, + TarGz, +} + +impl PackageArchive { + pub fn from_filename(name: &str) -> Self { + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Self::TarGz + } else { + Self::Zip + } + } +} + +pub fn java_executable_relative_path() -> PathBuf { + #[cfg(target_os = "macos")] + { + PathBuf::from("Contents/Home/bin").join(oneclient_common::constants::JAVA_BIN) + } + #[cfg(not(target_os = "macos"))] + { + PathBuf::from("bin").join(oneclient_common::constants::JAVA_BIN) + } +} diff --git a/packages/oneclient_java/src/error.rs b/packages/oneclient_java/src/error.rs new file mode 100644 index 00000000..ecd4ec0e --- /dev/null +++ b/packages/oneclient_java/src/error.rs @@ -0,0 +1,119 @@ +use thiserror::Error; + +pub type JavaResult = Result; + +#[derive(Debug, Error)] +pub enum JavaError { + #[error(transparent)] + Store(#[from] crate::store::StoreError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Events(#[from] oneclient_events::EventError), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), + + #[error("Error spawning Java runtime from path '{path}': {source}")] + RuntimeCheckError { + #[source] + source: std::io::Error, + path: String, + }, + + #[error("IO Error: {0}")] + PolyIOError(#[from] polyio::IOError), + + #[error("no java package found for major version {major}")] + PackageNotFound { major: u32 }, + + #[error("no java installations available")] + MissingJava, + + #[error("java setup was cancelled")] + Cancelled, + + #[error("selected path is not a valid java installation: {path}")] + InvalidJavaPath { path: String }, + + #[error( + "java installation at '{path}' has no usable java.awt (the java.desktop module or its native libraries are missing)" + )] + MissingAwtSupport { path: String }, + + #[error("selected java is version {found}, but version {expected} is required")] + VersionMismatch { expected: u32, found: u32 }, + + #[error("failed to parse java version '{version}'")] + ParseVersion { version: String }, + + #[error("failed to extract archive '{archive}': {source}")] + ArchiveExtract { + archive: String, + source: std::io::Error, + }, + + #[error("failed to extract archive '{archive}'")] + ArchiveExtractFailed { archive: String }, +} + +impl JavaError { + #[must_use] + pub fn is_invalid_installation(&self) -> bool { + matches!( + self, + JavaError::InvalidJavaPath { .. } + | JavaError::MissingAwtSupport { .. } + | JavaError::ParseVersion { .. } + ) + } + + /// Whether this is an expected outcome or the user's environment rather + /// than a launcher bug + /// Callers decide what to do with that (e.g. suppress error reporting) + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, + JavaError::RuntimeCheckError { source, .. } + | JavaError::ArchiveExtract { source, .. } => is_transient_io(source), + JavaError::PolyIOError(polyio::IOError::IOError(source)) + | JavaError::PolyIOError(polyio::IOError::PathIOError { source, .. }) => { + is_transient_io(source) + } + JavaError::Request(source) => source.is_transient(), + _ => false, + } + } +} + +fn is_transient_io(error: &std::io::Error) -> bool { + use std::io::ErrorKind; + + if let Some(code) = error.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + return true; + } + let _ = code; + } + + matches!( + error.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} diff --git a/packages/oneclient_java/src/install.rs b/packages/oneclient_java/src/install.rs new file mode 100644 index 00000000..3c1e6c9d --- /dev/null +++ b/packages/oneclient_java/src/install.rs @@ -0,0 +1,161 @@ +use std::path::{Path, PathBuf}; + +use crate::data::{JavaPackage, PackageArchive, java_executable_relative_path}; +use crate::error::JavaResult; +use oneclient_common::paths; +use oneclient_events::EventBus; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; +use oneclient_net::RequestClient; +use oneclient_net::ResponseNotifyOptions; + +#[tracing::instrument(level = "debug", skip(net, events, progress))] +pub async fn install_package( + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, +) -> JavaResult { + let java_dir = paths::java_dir()?; + polyio::create_dir_all(&java_dir).await?; + + let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); + + let major = package.java_version.first().copied().unwrap_or(0); + + // 0 means unknown the real total comes from Content-Length once headers land + let expected_size = package.size.unwrap_or(0); + let child = progress.map(|session| { + session.child( + format!("{} {}", package.vendor, major), + expected_size, + TaskCategory::Java, + ) + }); + + let notify = match &child { + Some(child) => ResponseNotifyOptions::grouped(child.clone()), + None => { + ResponseNotifyOptions::standalone(format!("Installing {} {}", package.vendor, major)) + .done_label(format!("Installed {} {}", package.vendor, major)) + } + }; + + if package.checksum.is_none() { + tracing::warn!( + vendor = %package.vendor, + major, + "vendor published no usable checksum; installing runtime unverified" + ); + } + + oneclient_net::download_verified( + net, + events, + &package.download_url, + &archive_path, + package.checksum.as_ref(), + expected_size, + Some(notify), + ) + .await?; + + let extract_root = java_dir.join(stem_without_archive(&package.name)); + + if let Some(child) = &child { + child.set_phase(TaskPhase::Extracting); + } + //events.notify("Extracting package...").body(format!("{} {}", package.vendor, major)).send(); + + match package.archive { + PackageArchive::Zip => polyio::extract_zip(&archive_path, &extract_root).await?, + PackageArchive::TarGz => polyio::extract_tar_gz(&archive_path, &extract_root).await?, + } + + let executable = resolve_installed_executable(&extract_root, package); + + #[cfg(unix)] + { + let _ = tokio::process::Command::new("chmod") + .arg("755") + .arg(&executable) + .output() + .await; + } + + let _ = polyio::remove_file(&archive_path).await; + + tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); + + Ok(executable) +} + +#[tracing::instrument(level = "debug")] +fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> PathBuf { + let mut base_path = extract_root.to_path_buf(); + + if let Ok(entries) = std::fs::read_dir(extract_root) { + let valid_dirs: Vec = entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + + if valid_dirs.len() == 1 { + base_path = valid_dirs[0].clone(); + } + } + + #[cfg(not(target_os = "macos"))] + { + let _ = package; + let subfolder_exec = base_path.join(java_executable_relative_path()); + if subfolder_exec.exists() { + subfolder_exec + } else { + extract_root.join(java_executable_relative_path()) + } + } + + #[cfg(target_os = "macos")] + { + use crate::vendors::JavaVendor; + + if !base_path.join("Contents").join("Home").exists() + && let Ok(entries) = std::fs::read_dir(&base_path) + && let Some(dir) = entries.flatten().find(|entry| { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + (name.ends_with(".jre") || name.ends_with(".jdk") || name.contains("zulu")) + && entry.path().join("Contents").join("Home").exists() + }) + { + base_path = dir.path(); + } + + if package.vendor == JavaVendor::Zulu + && let Some(major) = package.java_version.first() + { + let zulu_bundle = base_path.join(format!("zulu-{major}.jre")); + if zulu_bundle.join("Contents").join("Home").exists() { + base_path = zulu_bundle; + } + } + + base_path.join(java_executable_relative_path()) + } +} + +fn stem_without_archive(name: &str) -> String { + let path = std::path::Path::new(name); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name); + + if stem.ends_with(".tar") { + std::path::Path::new(stem) + .file_stem() + .and_then(|s| s.to_str()) + .map(String::from) + .unwrap_or_else(|| stem.to_string()) + } else { + stem.to_string() + } +} diff --git a/packages/oneclient_java/src/lib.rs b/packages/oneclient_java/src/lib.rs new file mode 100644 index 00000000..4a43860f --- /dev/null +++ b/packages/oneclient_java/src/lib.rs @@ -0,0 +1,27 @@ +//! [`JavaService`] takes a [`JavaStore`] rather than a database handle so this +//! crate has no schema dependency + +mod checker; +mod data; +mod error; +mod install; +mod locate; +mod platform; +mod resolve; +mod service; +mod store; + +pub mod vendors; + +pub use checker::{JavaCheckInfo, check_java_runtime}; +pub use data::{JavaPackage, JavaRuntime, PackageArchive, java_executable_relative_path}; +pub use error::{JavaError, JavaResult}; +pub use install::install_package; +pub use locate::{LocatedJava, best_for_major, locate_java}; +pub use platform::{HostArch, HostOs, HostTarget}; +pub use service::{ + AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, + JavaService, +}; +pub use store::{JavaStore, MemoryJavaStore, StoreError, StoreResult}; +pub use vendors::JavaVendor; diff --git a/packages/oneclient_java/src/locate.rs b/packages/oneclient_java/src/locate.rs new file mode 100644 index 00000000..88341500 --- /dev/null +++ b/packages/oneclient_java/src/locate.rs @@ -0,0 +1,288 @@ +use std::cmp::Ordering; +use std::path::PathBuf; + +use crate::checker::{JavaCheckInfo, check_java_runtime}; +use crate::data::java_executable_relative_path; +use crate::error::JavaResult; +use crate::service::parse_major_version; +use oneclient_common::constants::JAVA_BIN; + +pub type LocatedJava = (PathBuf, JavaCheckInfo); + +/// Best first +#[tracing::instrument(level = "debug")] +pub async fn locate_java() -> JavaResult> { + let paths = internal_locate_java(); + let mut valid: Vec = Vec::new(); + + for path in paths { + if valid.iter().any(|(existing, _)| existing == &path) { + continue; + } + + let Ok(info) = check_java_runtime(path.display().to_string()).await else { + tracing::warn!("java installation at '{}' is not valid", path.display()); + continue; + }; + valid.push((path, info)); + } + + valid.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + + tracing::debug!(count = valid.len(), "located Java runtimes"); + + Ok(valid) +} + +/// `major` is a filter not a ranking input +/// a newer JDK never stands in for the requested major +#[must_use] +pub fn best_for_major(candidates: &[LocatedJava], major: u32) -> Option<&LocatedJava> { + candidates + .iter() + .filter(|(_, info)| parse_major_version(&info.version).is_ok_and(|found| found == major)) + .min_by(|(_, a), (_, b)| compare_candidates(a, b)) +} + +/// Best first a JDK beats a JRE outright then the newer version wins +fn compare_candidates(a: &JavaCheckInfo, b: &JavaCheckInfo) -> Ordering { + b.is_jdk + .cmp(&a.is_jdk) + .then_with(|| version_key(b).cmp(&version_key(a))) +} + +/// Numeric so `21.0.10` sorts above `21.0.9` +fn version_key(info: &JavaCheckInfo) -> (u32, Vec) { + let major = parse_major_version(&info.version).unwrap_or(0); + let parts = info + .version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse::().ok()) + .collect(); + + (major, parts) +} + +#[cfg(target_os = "windows")] +#[tracing::instrument(level = "debug")] +fn internal_locate_java() -> Vec { + let mut java_homes = Vec::new(); + + let windows_paths = { + let mut paths = vec![ + PathBuf::from(r"C:\Program Files\Java"), + PathBuf::from(r"C:\Program Files (x86)\Java"), + PathBuf::from(r"C:\Program Files\Eclipse Adoptium"), + PathBuf::from(r"C:\Program Files\Amazon Corretto"), + PathBuf::from(r"C:\Program Files\Zulu"), + PathBuf::from(r"C:\Program Files\OpenJDK"), + PathBuf::from(r"C:\Program Files\Microsoft"), + PathBuf::from(r"C:\ProgramData\chocolatey\lib"), + ]; + if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) { + paths.push(profile.join(".jdks")); + paths.push(profile.join(".gradle").join("jdks")); + paths.push(profile.join(".m2").join("wrapper").join("dists")); + paths.push(profile.join("scoop").join("apps")); + } + paths + }; + + for base_path in windows_paths { + if let Ok(entries) = std::fs::read_dir(base_path) { + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|ft| ft.is_dir()) { + let java_exe = entry.path().join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } + } + } + } + + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let java_exe = PathBuf::from(java_home).join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } + + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(';') { + let java_exe = PathBuf::from(path_entry).join(JAVA_BIN); + if java_exe.exists() && !java_homes.contains(&java_exe) { + java_homes.push(java_exe); + } + } + } + + java_homes +} + +#[cfg(target_os = "macos")] +#[tracing::instrument(level = "debug")] +fn internal_locate_java() -> Vec { + let mut found = Vec::new(); + + let macos_paths = { + let mut paths = vec![ + PathBuf::from("/Library/Java/JavaVirtualMachines"), + PathBuf::from("/System/Library/Java/JavaVirtualMachines"), + PathBuf::from("/System/Library/Frameworks/JavaVM.framework/Versions/Current/Commands"), + PathBuf::from("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home"), + PathBuf::from("/opt/homebrew/Cellar"), + PathBuf::from("/opt/homebrew/opt"), + PathBuf::from("/usr/local/Cellar"), + PathBuf::from("/usr/local/opt"), + ]; + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + paths.push( + home.join("Library") + .join("Java") + .join("JavaVirtualMachines"), + ); + paths.push(home.join(".jdks")); + paths.push(home.join(".sdkman").join("candidates").join("java")); + } + paths + }; + + for path in macos_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } + + find_java_in_path(found) +} + +#[cfg(target_os = "linux")] +#[tracing::instrument(level = "debug")] +fn internal_locate_java() -> Vec { + let mut found = Vec::new(); + + let linux_paths = { + let mut paths = vec![ + PathBuf::from("/usr/lib/jvm"), + PathBuf::from("/usr/lib64/jvm"), + PathBuf::from("/usr/lib32/jvm"), + PathBuf::from("/usr/java"), + PathBuf::from("/opt/jdk"), + PathBuf::from("/opt/jdks"), + PathBuf::from("/app/jdk"), + ]; + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + paths.push(home.join(".jdks")); + paths.push(home.join(".gradle").join("jdks")); + paths.push(home.join(".sdkman").join("candidates").join("java")); + } + paths + }; + + for path in linux_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } + + find_java_in_path(found) +} + +#[cfg(unix)] +fn find_java_in_path(mut found: Vec) -> Vec { + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(':') { + let java = PathBuf::from(path_entry).join(JAVA_BIN); + if java.exists() && java.is_file() && !found.contains(&java) { + found.push(java); + } + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate(path: &str, version: &str, is_jdk: bool) -> LocatedJava { + ( + PathBuf::from(path), + JavaCheckInfo { + version: version.to_string(), + vendor: String::from("Test"), + os_arch: String::from("x64"), + is_jdk, + }, + ) + } + + fn ranked(mut candidates: Vec) -> Vec { + candidates.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + candidates + .into_iter() + .map(|(path, _)| path.display().to_string()) + .collect() + } + + #[test] + fn a_kit_outranks_a_runtime_even_when_it_is_older() { + let ranking = ranked(vec![ + candidate("/jre-21", "21.0.9", false), + candidate("/jdk-21", "21.0.1", true), + ]); + + assert_eq!(ranking, vec!["/jdk-21", "/jre-21"]); + } + + #[test] + fn patch_levels_are_compared_as_numbers() { + let ranking = ranked(vec![ + candidate("/jdk-21.0.9", "21.0.9", true), + candidate("/jdk-21.0.10", "21.0.10", true), + ]); + + assert_eq!(ranking, vec!["/jdk-21.0.10", "/jdk-21.0.9"]); + } + + #[test] + fn the_requested_major_still_wins_over_a_newer_kit() { + let candidates = vec![ + candidate("/jdk-21", "21.0.3", true), + candidate("/jre-8", "1.8.0_412", false), + ]; + + let (path, _) = best_for_major(&candidates, 8).expect("a java 8 candidate"); + assert_eq!(path, &PathBuf::from("/jre-8")); + } + + #[test] + fn the_kit_wins_among_candidates_of_the_requested_major() { + let candidates = vec![ + candidate("/jre-17-newer", "17.0.11", false), + candidate("/jdk-17", "17.0.2", true), + candidate("/jdk-21", "21.0.3", true), + ]; + + let (path, _) = best_for_major(&candidates, 17).expect("a java 17 candidate"); + assert_eq!(path, &PathBuf::from("/jdk-17")); + } + + #[test] + fn a_major_nobody_satisfies_has_no_best() { + let candidates = vec![candidate("/jdk-21", "21.0.3", true)]; + + assert!(best_for_major(&candidates, 8).is_none()); + } +} diff --git a/packages/oneclient_java/src/platform.rs b/packages/oneclient_java/src/platform.rs new file mode 100644 index 00000000..208a9d3a --- /dev/null +++ b/packages/oneclient_java/src/platform.rs @@ -0,0 +1,110 @@ +//! Host detection is shared here but the *naming* stays per-vendor +//! each API spells the same platform differently (e.g. Adoptium `mac`/`alpine-linux`/`x32`) + +use crate::data::PackageArchive; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostArch { + X86, + X86_64, + Arm, + Aarch64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostOs { + Windows, + MacOs, + /// Several vendors publish musl builds under a separate name + Linux { + musl: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HostTarget { + pub arch: HostArch, + pub os: HostOs, +} + +impl HostTarget { + pub const CURRENT: Self = Self { + arch: cfg_select! { + target_arch = "x86" => HostArch::X86, + target_arch = "x86_64" => HostArch::X86_64, + target_arch = "arm" => HostArch::Arm, + target_arch = "aarch64" => HostArch::Aarch64, + }, + os: cfg_select! { + target_os = "windows" => HostOs::Windows, + target_os = "macos" => HostOs::MacOs, + target_os = "linux" => cfg_select! { + target_env = "musl" => HostOs::Linux { musl: true }, + _ => HostOs::Linux { musl: false }, + }, + }, + }; + + /// Liberica takes architecture and bitness as separate query parameters + #[must_use] + pub const fn bitness(self) -> &'static str { + match self.arch { + HostArch::X86 | HostArch::Arm => "32", + HostArch::X86_64 | HostArch::Aarch64 => "64", + } + } + + #[must_use] + pub const fn archive(self) -> PackageArchive { + match self.os { + HostOs::Windows => PackageArchive::Zip, + _ => PackageArchive::TarGz, + } + } + + #[must_use] + pub const fn archive_ext(self) -> &'static str { + match self.os { + HostOs::Windows => "zip", + _ => "tar.gz", + } + } + + #[must_use] + pub const fn is_musl(self) -> bool { + matches!(self.os, HostOs::Linux { musl: true }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bitness_matches_the_architecture() { + assert_eq!(HostTarget::CURRENT.bitness(), { + if cfg!(target_pointer_width = "64") { + "64" + } else { + "32" + } + }); + } + + #[test] + fn windows_ships_zips_and_everything_else_tarballs() { + let expected = if cfg!(windows) { + PackageArchive::Zip + } else { + PackageArchive::TarGz + }; + assert_eq!(HostTarget::CURRENT.archive(), expected); + } + + #[test] + fn musl_is_only_ever_reported_on_linux() { + if HostTarget::CURRENT.is_musl() { + assert!(matches!(HostTarget::CURRENT.os, HostOs::Linux { .. })); + } + } +} diff --git a/packages/oneclient_java/src/resolve.rs b/packages/oneclient_java/src/resolve.rs new file mode 100644 index 00000000..0c4b85b5 --- /dev/null +++ b/packages/oneclient_java/src/resolve.rs @@ -0,0 +1,63 @@ +use std::path::{Path, PathBuf}; + +use crate::data::java_executable_relative_path; +use crate::error::{JavaError, JavaResult}; +use oneclient_common::constants::JAVA_BIN; + +#[tracing::instrument(level = "debug", skip_all)] +pub fn resolve_java_executable(selection: impl AsRef) -> JavaResult { + let selection = selection.as_ref(); + + if selection.is_file() { + return validate_executable(selection); + } + + let relative = java_executable_relative_path(); + let candidates = [ + selection.join(&relative), + selection.join(JAVA_BIN), + #[cfg(target_os = "macos")] + selection.join("Contents").join("Home").join(&relative), + ]; + + for candidate in candidates { + if candidate.is_file() { + return validate_executable(&candidate); + } + } + + if let Ok(entries) = std::fs::read_dir(selection) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let nested = path.join(&relative); + if nested.is_file() { + return validate_executable(&nested); + } + } + } + + tracing::warn!(path = %selection.display(), "no Java executable found in selection"); + + Err(JavaError::InvalidJavaPath { + path: selection.display().to_string(), + }) +} + +fn validate_executable(path: &Path) -> JavaResult { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + + if file_name != JAVA_BIN && file_name != "java.exe" && file_name != "javaw.exe" { + return Err(JavaError::InvalidJavaPath { + path: path.display().to_string(), + }); + } + + Ok(path.to_path_buf()) +} diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs new file mode 100644 index 00000000..11242e96 --- /dev/null +++ b/packages/oneclient_java/src/service.rs @@ -0,0 +1,437 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +use oneclient_events::{Choice, EventBus, GroupedProgressSession, Prompt}; +use oneclient_net::RequestClient; + +use crate::checker::{self, JavaCheckInfo}; +use crate::data::{JavaPackage, JavaRuntime}; +use crate::error::{JavaError, JavaResult}; +use crate::resolve::resolve_java_executable; +use crate::store::JavaStore; +use crate::vendors::{self, JavaVendor}; + +/// Choice ids for the "Java required" prompt front-ends match on these +pub const JAVA_CHOICE_DOWNLOAD: &str = "java.download"; +pub const JAVA_CHOICE_FOLDER: &str = "java.folder"; + +/// Tells the front-end to show its vendor list rather than another picker +pub const JAVA_VENDOR_HINT: &str = "java-vendor"; + +pub const INSTALLABLE_MAJORS: &[u32] = &[8, 11, 16, 17, 18, 19, 20, 21, 22, 23]; + +pub const PROBE_VERSION: u32 = 1; + +const _: () = assert!(PROBE_VERSION > 0); + +enum JavaPromptAnswer { + Download, + PickFolder, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvailableJava { + pub major: u32, + pub package: JavaPackage, +} + +pub struct JavaService { + store: Arc, + net: RequestClient, + events: EventBus, +} + +impl JavaService { + #[must_use] + pub fn new(store: Arc, net: RequestClient, events: EventBus) -> Self { + Self { store, net, events } + } + + #[must_use] + pub fn net(&self) -> &RequestClient { + &self.net + } + + #[must_use] + pub fn events(&self) -> &EventBus { + &self.events + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list_runtimes(&self) -> JavaResult> { + Ok(self.store.list().await?) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn runtime_for_profile( + &self, + java_path: Option<&str>, + ) -> JavaResult> { + let Some(path) = java_path else { + return Ok(None); + }; + let Some(runtime) = self.store.get_by_path(path).await? else { + return Ok(None); + }; + self.revalidate(runtime).await + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn revalidate(&self, runtime: JavaRuntime) -> JavaResult> { + if runtime.probe_version == PROBE_VERSION { + if Path::new(&runtime.absolute_path).is_file() { + return Ok(Some(runtime)); + } + + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: the executable is gone" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + return Ok(None); + } + + match checker::check_java_runtime(runtime.absolute_path.clone()).await { + Ok(info) => Ok(Some( + self.persist(Path::new(&runtime.absolute_path), &info) + .await?, + )), + Err(err) if err.is_invalid_installation() => { + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: {err}" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + Ok(None) + } + Err(err) => { + tracing::warn!( + path = %runtime.absolute_path, + "could not re-probe recorded Java runtime, trusting the record: {err}" + ); + Ok(Some(runtime)) + } + } + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn available_versions(&self, vendor: &JavaVendor) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(Vec::new()); + }; + + let packages = provider.list_packages(None, &self.net).await?; + + let mut by_major = BTreeMap::::new(); + for package in packages { + let Some(&major) = package.java_version.first() else { + continue; + }; + by_major.entry(major).or_insert(package); + } + + let mut available: Vec = by_major + .into_iter() + .map(|(major, package)| AvailableJava { major, package }) + .collect(); + + available.sort_by_key(|entry| std::cmp::Reverse(entry.major)); + Ok(available) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn rescan(&self) -> JavaResult<()> { + self.register_located(&crate::locate::locate_java().await?) + .await + } + + #[tracing::instrument(skip(self))] + pub async fn add_custom_runtime(&self, folder: PathBuf) -> JavaResult { + let executable = resolve_java_executable(&folder)?; + self.register_checked(&executable, None).await + } + + #[tracing::instrument(skip(self))] + pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { + self.store.delete_by_path(absolute_path).await?; + tracing::info!("removed Java runtime"); + Ok(()) + } + + /// A recorded JRE is kept only as a fallback so a system scan gets its + /// chance to turn up a JDK of the same major first + #[tracing::instrument(level = "debug", skip(self, progress))] + pub async fn prepare( + &self, + major: u32, + search_system: bool, + auto_install: bool, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let recorded = loop { + let Some(runtime) = self.store.latest_by_major(major).await? else { + break None; + }; + if let Some(valid) = self.revalidate(runtime).await? { + break Some(valid); + } + }; + + if let Some(runtime) = &recorded + && runtime.is_jdk + { + return Ok(runtime.clone()); + } + + if search_system { + let located = crate::locate::locate_java().await?; + self.register_located(&located).await?; + + // A located runtime only displaces a recorded one by being a JDK + if let Some((path, info)) = crate::locate::best_for_major(&located, major) + && (info.is_jdk || recorded.is_none()) + { + return self.persist(path, info).await; + } + } + + if let Some(runtime) = recorded { + return Ok(runtime); + } + + if auto_install { + return self.download_and_register(major, progress).await; + } + + self.prompt_and_install(major, progress).await + } + + #[tracing::instrument(skip(self, progress))] + pub async fn install_runtime( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + self.download_and_register(major, progress).await + } + + #[tracing::instrument(skip(self))] + pub async fn install_runtime_from( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult { + self.install_vendor_runtime(vendor, major, None).await + } + + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn install_vendor_runtime( + &self, + vendor: &JavaVendor, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let provider = provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; + let package = provider + .latest_package_by_major(major, &self.net) + .await? + .ok_or(JavaError::PackageNotFound { major })?; + + let owned = progress.is_none().then(|| { + GroupedProgressSession::start(&self.events, format!("Installing Java {major}")) + }); + let session = progress.or(owned.as_ref()).expect("session present"); + + let executable = provider + .install_package(&package, &self.net, &self.events, Some(session)) + .await?; + + self.register_checked(&executable, Some(major)).await + } + + async fn prompt_and_install( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let prompt = Prompt::new( + "Java required", + format!( + "No Java {major} runtime was found. Download it automatically, choose an existing installation folder, or cancel?" + ), + ) + .option( + Choice::primary(JAVA_CHOICE_DOWNLOAD, "Download").picks_selection(JAVA_VENDOR_HINT), + JavaPromptAnswer::Download, + ) + .option( + Choice::new(JAVA_CHOICE_FOLDER, "Choose folder") + .picks_folder(format!("Select a Java {major} installation folder")), + JavaPromptAnswer::PickFolder, + ) + .dismiss("Cancel"); + + let Some(chosen) = self.events.ask(prompt).await? else { + return Err(JavaError::Cancelled); + }; + + match chosen.value { + JavaPromptAnswer::Download => match chosen.selection() { + Some(vendor) => { + let vendor = JavaVendor::from_str(vendor) + .unwrap_or_else(|_| JavaVendor::Other(vendor.to_string())); + self.install_vendor_runtime(&vendor, major, progress).await + } + None => self.download_and_register(major, progress).await, + }, + JavaPromptAnswer::PickFolder => match chosen.folder() { + Some(folder) => { + let executable = resolve_java_executable(folder)?; + self.register_checked(&executable, Some(major)).await + } + None => Err(JavaError::Cancelled), + }, + } + } + + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn download_and_register( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + for provider in vendors::runtime_providers() { + let vendor = provider.vendor(); + + let packages = provider.list_packages(Some(major), &self.net).await; + let package = match &packages { + Ok(packages) => match packages + .iter() + .find(|p| p.java_version.first() == Some(&major)) + .or_else(|| packages.first()) + { + Some(package) => package, + None => { + tracing::warn!(?vendor, major, "no packages found"); + continue; + } + }, + Err(err) => { + tracing::warn!(?vendor, major, "failed to query packages: {err}"); + continue; + } + }; + + tracing::info!(?vendor, major, "downloading Java runtime"); + match provider + .install_package(package, &self.net, &self.events, progress) + .await + { + Ok(executable) => return self.register_checked(&executable, Some(major)).await, + Err(err) => tracing::warn!(?vendor, major, "install failed: {err}"), + } + } + + Err(JavaError::PackageNotFound { major }) + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn register_checked( + &self, + executable: &Path, + expected_major: Option, + ) -> JavaResult { + let info = checker::check_java_runtime(executable.display().to_string()).await?; + let major = parse_major_version(&info.version)?; + + if let Some(expected) = expected_major + && major != expected + { + return Err(JavaError::VersionMismatch { + expected, + found: major, + }); + } + + self.persist(executable, &info).await + } + + /// Skips anything that fails to probe a broken JDK must not abort the scan + #[tracing::instrument(level = "debug", skip_all)] + async fn register_located(&self, located: &[(PathBuf, JavaCheckInfo)]) -> JavaResult<()> { + for (path, info) in located { + if let Err(err) = self.persist(path, info).await { + tracing::warn!( + path = %path.display(), + version = %info.version, + "skipping located Java runtime: {err}" + ); + } + } + Ok(()) + } + + async fn persist(&self, executable: &Path, info: &JavaCheckInfo) -> JavaResult { + let runtime = JavaRuntime { + absolute_path: executable.to_string_lossy().into_owned(), + major: parse_major_version(&info.version)?, + version: info.version.clone(), + vendor: JavaVendor::from_str(&info.vendor) + .unwrap_or_else(|_| JavaVendor::Other(info.vendor.clone())), + os_arch: info.os_arch.clone(), + is_jdk: info.is_jdk, + probe_version: PROBE_VERSION, + }; + + Ok(self.store.upsert(&runtime).await?) + } +} + +fn provider_for_vendor(vendor: &JavaVendor) -> Option> { + vendors::runtime_providers() + .into_iter() + .find(|provider| &provider.vendor() == vendor) +} + +/// Java 8 and earlier report `1.8.0_412` where the major is the *second* +/// component 9 and later report `21.0.3` where it is the first +pub(crate) fn parse_major_version(version: &str) -> Result { + if let Some(rest) = version.strip_prefix("1.") { + let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + })?; + return digit.to_digit(10).ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + }); + } + + let head = version.split('.').next().unwrap_or(version); + head.parse::().map_err(|_| JavaError::ParseVersion { + version: version.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_versions_take_the_second_component() { + assert_eq!(parse_major_version("1.8.0_412").unwrap(), 8); + assert_eq!(parse_major_version("1.7.0").unwrap(), 7); + } + + #[test] + fn modern_versions_take_the_first() { + assert_eq!(parse_major_version("21.0.3").unwrap(), 21); + assert_eq!(parse_major_version("17").unwrap(), 17); + } + + #[test] + fn garbage_is_rejected_rather_than_defaulted() { + assert!(parse_major_version("not-a-version").is_err()); + assert!(parse_major_version("1.x").is_err()); + } +} diff --git a/packages/oneclient_java/src/store.rs b/packages/oneclient_java/src/store.rs new file mode 100644 index 00000000..abaae97e --- /dev/null +++ b/packages/oneclient_java/src/store.rs @@ -0,0 +1,88 @@ +//! A port rather than a concrete database handle so this crate does not depend +//! on the launcher's schema + +use std::sync::Arc; + +use crate::data::JavaRuntime; + +/// Opaque so this crate never learns what is behind the port +#[derive(Debug, thiserror::Error)] +#[error("java store: {0}")] +pub struct StoreError(#[source] pub Box); + +impl StoreError { + pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self(Box::new(source)) + } +} + +pub type StoreResult = Result; + +#[async_trait::async_trait] +pub trait JavaStore: Send + Sync { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult; + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult>; + + async fn latest_by_major(&self, major: u32) -> StoreResult>; + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()>; + + async fn list(&self) -> StoreResult>; +} + +#[derive(Debug, Default)] +pub struct MemoryJavaStore { + runtimes: std::sync::Mutex>, +} + +impl MemoryJavaStore { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait::async_trait] +impl JavaStore for MemoryJavaStore { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|existing| existing.absolute_path != runtime.absolute_path); + runtimes.push(runtime.clone()); + Ok(runtime.clone()) + } + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .find(|runtime| runtime.absolute_path == absolute_path) + .cloned()) + } + + async fn latest_by_major(&self, major: u32) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .filter(|runtime| runtime.major == major) + .max_by(|a, b| a.version.cmp(&b.version)) + .cloned()) + } + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|runtime| runtime.absolute_path != absolute_path); + Ok(()) + } + + async fn list(&self) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + let mut out = runtimes.clone(); + out.sort_by(|a, b| { + b.major + .cmp(&a.major) + .then_with(|| b.version.cmp(&a.version)) + }); + Ok(out) + } +} diff --git a/packages/oneclient_java/src/vendors/adoptium.rs b/packages/oneclient_java/src/vendors/adoptium.rs new file mode 100644 index 00000000..7149832b --- /dev/null +++ b/packages/oneclient_java/src/vendors/adoptium.rs @@ -0,0 +1,170 @@ +use reqwest::{Method, Request}; +use serde::Deserialize; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct AdoptiumRuntimeProvider; + +#[derive(Debug, Deserialize)] +struct AdoptiumAvailableReleases { + available_releases: Vec, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumRelease { + version_data: AdoptiumVersionData, + binaries: Vec, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumVersionData { + semver: String, + major: u32, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumBinary { + package: AdoptiumPackage, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumPackage { + name: String, + link: String, + /// Always SHA-256 the API has no algorithm field + #[serde(default)] + checksum: Option, + #[serde(default)] + size: Option, +} + +#[async_trait::async_trait] +impl JavaRuntimeProvider for AdoptiumRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Adoptium + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let (low, high) = match major { + Some(major) => (major, major), + None => { + let info = net + .send_as::(Request::new( + Method::GET, + Url::parse("https://api.adoptium.net/v3/info/available_releases")?, + )) + .await?; + let low = info.available_releases.iter().copied().min().unwrap_or(8); + let high = info.available_releases.iter().copied().max().unwrap_or(low); + (low, high) + } + }; + + let url = adoptium_url(low, high)?; + let releases = net + .send_as::>(Request::new(Method::GET, url)) + .await?; + + let mut packages = Vec::new(); + + for release in releases { + let mut java_version: Vec = release + .version_data + .semver + .split(|c: char| !c.is_numeric()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if java_version.first() != Some(&release.version_data.major) { + java_version.insert(0, release.version_data.major); + } + + for binary in release.binaries { + let checksum = binary.package.checksum.as_deref().map(Checksum::sha256); + + packages.push( + JavaPackage { + archive: PackageArchive::from_filename(&binary.package.name), + download_url: binary.package.link, + java_version: java_version.clone(), + name: binary.package.name, + vendor: JavaVendor::Adoptium, + checksum: None, + size: binary.package.size, + } + .with_checksum(checksum), + ); + } + } + + tracing::debug!(count = packages.len(), "listed Adoptium packages"); + + Ok(packages) + } +} + +// A version range spans every GA major in one request instead of one per major +fn adoptium_url(low: u32, high: u32) -> JavaResult { + let range = format!("%5B{low}%2C{}%5D", high + 1); + + let mut url = Url::parse(&format!( + "https://api.adoptium.net/v3/assets/version/{range}" + ))?; + + url.query_pairs_mut() + .append_pair("os", ADOPTIUM_OS) + .append_pair("architecture", ADOPTIUM_ARCH) + // Kits only a JRE lacks tooling mod loaders expect + .append_pair("image_type", "jdk") + .append_pair("jvm_impl", "hotspot") + .append_pair("project", "jdk") + .append_pair("heap_size", "normal") + .append_pair("vendor", "eclipse") + .append_pair("release_type", "ga") + .append_pair("sort_method", "DATE") + .append_pair("sort_order", "DESC") + .append_pair("page", "0") + .append_pair("page_size", "50"); + Ok(url) +} + +/// Adoptium alone spells 32-bit x86 `x32` +const ADOPTIUM_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x32", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", +}; + +/// Adoptium says `mac` where the other vendors say `macos` and publishes musl +/// builds under `alpine-linux` rather than `linux-musl` +const ADOPTIUM_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "mac", + HostOs::Linux { musl: true } => "alpine-linux", + HostOs::Linux { musl: false } => "linux", +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_kits_are_ever_requested() { + let url = adoptium_url(21, 21).expect("a valid url"); + + assert!(url.query().is_some_and(|q| q.contains("image_type=jdk"))); + assert!(!url.query().is_some_and(|q| q.contains("image_type=jre"))); + } +} diff --git a/packages/oneclient_java/src/vendors/corretto.rs b/packages/oneclient_java/src/vendors/corretto.rs new file mode 100644 index 00000000..b62a23d8 --- /dev/null +++ b/packages/oneclient_java/src/vendors/corretto.rs @@ -0,0 +1,120 @@ +use reqwest::{Method, Request}; +use serde_json::Value; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct CorrettoRuntimeProvider; + +// { os: { arch: { "jdk": { major: { ext: ... } } } } } +const INDEX_URL: &str = + "https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json"; + +#[async_trait::async_trait] +impl JavaRuntimeProvider for CorrettoRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Corretto + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let index = net + .send_as::(Request::new(Method::GET, Url::parse(INDEX_URL)?)) + .await?; + + // Corretto ships JDK only + let Some(by_major) = index + .get(CORRETTO_OS) + .and_then(|by_arch| by_arch.get(CORRETTO_ARCH)) + .and_then(|by_type| by_type.get("jdk")) + .and_then(Value::as_object) + else { + return Ok(Vec::new()); + }; + + let mut packages = Vec::new(); + for (major_str, by_ext) in by_major { + let Ok(this_major) = major_str.parse::() else { + continue; + }; + if let Some(filter) = major + && filter != this_major + { + continue; + } + + let Some(entry) = by_ext.get(CORRETTO_EXT.0) else { + continue; + }; + + // Prefer the versioned URL the `latest` redirect resolves at + // download time and can move away from this index's checksum + let download_url = entry.get("resource").and_then(Value::as_str).map_or_else( + || latest_url(this_major, CORRETTO_EXT.0), + |resource| format!("https://corretto.aws{resource}"), + ); + + let checksum = entry + .get("checksum_sha256") + .and_then(Value::as_str) + .map(Checksum::sha256); + + let name = format!( + "amazon-corretto-{this_major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{}", + CORRETTO_EXT.0 + ); + + packages.push( + JavaPackage { + archive: CORRETTO_EXT.1, + download_url, + java_version: vec![this_major], + name, + vendor: JavaVendor::Corretto, + checksum: None, + size: None, + } + .with_checksum(checksum), + ); + } + + packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0))); + tracing::debug!(count = packages.len(), "listed Corretto packages"); + Ok(packages) + } +} + +fn latest_url(major: u32, ext: &str) -> String { + format!( + "https://corretto.aws/downloads/latest/amazon-corretto-{major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{ext}" + ) +} + +const CORRETTO_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x86", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", +}; + +/// Corretto publishes no separate musl build so Alpine gets the glibc one +const CORRETTO_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { .. } => "linux", +}; + +const CORRETTO_EXT: (&str, PackageArchive) = ( + HostTarget::CURRENT.archive_ext(), + HostTarget::CURRENT.archive(), +); diff --git a/packages/oneclient_java/src/vendors/liberica.rs b/packages/oneclient_java/src/vendors/liberica.rs new file mode 100644 index 00000000..8db786e6 --- /dev/null +++ b/packages/oneclient_java/src/vendors/liberica.rs @@ -0,0 +1,142 @@ +use reqwest::{Method, Request}; +use serde::Deserialize; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct LibericaRuntimeProvider; + +#[derive(Debug, Deserialize)] +struct LibericaRelease { + #[serde(rename = "downloadUrl")] + download_url: String, + filename: String, + #[serde(rename = "featureVersion")] + feature_version: u32, + #[serde(rename = "updateVersion", default)] + update_version: u32, + #[serde(rename = "buildVersion", default)] + build_version: u32, + version: String, + /// Liberica is the one vendor that publishes SHA-1 rather than SHA-256 + #[serde(default)] + sha1: Option, + #[serde(default)] + size: Option, +} + +#[async_trait::async_trait] +impl JavaRuntimeProvider for LibericaRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Liberica + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let url = liberica_url(major)?; + let releases = net + .send_as::>(Request::new(Method::GET, url)) + .await?; + + let mut releases = releases; + releases.sort_by_key(|r| { + std::cmp::Reverse((r.feature_version, r.update_version, r.build_version)) + }); + + let packages: Vec = releases + .into_iter() + .map(|r| { + let mut java_version: Vec = r + .version + .split(|c: char| !c.is_numeric()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if java_version.first() != Some(&r.feature_version) { + java_version.insert(0, r.feature_version); + } + + let checksum = r.sha1.as_deref().map(Checksum::sha1); + + JavaPackage { + archive: PackageArchive::from_filename(&r.filename), + download_url: r.download_url, + java_version, + name: r.filename, + vendor: JavaVendor::Liberica, + checksum: None, + size: r.size, + } + .with_checksum(checksum) + }) + .collect(); + + tracing::debug!(count = packages.len(), "listed Liberica packages"); + + Ok(packages) + } +} + +fn liberica_url(major: Option) -> JavaResult { + let mut url = Url::parse("https://api.bell-sw.com/v1/liberica/releases")?; + { + let mut q = url.query_pairs_mut(); + q.append_pair("os", LIBERICA_OS) + .append_pair("arch", LIBERICA_ARCH) + // Kits only never a bare runtime image + .append_pair("bundle-type", "jdk") + .append_pair("bitness", LIBERICA_BITNESS) + .append_pair("package-type", LIBERICA_PACKAGE) + .append_pair("installation-type", "archive") + .append_pair( + "fields", + // `sha1` and `size` are only returned when named here + "downloadUrl,filename,featureVersion,updateVersion,buildVersion,version,sha1,size", + ) + .append_pair("output", "json"); + if let Some(major) = major { + q.append_pair("version-feature", &major.to_string()); + } + } + Ok(url) +} + +/// Liberica takes architecture family and bitness as separate parameters so +/// x86/x86_64 collapse to `x86` and both ARM variants to `arm` +const LIBERICA_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 | HostArch::X86_64 => "x86", + HostArch::Arm | HostArch::Aarch64 => "arm", +}; + +const LIBERICA_BITNESS: &str = HostTarget::CURRENT.bitness(); + +const LIBERICA_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "linux-musl", + HostOs::Linux { musl: false } => "linux", +}; + +const LIBERICA_PACKAGE: &str = HostTarget::CURRENT.archive_ext(); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_kits_are_ever_requested() { + let url = liberica_url(Some(21)).expect("a valid url"); + + assert!(url.query().is_some_and(|q| q.contains("bundle-type=jdk"))); + assert!(!url.query().is_some_and(|q| q.contains("bundle-type=jre"))); + } +} diff --git a/packages/oneclient_java/src/vendors/mod.rs b/packages/oneclient_java/src/vendors/mod.rs new file mode 100644 index 00000000..5bc5b544 --- /dev/null +++ b/packages/oneclient_java/src/vendors/mod.rs @@ -0,0 +1,126 @@ +use std::{path::PathBuf, str::FromStr}; + +use oneclient_events::{EventBus, GroupedProgressSession}; +use oneclient_net::RequestClient; + +use crate::data::JavaPackage; +use crate::error::JavaResult; + +mod adoptium; +mod corretto; +mod liberica; +mod zulu; + +pub use adoptium::AdoptiumRuntimeProvider; +pub use corretto::CorrettoRuntimeProvider; +pub use liberica::LibericaRuntimeProvider; +use serde::{Deserialize, Deserializer, Serialize}; +pub use zulu::ZuluRuntimeProvider; + +pub fn runtime_providers() -> Vec> { + vec![ + Box::new(ZuluRuntimeProvider), + Box::new(AdoptiumRuntimeProvider), + Box::new(CorrettoRuntimeProvider), + Box::new(LibericaRuntimeProvider), + ] +} + +#[async_trait::async_trait] +pub trait JavaRuntimeProvider: Send + Sync { + fn vendor(&self) -> JavaVendor; + + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult>; + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn latest_package_by_major( + &self, + major: u32, + net: &RequestClient, + ) -> JavaResult> { + let packages = self.list_packages(Some(major), net).await?; + Ok(packages + .into_iter() + .find(|p| p.java_version.first() == Some(&major))) + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn install_package( + &self, + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + crate::install::install_package(package, net, events, progress).await + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] +pub enum JavaVendor { + Zulu, + Corretto, + Oracle, + Microsoft, + Adoptium, + Liberica, + OpenJDK, + Other(String), +} + +impl std::fmt::Display for JavaVendor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + JavaVendor::Zulu => f.write_str("Zulu"), + JavaVendor::Adoptium => f.write_str("Temurin"), + JavaVendor::Liberica => f.write_str("Liberica"), + JavaVendor::Corretto => f.write_str("Corretto"), + JavaVendor::Microsoft => f.write_str("Microsoft"), + JavaVendor::Oracle => f.write_str("Oracle"), + JavaVendor::OpenJDK => f.write_str("OpenJDK"), + JavaVendor::Other(other) => f.write_str(other), + } + } +} + +impl FromStr for JavaVendor { + type Err = (); + + fn from_str(s: &str) -> Result { + let v = s.to_lowercase(); + + Ok( + if v.contains("adoptium") || v.contains("adoptopenjdk") || v.contains("temurin") { + Self::Adoptium + } else if v.contains("liberica") || v.contains("bellsoft") { + Self::Liberica + } else if v.contains("microsoft") { + Self::Microsoft + } else if v.contains("openjdk") { + Self::OpenJDK + } else if v.contains("oracle") { + Self::Oracle + } else if v.contains("amazon") || v.contains("corretto") { + Self::Corretto + } else if v.contains("azul") || v.contains("zulu") { + Self::Zulu + } else { + Self::Other(s.to_string()) + }, + ) + } +} + +impl<'de> Deserialize<'de> for JavaVendor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::from_str(&s).unwrap()) + } +} diff --git a/packages/oneclient_java/src/vendors/zulu.rs b/packages/oneclient_java/src/vendors/zulu.rs new file mode 100644 index 00000000..6eab2afe --- /dev/null +++ b/packages/oneclient_java/src/vendors/zulu.rs @@ -0,0 +1,122 @@ +use reqwest::{Method, Request}; +use serde::Deserialize; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct ZuluRuntimeProvider; + +#[derive(Debug, Deserialize)] +struct ZuluPackage { + download_url: String, + name: String, + java_version: Vec, + /// Only present because `include_fields` asks for it + #[serde(default)] + sha256_hash: Option, + #[serde(default)] + size: Option, +} + +#[async_trait::async_trait] +impl JavaRuntimeProvider for ZuluRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Zulu + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let url = zulu_url(major)?; + let packages = net + .send_as::>(Request::new(Method::GET, url)) + .await?; + let packages: Vec = packages.into_iter().map(map_zulu_package).collect(); + + tracing::debug!(count = packages.len(), "listed Zulu packages"); + + Ok(packages) + } +} + +fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage { + let checksum = pkg.sha256_hash.as_deref().map(Checksum::sha256); + + JavaPackage { + archive: PackageArchive::Zip, + download_url: pkg.download_url, + java_version: pkg.java_version, + name: pkg.name, + vendor: JavaVendor::Zulu, + checksum: None, + size: pkg.size, + } + .with_checksum(checksum) +} + +fn zulu_url(major: Option) -> JavaResult { + let mut url = Url::parse("https://api.azul.com/metadata/v1/zulu/packages/")?; + { + let mut q = url.query_pairs_mut(); + q.append_pair("os", ZULU_OS) + .append_pair("arch", ZULU_ARCH) + .append_pair("archive_type", "zip") + // Kits only never a bare runtime image + .append_pair("java_package_type", "jdk") + .append_pair("javafx_bundled", "false") + .append_pair("release_status", "ga") + .append_pair("availability_types", "CA") + .append_pair("certifications", "tck") + .append_pair("latest", "true") + // Azul's default projection omits the hash + .append_pair("include_fields", "sha256_hash,size") + .append_pair("page", "1") + .append_pair("page_size", "100"); + if let Some(major) = major { + q.append_pair("java_version", &major.to_string()); + } + } + Ok(url) +} + +const ZULU_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x86", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", +}; + +const ZULU_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "linux-musl", + HostOs::Linux { musl: false } => "linux", +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_kits_are_ever_requested() { + let url = zulu_url(Some(21)).expect("a valid url"); + + assert!( + url.query() + .is_some_and(|q| q.contains("java_package_type=jdk")) + ); + assert!( + !url.query() + .is_some_and(|q| q.contains("java_package_type=jre")) + ); + } +} diff --git a/packages/oneclient_java/tests/vendor_checksums.rs b/packages/oneclient_java/tests/vendor_checksums.rs new file mode 100644 index 00000000..6746c23f --- /dev/null +++ b/packages/oneclient_java/tests/vendor_checksums.rs @@ -0,0 +1,95 @@ +use oneclient_java::vendors::runtime_providers; +use oneclient_net::{NetConfig, RequestClient}; +use polyio::ChecksumAlgorithm; + +/// A major every vendor still publishes so a listing coming back empty means +/// the query is wrong rather than the release being retired +const MAJOR: u32 = 21; + +#[tokio::test] +#[ignore = "requires network"] +async fn every_vendor_publishes_a_usable_checksum() { + let net = RequestClient::new(NetConfig::default()).expect("a client"); + let mut failures = Vec::new(); + + for provider in runtime_providers() { + let vendor = provider.vendor(); + + let packages = match provider.list_packages(Some(MAJOR), &net).await { + Ok(packages) => packages, + Err(err) => { + failures.push(format!("{vendor}: listing failed: {err}")); + continue; + } + }; + + if packages.is_empty() { + failures.push(format!("{vendor}: no packages listed for major {MAJOR}")); + continue; + } + + let unverifiable = packages + .iter() + .filter(|package| package.checksum.is_none()) + .count(); + + if unverifiable > 0 { + failures.push(format!( + "{vendor}: {unverifiable}/{} packages carry no usable checksum", + packages.len() + )); + continue; + } + + for package in &packages { + let checksum = package.checksum.as_ref().expect("checked above"); + assert!( + checksum.is_well_formed(), + "{vendor}: malformed checksum on {}: {}", + package.name, + checksum.hex + ); + } + + println!( + "{vendor}: {} packages, all carrying {}", + packages.len(), + packages[0] + .checksum + .as_ref() + .expect("checked above") + .algorithm + .name() + ); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn liberica_publishes_sha1_and_the_rest_publish_sha256() { + let net = RequestClient::new(NetConfig::default()).expect("a client"); + + for provider in runtime_providers() { + let vendor = provider.vendor(); + let packages = provider + .list_packages(Some(MAJOR), &net) + .await + .unwrap_or_else(|err| panic!("{vendor}: listing failed: {err}")); + + let Some(checksum) = packages.first().and_then(|p| p.checksum.as_ref()) else { + panic!("{vendor}: no checksum to check the algorithm of"); + }; + + let expected = match vendor.to_string().as_str() { + "Liberica" => ChecksumAlgorithm::Sha1, + _ => ChecksumAlgorithm::Sha256, + }; + + assert_eq!( + checksum.algorithm, expected, + "{vendor} changed the algorithm it publishes" + ); + } +} diff --git a/packages/oneclient_macro/.gitignore b/packages/oneclient_macro/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/oneclient_macro/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/oneclient_macro/Cargo.toml b/packages/oneclient_macro/Cargo.toml new file mode 100644 index 00000000..b8105117 --- /dev/null +++ b/packages/oneclient_macro/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "oneclient_macro" +description = "Macros for OneClient" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +documentation = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } + +[lib] +name = "oneclient_macro" +path = "src/lib.rs" +proc-macro = true + +[features] +default = [] +freya = [] + +[dependencies] +syn = { workspace = true } +quote = { workspace = true } +proc-macro2 = { workspace = true } \ No newline at end of file diff --git a/packages/oneclient_macro/src/freya.rs b/packages/oneclient_macro/src/freya.rs new file mode 100644 index 00000000..f82c31ca --- /dev/null +++ b/packages/oneclient_macro/src/freya.rs @@ -0,0 +1,64 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields}; + +pub(crate) fn expand_icon_named(input: DeriveInput) -> Result { + let name = &input.ident; + + let data_enum = match input.data { + Data::Enum(data) => data, + _ => { + return Err(syn::Error::new_spanned( + name, + "IconNamed can only be derived for enums", + )); + } + }; + + let mut match_arms = Vec::new(); + + for variant in data_enum.variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + variant, + "IconNamed only supports unit enum variants", + )); + } + + let variant_ident = &variant.ident; + let file_name = format!("icons/{}.svg", to_kebab_case(&variant_ident.to_string())); + + let doc_comment = format!("Icon preview:\n\n![{}](../{})", variant_ident, file_name); + + match_arms.push(quote! { + #[doc = #doc_comment] + Self::#variant_ident => #file_name, + }); + } + + let expanded = quote! { + impl #name { + pub fn path(self) -> &'static str { + match self { + #(#match_arms)* + } + } + } + }; + + Ok(expanded) +} + +fn to_kebab_case(s: &str) -> String { + let mut kebab = String::new(); + for (i, ch) in s.chars().enumerate() { + if i > 0 + && (ch.is_uppercase() + || (ch.is_numeric() && !s.chars().nth(i - 1).unwrap().is_numeric())) + { + kebab.push('-'); + } + kebab.push(ch.to_ascii_lowercase()); + } + kebab +} diff --git a/packages/oneclient_macro/src/lib.rs b/packages/oneclient_macro/src/lib.rs new file mode 100644 index 00000000..efae3076 --- /dev/null +++ b/packages/oneclient_macro/src/lib.rs @@ -0,0 +1,12 @@ +#[cfg(feature = "freya")] +mod freya; + +#[cfg(feature = "freya")] +#[proc_macro_derive(IconNamed)] +pub fn derive_icon_named(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + + freya::expand_icon_named(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} diff --git a/packages/oneclient_mc/Cargo.toml b/packages/oneclient_mc/Cargo.toml new file mode 100644 index 00000000..36510292 --- /dev/null +++ b/packages/oneclient_mc/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "oneclient_mc" +description = "Minecraft content: assets, libraries, natives, client jars, launch arguments and player profiles" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +# Mojang / modded metadata schemas +interfrost.workspace = true + +futures-util.workspace = true +tokio.workspace = true + +base64.workspace = true +chrono.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs new file mode 100644 index 00000000..7bc699a1 --- /dev/null +++ b/packages/oneclient_mc/src/arguments.rs @@ -0,0 +1,780 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use interfrost::api::minecraft::{Argument, ArgumentValue, Library, VersionType}; +use interfrost::api::modded::SidedDataEntry; +use interfrost::utils::get_path_from_artifact; + +use crate::error::McError; +use crate::error::McResult; +use crate::rules::validate_rules; +use oneclient_common::Resolution; +use oneclient_common::constants::{self, DUMMY_REPLACE_NEWLINE}; + +#[allow(clippy::too_many_arguments)] +pub fn java_arguments( + version_updated: bool, + arguments: Option<&[Argument]>, + natives_path: &Path, + libraries_path: &Path, + classpaths: &str, + version: &str, + mem_max: u32, + custom_args: String, + java_arch: &str, + java_major: u32, +) -> McResult> { + let mut parsed = Vec::new(); + if let Some(args) = arguments { + parse_arguments( + version_updated, + args, + &mut parsed, + |a| { + parse_java_argument( + a.to_string(), + natives_path, + libraries_path, + classpaths, + version, + ) + }, + java_arch, + )?; + } else { + parsed.push(format!( + "-Djava.library.path={}", + polyio::canonicalize(natives_path) + .map_err(|_| McError::LibraryPath(natives_path.display().to_string()))? + .display() + )); + parsed.push("-cp".to_string()); + parsed.push(classpaths.to_string()); + } + + let custom = split_custom_args(&custom_args); + + parsed.extend(performance_flags(java_major, java_arch, mem_max, &custom)); + + parsed.push(format!("-Xmx{mem_max}M")); + parsed.extend(custom); + + Ok(parsed) +} + +const ZGC_MIN_HEAP_MB: u32 = 8192; + +const INITIAL_HEAP_MB: u32 = 512; + +#[must_use] +pub fn performance_flags( + java_major: u32, + java_arch: &str, + mem_max: u32, + custom_args: &[String], +) -> Vec { + let mut flags = vec![format!("-Xms{}M", mem_max.min(INITIAL_HEAP_MB))]; + + let collector_chosen = custom_args.iter().any(|arg| is_collector_flag(arg)); + + let use_zgc = + !collector_chosen && java_major >= 21 && is_64_bit(java_arch) && mem_max >= ZGC_MIN_HEAP_MB; + let use_g1 = !collector_chosen && !use_zgc && java_major >= 8; + + if (use_zgc || use_g1) && java_major == 24 { + flags.push("-XX:+UnlockExperimentalVMOptions".to_string()); + } + + if use_zgc { + flags.push("-XX:+UseZGC".to_string()); + if java_major <= 22 { + flags.push("-XX:+ZGenerational".to_string()); + } + } else if use_g1 { + flags.push("-XX:+UseG1GC".to_string()); + flags.push("-XX:+ParallelRefProcEnabled".to_string()); + } + + if (use_zgc || use_g1) && java_major >= 24 && is_64_bit(java_arch) { + flags.push("-XX:+UseCompactObjectHeaders".to_string()); + } + + flags +} + +fn is_collector_flag(arg: &str) -> bool { + const COLLECTORS: [&str; 8] = [ + "UseG1GC", + "UseZGC", + "UseShenandoahGC", + "UseParallelGC", + "UseParallelOldGC", + "UseConcMarkSweepGC", + "UseSerialGC", + "UseEpsilonGC", + ]; + + arg.starts_with("-XX:") && COLLECTORS.iter().any(|name| arg.contains(name)) +} + +fn is_64_bit(java_arch: &str) -> bool { + matches!( + java_arch, + "amd64" | "x86_64" | "x64" | "aarch64" | "arm64" | "ppc64le" | "s390x" | "riscv64" + ) +} + +/// Whitespace-separated double quotes for values containing spaces +/// Must not be passed as one argv entry or the JVM sees one unrecognised option +fn split_custom_args(raw: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + let mut started = false; + + for ch in raw.chars() { + match ch { + '"' => { + quoted = !quoted; + started = true; + } + c if c.is_whitespace() && !quoted => { + if started { + args.push(std::mem::take(&mut current)); + started = false; + } + } + c => { + current.push(c); + started = true; + } + } + } + + if started { + args.push(current); + } + + args +} + +#[allow(clippy::too_many_arguments)] +pub fn minecraft_arguments( + version_updated: bool, + args: Option<&[Argument]>, + legacy_args: Option<&str>, + access_token: &str, + username: &str, + uuid: uuid::Uuid, + version: &str, + asset_index: &str, + game_directory: &Path, + assets_directory: &Path, + version_type: VersionType, + resolution: Resolution, + java_arch: &str, +) -> McResult> { + if let Some(args) = args { + let mut parsed = Vec::new(); + parse_arguments( + version_updated, + args, + &mut parsed, + |arg| { + parse_minecraft_argument( + arg, + access_token, + username, + uuid, + version, + asset_index, + game_directory, + assets_directory, + version_type, + resolution, + ) + }, + java_arch, + )?; + + Ok(parsed) + } else if let Some(legacy_args) = legacy_args { + let mut parsed = Vec::new(); + for arg in legacy_args.split(' ') { + parsed.push(parse_minecraft_argument( + &arg.replace(' ', DUMMY_REPLACE_NEWLINE), + access_token, + username, + uuid, + version, + asset_index, + game_directory, + assets_directory, + version_type, + resolution, + )?); + } + + Ok(parsed) + } else { + Ok(Vec::new()) + } +} + +pub fn append_profile_game_arguments( + args: &mut Vec, + force_fullscreen: Option, + launch_args: Option<&str>, +) { + if force_fullscreen.unwrap_or(false) { + args.push("--fullscreen".to_string()); + } + + if let Some(extra) = launch_args.map(str::trim).filter(|s| !s.is_empty()) { + args.push(extra.to_string()); + } +} + +pub fn processor_arguments, S: std::hash::BuildHasher>( + libraries_path: &Path, + args: &[T], + data: &HashMap, +) -> McResult> { + let mut parsed = Vec::new(); + + for arg in args { + let a = &arg.as_ref()[1..arg.as_ref().len() - 1]; + if arg.as_ref().starts_with('{') { + if let Some(entry) = data.get(a) { + parsed.push(if entry.client.starts_with('[') { + get_library( + libraries_path, + &entry.client[1..entry.client.len() - 1], + true, + )? + } else { + entry.client.clone() + }); + } + } else if arg.as_ref().starts_with('[') { + parsed.push(get_library(libraries_path, a, true)?); + } else { + parsed.push(arg.as_ref().to_string()); + } + } + + Ok(parsed) +} + +#[tracing::instrument(skip_all, level = "debug")] +pub async fn main_class(path: impl AsRef) -> McResult> { + let data = polyio::read(path.as_ref()).await?; + let mut class_name = None; + + use futures_util::TryStreamExt; + + let stream = polyio::stream_zip_entries_bytes(data); + let mut stream = std::pin::pin!(stream); + + while let Some(item) = stream.try_next().await? { + let (index, entry, reader) = item; + if entry.dir().map_err(polyio::IOError::from)? { + continue; + } + + if entry.filename().as_str().map_err(polyio::IOError::from)? != "META-INF/MANIFEST.MF" { + continue; + } + + let mut buf = String::new(); + let mut entry_reader = reader + .reader_without_entry(index) + .await + .map_err(polyio::IOError::from)?; + futures_util::AsyncReadExt::read_to_string(&mut entry_reader, &mut buf) + .await + .map_err(polyio::IOError::from)?; + + for line in buf.lines() { + let line = line.trim(); + if line.starts_with("Main-Class:") + && let Some(class) = line.split(':').nth(1) + { + class_name = Some(class.trim().to_string()); + break; + } + } + } + + Ok(class_name) +} + +#[allow(clippy::too_many_arguments)] +pub fn parse_minecraft_argument( + argument: &str, + access_token: &str, + username: &str, + uuid: uuid::Uuid, + version: &str, + asset_index: &str, + game_directory: &Path, + assets_directory: &Path, + version_type: VersionType, + resolution: Resolution, +) -> McResult { + Ok(argument + .replace("${accessToken}", access_token) + .replace("${auth_access_token}", access_token) + .replace("${auth_session}", access_token) + .replace("${auth_player_name}", username) + .replace("${auth_xuid}", "0") + .replace("${auth_uuid}", &uuid.simple().to_string()) + .replace("${uuid}", &uuid.simple().to_string()) + .replace( + "${clientid}", + oneclient_common::constants::MICROSOFT_CLIENT_ID, + ) + .replace("${user_properties}", "{}") + .replace("${user_type}", "msa") + .replace("${version_name}", version) + .replace("${assets_index_name}", asset_index) + .replace( + "${game_directory}", + &polyio::canonicalize(game_directory)?.display().to_string(), + ) + .replace( + "${assets_root}", + &polyio::canonicalize(assets_directory)? + .display() + .to_string(), + ) + .replace( + "${game_assets}", + &polyio::canonicalize(assets_directory)? + .display() + .to_string(), + ) + .replace("${version_type}", version_type.as_str()) + .replace("${resolution_width}", &resolution.width.to_string()) + .replace("${resolution_height}", &resolution.height.to_string())) +} + +fn parse_java_argument( + mut argument: String, + natives_path: &Path, + libraries_path: &Path, + classpaths: &str, + version: &str, +) -> McResult { + argument.retain(|c| !c.is_whitespace()); + Ok(argument + .replace( + "${natives_directory}", + &polyio::canonicalize(natives_path)?.display().to_string(), + ) + .replace( + "${library_directory}", + &polyio::canonicalize(libraries_path)?.display().to_string(), + ) + .replace("${classpath_separator}", constants::CLASSPATH_SEPARATOR) + .replace("${launcher_name}", "OneClient") + .replace("${launcher_version}", env!("CARGO_PKG_VERSION")) + .replace("${version_name}", version) + .replace("${classpath}", classpaths)) +} + +pub fn classpaths( + libraries_path: &Path, + libraries: &[Library], + client_path: &Path, + java_arch: &str, + updated: bool, +) -> McResult { + let mut chosen: HashMap, &str)> = HashMap::new(); + for lib in libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, java_arch, updated) + { + continue; + } + if !lib.include_in_classpath { + continue; + } + + let (artifact, version) = split_artifact_version(&lib.name); + let ver_key = version_key(version); + match chosen.get(&artifact) { + Some((existing, existing_name)) if *existing >= ver_key => { + tracing::debug!( + skipped = %lib.name, + kept = %existing_name, + "classpath: dropping older duplicate library" + ); + } + _ => { + chosen.insert(artifact, (ver_key, &lib.name)); + } + } + } + + let mut classpaths = chosen + .values() + .map(|(_, name)| get_library(libraries_path, name, false)) + .collect::, _>>()?; + + classpaths.insert(polyio::canonicalize(client_path)?.display().to_string()); + + tracing::debug!(entries = classpaths.len(), "classpath resolved"); + + Ok(classpaths + .into_iter() + .collect::>() + .join(constants::CLASSPATH_SEPARATOR)) +} + +fn split_artifact_version(name: &str) -> (String, &str) { + match name.rsplit_once(':') { + Some((key, version)) if version.starts_with(|c: char| c.is_ascii_digit()) => { + (key.to_string(), version) + } + _ => (name.to_string(), ""), + } +} + +fn version_key(version: &str) -> Vec { + version + .split(['.', '-', '+', '_']) + .map(|seg| seg.parse::().unwrap_or(0)) + .collect() +} + +pub fn get_classpath_library>( + libraries_path: &Path, + libraries: &[T], +) -> McResult { + let classpaths = libraries + .iter() + .map(|lib| get_library(libraries_path, lib.as_ref(), false)) + .collect::, _>>()?; + + Ok(classpaths.join(constants::CLASSPATH_SEPARATOR)) +} + +pub fn get_library(libraries_path: &Path, library: &str, error_exist: bool) -> McResult { + let mut path = libraries_path.to_path_buf(); + path.push( + get_path_from_artifact(library).map_err(|_| McError::LibraryPath(library.to_string()))?, + ); + + if !path.exists() && error_exist { + return Ok(path.display().to_string()); + } + + Ok(polyio::canonicalize(&path)?.display().to_string()) +} + +fn parse_arguments( + version_updated: bool, + args: &[Argument], + parsed: &mut Vec, + parse_function: ParseFn, + java_arch: &str, +) -> McResult<()> +where + ParseFn: Fn(&str) -> McResult, +{ + for arg in args { + match arg { + Argument::Normal(a) => { + let p = parse_function(&a.replace(' ', DUMMY_REPLACE_NEWLINE))?; + for split_p in p.split(DUMMY_REPLACE_NEWLINE) { + parsed.push(split_p.to_string()); + } + } + Argument::Ruled { rules, value } => { + if validate_rules(rules, java_arch, version_updated) { + match value { + ArgumentValue::Single(arg) => { + parsed.push(parse_function(&arg.replace(' ', DUMMY_REPLACE_NEWLINE))?); + } + ArgumentValue::Many(args) => { + for arg in args { + parsed.push(parse_function( + &arg.replace(' ', DUMMY_REPLACE_NEWLINE), + )?); + } + } + } + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ZGC_MIN_HEAP_MB, is_collector_flag, performance_flags, split_custom_args}; + + #[test] + fn blank_input_contributes_nothing() { + assert!(split_custom_args("").is_empty()); + assert!(split_custom_args(" \t ").is_empty()); + } + + #[test] + fn each_flag_becomes_its_own_argument() { + assert_eq!( + split_custom_args("-XX:+UseG1GC -Xms2G"), + vec!["-XX:+UseG1GC", "-Xms2G"] + ); + } + + #[test] + fn quotes_hold_a_value_with_spaces_together() { + assert_eq!( + split_custom_args(r#"-Dname="My Server" -Xss1M"#), + vec!["-Dname=My Server", "-Xss1M"] + ); + } + + #[test] + fn an_empty_quoted_value_survives() { + assert_eq!(split_custom_args(r#"-Dempty="""#), vec!["-Dempty="]); + } + + fn flags(java_major: u32, java_arch: &str, mem_max: u32) -> Vec { + performance_flags(java_major, java_arch, mem_max, &[]) + } + + fn custom(args: &str) -> Vec { + split_custom_args(args) + } + + #[test] + fn a_java_8_runtime_gets_g1() { + let j8 = flags(8, "amd64", 4096); + assert_eq!(j8.first().unwrap(), "-Xms512M"); + assert!(j8.contains(&"-XX:+UseG1GC".to_string())); + } + + #[test] + fn the_versions_told_to_upgrade_still_get_a_tuned_collector() { + for major in [9, 11, 17, 20] { + let flags = flags(major, "amd64", 4096); + assert!( + flags.contains(&"-XX:+UseG1GC".to_string()), + "java {major} should still be tuned, it is what old packs run on" + ); + assert!( + !flags.iter().any(|f| f.contains("UseZGC")), + "java {major} predates the generational ZGC we would want" + ); + } + } + + #[test] + fn a_32_bit_runtime_never_gets_zgc() { + for arch in ["x86", "arm", "i386", "unknown"] { + let flags = flags(25, arch, 16384); + assert!( + !flags.iter().any(|f| f.contains("UseZGC")), + "{arch} must not select a collector it cannot load" + ); + assert!( + !flags.contains(&"-XX:+UseCompactObjectHeaders".to_string()), + "{arch} has no compact headers to enable" + ); + assert!(flags.contains(&"-XX:+UseG1GC".to_string())); + } + } + + #[test] + fn a_small_heap_gets_g1_rather_than_zgc() { + for mem in [2048, 4096, ZGC_MIN_HEAP_MB - 1] { + let flags = flags(21, "amd64", mem); + assert!( + !flags.iter().any(|f| f.contains("UseZGC")), + "{mem}M is not enough heap for ZGC to stay ahead of a modded client" + ); + assert!(flags.contains(&"-XX:+UseG1GC".to_string())); + } + } + + #[test] + fn a_large_heap_still_gets_zgc() { + for mem in [ZGC_MIN_HEAP_MB, 12288, 16384] { + let flags = flags(21, "amd64", mem); + assert!(flags.contains(&"-XX:+UseZGC".to_string())); + assert!( + !flags.contains(&"-XX:+UseG1GC".to_string()), + "only one collector may be selected" + ); + } + } + + #[test] + fn the_collector_is_selected_but_not_sized() { + for mem in [2048, 4096, 8192, 16384] { + let flags = flags(17, "amd64", mem); + assert_eq!( + flags, + vec![ + "-Xms512M".to_string(), + "-XX:+UseG1GC".to_string(), + "-XX:+ParallelRefProcEnabled".to_string(), + ], + "{mem}M should get no hand-picked sizing" + ); + } + } + + #[test] + fn a_collector_in_the_custom_arguments_replaces_ours() { + for arg in [ + "-XX:+UseG1GC", + "-XX:+UseZGC", + "-XX:+UseSerialGC", + "-XX:+UseShenandoahGC", + "-XX:-UseG1GC", + ] { + let flags = performance_flags(21, "amd64", 16384, &custom(arg)); + assert!( + !flags.iter().any(|f| is_collector_flag(f)), + "{arg} should leave the collector entirely to the user" + ); + assert!( + !flags.iter().any(|f| f.starts_with("-XX:G1")), + "{arg} should not be tuned for a collector we did not select" + ); + assert_eq!( + flags, + vec!["-Xms512M"], + "{arg} should leave only the collector-independent flags" + ); + } + } + + #[test] + fn unrelated_custom_arguments_leave_the_collector_alone() { + let flags = performance_flags(21, "amd64", 4096, &custom("-Dfoo=bar -Xss1M")); + assert!(flags.contains(&"-XX:+UseG1GC".to_string())); + } + + #[test] + fn zgc_is_asked_for_a_young_generation_only_before_23() { + assert!(flags(21, "amd64", 16384).contains(&"-XX:+ZGenerational".to_string())); + assert!(flags(22, "aarch64", 16384).contains(&"-XX:+ZGenerational".to_string())); + assert!(!flags(23, "amd64", 16384).contains(&"-XX:+ZGenerational".to_string())); + assert!(!flags(25, "amd64", 16384).contains(&"-XX:+ZGenerational".to_string())); + } + + #[test] + fn compact_headers_arrive_on_24_whichever_collector_is_chosen() { + for major in [21, 23] { + assert!( + !flags(major, "amd64", 16384).contains(&"-XX:+UseCompactObjectHeaders".to_string()), + "java {major} predates compact object headers" + ); + } + + for mem in [4096, 16384] { + let j24 = flags(24, "amd64", mem); + let unlock = j24 + .iter() + .position(|f| f == "-XX:+UnlockExperimentalVMOptions") + .expect("24 needs the unlock flag"); + let compact = j24 + .iter() + .position(|f| f == "-XX:+UseCompactObjectHeaders") + .expect("24 supports compact headers"); + assert!( + unlock < compact, + "the unlock flag must precede the option it unlocks" + ); + } + + let j25_zgc = flags(25, "amd64", 16384); + assert!(j25_zgc.contains(&"-XX:+UseCompactObjectHeaders".to_string())); + assert!(!j25_zgc.contains(&"-XX:+UnlockExperimentalVMOptions".to_string())); + + assert!(flags(25, "amd64", 4096).contains(&"-XX:+UseCompactObjectHeaders".to_string())); + } + + #[test] + fn every_experimental_option_sits_after_the_unlock() { + const EXPERIMENTAL: [&str; 1] = ["-XX:+UseCompactObjectHeaders"]; + + for major in [8, 17, 21, 24, 25] { + for mem in [4096, 16384] { + let flags = flags(major, "amd64", mem); + let Some(unlock) = flags + .iter() + .position(|f| f == "-XX:+UnlockExperimentalVMOptions") + else { + assert!( + !flags + .iter() + .any(|f| EXPERIMENTAL.iter().any(|e| f.starts_with(e)) + && !(major >= 25 && f == "-XX:+UseCompactObjectHeaders")), + "java {major} at {mem}M emits an experimental option with no unlock" + ); + continue; + }; + + for (i, flag) in flags.iter().enumerate() { + if EXPERIMENTAL.iter().any(|e| flag.starts_with(e)) { + assert!( + unlock < i, + "java {major} at {mem}M puts {flag} before the unlock" + ); + } + } + } + } + } + + #[test] + fn string_deduplication_is_never_added() { + for major in [7, 8, 17, 21, 24, 25] { + assert!( + !flags(major, "amd64", 4096).contains(&"-XX:+UseStringDeduplication".to_string()), + "java {major} should not be told to deduplicate strings" + ); + } + } + + #[test] + fn a_modern_runtime_gets_the_full_set() { + assert_eq!( + flags(25, "aarch64", 16384), + vec!["-Xms512M", "-XX:+UseZGC", "-XX:+UseCompactObjectHeaders"] + ); + } + + #[test] + fn every_tier_starts_small_and_grows_into_the_memory_setting() { + for major in [8, 17, 21, 25] { + assert_eq!( + flags(major, "amd64", 8192).first().unwrap(), + "-Xms512M", + "java {major} should not commit the ceiling up front" + ); + } + } + + #[test] + fn a_tiny_profile_never_starts_above_its_ceiling() { + assert_eq!( + flags(21, "amd64", 256), + vec!["-Xms256M", "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled",], + "a start size above the maximum is refused by the JVM outright" + ); + } + + #[test] + fn a_java_7_runtime_is_left_untouched() { + assert_eq!(flags(7, "amd64", 4096), vec!["-Xms512M"]); + } +} diff --git a/packages/oneclient_mc/src/download.rs b/packages/oneclient_mc/src/download.rs new file mode 100644 index 00000000..f88bf8ac --- /dev/null +++ b/packages/oneclient_mc/src/download.rs @@ -0,0 +1,57 @@ +use oneclient_events::{EventBus, GroupedProgressSession, TaskCategory}; +use oneclient_net::RequestClient; + +use crate::error::McResult; + +/// The progress child is created before the request goes out otherwise small +/// files dominated by header wait never appear in the UI +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(skip(requester, events, progress, label, expected_sha1), fields(%url), level = "debug")] +pub async fn download_to_path( + requester: &RequestClient, + events: &EventBus, + progress: &GroupedProgressSession, + label: impl Into, + category: TaskCategory, + expected_size: u64, + url: &str, + dest: &std::path::Path, + expected_sha1: Option<&str>, +) -> McResult<()> { + let child = progress.child(label, expected_size, category); + + // Every hash in a Minecraft manifest is SHA-1 so the algorithm is implied + let expected = expected_sha1.map(polyio::Checksum::sha1); + + oneclient_net::download_verified( + requester, + events, + url, + dest, + expected.as_ref(), + expected_size, + Some(oneclient_net::ResponseNotifyOptions::grouped(child)), + ) + .await?; + + Ok(()) +} + +/// Downloads into memory instead of to disk for payloads that are parsed +/// rather than stored +#[tracing::instrument(skip(requester, events, progress, label), fields(%url), level = "debug")] +#[allow(clippy::too_many_arguments)] +pub async fn fetch_bytes_verified( + requester: &RequestClient, + events: &EventBus, + progress: &GroupedProgressSession, + label: impl Into, + category: TaskCategory, + expected_size: u64, + url: &str, + expected_sha1: &str, +) -> McResult> { + let child = progress.child(label, expected_size, category); + + Ok(oneclient_net::fetch_verified(requester, events, url, expected_sha1, Some(child)).await?) +} diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs new file mode 100644 index 00000000..4ef220c4 --- /dev/null +++ b/packages/oneclient_mc/src/error.rs @@ -0,0 +1,83 @@ +use oneclient_common::domain::GameLoader; +use thiserror::Error; + +pub type McResult = Result; + +#[derive(Debug, Error)] +pub enum McError { + #[error("no client download exists for version {0}")] + NoClientDownload(String), + + #[error("failed to resolve library artifact path for {0}")] + LibraryPath(String), + + #[error("invalid game version {0}")] + InvalidVersion(String), + + #[error("failed to fetch metadata")] + FetchError, + + #[error("loader {0} does not use a modded manifest")] + NotModdedManifest(GameLoader), + + #[error("loader {0} does not use a vanilla manifest")] + NotVanillaManifest(GameLoader), + + #[error("no matching loader found")] + NoMatchingLoader, + + #[error("requested loader version '{requested}' was not found")] + RequestedLoaderVersionNotFound { requested: String }, + + #[error("no matching version found")] + NoMatchingVersion, + + #[error("cancelled: {failed} file(s) could not be downloaded")] + IncompleteInstallCancelled { failed: usize }, + + /// A Mojang response was well-formed JSON but not what the API documents + #[error("minecraft: {0}")] + Minecraft(String), + + #[error("failed to parse metadata: {0}")] + ParseError(#[from] serde_json::Error), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), +} + +impl McError { + /// Environment fault rather than a launcher bug drives Sentry policy upstream + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } +} diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs new file mode 100644 index 00000000..19dec936 --- /dev/null +++ b/packages/oneclient_mc/src/install.rs @@ -0,0 +1,1300 @@ +use std::path::{Path, PathBuf}; + +use futures_util::{StreamExt, stream}; +use interfrost::api::minecraft::{ + Asset, AssetsIndex, DownloadType, Library, Os, Version, VersionInfo, +}; +use interfrost::api::modded::LoaderVersion; +use reqwest::Method; + +use crate::download::{download_to_path, fetch_bytes_verified}; +use crate::rules::validate_rules; + +use crate::McCtx; +use crate::error::{McError, McResult}; +use crate::manifest::MetadataStore; +use oneclient_common::domain::GameLoader; +use oneclient_common::os_ext::OsExt; +use oneclient_common::paths; +use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase}; + +/// Asset objects are tiny (median ~10 KiB) and latency-bound so throughput +/// scales with how many are in flight not with bandwidth +const ASSET_DOWNLOAD_CONCURRENCY: usize = 32; +/// Libraries are larger and fewer less fan-out is needed to saturate the link +const LIBRARY_DOWNLOAD_CONCURRENCY: usize = 16; + +/// Computed up-front so the pre-download size estimate and the progress-bar +/// denominators come from the same numbers +#[derive(Debug, Default)] +pub struct DownloadPlan { + pub assets: Vec<(String, Asset)>, + pub asset_bytes: u64, + pub libraries: Vec, + pub library_bytes: u64, + pub client: bool, + pub client_bytes: u64, +} + +impl DownloadPlan { + #[must_use] + pub fn total_bytes(&self) -> u64 { + self.asset_bytes + self.library_bytes + self.client_bytes + } +} + +fn asset_object_path(dir: &Path, name: &str, hash: &str, legacy: bool) -> PathBuf { + if legacy { + dir.join(name.replace('/', std::path::MAIN_SEPARATOR_STR)) + } else { + dir.join(&hash[0..2]).join(hash) + } +} + +/// Size not hash catches interrupted-download truncation without reading +/// ~5000 assets per launch +/// `expected` of 0 means the manifest gave no size +fn matches_expected_size(path: &Path, expected: u64) -> bool { + match std::fs::metadata(path) { + Ok(meta) => expected == 0 || meta.len() == expected, + Err(_) => false, + } +} + +/// 0 when the entry carries no artifact metadata (maven-coordinate libraries +/// from loader manifests) +fn library_artifact_size(lib: &Library) -> u64 { + lib.downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + .map_or(0, |artifact| u64::from(artifact.size)) +} + +fn native_download_size(lib: &Library, java_arch: &str) -> u64 { + let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| { + Some(( + natives.get(&Os::native_arch(java_arch))?, + lib.downloads.as_ref()?.classifiers.as_ref()?, + )) + }) else { + return 0; + }; + + let parsed = os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); + classifiers + .get(&parsed) + .map_or(0, |native| u64::from(native.size)) +} + +/// Stats thousands of files so it runs on the blocking pool and keeps the +/// download fan-outs from stat-ing inside a concurrency slot +#[tracing::instrument(skip_all, level = "debug")] +pub async fn plan_downloads( + version: &VersionInfo, + assets_index: AssetsIndex, + java_arch: &str, + minecraft_updated: bool, + force: bool, +) -> McResult { + let legacy = version.assets == "legacy"; + let asset_dir = if legacy { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + let lib_dir = paths::libraries_dir()?; + + let client = version.downloads.get(&DownloadType::Client).map(|client| { + ( + paths::versions_dir() + .map(|dir| dir.join(&version.id).join(format!("{}.jar", version.id))), + u64::from(client.size), + ) + }); + + let libraries = version.libraries.clone(); + let java_arch = java_arch.to_string(); + + tokio::task::spawn_blocking(move || { + let mut plan = DownloadPlan::default(); + + for (name, asset) in assets_index.objects { + let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy); + if !force && matches_expected_size(&path, u64::from(asset.size)) { + continue; + } + plan.asset_bytes += u64::from(asset.size); + plan.assets.push((name, asset)); + } + + for lib in libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, &java_arch, minecraft_updated) + { + continue; + } + if !lib.downloadable { + continue; + } + + // A library whose coordinates don't resolve is kept in the plan so the + // download path still reports it as a failure rather than skipping it + let Ok(artifact_path) = interfrost::utils::get_path_from_artifact(&lib.name) else { + plan.libraries.push(lib); + continue; + }; + + if !force + && matches_expected_size(&lib_dir.join(&artifact_path), library_artifact_size(&lib)) + { + continue; + } + + plan.library_bytes += library_artifact_size(&lib); + plan.library_bytes += native_download_size(&lib, &java_arch); + plan.libraries.push(lib); + } + + if let Some((path, size)) = client { + let present = path + .map(|path| matches_expected_size(&path, size)) + .unwrap_or(false); + if force || !present { + plan.client = true; + plan.client_bytes = size; + } + } + + plan + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct VerifyReport { + pub checked: usize, + /// Mismatched the manifest and were removed + pub corrupt: usize, + pub missing: usize, +} + +impl VerifyReport { + #[must_use] + pub fn needs_repair(&self) -> bool { + self.corrupt > 0 || self.missing > 0 + } +} + +/// Expensive reads every asset and library so it belongs behind an explicit +/// user action not the launch path +/// Nothing is re-downloaded here corrupt files are deleted so the next prepare +/// refetches them +#[tracing::instrument(skip(progress, version, assets_index), fields(version_id = %version.id), level = "debug")] +pub async fn verify_game_files( + progress: &GroupedProgressSession, + version: &VersionInfo, + assets_index: AssetsIndex, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let legacy = version.assets == "legacy"; + let asset_dir = if legacy { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + let lib_dir = paths::libraries_dir()?; + + let mut targets: Vec<(PathBuf, String)> = Vec::new(); + + for (name, asset) in assets_index.objects { + let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy); + targets.push((path, asset.hash)); + } + + for lib in &version.libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, java_arch, minecraft_updated) + { + continue; + } + if !lib.downloadable { + continue; + } + let Ok(artifact_path) = interfrost::utils::get_path_from_artifact(&lib.name) else { + continue; + }; + // Only libraries whose manifest entry carries a hash can be checked + // maven-coordinate entries from loader manifests publish none + if let Some(artifact) = lib + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + && !artifact.sha1.is_empty() + { + targets.push((lib_dir.join(&artifact_path), artifact.sha1.clone())); + } + } + + if let Some(client) = version.downloads.get(&DownloadType::Client) { + let path = paths::versions_dir()? + .join(&version.id) + .join(format!("{}.jar", version.id)); + targets.push((path, client.sha1.clone())); + } + + let total = targets.len() as u64; + let child = progress.child("Verifying game files", total.max(1), TaskCategory::Assets); + child.set_phase(TaskPhase::Verifying); + + let started = std::time::Instant::now(); + let child_for_progress = child.clone(); + + // One blocking task for the whole sweep a spawn_blocking per small asset + // would cost more than the hashing itself + let report = tokio::task::spawn_blocking(move || { + let mut report = VerifyReport::default(); + + for (index, (path, expected)) in targets.iter().enumerate() { + if !path.is_file() { + report.missing += 1; + continue; + } + + match polyio::sha1_file_sync(path) { + Ok(actual) + if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => + { + report.checked += 1; + } + Ok(actual) => { + tracing::warn!( + path = %path.display(), + %actual, + %expected, + "corrupt game file; removing so it is re-downloaded" + ); + report.checked += 1; + report.corrupt += 1; + if let Err(err) = std::fs::remove_file(path) { + tracing::error!(path = %path.display(), "could not remove: {err}"); + } + } + Err(err) => { + // Unreadable is as good as corrupt removing it lets the + // download path replace it + tracing::warn!(path = %path.display(), "could not hash: {err}"); + report.corrupt += 1; + let _ = std::fs::remove_file(path); + } + } + + // The child's sampling is per-call so throttle here or this emits + // thousands of events + if index % 64 == 0 { + child_for_progress.set_progress(index as u64, Some(total.max(1))); + } + } + + report + }) + .await + .map_err(std::io::Error::other)?; + + child.finish(); + + tracing::info!( + checked = report.checked, + corrupt = report.corrupt, + missing = report.missing, + elapsed_ms = started.elapsed().as_millis() as u64, + "verified game files" + ); + + Ok(report) +} + +#[tracing::instrument(skip_all)] +pub async fn download_minecraft( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, + force: bool, +) -> McResult<()> { + let started = std::time::Instant::now(); + let asset_index = download_assets_index(ctx, progress, version, force).await?; + let plan = plan_downloads(version, asset_index, java_arch, minecraft_updated, force).await?; + + tracing::info!( + version = %version.id, + assets = plan.assets.len(), + libraries = plan.libraries.len(), + bytes = plan.total_bytes(), + "planned minecraft download" + ); + + // Reserve each category's real total before any child appears so the bars + // start at the right denominator instead of growing towards it + progress.expect( + TaskCategory::Assets, + plan.assets.len() as u64, + plan.asset_bytes, + ); + progress.expect( + TaskCategory::Libraries, + plan.libraries.len() as u64, + plan.library_bytes, + ); + progress.expect( + TaskCategory::Client, + u64::from(plan.client), + plan.client_bytes, + ); + + let DownloadPlan { + assets, libraries, .. + } = plan; + + let (failed_assets, _client, failed_libraries) = tokio::try_join!( + download_assets(ctx, progress, version.assets == "legacy", assets,), + download_client(ctx, progress, version, force), + download_libraries(ctx, progress, version.id.clone(), libraries, java_arch,), + )?; + + confirm_incomplete_install(ctx, failed_assets, failed_libraries).await?; + + tracing::info!( + version = %version.id, + elapsed_ms = started.elapsed().as_millis() as u64, + "minecraft download complete" + ); + + Ok(()) +} + +enum IncompleteInstall { + LaunchAnyway, +} + +/// Download failures are counted not propagated so one unreachable file does +/// not abandon the other four thousand +/// They already survived every retry but it stays the user's call refusing to +/// launch would strand offline players +#[tracing::instrument(skip(ctx), level = "debug")] +pub async fn confirm_incomplete_install( + ctx: &McCtx, + failed_assets: usize, + failed_libraries: usize, +) -> McResult<()> { + let failed = failed_assets + failed_libraries; + if failed == 0 { + return Ok(()); + } + + tracing::warn!( + failed_assets, + failed_libraries, + "some files could not be downloaded; asking the user how to proceed" + ); + + let body = match (failed_assets, failed_libraries) { + (0, libraries) => format!( + "{libraries} librar{} could not be downloaded after several attempts. \ + The game will most likely fail to start without {}.", + if libraries == 1 { "y" } else { "ies" }, + if libraries == 1 { "it" } else { "them" }, + ), + (assets, 0) => format!( + "{assets} game asset{} could not be downloaded after several attempts. \ + Sounds or textures may be missing.", + if assets == 1 { "" } else { "s" }, + ), + (assets, libraries) => format!( + "{assets} asset(s) and {libraries} librar{} could not be downloaded after \ + several attempts. The game may fail to start or be missing content.", + if libraries == 1 { "y" } else { "ies" }, + ), + }; + + let answer = ctx + .events + .ask( + Prompt::new("Some files could not be downloaded", body) + .option( + Choice::new("launch", "Launch anyway"), + IncompleteInstall::LaunchAnyway, + ) + .dismiss("Cancel"), + ) + .await; + + match answer { + Ok(Some(chosen)) => match chosen.value { + IncompleteInstall::LaunchAnyway => { + tracing::warn!(failed, "user chose to launch with files missing"); + Ok(()) + } + }, + // Dismissed or undeliverable nobody agreed to launch a broken install + // and a headless caller must not silently opt in + Ok(None) => Err(McError::IncompleteInstallCancelled { failed }), + Err(err) => { + tracing::warn!("could not ask about the incomplete install: {err}"); + Err(McError::IncompleteInstallCancelled { failed }) + } + } +} + +#[tracing::instrument(skip(ctx, progress), level = "debug")] +pub async fn download_version_info( + ctx: &McCtx, + progress: Option<&GroupedProgressSession>, + version: &Version, + loader: Option<&LoaderVersion>, + force: bool, +) -> McResult { + let version_id = loader + .map(|it| format!("{}-{}", version.id, it.id)) + .unwrap_or_else(|| version.id.clone()); + + let path = paths::versions_dir()? + .join(&version_id) + .join(format!("{version_id}.json")); + + let result = if path.exists() && !force { + let data = polyio::read(&path).await?; + serde_json::from_slice(&data)? + } else { + tracing::debug!( + version_id = %version_id, + "downloading Minecraft version metadata" + ); + + let version_url = version.url.parse().map_err(McError::Url)?; + let requester = ctx.net.clone(); + let mut info: VersionInfo = match progress { + Some(progress) => { + progress + .run_child( + format!("Version metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::(result) + } + }, + ) + .await? + } + None => requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?, + }; + + if let Some(loader) = loader { + let loader_url = loader.url.parse().map_err(McError::Url)?; + let requester = ctx.net.clone(); + let partial: interfrost::api::modded::PartialVersionInfo = match progress { + Some(progress) => { + progress + .run_child( + format!("Loader metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, loader_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::( + result, + ) + } + }, + ) + .await? + } + None => requester + .send_json(Method::GET, loader_url, None, &[]) + .await + .map_err(McError::from)?, + }; + + info = interfrost::api::modded::merge_partial_version(partial, info); + + for lib in &mut info.libraries { + lib.name = lib.name.replace("${interpulse.gameVersion}", &version.id); + } + } + + info.id.clone_from(&version_id); + + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + + polyio::write(&path, &serde_json::to_vec(&info)?).await?; + + info + }; + + Ok(result) +} + +#[tracing::instrument(skip_all, level = "debug")] +pub async fn download_assets_index( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: &VersionInfo, + force: bool, +) -> McResult { + let path = paths::assets_index_dir()?.join(format!("{}.json", version.asset_index.id)); + + if path.exists() && !force { + if let Ok(data) = polyio::read_json::(&path).await { + return Ok(data); + } + tracing::warn!("cached assets index is invalid, redownloading"); + } + + download_to_path( + &ctx.net, + &ctx.events, + progress, + format!("Assets index ({})", version.asset_index.id), + TaskCategory::Assets, + u64::from(version.asset_index.size), + &version.asset_index.url, + &path, + Some(version.asset_index.sha1.as_str()), + ) + .await?; + + polyio::read_json(&path).await.map_err(Into::into) +} + +#[tracing::instrument(skip_all, level = "debug")] +pub async fn download_assets( + ctx: &McCtx, + progress: &GroupedProgressSession, + legacy: bool, + assets: Vec<(String, Asset)>, +) -> McResult { + if assets.is_empty() { + return Ok(0); + } + + let dir = if legacy { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + + polyio::create_dir_all(&dir).await?; + + // Objects are sharded into 256 fixed subdirectories creating them once + // here saves a blocking-pool round trip on every one of the ~5000 files + if !legacy { + let dir = dir.clone(); + tokio::task::spawn_blocking(move || { + for subhash in 0u16..256 { + let _ = std::fs::create_dir_all(dir.join(format!("{subhash:02x}"))); + } + }) + .await + .map_err(std::io::Error::other)?; + } + + let started = std::time::Instant::now(); + let count = assets.len(); + let requester = ctx.net.clone(); + let events = ctx.events.clone(); + let progress = progress.clone(); + let requests = stream::iter(assets.into_iter().map(|(name, asset)| { + let dir = dir.clone(); + let requester = requester.clone(); + let events = events.clone(); + let progress = progress.clone(); + + async move { + let hash = &asset.hash; + let subhash = &hash[0..2]; + let path = asset_object_path(&dir, &name, hash, legacy); + + let url = format!("https://resources.download.minecraft.net/{subhash}/{hash}"); + download_to_path( + &requester, + &events, + &progress, + format!("Asset {name}"), + TaskCategory::Assets, + u64::from(asset.size), + &url, + &path, + Some(hash), + ) + .await + } + })) + .buffer_unordered(ASSET_DOWNLOAD_CONCURRENCY) + .collect::>(); + + let mut failed = 0; + for res in requests.await { + if let Err(err) = res { + tracing::error!("failed to download asset: {err:?}"); + failed += 1; + } + } + + tracing::info!( + count, + failed, + elapsed_ms = started.elapsed().as_millis() as u64, + "assets ready" + ); + + Ok(failed) +} + +#[tracing::instrument(skip(ctx, progress, version), fields(version_id = %version.id, force), level = "debug")] +pub async fn download_client( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: &VersionInfo, + force: bool, +) -> McResult { + let client = version + .downloads + .get(&DownloadType::Client) + .ok_or_else(|| McError::NoClientDownload(version.id.clone()))?; + + let path = paths::versions_dir()? + .join(&version.id) + .join(format!("{}.jar", version.id)); + + // Size not hash the jar is only published by rename after its hash + // matched so a wrong-but-right-length jar is unreachable + let expected_size = u64::from(client.size); + if !force + && let Ok(meta) = polyio::stat(&path).await + && (expected_size == 0 || meta.len() == expected_size) + { + return Ok(path); + } + + if path.exists() { + tracing::warn!("client jar is the wrong size, redownloading"); + } + + download_to_path( + &ctx.net, + &ctx.events, + progress, + format!("Client {}", version.id), + TaskCategory::Client, + u64::from(client.size), + &client.url, + &path, + Some(&client.sha1), + ) + .await?; + Ok(path) +} + +#[tracing::instrument(skip(version_info), level = "debug")] +pub fn libraries_missing( + version_info: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let lib_dir = paths::libraries_dir()?; + for lib in &version_info.libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, java_arch, minecraft_updated) + { + continue; + } + if !lib.include_in_classpath { + continue; + } + let Ok(rel) = interfrost::utils::get_path_from_artifact(&lib.name) else { + continue; + }; + if !matches_expected_size(&lib_dir.join(&rel), library_artifact_size(lib)) { + tracing::warn!(library = %lib.name, "missing or truncated classpath library; will repair"); + return Ok(true); + } + } + Ok(false) +} + +/// Takes its paths rather than resolving them so it can be tested without the +/// process-global launcher directory +fn assets_tree_missing(index: &Path, objects: &Path) -> bool { + if !index.is_file() { + tracing::warn!(path = %index.display(), "assets index is missing; will repair"); + return true; + } + + if !objects.is_dir() { + tracing::warn!(path = %objects.display(), "assets directory is missing; will repair"); + return true; + } + + false +} + +/// A `Ready` cluster skips preparation so nothing else re-checks its files +/// this turns a deleted assets tree into an automatic repair +/// Coarse checks only per-object verification belongs to `download_minecraft`'s plan +#[tracing::instrument(skip(version_info), level = "debug")] +pub fn game_files_missing( + version_info: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let index = paths::assets_index_dir()?.join(format!("{}.json", version_info.asset_index.id)); + let objects = if version_info.assets == "legacy" { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + + if assets_tree_missing(&index, &objects) { + return Ok(true); + } + + if let Some(client) = version_info.downloads.get(&DownloadType::Client) { + let jar = paths::versions_dir()? + .join(&version_info.id) + .join(format!("{}.jar", version_info.id)); + if !matches_expected_size(&jar, u64::from(client.size)) { + tracing::warn!(path = %jar.display(), "client jar is missing or truncated; will repair"); + return Ok(true); + } + } + + libraries_missing(version_info, java_arch, minecraft_updated) +} + +fn lib_short(name: &str) -> String { + let parts: Vec<&str> = name.split(':').collect(); + match parts.as_slice() { + [_group, artifact, version, ..] => format!("{artifact} {version}"), + _ => name.to_string(), + } +} + +#[tracing::instrument(skip(ctx, progress, libraries), level = "debug")] +pub async fn download_libraries( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: String, + libraries: Vec, + java_arch: &str, +) -> McResult { + if libraries.is_empty() { + return Ok(0); + } + + let lib_dir = paths::libraries_dir()?; + let natives_dest = paths::natives_dir()?.join(&version); + let java_arch = java_arch.to_string(); + + polyio::create_dir_all(&lib_dir).await?; + polyio::create_dir_all(&natives_dest).await?; + + let started = std::time::Instant::now(); + let count = libraries.len(); + let requests = stream::iter(libraries.into_iter().map(|lib| { + let lib_dir = lib_dir.clone(); + let natives_dest = natives_dest.clone(); + let java_arch = java_arch.clone(); + let requester = ctx.net.clone(); + let events = ctx.events.clone(); + let progress = progress.clone(); + + async move { + // Rules and the on-disk check already ran in `plan_downloads` + let artifact_path = interfrost::utils::get_path_from_artifact(&lib.name) + .map_err(|_| McError::LibraryPath(lib.name.clone()))?; + let path = lib_dir.join(&artifact_path); + + tokio::try_join!( + async { + if let Some(interfrost::api::minecraft::LibraryDownloads { + artifact: Some(ref artifact), + .. + }) = lib.downloads + && !artifact.url.is_empty() + { + download_to_path( + &requester, + &events, + &progress, + format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + u64::from(artifact.size), + &artifact.url, + &path, + Some(&artifact.sha1), + ) + .await?; + return Ok::<_, McError>(()); + } + + let url = [ + lib.url + .as_deref() + .unwrap_or("https://libraries.minecraft.net/"), + &artifact_path, + ] + .concat(); + download_to_path( + &requester, + &events, + &progress, + format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + 0, + &url, + &path, + None, + ) + .await?; + Ok(()) + }, + async { + if let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| { + Some(( + natives.get(&Os::native_arch(&java_arch))?, + lib.downloads.as_ref()?.classifiers.as_ref()?, + )) + }) { + let parsed = + os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); + if let Some(native) = classifiers.get(&parsed) { + let data = fetch_bytes_verified( + &requester, + &events, + &progress, + format!("Natives {}", lib_short(&lib.name)), + TaskCategory::Natives, + u64::from(native.size), + &native.url, + &native.sha1, + ) + .await?; + + let extract = progress.child( + format!("Natives {}", lib_short(&lib.name)), + 1, + TaskCategory::Natives, + ); + extract.set_phase(oneclient_events::TaskPhase::Extracting); + polyio::unzip_bytes_filtered( + data, + Some(|name: &str| !name.starts_with("META-INF")), + &natives_dest, + ) + .await?; + extract.finish(); + } + } + + Ok(()) + } + )?; + + Ok::<(), McError>(()) + } + })) + .buffer_unordered(LIBRARY_DOWNLOAD_CONCURRENCY) + .collect::>(); + + let mut failed = 0; + for res in requests.await { + if let Err(err) = res { + tracing::error!("failed to download library: {err:?}"); + failed += 1; + } + } + + tracing::info!( + count, + failed, + elapsed_ms = started.elapsed().as_millis() as u64, + "libraries ready" + ); + + Ok(failed) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_loader_versions( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, + loader: GameLoader, +) -> McResult> { + if loader == GameLoader::Vanilla { + return Ok(Vec::new()); + } + + let manifest = metadata.get_modded_or_fetch(ctx, loader).await?; + for entry in &manifest.game_versions { + let id = entry + .id + .replace("${interpulse.gameVersion}", mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version); + if id == mc_version { + return Ok(entry.loaders.iter().map(|l| l.id.clone()).collect()); + } + } + Ok(Vec::new()) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_loader_version( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, + loader: GameLoader, + loader_version: Option<&str>, +) -> McResult> { + if loader == GameLoader::Vanilla { + return Ok(None); + } + + let resolve_from_manifest = |manifest: &interfrost::api::modded::Manifest| { + let mut saw_matching_game_version = false; + + for entry in &manifest.game_versions { + if entry + .id + .replace("${interpulse.gameVersion}", mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) + != mc_version + { + continue; + } + + saw_matching_game_version = true; + + if let Some(requested) = loader_version { + if let Some(found) = entry + .loaders + .iter() + .find(|loader_entry| loader_entry.id == requested) + { + return (saw_matching_game_version, Some(found.clone())); + } + continue; + } + + if let Some(found) = entry + .loaders + .iter() + .find(|l| l.stable) + .or_else(|| entry.loaders.first()) + { + return (saw_matching_game_version, Some(found.clone())); + } + } + + (saw_matching_game_version, None) + }; + + let mut manifest = metadata.get_modded_or_fetch(ctx, loader).await?; + let (mut saw_matching, mut resolved) = resolve_from_manifest(manifest); + if resolved.is_some() { + return Ok(resolved); + } + + if !saw_matching || loader_version.is_some() { + metadata.fetch_all(ctx).await; + manifest = metadata.get_modded(loader)?; + (saw_matching, resolved) = resolve_from_manifest(manifest); + if resolved.is_some() { + return Ok(resolved); + } + } + + if let Some(requested) = loader_version { + if !saw_matching { + return Err(McError::NoMatchingVersion); + } + return Err(McError::RequestedLoaderVersionNotFound { + requested: requested.to_string(), + }); + } + + if saw_matching { + Err(McError::NoMatchingLoader) + } else { + Err(McError::NoMatchingVersion) + } +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn resolve_minecraft_version( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, +) -> McResult<(Version, usize, bool)> { + let mut manifest = metadata.get_vanilla_or_fetch(ctx).await?; + let mut version_index = manifest.versions.iter().position(|it| it.id == mc_version); + + if version_index.is_none() { + metadata.fetch_all(ctx).await; + manifest = metadata.get_vanilla()?; + version_index = manifest.versions.iter().position(|it| it.id == mc_version); + } + + let version_index = version_index.ok_or(McError::NoMatchingVersion)?; + let versions = &manifest.versions; + + Ok(( + versions[version_index].clone(), + version_index, + is_version_updated(version_index, versions), + )) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_game_versions( + metadata: &mut MetadataStore, + ctx: &McCtx, +) -> McResult> { + let manifest = metadata.get_vanilla_or_fetch(ctx).await?; + Ok(manifest.versions.clone()) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_loaders_for_version( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, +) -> McResult> { + metadata.get_loaders_for_version(ctx, mc_version).await +} + +#[must_use] +pub fn is_version_updated(version_index: usize, versions: &[Version]) -> bool { + version_index <= versions.iter().position(|x| x.id == "22w16a").unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("oneclient-install-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn install_assets(dir: &Path) -> (PathBuf, PathBuf) { + let indexes = dir.join("assets").join("indexes"); + let objects = dir.join("assets").join("objects"); + std::fs::create_dir_all(&indexes).unwrap(); + std::fs::create_dir_all(&objects).unwrap(); + + let index = indexes.join("17.json"); + std::fs::write(&index, b"{}").unwrap(); + + (index, objects) + } + + #[test] + fn a_complete_assets_tree_needs_no_repair() { + let dir = scratch("assets-ok"); + let (index, objects) = install_assets(&dir); + + assert!(!assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_deleted_assets_directory_asks_for_a_repair() { + let dir = scratch("assets-deleted"); + let (index, objects) = install_assets(&dir); + std::fs::remove_dir_all(dir.join("assets")).unwrap(); + + assert!(assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_deleted_assets_index_asks_for_a_repair() { + let dir = scratch("assets-no-index"); + let (index, objects) = install_assets(&dir); + std::fs::remove_file(&index).unwrap(); + + assert!(assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn an_index_that_is_a_directory_is_not_mistaken_for_a_file() { + // The launcher has produced this shape before so the check must be + // `is_file` not `exists` + let dir = scratch("assets-index-dir"); + let (index, objects) = install_assets(&dir); + std::fs::remove_file(&index).unwrap(); + std::fs::create_dir(&index).unwrap(); + + assert!(assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn size_check_accepts_a_complete_file() { + let dir = scratch("size-ok"); + let file = dir.join("object.bin"); + std::fs::write(&file, b"0123456789").unwrap(); + + assert!(matches_expected_size(&file, 10)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn size_check_rejects_a_truncated_file() { + let dir = scratch("size-short"); + let file = dir.join("object.bin"); + std::fs::write(&file, b"0123").unwrap(); + + assert!(!matches_expected_size(&file, 10)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn size_check_rejects_a_missing_file() { + let dir = scratch("size-missing"); + + assert!(!matches_expected_size(&dir.join("nope.bin"), 10)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// The bus is drained by the test so `ask` has somebody to talk to no + /// request is ever sent through the client + fn test_ctx() -> ( + McCtx, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (events, rx) = oneclient_events::EventBus::channel(); + let net = oneclient_net::RequestClient::new(oneclient_net::NetConfig::default()) + .expect("a client"); + (McCtx::new(net, events), rx) + } + + #[tokio::test] + async fn a_complete_install_asks_nothing() { + let (ctx, mut rx) = test_ctx(); + + confirm_incomplete_install(&ctx, 0, 0) + .await + .expect("nothing failed, so nothing to confirm"); + + assert!( + rx.try_recv().is_err(), + "a clean install must not interrupt the user" + ); + } + + #[tokio::test] + async fn launching_anyway_is_allowed() { + let (ctx, mut rx) = test_ctx(); + + let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 3, 0).await }); + + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await + else { + panic!("expected a prompt"); + }; + assert!(request.body.contains("3 game assets"), "{}", request.body); + request + .reply + .send(Some(oneclient_events::Answer::new("launch"))) + .unwrap(); + + asking.await.unwrap().expect("the user opted in"); + } + + #[tokio::test] + async fn dismissing_the_prompt_cancels_the_launch() { + let (ctx, mut rx) = test_ctx(); + + let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 0, 2).await }); + + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await + else { + panic!("expected a prompt"); + }; + assert!(request.body.contains("fail to start"), "{}", request.body); + request.reply.send(None).unwrap(); + + let err = asking.await.unwrap().expect_err("dismissal must cancel"); + assert!( + matches!(err, McError::IncompleteInstallCancelled { failed: 2 }), + "{err:?}" + ); + } + + #[tokio::test] + async fn a_bus_with_nobody_listening_cancels_rather_than_launching() { + let (ctx, rx) = test_ctx(); + drop(rx); + + let err = confirm_incomplete_install(&ctx, 1, 1) + .await + .expect_err("an unanswerable prompt must not launch the game"); + assert!( + matches!(err, McError::IncompleteInstallCancelled { failed: 2 }), + "{err:?}" + ); + } + + #[test] + fn size_check_falls_back_to_presence_without_a_manifest_size() { + let dir = scratch("size-unknown"); + let file = dir.join("object.bin"); + std::fs::write(&file, b"anything").unwrap(); + + // Maven-coordinate libraries carry no artifact metadata + assert!(matches_expected_size(&file, 0)); + assert!(!matches_expected_size(&dir.join("nope.bin"), 0)); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/packages/oneclient_mc/src/lib.rs b/packages/oneclient_mc/src/lib.rs new file mode 100644 index 00000000..25ac2f1e --- /dev/null +++ b/packages/oneclient_mc/src/lib.rs @@ -0,0 +1,37 @@ +//! No database here this crate works against paths so the download planner is +//! testable on its own + +use oneclient_events::EventBus; +use oneclient_net::RequestClient; + +#[derive(Clone)] +pub struct McCtx { + pub net: RequestClient, + pub events: EventBus, +} + +impl McCtx { + #[must_use] + pub fn new(net: RequestClient, events: EventBus) -> Self { + Self { net, events } + } +} + +mod arguments; +mod download; +mod error; +mod install; +mod manifest; +mod profile; +mod rules; + +pub use arguments::*; +pub use download::{download_to_path, fetch_bytes_verified}; +pub use error::{McError, McResult}; +pub use install::*; +pub use manifest::MetadataStore; +pub use profile::{ + MojangCape, MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, + SkinVariant, fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, +}; +pub use rules::validate_rules; diff --git a/packages/oneclient_mc/src/manifest.rs b/packages/oneclient_mc/src/manifest.rs new file mode 100644 index 00000000..20a7d453 --- /dev/null +++ b/packages/oneclient_mc/src/manifest.rs @@ -0,0 +1,264 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use interfrost::api::minecraft::VersionManifest as VanillaManifest; +use interfrost::api::modded::Manifest as ModdedManifest; +use reqwest::Method; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::McCtx; +use crate::error::{McError, McResult}; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +#[derive(Debug, Default)] +pub struct MetadataStore { + initialized: bool, + inner: MetadataInner, + version_loader_cache: HashMap>, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct MetadataInner { + minecraft: Option, + forge: Option, + neo: Option, + fabric: Option, + quilt: Option, + legacyfabric: Option, +} + +impl MetadataStore { + #[must_use] + pub const fn initialized(&self) -> bool { + self.initialized + } + + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_vanilla_or_fetch(&mut self, ctx: &McCtx) -> McResult<&VanillaManifest> { + if !self.initialized() { + self.initialize(ctx).await?; + } + + self.get_vanilla() + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_modded_or_fetch( + &mut self, + ctx: &McCtx, + loader: GameLoader, + ) -> McResult<&ModdedManifest> { + if !loader.is_modded() { + return Err(McError::NotModdedManifest(loader)); + } + + if !self.initialized() { + self.initialize(ctx).await?; + } + + self.get_modded(loader) + } + + pub fn get_vanilla(&self) -> McResult<&VanillaManifest> { + self.inner + .minecraft + .as_ref() + .ok_or_else(|| McError::FetchError) + } + + pub fn get_modded(&self, loader: GameLoader) -> McResult<&ModdedManifest> { + if !loader.is_modded() { + return Err(McError::NotModdedManifest(loader)); + } + + match loader { + GameLoader::Forge => self.inner.forge.as_ref(), + GameLoader::NeoForge => self.inner.neo.as_ref(), + GameLoader::Fabric => self.inner.fabric.as_ref(), + GameLoader::Quilt => self.inner.quilt.as_ref(), + GameLoader::LegacyFabric => self.inner.legacyfabric.as_ref(), + GameLoader::Vanilla => None, + } + .ok_or_else(|| McError::FetchError) + } + + #[tracing::instrument(skip_all)] + pub async fn initialize(&mut self, ctx: &McCtx) -> McResult<()> { + let path = paths::caches_dir()?.join("metadata.json"); + let mut save_file = false; + let mut metadata = Self::default(); + + if let Ok(bytes) = polyio::read(&path).await { + if let Ok(inner) = serde_json::from_slice::(&bytes) { + metadata.inner = inner; + + if metadata.refetch_errored(ctx).await > 0 { + save_file = true; + } + } else { + metadata.fetch_all(ctx).await; + save_file = true; + } + } else { + metadata.fetch_all(ctx).await; + save_file = true; + } + + if save_file { + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::write(&path, &serde_json::to_vec(&metadata.inner)?).await?; + } + + *self = metadata; + self.initialized = true; + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn refetch_errored(&mut self, ctx: &McCtx) -> u8 { + let mut changed: u8 = 0; + + if self.inner.minecraft.is_none() + && let Ok(data) = fetch_vanilla_manifest(ctx).await + { + self.inner.minecraft = Some(data); + changed += 1; + } + + macro_rules! check_modded { + ($var:ident) => { + if self.inner.$var.is_none() { + if let Ok(loader) = GameLoader::from_str(stringify!($var)) { + match fetch_modded_manifest(ctx, loader).await { + Ok(data) => { + self.inner.$var = Some(data); + changed += 1; + } + Err(err) => { + tracing::error!("failed to fetch manifest for {}: {}", loader, err); + } + } + } + } + }; + } + + check_modded!(forge); + check_modded!(neo); + check_modded!(fabric); + check_modded!(quilt); + + changed + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn fetch_all(&mut self, ctx: &McCtx) { + let (minecraft, forge, neo, fabric, quilt) = tokio::join!( + fetch_vanilla_manifest(ctx), + fetch_modded_manifest(ctx, GameLoader::Forge), + fetch_modded_manifest(ctx, GameLoader::NeoForge), + fetch_modded_manifest(ctx, GameLoader::Fabric), + fetch_modded_manifest(ctx, GameLoader::Quilt), + ); + + self.inner.minecraft = minecraft.ok(); + self.inner.forge = forge.ok(); + self.inner.neo = neo.ok(); + self.inner.fabric = fabric.ok(); + self.inner.quilt = quilt.ok(); + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_loaders_for_version( + &mut self, + ctx: &McCtx, + mc_version: &str, + ) -> McResult> { + if !self.initialized() { + self.initialize(ctx).await?; + } + + if let Some(hit) = self.version_loader_cache.get(mc_version) { + return Ok(hit.clone()); + } + + let mut loaders = Vec::new(); + for loader in GameLoader::modded_loaders() { + let manifest = match self.get_modded(*loader) { + Ok(manifest) => manifest, + Err(McError::NotModdedManifest(_)) => continue, + Err(e) => return Err(e), + }; + + let found = manifest.game_versions.iter().any(|entry| { + entry + .id + .replace("${interpulse.gameVersion}", mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) + == mc_version + }); + + if found { + loaders.push(*loader); + } + } + + self.version_loader_cache + .insert(mc_version.to_owned(), loaders.clone()); + + Ok(loaders) + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { + match fetch_manifest::(ctx, GameLoader::Vanilla).await { + Ok(manifest) => Ok(manifest), + Err(err) => { + tracing::warn!( + "failed to fetch vanilla manifest from metadata mirror: {err}; falling back to Mojang" + ); + + { + let url = interfrost::api::minecraft::VERSION_MANIFEST_URL + .parse() + .map_err(McError::Url)?; + ctx.net + .send_json(Method::GET, url, None, &[]) + .await + .map_err(McError::from) + } + } + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn fetch_modded_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { + fetch_manifest(ctx, loader).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn fetch_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { + let url = format!( + "{}/{}/v{}/manifest.json", + ctx.net.config().metadata_api_url, + loader.get_format_name(), + loader.get_format_version() + ); + + let parsed = url.parse().map_err(McError::Url)?; + ctx.net + .send_json(Method::GET, parsed, None, &[]) + .await + .map_err(McError::from) +} diff --git a/packages/oneclient_mc/src/profile.rs b/packages/oneclient_mc/src/profile.rs new file mode 100644 index 00000000..4e69b320 --- /dev/null +++ b/packages/oneclient_mc/src/profile.rs @@ -0,0 +1,429 @@ +use base64::Engine; +use chrono::{DateTime, TimeDelta, Utc}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::error::{McError, McResult}; +use oneclient_net::RequestClient; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangPlayerProfile { + pub uuid: String, + pub username: String, + pub is_slim: bool, + pub skin_url: Option, + pub cape_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct PlayerProfileView { + pub uuid: String, + pub username: String, + pub is_slim: bool, + pub skin_url: Option, + pub cape_url: Option, + pub skins: Vec, + pub capes: Vec, +} + +impl PlayerProfileView { + pub fn placeholder() -> Self { + Self { + uuid: "00000000-0000-0000-0000-000000000000".into(), + username: "Player".into(), + is_slim: false, + skin_url: None, + cape_url: None, + skins: Vec::new(), + capes: Vec::new(), + } + } +} + +impl From for PlayerProfileView { + fn from(profile: MojangPlayerProfile) -> Self { + Self { + uuid: profile.uuid, + username: profile.username, + is_slim: profile.is_slim, + skin_url: profile.skin_url, + cape_url: profile.cape_url, + skins: Vec::new(), + capes: Vec::new(), + } + } +} + +impl From for PlayerProfileView { + fn from(profile: MojangFullPlayerProfile) -> Self { + let active_skin = profile + .skins + .iter() + .find(|skin| skin.state.eq_ignore_ascii_case("ACTIVE")); + let active_cape = profile + .capes + .iter() + .find(|cape| cape.state.eq_ignore_ascii_case("ACTIVE")); + + Self { + uuid: profile.id, + username: profile.username, + is_slim: active_skin.is_some_and(|skin| skin.variant == SkinVariant::Slim), + skin_url: active_skin.map(|skin| skin.url.clone()), + cape_url: active_cape.map(|cape| cape.url.clone()), + skins: profile.skins, + capes: profile.capes, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SkinVariant { + #[serde(alias = "CLASSIC")] + Classic, + #[serde(alias = "SLIM")] + Slim, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangFullPlayerProfile { + pub id: String, + #[serde(alias = "name")] + pub username: String, + pub skins: Vec, + pub capes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangSkin { + pub id: String, + pub state: String, + pub url: String, + pub variant: SkinVariant, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangCape { + pub id: String, + pub state: String, + pub url: String, + pub alias: String, +} + +fn normalize_texture_url(url: &str) -> String { + url.replace( + "http://textures.minecraft.net", + "https://textures.minecraft.net", + ) +} + +fn normalize_uuid(uuid: &str) -> &str { + uuid.trim() + .trim_matches(|c: char| c == '-' || c.is_whitespace()) +} + +fn auth_headers(access_token: &str) -> [(&'static str, String); 1] { + [("Authorization", format!("Bearer {access_token}"))] +} + +#[tracing::instrument(level = "debug", skip(client))] +pub async fn fetch_player_profile( + client: &RequestClient, + uuid: &str, +) -> McResult { + #[derive(Deserialize)] + struct Properties { + name: String, + value: String, + } + + #[derive(Deserialize)] + struct Response { + id: String, + name: String, + properties: Vec, + } + + #[derive(Default, Deserialize)] + #[serde(default)] + struct TextureMetadata { + model: Option, + } + + #[derive(Deserialize)] + struct Texture { + url: String, + #[serde(default)] + metadata: TextureMetadata, + } + + #[derive(Deserialize)] + #[serde(rename_all = "UPPERCASE")] + struct Textures { + skin: Option, + cape: Option, + } + + #[derive(Deserialize)] + struct DecodedProperties { + textures: Textures, + } + + let uuid = normalize_uuid(uuid); + let url = format!("https://sessionserver.mojang.com/session/minecraft/profile/{uuid}") + .parse() + .map_err(McError::Url)?; + + let response = client + .send_as::(reqwest::Request::new(Method::GET, url)) + .await?; + + let texture_property = response + .properties + .iter() + .find(|p| p.name == "textures") + .ok_or_else(|| McError::Minecraft("no textures in player profile".into()))?; + + let decoded = base64::prelude::BASE64_STANDARD + .decode(texture_property.value.as_bytes()) + .map_err(|_| McError::Minecraft("failed to decode profile texture property".into()))?; + + let decoded = serde_json::from_slice::(&decoded) + .map_err(McError::ParseError)? + .textures; + + let is_slim = decoded + .skin + .as_ref() + .is_some_and(|s| s.metadata.model.as_deref() == Some("slim")); + + Ok(MojangPlayerProfile { + uuid: response.id, + username: response.name, + is_slim, + skin_url: decoded.skin.as_ref().map(|s| normalize_texture_url(&s.url)), + cape_url: decoded.cape.as_ref().map(|c| normalize_texture_url(&c.url)), + }) +} + +#[tracing::instrument(level = "debug", skip(client, access_token))] +pub async fn fetch_logged_in_profile( + client: &RequestClient, + access_token: &str, +) -> McResult { + let url = "https://api.minecraftservices.com/minecraft/profile" + .parse() + .map_err(McError::Url)?; + + let headers = auth_headers(access_token); + let header_refs: Vec<(&str, &str)> = headers + .iter() + .map(|(name, value)| (*name, value.as_str())) + .collect(); + + let mut profile = client + .send_json::(Method::GET, url, None, &header_refs) + .await?; + + for skin in &mut profile.skins { + skin.url = normalize_texture_url(&skin.url); + } + for cape in &mut profile.capes { + cape.url = normalize_texture_url(&cape.url); + } + + Ok(profile) +} + +/// Served without revalidating so cold start doesn't wait on a Mojang round +/// trip before the first avatar paints +const PROFILE_CACHE_TTL_HOURS: i64 = 6; + +#[derive(Debug, Serialize, Deserialize)] +struct CachedProfile { + fetched_at: DateTime, + view: PlayerProfileView, +} + +impl CachedProfile { + fn is_fresh(&self) -> bool { + Utc::now() - self.fetched_at < TimeDelta::hours(PROFILE_CACHE_TTL_HOURS) + } +} + +async fn fetch_profile_view( + client: &RequestClient, + uuid: &str, + access_token: Option<&str>, +) -> McResult { + if let Some(token) = access_token.filter(|t| !t.is_empty()) { + let full = fetch_logged_in_profile(client, token).await?; + return Ok(PlayerProfileView::from(full)); + } + let summary = fetch_player_profile(client, uuid).await?; + Ok(PlayerProfileView::from(summary)) +} + +#[tracing::instrument(level = "debug", skip(client, access_token))] +pub async fn fetch_player_profile_view( + client: &RequestClient, + uuid: &str, + access_token: Option<&str>, +) -> McResult { + let authenticated = access_token.is_some_and(|t| !t.is_empty()); + + if !authenticated && let Some(cached) = read_cached_profile(uuid).await { + if cached.is_fresh() { + return Ok(cached.view); + } + + return match fetch_profile_view(client, uuid, access_token).await { + Ok(view) => { + write_cached_profile(uuid, &view).await; + Ok(view) + } + Err(err) => { + tracing::warn!("serving stale cached profile for {uuid}: {err}"); + Ok(cached.view) + } + }; + } + + match fetch_profile_view(client, uuid, access_token).await { + Ok(view) => { + if !authenticated { + write_cached_profile(uuid, &view).await; + } + Ok(view) + } + Err(err) => match read_cached_profile(uuid).await { + Some(cached) => { + tracing::warn!("using cached player profile for {uuid}: {err}"); + Ok(cached.view) + } + None => Err(err), + }, + } +} + +fn profile_cache_path(uuid: &str) -> Option { + let key = polyio::sha1_bytes(normalize_uuid(uuid).as_bytes()); + Some( + oneclient_common::paths::profiles_cache_dir() + .ok()? + .join(format!("{key}.json")), + ) +} + +async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { + let Some(path) = profile_cache_path(uuid) else { + return; + }; + let entry = CachedProfile { + fetched_at: Utc::now(), + view: view.clone(), + }; + let Ok(bytes) = serde_json::to_vec(&entry) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = polyio::create_dir_all(parent).await; + } + if let Err(err) = polyio::write(&path, &bytes).await { + tracing::warn!("failed to cache player profile for {uuid}: {err}"); + } +} + +async fn read_cached_profile(uuid: &str) -> Option { + let path = profile_cache_path(uuid)?; + let bytes = polyio::read(&path).await.ok()?; + + if let Ok(entry) = serde_json::from_slice::(&bytes) { + return Some(entry); + } + + let view = serde_json::from_slice::(&bytes).ok()?; + Some(CachedProfile { + fetched_at: DateTime::UNIX_EPOCH, + view, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_launcher_dir() { + let dir = + std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); + oneclient_common::paths::set_launcher_dir(dir); + } + + fn sample_view(username: &str) -> PlayerProfileView { + PlayerProfileView { + uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5".into(), + username: username.into(), + is_slim: true, + skin_url: Some("https://textures.minecraft.net/texture/abc".into()), + cape_url: None, + skins: Vec::new(), + capes: Vec::new(), + } + } + + #[tokio::test] + async fn fresh_cache_entry_is_served_without_network() { + temp_launcher_dir(); + let uuid = uuid::Uuid::new_v4().to_string(); + let view = sample_view("Cached"); + write_cached_profile(&uuid, &view).await; + + let client = RequestClient::new(oneclient_net::NetConfig::default()).unwrap(); + let resolved = fetch_player_profile_view(&client, &uuid, None) + .await + .expect("fresh cache entry should resolve offline"); + + assert_eq!(resolved, view); + } + + #[tokio::test] + async fn entry_past_ttl_is_not_fresh() { + let stale = CachedProfile { + fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS + 1), + view: sample_view("Stale"), + }; + assert!(!stale.is_fresh()); + + let recent = CachedProfile { + fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS - 1), + view: sample_view("Recent"), + }; + assert!(recent.is_fresh()); + } + + #[tokio::test] + async fn legacy_cache_entry_loads_and_revalidates() { + temp_launcher_dir(); + let uuid = uuid::Uuid::new_v4().to_string(); + let view = sample_view("Legacy"); + + let path = profile_cache_path(&uuid).unwrap(); + polyio::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + polyio::write(&path, &serde_json::to_vec(&view).unwrap()) + .await + .unwrap(); + + let cached = read_cached_profile(&uuid) + .await + .expect("legacy entry should still parse"); + + assert_eq!(cached.view, view); + assert!( + !cached.is_fresh(), + "legacy entry should be due for revalidation" + ); + } +} diff --git a/packages/oneclient_mc/src/rules.rs b/packages/oneclient_mc/src/rules.rs new file mode 100644 index 00000000..323200c5 --- /dev/null +++ b/packages/oneclient_mc/src/rules.rs @@ -0,0 +1,69 @@ +use interfrost::api::minecraft::{Os, OsRule, Rule, RuleAction}; + +use oneclient_common::os_ext::OsExt; + +#[must_use] +pub fn validate_rules(rules: &[Rule], java_arch: &str, updated: bool) -> bool { + let mut rule = rules + .iter() + .map(|r| validate_rule(r, java_arch, updated)) + .collect::>>(); + + if rules + .iter() + .all(|r| matches!(r.action, RuleAction::Disallow)) + { + rule.push(Some(true)); + } + + !(rule.iter().any(|r| r == &Some(false)) || rule.iter().all(Option::is_none)) +} + +#[must_use] +pub fn validate_rule(rule: &Rule, java_arch: &str, updated: bool) -> Option { + let result = match rule { + Rule { os: Some(os), .. } => validate_os_rule(os, java_arch, updated), + Rule { + features: Some(features), + .. + } => { + !features.is_demo_user.unwrap_or(true) + || features.has_custom_resolution.unwrap_or(false) + || !features.has_quick_plays_support.unwrap_or(true) + || !features.is_quick_play_multiplayer.unwrap_or(true) + || !features.is_quick_play_realms.unwrap_or(true) + || !features.is_quick_play_singleplayer.unwrap_or(true) + } + _ => return Some(true), + }; + + match rule.action { + RuleAction::Allow => Some(result), + RuleAction::Disallow => { + if result { + Some(false) + } else { + None + } + } + } +} + +#[must_use] +pub fn validate_os_rule(rule: &OsRule, java_arch: &str, updated: bool) -> bool { + let mut rule_match = true; + + if let Some(ref arch) = rule.arch { + rule_match &= !matches!(arch.as_str(), "x86" | "arm"); + } + + if let Some(name) = &rule.name { + if updated && (name != &Os::LinuxArm64 || name != &Os::LinuxArm32) { + rule_match &= Os::native() == name.get_os() || &Os::native_arch(java_arch) == name; + } else { + rule_match &= &Os::native_arch(java_arch) == name; + } + } + + rule_match +} diff --git a/packages/oneclient_net/Cargo.toml b/packages/oneclient_net/Cargo.toml new file mode 100644 index 00000000..50dd5502 --- /dev/null +++ b/packages/oneclient_net/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "oneclient_net" +description = "OneClient HTTP transport: retries, throttling, ETag caching and verified downloads" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +polyio.workspace = true + +reqwest.workspace = true +tokio.workspace = true +futures-lite.workspace = true +futures-util.workspace = true +async-trait.workspace = true +bytes.workspace = true + +# Config read on every outbound request, written only on a settings save. +arc-swap.workspace = true + +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true diff --git a/packages/oneclient_net/src/cache.rs b/packages/oneclient_net/src/cache.rs new file mode 100644 index 00000000..964744fe --- /dev/null +++ b/packages/oneclient_net/src/cache.rs @@ -0,0 +1,138 @@ +use std::path::{Path, PathBuf}; + +use reqwest::{Method, StatusCode, header}; + +use crate::error::RequestError; +use crate::service::RequestClient; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EtagPolicy { + CommitNow, + /// Hands the ETag back on [`Fetched::etag`] + /// Use when work downstream of the body can still fail so an interrupted run + /// retries instead of getting 304 + Defer, +} + +#[derive(Debug, Clone)] +pub struct Fetched { + pub bytes: Vec, + /// `false` when the server answered 304 or the request failed and the + /// cached copy was served instead + pub changed: bool, + /// Present only for a fresh body under [`EtagPolicy::Defer`] + pub etag: Option, +} + +impl Fetched { + pub fn json(&self) -> Result { + serde_json::from_slice(&self.bytes).map_err(|source| RequestError::DeserializeError { + source, + type_name: std::any::type_name::().to_string(), + url: String::new(), + status: 200, + snippet: crate::error::body_snippet(&self.bytes), + }) + } + + /// Lossy a bad byte becomes a replacement character rather than an error + #[must_use] + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } +} + +fn etag_path(cache_path: &Path) -> PathBuf { + let mut path = cache_path.as_os_str().to_os_string(); + path.push(".etag"); + PathBuf::from(path) +} + +async fn read_sidecar_etag(cache_path: &Path) -> Option { + polyio::read(etag_path(cache_path)) + .await + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|etag| !etag.is_empty()) +} + +/// Callers using [`EtagPolicy::Defer`] call this once the work depending on the +/// body has succeeded +pub async fn commit_etag(cache_path: &Path, etag: &str) { + if let Err(err) = polyio::write_atomic(etag_path(cache_path), etag.as_bytes()).await { + tracing::warn!("failed to write etag sidecar: {err}"); + } +} + +/// Returns `Ok(None)` only when the request failed *and* there is no cached +/// copy +#[tracing::instrument(level = "debug", skip(client), fields(%url))] +pub async fn fetch_cached( + client: &RequestClient, + url: &str, + cache_path: &Path, + policy: EtagPolicy, +) -> Result, RequestError> { + let stored_etag = read_sidecar_etag(cache_path).await; + let mut request = reqwest::Request::new(Method::GET, url.parse()?); + + if let Some(etag) = &stored_etag + && let Ok(value) = header::HeaderValue::from_str(etag) + { + request.headers_mut().insert(header::IF_NONE_MATCH, value); + } + + let cached = || async { + match polyio::read(cache_path).await { + Ok(bytes) => Ok(Some(Fetched { + bytes, + changed: false, + etag: None, + })), + Err(err) => { + tracing::debug!("no usable cache at {}: {err}", cache_path.display()); + Ok(None) + } + } + }; + + match client.send(request).await { + Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { + tracing::debug!("cache hit (304)"); + cached().await + } + Ok(res) if res.status().is_success() => { + let server_etag = res + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + + let bytes = res.bytes().await?.to_vec(); + polyio::write_atomic(cache_path, &bytes).await?; + + let etag = match (&server_etag, policy) { + (Some(etag), EtagPolicy::CommitNow) => { + commit_etag(cache_path, etag).await; + None + } + (_, EtagPolicy::Defer) => server_etag, + (None, EtagPolicy::CommitNow) => None, + }; + + Ok(Some(Fetched { + bytes, + changed: true, + etag, + })) + } + Ok(res) => { + tracing::warn!(status = %res.status(), "unexpected response; using cache if present"); + cached().await + } + Err(err) => { + tracing::debug!("fetch failed, using cache if present: {err}"); + cached().await + } + } +} diff --git a/packages/oneclient_net/src/config.rs b/packages/oneclient_net/src/config.rs new file mode 100644 index 00000000..e2d9d7a4 --- /dev/null +++ b/packages/oneclient_net/src/config.rs @@ -0,0 +1,105 @@ +use oneclient_common::constants; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetConfig { + pub curseforge_api_key: String, + pub modrinth_api_key: Option, + pub metadata_api_url: String, + /// Never has a trailing slash + pub meta_url_base: String, +} + +impl Default for NetConfig { + fn default() -> Self { + Self { + curseforge_api_key: constants::CURSEFORGE_API_KEY.to_string(), + modrinth_api_key: None, + metadata_api_url: constants::METADATA_API_URL.to_string(), + meta_url_base: constants::META_URL_BASE.to_string(), + } + } +} + +impl NetConfig { + /// Blank is treated as absent so clearing a field in the settings UI + /// restores the default rather than sending an empty key + #[must_use] + pub fn with_overrides( + mut self, + curseforge_api_key: Option<&str>, + modrinth_api_key: Option<&str>, + metadata_api_url: Option<&str>, + meta_url_base: Option<&str>, + ) -> Self { + if let Some(value) = non_empty(curseforge_api_key) { + self.curseforge_api_key = value; + } + self.modrinth_api_key = non_empty(modrinth_api_key); + if let Some(value) = non_empty(metadata_api_url) { + self.metadata_api_url = value; + } + if let Some(value) = non_empty(meta_url_base) { + self.meta_url_base = value; + } + self.meta_url_base = self.meta_url_base.trim_end_matches('/').to_string(); + self + } + + #[must_use] + pub fn modrinth_headers(&self) -> Vec<(String, String)> { + match &self.modrinth_api_key { + Some(token) => vec![("Authorization".to_string(), token.clone())], + None => Vec::new(), + } + } +} + +fn non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_overrides_fall_back_to_defaults() { + let config = NetConfig::default().with_overrides(Some(" "), Some(""), None, None); + + assert_eq!(config.curseforge_api_key, constants::CURSEFORGE_API_KEY); + assert_eq!(config.modrinth_api_key, None); + assert_eq!(config.metadata_api_url, constants::METADATA_API_URL); + } + + #[test] + fn meta_url_base_never_keeps_a_trailing_slash() { + let config = NetConfig::default().with_overrides( + None, + None, + None, + Some("https://example.test/base/"), + ); + + assert_eq!(config.meta_url_base, "https://example.test/base"); + } + + #[test] + fn overrides_replace_defaults() { + let config = NetConfig::default().with_overrides( + Some("key"), + Some("token"), + Some("https://a"), + None, + ); + + assert_eq!(config.curseforge_api_key, "key"); + assert_eq!( + config.modrinth_headers(), + vec![("Authorization".to_string(), "token".to_string())] + ); + assert_eq!(config.metadata_api_url, "https://a"); + } +} diff --git a/packages/oneclient_net/src/download.rs b/packages/oneclient_net/src/download.rs new file mode 100644 index 00000000..25b7b831 --- /dev/null +++ b/packages/oneclient_net/src/download.rs @@ -0,0 +1,235 @@ +use std::path::Path; + +use oneclient_events::{EventBus, GroupedProgressChild, TaskPhase}; +use polyio::{Checksum, ChecksumStream, normalize_hash}; +use reqwest::Method; + +use crate::error::RequestError; +use crate::response::{ResponseExt, ResponseNotifyOptions, ResponseOptions}; +use crate::service::RequestClient; + +/// [`RequestClient::send`] only retries failures before the response headers +/// land this covers failures mid-body which would otherwise silently leave an +/// install incomplete +const MAX_DOWNLOAD_ATTEMPTS: u32 = 3; + +/// Only errors meaning the bytes arrived wrong are retried a 404 bad URL or +/// full disk fails identically every time +fn worth_retrying(err: &RequestError) -> bool { + matches!( + err, + RequestError::ReqwestError(_) + | RequestError::HashMismatch { .. } + | RequestError::IncompleteBody { .. } + ) +} + +fn retry_delay(attempt: u32) -> std::time::Duration { + std::time::Duration::from_millis(300 * u64::from(attempt)) +} + +/// Returns `false` when the file is missing or unreadable +pub async fn matches_on_disk(path: &Path, expected_sha1: &str) -> bool { + if !path.is_file() { + return false; + } + + match polyio::sha1_file(path).await { + Ok(actual) => normalize_hash(&actual) == normalize_hash(expected_sha1), + Err(err) => { + tracing::debug!("could not hash {}: {err}", path.display()); + false + } + } +} + +/// `dest` is only created once the hash matches so a failed attempt leaves +/// nothing behind for a later `exists()` check to trip over +#[tracing::instrument(level = "debug", skip(client, events, notify), fields(%url))] +pub async fn download_verified( + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option, +) -> Result<(), RequestError> { + // Callers fanning out over thousands of files pre-create the tree so this + // stat avoids a blocking-pool round trip per file + if let Some(parent) = dest.parent() + && !parent.is_dir() + { + polyio::create_dir_all(parent).await?; + } + + // Needs a fixed id up front or each retry opens a new UI entry + let notify = notify.map(ResponseNotifyOptions::pinned); + + let mut attempt = 1; + loop { + match download_attempt( + client, + events, + url, + dest, + expected, + expected_size, + notify.as_ref(), + ) + .await + { + Ok(()) => break, + Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => { + tracing::warn!( + attempt, + %url, + "download failed, retrying: {err}" + ); + tokio::time::sleep(retry_delay(attempt)).await; + attempt += 1; + } + Err(err) => return Err(err), + } + } + + if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) { + child.finish(); + } + + Ok(()) +} + +/// The progress child is shared across attempts and reports absolute byte +/// counts so a restarted attempt rewinds its bar rather than double-counting +#[allow(clippy::too_many_arguments)] +async fn download_attempt( + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option<&ResponseNotifyOptions>, +) -> Result<(), RequestError> { + let child = notify.and_then(ResponseNotifyOptions::child); + if let Some(child) = child { + child.set_phase(TaskPhase::Downloading); + } + + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; + + // Without this an error body is written to disk under the requested file's name + let status = response.status(); + if !status.is_success() { + let bytes = response.bytes().await?; + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url: url.to_string(), + snippet: crate::error::body_snippet(&bytes), + }); + } + + // Prefer the manifest size Content-Length is absent for compressed or chunked bodies + let declared = if expected_size > 0 { + Some(expected_size) + } else { + response.content_length() + }; + let total = declared.unwrap_or(0).max(1); + + let options = ResponseOptions { + notify: notify.cloned(), + }; + let stream = response.stream(options, events).await?; + + let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm)); + let mut received = 0u64; + { + let stream = futures_lite::StreamExt::map(stream, |item| { + if let Ok(chunk) = &item { + received += chunk.len() as u64; + if let Some(hasher) = hasher.as_mut() { + hasher.update(chunk); + } + } + item + }); + let stream = std::pin::pin!(stream); + // Writes to a scratch sibling and renames so `dest` is never truncated + polyio::write_stream(dest, stream, Some(total)).await?; + } + + if let (Some(expected), Some(hasher)) = (expected, hasher) { + if let Some(child) = child { + child.set_phase(TaskPhase::Verifying); + } + + let actual = hasher.finish(); + if !expected.matches(&actual) { + // Remove it rather than leave it for the next `exists()` check + let _ = polyio::remove_file(dest).await; + return Err(RequestError::HashMismatch { + source_desc: dest.display().to_string(), + expected: format!("{} {}", expected.algorithm.name(), expected.hex), + actual, + }); + } + } else if let Some(declared) = declared + && received != declared + { + // With no hash the byte count is the only evidence the transfer finished + let _ = polyio::remove_file(dest).await; + return Err(RequestError::IncompleteBody { + source_desc: dest.display().to_string(), + expected: declared, + actual: received, + }); + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))] +pub async fn fetch_verified( + client: &RequestClient, + events: &EventBus, + url: &str, + expected_sha1: &str, + progress: Option, +) -> Result, RequestError> { + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; + + let options = ResponseOptions { + notify: progress.clone().map(ResponseNotifyOptions::grouped), + }; + let stream = response.stream(options, events).await?; + + use futures_util::StreamExt; + let mut stream = std::pin::pin!(stream); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk?); + } + + if let Some(child) = &progress { + child.set_phase(TaskPhase::Verifying); + } + + let actual = polyio::sha1_bytes(&bytes); + if normalize_hash(&actual) != normalize_hash(expected_sha1) { + return Err(RequestError::HashMismatch { + source_desc: url.to_string(), + expected: expected_sha1.to_string(), + actual, + }); + } + + if let Some(child) = progress { + child.finish(); + } + + Ok(bytes) +} diff --git a/packages/oneclient_net/src/error.rs b/packages/oneclient_net/src/error.rs new file mode 100644 index 00000000..de7427e9 --- /dev/null +++ b/packages/oneclient_net/src/error.rs @@ -0,0 +1,245 @@ +/// `reqwest::Error`'s own `Display` stops at "error sending request for url" +/// the fact that names the failure lives further down the `source()` chain +#[must_use] +pub fn error_chain(err: &dyn std::error::Error) -> String { + let mut rendered = err.to_string(); + let mut cursor = err.source(); + + while let Some(cause) = cursor { + let text = cause.to_string(); + // hyper and reqwest repeat the same sentence across several layers + if !rendered.ends_with(&text) { + rendered.push_str(": "); + rendered.push_str(&text); + } + cursor = cause.source(); + } + + rendered +} + +#[derive(Debug, thiserror::Error)] +pub enum RequestError { + #[error("{}", error_chain(.0))] + ReqwestError(#[from] reqwest::Error), + + #[error("IO Error: {0}")] + IOError(#[from] polyio::IOError), + + #[error( + "Failed to parse {type_name} from {url} (HTTP {status}): {source}; body starts: {snippet}" + )] + DeserializeError { + #[source] + source: serde_json::Error, + type_name: String, + url: String, + status: u16, + snippet: String, + }, + + #[error("HTTP {status} from {url}: {snippet}")] + HttpStatus { + status: u16, + url: String, + snippet: String, + }, + + #[error("Failed to serialize request body: {0}")] + SerializeError(#[source] serde_json::Error), + + #[error("{source_desc} has SHA-1 {actual}, expected {expected}")] + HashMismatch { + source_desc: String, + expected: String, + actual: String, + }, + + /// Only reachable when no hash was supplied otherwise it fails as a mismatch + #[error("{source_desc} ended after {actual} of {expected} bytes")] + IncompleteBody { + source_desc: String, + expected: u64, + actual: u64, + }, + + #[error("Invalid URL: {0}")] + UrlParseError(#[from] url::ParseError), + + #[error("Invalid HTTP header name: {0}")] + InvalidHeaderName(#[from] reqwest::header::InvalidHeaderName), + + #[error("Invalid HTTP header value: {0}")] + InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkFailure { + Certificate, + Handshake, + Dns, + Generic, +} + +impl NetworkFailure { + #[must_use] + pub fn user_message(self) -> &'static str { + match self { + Self::Certificate => { + "Something on this network or machine is intercepting secure connections, so OneClient cannot verify it is really talking to the servers it needs. This is usually antivirus HTTPS scanning, a school or workplace filter, or a VPN. Try another network, turn off HTTPS/SSL scanning in your antivirus, or ask your network administrator to allow OneClient." + } + Self::Handshake => { + "OneClient reached the server but the two could not agree on a secure connection. A proxy, filter, or antivirus in between is usually using outdated encryption settings. Try another network or turn off HTTPS/SSL scanning in your antivirus." + } + Self::Dns => { + "OneClient could not look up the address of the server. Check your internet connection, disable any VPN or proxy, and try a different DNS server such as 1.1.1.1." + } + Self::Generic => { + "OneClient could not reach the server. Check your internet connection, and any firewall, proxy, or VPN that could be blocking it." + } + } + } + + /// Retrying or waiting cannot clear these the user has to change something + #[must_use] + pub fn is_tls_interception(self) -> bool { + matches!(self, Self::Certificate | Self::Handshake) + } +} + +#[must_use] +pub fn classify_network_failure(err: &reqwest::Error) -> NetworkFailure { + let chain = error_chain(err).to_ascii_lowercase(); + + // Certificate before handshake a rejected certificate also aborts the + // handshake and the certificate wording is the more specific of the two + const CERTIFICATE: &[&str] = &[ + "invalid peer certificate", + "unknownissuer", + "certificate", + "certexpired", + "notvalidforname", + ]; + const HANDSHAKE: &[&str] = &[ + "handshake", + "received fatal alert", + "peer misbehaved", + "no cipher suites in common", + "protocol version", + "unsupported protocol", + ]; + const DNS: &[&str] = &[ + "dns error", + "failed to lookup address", + "no record found", + "nodename nor servname", + "name or service not known", + "no such host", + ]; + + let matches = |markers: &[&str]| markers.iter().any(|marker| chain.contains(marker)); + + if matches(CERTIFICATE) { + NetworkFailure::Certificate + } else if matches(HANDSHAKE) { + NetworkFailure::Handshake + } else if matches(DNS) { + NetworkFailure::Dns + } else { + NetworkFailure::Generic + } +} + +impl RequestError { + /// Whether this is the user's environment rather than a launcher bug offline + /// connection refused timed out disk full intercepted TLS + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::ReqwestError(source) => { + source.is_timeout() + || source.is_connect() + || classify_network_failure(source).is_tls_interception() + } + Self::IOError(source) => is_transient_io(source), + _ => false, + } + } + + #[must_use] + pub fn network_failure(&self) -> Option { + let Self::ReqwestError(source) = self else { + return None; + }; + + let failure = classify_network_failure(source); + + if failure != NetworkFailure::Generic || source.is_connect() || source.is_timeout() { + Some(failure) + } else { + None + } + } + + #[must_use] + pub fn user_message(&self) -> Option<&'static str> { + self.network_failure().map(NetworkFailure::user_message) + } +} + +fn is_transient_io(error: &polyio::IOError) -> bool { + let source = match error { + polyio::IOError::IOError(source) | polyio::IOError::PathIOError { source, .. } => source, + _ => return false, + }; + + use std::io::ErrorKind; + + // `ErrorKind::StorageFull` is unstable so match raw OS codes platform-gated + // because 112 is the unrelated EHOSTDOWN on Linux + if let Some(code) = source.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + // ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL + return true; + } + let _ = code; + } + + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} + +pub(crate) fn body_snippet(bytes: &[u8]) -> String { + const MAX: usize = 240; + if bytes.is_empty() { + return "".to_string(); + } + let text = String::from_utf8_lossy(bytes); + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return "".to_string(); + } + if collapsed.len() > MAX { + let end = collapsed + .char_indices() + .map(|(i, _)| i) + .take_while(|i| *i <= MAX) + .last() + .unwrap_or(0); + format!("{}...", &collapsed[..end]) + } else { + collapsed + } +} diff --git a/packages/oneclient_net/src/lib.rs b/packages/oneclient_net/src/lib.rs new file mode 100644 index 00000000..c9ae2b12 --- /dev/null +++ b/packages/oneclient_net/src/lib.rs @@ -0,0 +1,17 @@ +mod cache; +mod config; +mod download; +mod error; +mod request; +mod response; +mod service; + +pub mod status; + +pub use cache::{EtagPolicy, Fetched, commit_etag, fetch_cached}; +pub use config::NetConfig; +pub use download::{download_verified, fetch_verified, matches_on_disk}; +pub use error::{NetworkFailure, RequestError, classify_network_failure, error_chain}; +pub use request::*; +pub use response::*; +pub use service::RequestClient; diff --git a/packages/oneclient_net/src/request.rs b/packages/oneclient_net/src/request.rs new file mode 100644 index 00000000..2fdcb0ab --- /dev/null +++ b/packages/oneclient_net/src/request.rs @@ -0,0 +1,41 @@ +#[derive(Debug)] +pub struct HttpRequest { + pub request: reqwest::Request, + pub options: RequestOptions, +} + +impl From for HttpRequest { + fn from(value: reqwest::Request) -> Self { + Self { + request: value, + options: RequestOptions::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RequestOptions { + pub use_semaphore: bool, + pub max_retries: usize, +} + +impl Default for RequestOptions { + fn default() -> Self { + Self { + use_semaphore: true, + max_retries: 1, + } + } +} + +impl RequestOptions { + pub fn use_semaphore(mut self, should_use: bool) -> Self { + self.use_semaphore = should_use; + self + } + + pub fn with_retries(mut self, retries: usize) -> Self { + self.max_retries = retries; + self + } +} diff --git a/packages/oneclient_net/src/response.rs b/packages/oneclient_net/src/response.rs new file mode 100644 index 00000000..f2e4e69d --- /dev/null +++ b/packages/oneclient_net/src/response.rs @@ -0,0 +1,143 @@ +use reqwest::Response; +use uuid::Uuid; + +use crate::error::RequestError; +use oneclient_events::{EventBus, GroupedProgressChild}; + +const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + +#[derive(Clone, Default)] +pub struct ResponseOptions { + pub notify: Option, +} + +#[derive(Clone)] +pub struct ResponseNotifyOptions { + child: Option, + standalone_label: Option, + standalone_id: Option, + done_label: Option, +} + +impl ResponseNotifyOptions { + pub fn grouped(child: GroupedProgressChild) -> Self { + Self { + child: Some(child), + standalone_label: None, + standalone_id: None, + done_label: None, + } + } + + pub fn standalone(label: impl Into) -> Self { + Self { + child: None, + standalone_label: Some(label.into()), + standalone_id: None, + done_label: None, + } + } + + pub fn with_id(mut self, id: Uuid) -> Self { + self.standalone_id = Some(id); + self + } + + pub fn done_label(mut self, label: impl Into) -> Self { + self.done_label = Some(label.into()); + self + } + + #[must_use] + pub fn child(&self) -> Option<&GroupedProgressChild> { + self.child.as_ref() + } + + /// Pins the standalone notification id so a retried request keeps updating + /// one notification instead of opening a fresh UI entry per attempt + #[must_use] + pub fn pinned(mut self) -> Self { + if self.standalone_label.is_some() && self.standalone_id.is_none() { + self.standalone_id = Some(Uuid::new_v4()); + } + self + } +} + +#[async_trait::async_trait] +pub trait ResponseExt { + async fn stream( + self, + options: ResponseOptions, + events: &EventBus, + ) -> Result>, RequestError>; +} + +#[async_trait::async_trait] +impl ResponseExt for Response { + #[tracing::instrument(level = "debug", skip_all)] + async fn stream( + self, + options: ResponseOptions, + events: &EventBus, + ) -> Result>, RequestError> + { + let total = self.content_length().unwrap_or(0).max(1); + let mut current = 0u64; + + let grouped_child = options.notify.as_ref().and_then(|n| n.child.clone()); + let standalone_label = options + .notify + .as_ref() + .and_then(|n| n.standalone_label.clone()); + let done_label = options.notify.as_ref().and_then(|n| n.done_label.clone()); + + let standalone_id = standalone_label.as_ref().map(|_| { + options + .notify + .as_ref() + .and_then(|n| n.standalone_id) + .unwrap_or_else(Uuid::new_v4) + }); + + if let Some(ref child) = grouped_child { + child.set_progress(0, Some(total)); + } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { + events.progress(*id, label, 0, total); + } + + let events = events.clone(); + // One event per chunk would bury the UI in tens of thousands of updates + // so sample the last one always emits so the bar lands on complete + let mut last_emit: Option = None; + let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| match item { + Ok(chunk) => { + current += chunk.len() as u64; + + let now = std::time::Instant::now(); + let due = current >= total + || last_emit.is_none_or(|last| now.duration_since(last) >= PROGRESS_INTERVAL); + + if due { + last_emit = Some(now); + if let Some(ref child) = grouped_child { + child.set_progress(current, Some(total)); + } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { + let done = current >= total; + let label = if done { + done_label.as_deref().unwrap_or(label) + } else { + label + }; + events.progress(*id, label, current, total); + } + } + + Ok(chunk) + } + Err(err) => Err(RequestError::from(err)), + }); + + Ok(stream) + } +} diff --git a/packages/oneclient_net/src/service.rs b/packages/oneclient_net/src/service.rs new file mode 100644 index 00000000..ef6fc145 --- /dev/null +++ b/packages/oneclient_net/src/service.rs @@ -0,0 +1,354 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; +use oneclient_events::EventBus; +use reqwest::{ClientBuilder, Response}; +use serde::de::DeserializeOwned; +use tokio::sync::Semaphore; + +use crate::config::NetConfig; +use crate::error::{RequestError, body_snippet}; +use crate::request::HttpRequest; +use crate::response::{ResponseExt, ResponseOptions}; + +const MAX_THROTTLE_RETRIES: u32 = 6; + +/// Backstop against runaway fan-out not the download throttle +/// Must stay above the sum of per-phase caller concurrency or it becomes the +/// bottleneck +const MAX_INFLIGHT_REQUESTS: usize = 64; + +fn retry_after(response: &Response) -> Option { + let raw = response + .headers() + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .to_string(); + let secs: u64 = raw.parse().ok()?; + Some(std::time::Duration::from_secs(secs.min(60))) +} + +fn backoff_delay(attempt: u32) -> std::time::Duration { + let base = 500u64.saturating_mul(1 << attempt.min(6)); + let capped = base.min(30_000); + let jitter = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_millis() as u64) + .unwrap_or(0)) + % 250; + std::time::Duration::from_millis(capped + jitter) +} + +fn is_curseforge_host(url: &reqwest::Url) -> bool { + if url.scheme() != "https" { + return false; + } + + let Some(host) = url.host_str() else { + return false; + }; + let host = host.trim_end_matches('.').to_ascii_lowercase(); + + matches!(host.as_str(), "curseforge.com" | "forgecdn.net") + || host.ends_with(".curseforge.com") + || host.ends_with(".forgecdn.net") +} + +fn apply_curseforge_auth( + request: &mut reqwest::Request, + api_key: &str, +) -> Result<(), RequestError> { + const API_KEY_HEADER: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("x-api-key"); + + if !is_curseforge_host(request.url()) { + return Ok(()); + } + + let headers = request.headers_mut(); + if headers.contains_key(&API_KEY_HEADER) { + return Ok(()); + } + + let mut value = reqwest::header::HeaderValue::try_from(api_key)?; + value.set_sensitive(true); + headers.insert(API_KEY_HEADER, value); + + Ok(()) +} + +#[derive(Clone)] +pub struct RequestClient { + client: reqwest::Client, + semaphore: Arc, + /// Shared across every clone so a settings save is visible to in-flight + /// handles + /// `ArcSwap` rather than a lock which would serialise requests + config: Arc>, +} + +impl RequestClient { + pub fn http(&self) -> &reqwest::Client { + &self.client + } + + #[must_use] + pub fn config(&self) -> arc_swap::Guard> { + self.config.load() + } + + pub fn set_config(&self, config: NetConfig) { + self.config.store(Arc::new(config)); + } + + pub fn new(config: NetConfig) -> Result { + let mut builder = ClientBuilder::new() + .connect_timeout(std::time::Duration::from_secs(10)) + .read_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_mins(10)) + .tls_backend_rustls() + .user_agent(format!( + "OneClient {} ({})", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_HOMEPAGE") + )); + + builder = builder.no_hickory_dns(); + + let client = builder.build()?; + + Ok(Self { + client, + semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)), + config: Arc::new(ArcSwap::from_pointee(config)), + }) + } +} + +impl RequestClient { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn send(&self, request: impl Into) -> Result { + let mut request: HttpRequest = request.into(); + let mut retries = 0; + let mut throttle_retries = 0u32; + + apply_curseforge_auth(&mut request.request, &self.config.load().curseforge_api_key)?; + + let cloned_backup = request.request.try_clone(); + let cloneable = cloned_backup.is_some(); + + let max_retries = if cloneable { + request.options.max_retries + } else { + 0 + }; + + let mut active_request = request.request; + + tracing::debug!( + method = %active_request.method(), + url = %active_request.url(), + "dispatching http request" + ); + + let res = loop { + let permit = if request.options.use_semaphore { + self.semaphore.acquire().await.ok() + } else { + None + }; + + match self.client.execute(active_request).await { + Ok(response) => { + let status = response.status(); + let retryable = status.as_u16() == 429 || status.is_server_error(); + + if retryable && cloneable && throttle_retries < MAX_THROTTLE_RETRIES { + throttle_retries += 1; + let delay = retry_after(&response) + .unwrap_or_else(|| backoff_delay(throttle_retries)); + tracing::warn!( + status = status.as_u16(), + attempt = throttle_retries, + delay_ms = delay.as_millis() as u64, + url = %response.url(), + "rate limited / transient error; backing off" + ); + drop(permit); + tokio::time::sleep(delay).await; + active_request = cloned_backup.as_ref().unwrap().try_clone().unwrap(); + continue; + } + + break response; + } + Err(err) => { + if retries < max_retries { + retries += 1; + + let current_backup = cloned_backup.as_ref().unwrap(); + + active_request = current_backup.try_clone().unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(500 * retries as u64)) + .await; + + continue; + } + + crate::status::note_request_result(false); + return Err(RequestError::ReqwestError(err)); + } + } + }; + + crate::status::note_request_result(true); + Ok(res) + } + + #[tracing::instrument(level = "debug", skip(self, request, options, events), fields(dest = %dest.as_ref().display()))] + pub async fn download_file( + &self, + request: impl Into, + dest: impl AsRef + Send, + options: ResponseOptions, + events: &EventBus, + ) -> Result<(), RequestError> { + let res = self.send(request).await?; + + // Without this an error body is written to disk under the requested file's name + let status = res.status(); + if !status.is_success() { + let url = res.url().to_string(); + let bytes = res.bytes().await?; + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url, + snippet: body_snippet(&bytes), + }); + } + + let size_hint = res.content_length(); + let http_stream = res.stream(options, events).await?; + let http_stream = std::pin::pin!(http_stream); + + polyio::write_stream(dest, http_stream, size_hint).await?; + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn send_as( + &self, + request: impl Into, + ) -> Result { + let res = self.send(request).await?; + let status = res.status(); + let url = res.url().to_string(); + let bytes = res.bytes().await?; + + if !status.is_success() { + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url, + snippet: body_snippet(&bytes), + }); + } + + serde_json::from_slice(&bytes).map_err(|err| RequestError::DeserializeError { + source: err, + type_name: std::any::type_name::().to_string(), + url, + status: status.as_u16(), + snippet: body_snippet(&bytes), + }) + } + + #[tracing::instrument(level = "debug", skip(self, body, extra_headers), fields(method = %method, %url))] + pub async fn send_json( + &self, + method: reqwest::Method, + url: reqwest::Url, + body: Option, + extra_headers: &[(&str, &str)], + ) -> Result { + let mut request = reqwest::Request::new(method, url); + for (name, value) in extra_headers { + request.headers_mut().insert( + reqwest::header::HeaderName::try_from(*name)?, + reqwest::header::HeaderValue::try_from(*value)?, + ); + } + + if let Some(body) = body { + let bytes = serde_json::to_vec(&body).map_err(RequestError::SerializeError)?; + request.headers_mut().insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + *request.body_mut() = Some(bytes.into()); + } + + self.send_as(request).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(url: &str) -> reqwest::Request { + reqwest::Request::new(reqwest::Method::GET, url.parse().unwrap()) + } + + fn api_key_of(url: &str) -> Option { + let mut req = request(url); + apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); + req.headers() + .get("x-api-key") + .map(|v| v.to_str().unwrap().to_string()) + } + + #[test] + fn attaches_key_to_curseforge_api_and_cdn() { + for url in [ + "https://api.curseforge.com/v1/mods/search", + "https://media.forgecdn.net/avatars/1/2/icon.png", + "https://edge.forgecdn.net/files/1/2/mod.jar", + "https://www.curseforge.com/minecraft", + "https://forgecdn.net/thing", + ] { + assert_eq!( + api_key_of(url).as_deref(), + Some(oneclient_common::constants::CURSEFORGE_API_KEY), + "expected key on {url}" + ); + } + } + + #[test] + fn ignores_unrelated_and_insecure_hosts() { + for url in [ + "https://api.modrinth.com/v2/search", + "https://cdn.modrinth.com/data/x/y.jar", + "https://notcurseforge.com/x", + "https://evil-forgecdn.net.attacker.com/x", + "http://media.forgecdn.net/insecure.png", + ] { + assert_eq!(api_key_of(url), None, "unexpected key on {url}"); + } + } + + #[test] + fn does_not_override_explicit_key() { + let mut req = request("https://api.curseforge.com/v1/mods"); + req.headers_mut().insert( + "x-api-key", + reqwest::header::HeaderValue::from_static("mine"), + ); + apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); + assert_eq!(req.headers()["x-api-key"], "mine"); + } +} diff --git a/packages/oneclient_net/src/status.rs b/packages/oneclient_net/src/status.rs new file mode 100644 index 00000000..e1c8006d --- /dev/null +++ b/packages/oneclient_net/src/status.rs @@ -0,0 +1,159 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{OnceLock, RwLock}; +use std::time::Duration; + +use tokio::sync::{Notify, watch}; + +use crate::service::RequestClient; + +const CONNECTIVITY_URL: &str = "https://cp.cloudflare.com/generate_204"; +const MC_AUTH_URL: &str = "https://api.minecraftservices.com/"; +const POLYFROST_STATUS_URL: &str = "https://status.polyfrost.org/index.json"; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(8); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ServiceStatus { + pub online: bool, + pub mc_auth_up: bool, + pub polyfrost_up: bool, +} + +impl Default for ServiceStatus { + fn default() -> Self { + Self { + online: true, + mc_auth_up: true, + polyfrost_up: true, + } + } +} + +struct Connectivity { + tx: watch::Sender, + rx: watch::Receiver, + notify: Notify, + started: AtomicBool, + /// While set probe results are never published so the forced status + /// survives a recheck + forced: RwLock>, +} + +fn connectivity() -> &'static Connectivity { + static CONNECTIVITY: OnceLock = OnceLock::new(); + CONNECTIVITY.get_or_init(|| { + let (tx, rx) = watch::channel(ServiceStatus::default()); + Connectivity { + tx, + rx, + notify: Notify::new(), + started: AtomicBool::new(false), + forced: RwLock::new(None), + } + }) +} + +/// Pins the reported status to `status` or releases the pin with `None` and +/// re-probes +pub fn force(status: Option) { + let conn = connectivity(); + *conn.forced.write().unwrap_or_else(|e| e.into_inner()) = status; + + match status { + Some(status) => { + let _ = conn.tx.send(status); + } + None => request_recheck(), + } +} + +#[must_use] +pub fn forced() -> Option { + *connectivity() + .forced + .read() + .unwrap_or_else(|e| e.into_inner()) +} + +pub fn subscribe() -> watch::Receiver { + connectivity().rx.clone() +} + +pub fn current() -> ServiceStatus { + *connectivity().rx.borrow() +} + +pub fn request_recheck() { + connectivity().notify.notify_one(); +} + +pub fn note_request_result(success: bool) { + let online = connectivity().rx.borrow().online; + if !success || !online { + request_recheck(); + } +} + +/// Idempotent later calls are ignored so the client handed in on the first +/// call is used for the process lifetime +pub fn start(client: RequestClient) { + let conn = connectivity(); + if conn.started.swap(true, Ordering::SeqCst) { + return; + } + + tokio::spawn(async move { + loop { + let probed = check_service_status(&client).await; + if forced().is_none() { + let _ = conn.tx.send(probed); + } + conn.notify.notified().await; + } + }); +} + +async fn reachable(client: &reqwest::Client, url: &str) -> bool { + client.get(url).timeout(PROBE_TIMEOUT).send().await.is_ok() +} + +pub async fn check_service_status(requester: &RequestClient) -> ServiceStatus { + let client = requester.http(); + + if !reachable(client, CONNECTIVITY_URL).await { + return ServiceStatus { + online: false, + mc_auth_up: false, + polyfrost_up: false, + }; + } + + let mc_auth_up = reachable(client, MC_AUTH_URL).await; + + let polyfrost_up = match client + .get(POLYFROST_STATUS_URL) + .timeout(PROBE_TIMEOUT) + .send() + .await + { + Ok(resp) => resp + .json::() + .await + .ok() + .and_then(|json| { + json.get("data")? + .get("attributes")? + .get("aggregate_state")? + .as_str() + .map(|s| s == "operational") + }) + .unwrap_or(true), + Err(_) => false, + }; + + ServiceStatus { + online: true, + mc_auth_up, + polyfrost_up, + } +} diff --git a/packages/oneclient_polyplus/Cargo.toml b/packages/oneclient_polyplus/Cargo.toml new file mode 100644 index 00000000..b62e0684 --- /dev/null +++ b/packages/oneclient_polyplus/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "oneclient_polyplus" +description = "Poly+ playtime reporting" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_auth.workspace = true +oneclient_common.workspace = true +polyio.workspace = true + +reqwest.workspace = true +reqwest-websocket.workspace = true +tokio.workspace = true +futures-util.workspace = true + +bytes.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } +tokio = { workspace = true } diff --git a/packages/oneclient_polyplus/src/lib.rs b/packages/oneclient_polyplus/src/lib.rs new file mode 100644 index 00000000..f696d6ea --- /dev/null +++ b/packages/oneclient_polyplus/src/lib.rs @@ -0,0 +1,353 @@ +use std::sync::Arc; +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 oneclient_auth::MinecraftAccount; +use oneclient_common::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(transparent)] + Auth(#[from] oneclient_auth::AuthError), + + #[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), +} + +/// Idempotent the first caller's handle is used for the process lifetime +pub fn start(auth: Arc) { + 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(&auth, &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 { + let builder = reqwest::Client::builder() + .tcp_keepalive(Some(Duration::from_secs(15))) + .connect_timeout(Duration::from_secs(10)) + // reqwest-websocket only supports Http <1.1 + .http1_only() + .tls_backend_rustls() + .user_agent(format!( + "OneClient {} ({})", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_HOMEPAGE") + )); + + builder.no_hickory_dns().build() +} + +fn base_url() -> &'static str { + constants::PLUS_BACKEND_URL.trim_end_matches('/') +} + +async fn session(auth: &oneclient_auth::AuthService, client: &reqwest::Client) -> Outcome { + let account = match current_account(auth).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(auth, 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( + auth: &oneclient_auth::AuthService, +) -> Result, PlusError> { + let Some(account) = auth.default_account_for_launch().await? else { + return Ok(None); + }; + + Ok(account.is_microsoft().then_some(account)) +} + +async fn run_session( + auth: &oneclient_auth::AuthService, + 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); + + let account_id = account.id; + pump(&mut websocket, PING_INTERVAL, account_id, || { + account_changed(auth, account_id) + }) + .await +} + +/// Returns when `should_stop` reports the session invalid or the server hangs up +async fn pump( + websocket: &mut reqwest_websocket::WebSocket, + ping_interval: Duration, + account_id: Uuid, + should_stop: F, +) -> Result<(), PlusError> +where + F: Fn() -> Fut, + Fut: std::future::Future, +{ + 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 should_stop().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(auth: &oneclient_auth::AuthService, id: Uuid) -> bool { + match auth.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); + polyio::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() { + 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(), + || async { false }, + ), + ) + .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/polyio/.gitignore b/packages/polyio/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/polyio/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml new file mode 100644 index 00000000..3c8961c0 --- /dev/null +++ b/packages/polyio/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "polyio" +description = "PolyIO-rs" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +documentation = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } + +[lib] +path = "src/lib.rs" + +[features] +default = [] +# Test-only helpers (`polyio::testing`). Enable as a dev-dependency. +testing = [] + +[dependencies] +tracing.workspace = true +thiserror.workspace = true + +tokio.workspace = true +tokio-util.workspace = true +futures-util.workspace = true +futures-lite.workspace = true + +async-tempfile.workspace = true +async-compression.workspace = true +async-stream.workspace = true +async_zip.workspace = true +astral-tokio-tar.workspace = true +sanitize-filename.workspace = true + +serde.workspace = true +serde_json.workspace = true +dunce.workspace = true +bytes.workspace = true + +# content hashing +digest.workspace = true +sha1.workspace = true +sha2.workspace = true + +[target.'cfg(windows)'.dependencies] +junction.workspace = true + diff --git a/packages/polyio/src/archive.rs b/packages/polyio/src/archive.rs new file mode 100644 index 00000000..82f9ec0a --- /dev/null +++ b/packages/polyio/src/archive.rs @@ -0,0 +1,291 @@ +use std::sync::Arc; + +use async_stream::try_stream; +use async_zip::StoredZipEntry; +use async_zip::base::read::WithoutEntry; +use futures_util::{Stream, TryStreamExt, pin_mut}; +use tokio_util::compat::TokioAsyncWriteCompatExt; + +use crate::{IOError, PolyIOResult}; + +#[tracing::instrument(skip(data, f), level = "debug")] +pub async fn read_zip_entries_bytes(data: Vec, mut f: F) -> PolyIOResult<()> +where + F: AsyncFnMut( + usize, + &StoredZipEntry, + &mut async_zip::base::read::ZipEntryReader< + '_, + futures_lite::io::Cursor<&[u8]>, + WithoutEntry, + >, + ) -> PolyIOResult<()>, +{ + let reader = async_zip::base::read::mem::ZipFileReader::new(data).await?; + + let entries = reader.file().entries(); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + let mut entry_reader = reader + .reader_without_entry(index) + .await + .unwrap_or_else(|_| panic!("expected zip entry at index '{index}'")); + + f(index, entry, &mut entry_reader).await?; + } + + Ok(()) +} + +#[tracing::instrument(skip(data), level = "debug")] +pub fn stream_zip_entries_bytes( + data: Vec, +) -> impl Stream< + Item = Result< + ( + usize, + StoredZipEntry, + Arc, + ), + IOError, + >, +> { + try_stream! { + let reader = Arc::new(async_zip::base::read::mem::ZipFileReader::new(data).await?); + let entries = reader.file().entries(); + + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + + yield (index, entry.clone(), reader.clone()); + } + } +} + +#[tracing::instrument( + level = "debug", + skip(data, dest_path), + fields( + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn unzip_bytes( + data: Vec, + dest_path: impl AsRef, +) -> PolyIOResult<()> { + unzip_bytes_filtered(data, None:: bool>, dest_path).await +} + +#[tracing::instrument( + level = "debug", + skip(data, filter_entries, dest_path), + fields( + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn unzip_bytes_filtered( + data: Vec, + filter_entries: Option bool + Send + Sync>, + dest_path: impl AsRef, +) -> PolyIOResult<()> { + let stream = stream_zip_entries_bytes(data); + pin_mut!(stream); + + while let Some((index, entry, reader)) = stream.try_next().await? { + let file_name = entry.filename().as_str()?; + + if let Some(filter) = &filter_entries + && !filter(file_name) + { + continue; + } + + let path = dest_path.as_ref().join(crate::sanitize_path(file_name)); + + let is_dir = entry.dir()?; + + if is_dir { + crate::create_dir_all(path).await?; + } else { + if let Some(parent) = path.parent() { + crate::create_dir_all(parent).await?; + } + + let entry_reader = reader.reader_without_entry(index).await?; + + let file = tokio::fs::File::create(&path).await?; + let writer = tokio::io::BufWriter::new(file); + + futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; + } + } + + Ok(()) +} + +#[tracing::instrument( + level = "debug", + skip(zip_path, dest_path), + fields( + zip_path = %zip_path.as_ref().display(), + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn extract_zip( + zip_path: impl AsRef, + dest_path: impl AsRef, +) -> PolyIOResult<()> { + extract_zip_filtered( + zip_path, + dest_path, + None:: bool>, + None:: String>, + ) + .await +} + +#[tracing::instrument( + level = "debug", + skip(zip_path, dest_path, filter_entries, modify_entry_name), + fields( + zip_path = %zip_path.as_ref().display(), + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn extract_zip_filtered( + zip_path: impl AsRef, + dest_path: impl AsRef, + filter_entries: Option bool + Send + Sync>, + modify_entry_name: Option String>, +) -> PolyIOResult<()> { + let zip_path = zip_path.as_ref(); + let dest_path = dest_path.as_ref(); + + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; + let entries = reader.file().entries(); + + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + + if let Some(filter) = &filter_entries + && !filter(entry) + { + continue; + } + + let old_name = entry.filename().as_str()?; + let name: String = modify_entry_name + .as_ref() + .map_or_else(|| old_name.to_string(), |modify| modify(old_name)); + + let name = crate::sanitize_path(name); + + let path = dest_path.join(name); + let is_dir = entry.dir()?; + + if is_dir { + crate::create_dir_all(&path).await?; + } else { + if let Some(parent) = path.parent() { + crate::create_dir_all(parent).await?; + } + + let file = tokio::fs::File::create(&path).await?; + let writer = tokio::io::BufWriter::new(file); + let entry_reader = reader.reader_without_entry(index).await?; + + futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; + } + } + + Ok(()) +} + +/// Each matching entry is read fully into memory so this is for small files +/// not large archives +#[tracing::instrument( + level = "debug", + skip(zip_path, filter), + fields(zip_path = %zip_path.as_ref().display()) +)] +pub async fn read_zip_file_entries( + zip_path: impl AsRef, + filter: impl Fn(&str) -> bool, +) -> PolyIOResult)>> { + let zip_path = zip_path.as_ref(); + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; + let entries = reader.file().entries(); + + let mut out = Vec::new(); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + let Ok(name) = entry.filename().as_str() else { + continue; + }; + let name = name.to_string(); + + if entry.dir().unwrap_or(false) || !filter(&name) { + continue; + } + + let mut entry_reader = reader.reader_without_entry(index).await?; + let mut data = Vec::new(); + futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + out.push((name, data)); + } + + Ok(out) +} + +/// Returns a zip file entry's bytes without reading the entire file into memory +#[tracing::instrument(level = "debug", skip(reader))] +pub async fn try_read_zip_entry_bytes(reader: R, file_name: &str) -> PolyIOResult> +where + R: tokio::io::AsyncRead + tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, +{ + use tokio_util::compat::TokioAsyncReadCompatExt; + let compat = reader.compat(); + + let mut zip_reader = async_zip::base::read::seek::ZipFileReader::new(compat).await?; + + let index = zip_reader + .file() + .entries() + .iter() + .position(|entry| entry.filename().as_str().is_ok_and(|n| n == file_name)) + .ok_or_else(|| IOError::FileNotFoundInZip { + file_name: file_name.to_string(), + })?; + + let mut entry_reader = zip_reader.reader_without_entry(index).await?; + + let mut data: Vec = Vec::new(); + + futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + + Ok(data) +} + +#[tracing::instrument( + level = "debug", + skip(archive, dest), + fields( + archive = %archive.as_ref().display(), + dest = %dest.as_ref().display(), + ) +)] +pub async fn extract_tar_gz( + archive: impl AsRef, + dest: impl AsRef, +) -> PolyIOResult<()> { + crate::create_dir_all(&dest).await?; + + let file = tokio::fs::File::open(archive).await?; + let buf_reader = tokio::io::BufReader::new(file); + let gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader); + + let mut tar_archive = tokio_tar::Archive::new(gzip_decoder); + tar_archive.unpack(dest).await?; + + Ok(()) +} diff --git a/packages/polyio/src/error.rs b/packages/polyio/src/error.rs new file mode 100644 index 00000000..f77f1d5d --- /dev/null +++ b/packages/polyio/src/error.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +pub type PolyIOResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum IOError { + #[error("Invalid absolute path '{path}'")] + InvalidAbsolutePath { path: PathBuf }, + + #[error("Couldn't find file '{file_name}' in zip")] + FileNotFoundInZip { file_name: String }, + + #[error("An error occurred whilst accessing path '{path}': {source}")] + PathIOError { + #[source] + source: std::io::Error, + path: String, + }, + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error("Failed to parse JSON for file '{file}': {source}")] + JsonFileParseError { + #[source] + source: serde_json::Error, + file: PathBuf, + }, + + #[error("Failed to convert to JSON for file '{file}': {source}")] + JsonFileWrite { + #[source] + source: serde_json::Error, + file: PathBuf, + }, + + #[error(transparent)] + AsyncZipError(#[from] async_zip::error::ZipError), + + #[error("Temporary file error: {0}")] + TempFileError(#[from] async_tempfile::Error), +} diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs new file mode 100644 index 00000000..bd7c7672 --- /dev/null +++ b/packages/polyio/src/file.rs @@ -0,0 +1,879 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::{ + fs::Metadata, + path::{Path, PathBuf}, +}; + +use async_tempfile::{TempDir, TempFile}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{IOError, PolyIOResult}; + +/// HTTP chunks already arrive at ~16 KiB +const MIN_WRITE_BUFFER: usize = 16 * 1024; +const MAX_WRITE_BUFFER: usize = 256 * 1024; +/// Used when the response has no length +const DEFAULT_WRITE_BUFFER: usize = 64 * 1024; + +/// Sizing the buffer to the file is a memory-footprint choice not a throughput +/// one a fixed 256 KiB costs ~8 MiB across 32 concurrent small-asset downloads +fn write_buffer_size(size_hint: Option) -> usize { + match size_hint { + Some(0) | None => DEFAULT_WRITE_BUFFER, + Some(size) => (size.min(MAX_WRITE_BUFFER as u64) as usize).max(MIN_WRITE_BUFFER), + } +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_dir(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + tokio::fs::read_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn create_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + if path.exists() { + return Ok(()); + } + + tokio::fs::create_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + tokio::fs::create_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +/// Fails if the directory is non-empty making "remove if empty" atomic with no +/// read-then-delete window +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + tokio::fs::remove_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn try_exists(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + tokio::fs::try_exists(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResult { + let mut f = tokio::fs::File::open(path).await?; + let mut buf = vec![]; + tokio::io::AsyncReadExt::read_to_end(&mut f, &mut buf).await?; + + let mut decoder = async_compression::tokio::bufread::GzipDecoder::new(buf.as_slice()); + let mut dst = String::new(); + tokio::io::AsyncReadExt::read_to_string(&mut decoder, &mut dst).await?; + + Ok(dst) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_to_string(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + tokio::fs::read_to_string(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read(path: impl AsRef) -> PolyIOResult> { + let path = path.as_ref(); + + tokio::fs::read(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_json(path: impl AsRef) -> PolyIOResult { + serde_json::from_slice(&read(&path).await?).map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); + + tokio::fs::write(path, data) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path, f), + fields(path = %path.as_ref().display()) +)] +pub async fn write_buf(path: impl AsRef, f: F) -> Result<(), E> +where + E: From, + F: for<'a> FnOnce(&'a mut tokio::io::BufWriter) -> Fut, + Fut: std::future::Future>, +{ + let path = path.as_ref(); + let file = tokio::fs::File::create(path).await.map_err(IOError::from)?; + let mut writer = tokio::io::BufWriter::new(file); + + let write_result = f(&mut writer).await; + + let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer) + .await + .map_err(IOError::from); + + write_result?; + flush_result?; + + Ok(()) +} + +/// Streams into a scratch sibling renamed over `path` on success so `path` +/// never holds a truncated file and an existing good file survives a failure +/// +/// Unlike [`write_atomic`] this deliberately does not fsync too expensive +/// across thousands of asset downloads and nothing here must survive power loss +#[tracing::instrument( + level = "debug", + skip(path, stream), + fields(path = %path.as_ref().display()) +)] +pub async fn write_stream( + path: impl AsRef, + mut stream: S, + size_hint: Option, +) -> Result<(), E> +where + S: futures_lite::Stream> + Unpin + Send, + E: From, +{ + let path = path.as_ref(); + let tmp = temp_sibling(path); + + let write = async { + let file = tokio::fs::File::create(&tmp).await.map_err(IOError::from)?; + let mut writer = tokio::io::BufWriter::with_capacity(write_buffer_size(size_hint), file); + + while let Some(chunk_result) = futures_lite::StreamExt::next(&mut stream).await { + let chunk = chunk_result?; + tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk) + .await + .map_err(IOError::from)?; + } + + tokio::io::AsyncWriteExt::flush(&mut writer) + .await + .map_err(IOError::from)?; + + Ok::<_, E>(()) + } + .await; + + if let Err(err) = write { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + if let Err(err) = rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(E::from(err)); + } + + Ok(()) +} + +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_json( + path: impl AsRef, + data: T, +) -> PolyIOResult<()> { + write( + &path, + serde_json::to_vec(&data).map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf(), + })?, + ) + .await +} + +/// Keeps two concurrent atomic writes to the same path off the same scratch file +static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Sibling rather than temp dir `rename` across mount points fails with +/// `EXDEV` and `/tmp` is often a separate tmpfs on Linux +fn temp_sibling(path: &Path) -> PathBuf { + let n = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); + let stem = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "tmp".to_string()); + + let name = format!(".{stem}.{}.{n}.tmp", std::process::id()); + match path.parent() { + Some(parent) => parent.join(name), + None => PathBuf::from(name), + } +} + +/// Readers see either the old contents or the complete new ones +/// The fsync before the rename stops a crash leaving a correctly-named zero-length file +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_atomic(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); + + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + create_dir_all(parent).await?; + } + + let tmp = temp_sibling(path); + let ctx = |e: std::io::Error| IOError::PathIOError { + source: e, + path: tmp.to_string_lossy().to_string(), + }; + + let write = async { + let mut file = tokio::fs::File::create(&tmp).await.map_err(ctx)?; + tokio::io::AsyncWriteExt::write_all(&mut file, data.as_ref()) + .await + .map_err(ctx)?; + file.sync_all().await.map_err(ctx)?; + Ok::<_, IOError>(()) + } + .await; + + if let Err(err) = write { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + if let Err(err) = rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + Ok(()) +} + +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_json_atomic(path: impl AsRef, data: T) -> PolyIOResult<()> { + let bytes = serde_json::to_vec(&data).map_err(|err| IOError::JsonFileWrite { + source: err, + file: path.as_ref().to_path_buf(), + })?; + + write_atomic(path, bytes).await +} + +/// Traversal guard for externally supplied paths +/// `Ok(None)` when the path resolves outside every root +/// `Err` only when `path` cannot be canonicalised +/// Roots that cannot be canonicalised (e.g. not yet created) are skipped +#[tracing::instrument( + level = "debug", + skip(path, roots), + fields(path = %path.as_ref().display()) +)] +pub fn ensure_under>( + path: impl AsRef, + roots: impl IntoIterator, +) -> PolyIOResult> { + // Not `std::fs::canonicalize` its Windows `\\?\` UNC output would never + // `starts_with` the plain-path roots so everything would look like an escape + let canon = crate::canonicalize(path)?; + + for root in roots { + if let Ok(root) = crate::canonicalize(root) + && canon.starts_with(&root) + { + return Ok(Some(canon)); + } + } + + Ok(None) +} + +/// `exclude_top` applies only at the top level a nested directory of the same +/// name is still copied +/// Symlinks are followed and copied as their contents +#[tracing::instrument(level = "debug", skip(exclude_top))] +pub async fn copy_dir(src: &Path, dst: &Path, exclude_top: &[&str]) -> PolyIOResult<()> { + let mut stack: Vec<(PathBuf, PathBuf, bool)> = + vec![(src.to_path_buf(), dst.to_path_buf(), true)]; + + while let Some((cur_src, cur_dst, is_top)) = stack.pop() { + let mut entries = read_dir(&cur_src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + + if is_top + && let Some(name_str) = name.to_str() + && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) + { + continue; + } + + let child_src = entry.path(); + let child_dst = cur_dst.join(&name); + let file_type = entry.file_type().await?; + + if file_type.is_dir() { + create_dir_all(&child_dst).await?; + + stack.push((child_src, child_dst, false)); + } else if file_type.is_file() { + if let Some(parent) = child_dst.parent() { + create_dir_all(parent).await?; + } + + copy(&child_src, &child_dst).await?; + } + } + } + + Ok(()) +} + +pub async fn dir_has_content(dir: &Path) -> bool { + if !dir.is_dir() { + return false; + } + + match read_dir(dir).await { + Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), + Err(_) => false, + } +} + +/// Replaces `to` if it already exists +#[tracing::instrument( + level = "debug", + skip(from, to), + fields( + from = %from.as_ref().display(), + to = %to.as_ref().display() + ) +)] +pub async fn rename( + from: impl AsRef, + to: impl AsRef, +) -> PolyIOResult<()> { + let from = from.as_ref(); + let to = to.as_ref(); + + tokio::fs::rename(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) +} + +/// Also copies permission bits and overwrites `to` +#[tracing::instrument( + level = "debug", + skip(from, to), + fields( + from = %from.as_ref().display(), + to = %to.as_ref().display() + ) +)] +pub async fn copy( + from: impl AsRef, + to: impl AsRef, +) -> PolyIOResult { + let from = from.as_ref(); + let to = to.as_ref(); + + tokio::fs::copy(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + tokio::fs::remove_file(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +/// Unlike [`stat`] returns metadata about the link itself not its target +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn symlink_metadata(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + tokio::fs::symlink_metadata(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +/// Symlink on Unix hard link on Windows so both paths must be on one volume +#[tracing::instrument( + level = "debug", + skip(original, link), + fields( + original = %original.as_ref().display(), + link = %link.as_ref().display() + ) +)] +pub async fn symlink_file( + original: impl AsRef, + link: impl AsRef, +) -> PolyIOResult<()> { + let original = original.as_ref(); + let link = link.as_ref(); + + #[cfg(windows)] + let res = tokio::fs::hard_link(original, link).await; + #[cfg(not(windows))] + let res = tokio::fs::symlink(original, link).await; + + res.map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) +} + +/// Windows gets a junction which needs no elevated privilege unlike a real +/// directory symlink +/// Remove with [`remove_symlink_dir`] +#[tracing::instrument( + level = "debug", + skip(original, link), + fields( + original = %original.as_ref().display(), + link = %link.as_ref().display() + ) +)] +pub async fn symlink_dir( + original: impl AsRef, + link: impl AsRef, +) -> PolyIOResult<()> { + let original = original.as_ref(); + let link = link.as_ref(); + + #[cfg(windows)] + { + let path = link.to_string_lossy().to_string(); + let original = original.to_path_buf(); + let link = link.to_path_buf(); + return tokio::task::spawn_blocking(move || junction::create(&original, &link)) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { source: e, path }); + } + + #[cfg(not(windows))] + tokio::fs::symlink(original, link) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) +} + +/// A Windows junction must be removed with `remove_dir` not `remove_file` +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_symlink_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + #[cfg(windows)] + let res = tokio::fs::remove_dir(path).await; + + #[cfg(not(windows))] + let res = tokio::fs::remove_file(path).await; + + res.map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument(level = "debug")] +pub async fn tempdir() -> PolyIOResult { + Ok(TempDir::new().await?) +} + +#[tracing::instrument(level = "debug")] +pub async fn tempfile() -> PolyIOResult { + Ok(TempFile::new().await?) +} + +/// Sanitises every component and normalises separators to `/` +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub fn sanitize_path(path: impl AsRef) -> PathBuf { + path.as_ref() + .to_string_lossy() + .replace('\\', "/") + .split('/') + .map(sanitize_filename::sanitize) + .collect() +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn stat(path: impl AsRef) -> PolyIOResult { + tokio::fs::metadata(path).await.map_err(IOError::from) +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_buffer_fits_the_file() { + assert_eq!(write_buffer_size(Some(10_217)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(1)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(64 * 1024)), 64 * 1024); + assert_eq!(write_buffer_size(Some(25_000_000)), MAX_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(u64::MAX)), MAX_WRITE_BUFFER); + } + + #[test] + fn write_buffer_falls_back_without_a_length() { + assert_eq!(write_buffer_size(None), DEFAULT_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); + } + + fn scratch(tag: &str) -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "polyio-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn write_atomic_creates_missing_parents() { + let dir = scratch("atomic-parents"); + let target = dir.join("a").join("b").join("settings.json"); + + write_atomic(&target, b"{}").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"{}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_atomic_replaces_and_leaves_no_scratch_files() { + let dir = scratch("atomic-replace"); + let target = dir.join("settings.json"); + + write_atomic(&target, b"old").await.unwrap(); + write_atomic(&target, b"new-and-longer").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new-and-longer"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "settings.json") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + fn failing_stream( + chunks: Vec<&'static [u8]>, + ) -> impl futures_lite::Stream> + Unpin + Send { + let items = chunks + .into_iter() + .map(|c| Ok(bytes::Bytes::from_static(c))) + .chain(std::iter::once(Err(IOError::IOError( + std::io::Error::from(std::io::ErrorKind::ConnectionReset), + )))); + + Box::pin(futures_lite::stream::iter(items)) + } + + #[tokio::test] + async fn write_stream_publishes_only_a_complete_file() { + let dir = scratch("stream-ok"); + let target = dir.join("object.bin"); + + let chunks = vec![ + Ok(bytes::Bytes::from_static(b"hello ")), + Ok(bytes::Bytes::from_static(b"world")), + ]; + let stream = Box::pin(futures_lite::stream::iter(chunks)); + + write_stream::<_, IOError>(&target, stream, Some(11)) + .await + .unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"hello world"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "object.bin") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_leaves_nothing_when_the_stream_dies() { + let dir = scratch("stream-drop"); + let target = dir.join("object.bin"); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert!(!target.exists(), "left a truncated file at the destination"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() { + let dir = scratch("stream-keep"); + let target = dir.join("object.bin"); + std::fs::write(&target, b"known-good").unwrap(); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert_eq!(std::fs::read(&target).unwrap(), b"known-good"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_accepts_a_path_inside_a_root() { + let dir = scratch("under-inside"); + let nested = dir.join("clusters").join("logs"); + std::fs::create_dir_all(&nested).unwrap(); + let file = nested.join("latest.log"); + std::fs::write(&file, b"").unwrap(); + + let resolved = ensure_under(&file, [&dir]).unwrap(); + assert!(resolved.is_some()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_rejects_a_traversal_out_of_every_root() { + let dir = scratch("under-escape"); + let root = dir.join("clusters"); + let outside = dir.join("secrets"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let file = outside.join("auth.json"); + std::fs::write(&file, b"").unwrap(); + + let sneaky = root.join("..").join("secrets").join("auth.json"); + assert_eq!(ensure_under(&sneaky, [&root]).unwrap(), None); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_skips_roots_that_do_not_exist() { + let dir = scratch("under-missing-root"); + let file = dir.join("a.log"); + std::fs::write(&file, b"").unwrap(); + + let missing = dir.join("not-created-yet"); + let resolved = ensure_under(&file, [&missing, &dir]).unwrap(); + assert!( + resolved.is_some(), + "a missing root must not shadow a real one" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn copy_dir_excludes_only_at_the_top_level() { + let dir = scratch("copy-dir"); + let src = dir.join("src"); + let dst = dir.join("dst"); + std::fs::create_dir_all(src.join("mods")).unwrap(); + std::fs::create_dir_all(src.join("keep").join("mods")).unwrap(); + std::fs::write(src.join("mods").join("top.jar"), b"top").unwrap(); + std::fs::write(src.join("keep").join("mods").join("nested.jar"), b"nested").unwrap(); + std::fs::write(src.join("options.txt"), b"opts").unwrap(); + + copy_dir(&src, &dst, &["mods"]).await.unwrap(); + + assert!( + !dst.join("mods").exists(), + "top-level `mods` should be excluded" + ); + assert!(dst.join("options.txt").exists()); + assert!( + dst.join("keep").join("mods").join("nested.jar").exists(), + "exclusion must not apply below the top level" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/packages/polyio/src/hash.rs b/packages/polyio/src/hash.rs new file mode 100644 index 00000000..e596d554 --- /dev/null +++ b/packages/polyio/src/hash.rs @@ -0,0 +1,252 @@ +use std::fmt::Write; +use std::io::Read; +use std::path::Path; + +use digest::Digest; + +use crate::{IOError, PolyIOResult}; + +/// Upper bound only smaller files get a buffer sized to fit +const MAX_HASH_BUFFER: usize = 256 * 1024; + +pub struct Sha1Stream(sha1::Sha1); + +impl Sha1Stream { + #[must_use] + pub fn new() -> Self { + Self(sha1::Sha1::new()) + } + + pub fn update(&mut self, data: &[u8]) { + Digest::update(&mut self.0, data); + } + + #[must_use] + pub fn finish(self) -> String { + to_hex(&self.0.finalize()) + } +} + +impl Default for Sha1Stream { + fn default() -> Self { + Self::new() + } +} + +/// Runs on the blocking pool one dispatch for the whole file keeping the +/// digest off the async workers driving concurrent downloads +pub async fn sha1_file(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref().to_path_buf(); + tokio::task::spawn_blocking(move || sha1_file_sync(&path)) + .await + .map_err(std::io::Error::other)? +} + +pub fn sha1_file_sync(path: &Path) -> PolyIOResult { + let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + let mut hasher = Sha1Stream::new(); + + let size = file + .metadata() + .map(|meta| meta.len()) + .unwrap_or(MAX_HASH_BUFFER as u64); + let capacity = (size.max(1).min(MAX_HASH_BUFFER as u64)) as usize; + let mut buffer = vec![0u8; capacity]; + + loop { + let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + + Ok(hasher.finish()) +} + +#[must_use] +pub fn sha1_bytes(data: &[u8]) -> String { + let mut hasher = Sha1Stream::new(); + hasher.update(data); + hasher.finish() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChecksumAlgorithm { + Sha1, + Sha256, +} + +impl ChecksumAlgorithm { + /// Hex length which is how a bare manifest string with no algorithm field + /// gets identified + #[must_use] + pub const fn hex_len(self) -> usize { + match self { + Self::Sha1 => 40, + Self::Sha256 => 64, + } + } + + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Sha1 => "SHA-1", + Self::Sha256 => "SHA-256", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Checksum { + pub algorithm: ChecksumAlgorithm, + /// Always normalized (trimmed lowercase) + pub hex: String, +} + +impl Checksum { + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef) -> Self { + Self { + algorithm, + hex: normalize_hash(hex.as_ref()), + } + } + + #[must_use] + pub fn sha1(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha1, hex) + } + + #[must_use] + pub fn sha256(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha256, hex) + } + + /// Whether the hash is the right shape for its algorithm + /// Callers drop malformed checksums and download unverified + #[must_use] + pub fn is_well_formed(&self) -> bool { + self.hex.len() == self.algorithm.hex_len() + && self.hex.bytes().all(|b| b.is_ascii_hexdigit()) + } + + #[must_use] + pub fn matches(&self, actual: &str) -> bool { + self.hex == normalize_hash(actual) + } +} + +pub enum ChecksumStream { + Sha1(sha1::Sha1), + Sha256(sha2::Sha256), +} + +impl ChecksumStream { + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm) -> Self { + match algorithm { + ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()), + ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()), + } + } + + pub fn update(&mut self, data: &[u8]) { + match self { + Self::Sha1(hasher) => Digest::update(hasher, data), + Self::Sha256(hasher) => Digest::update(hasher, data), + } + } + + #[must_use] + pub fn finish(self) -> String { + match self { + Self::Sha1(hasher) => to_hex(&hasher.finalize()), + Self::Sha256(hasher) => to_hex(&hasher.finalize()), + } + } +} + +#[must_use] +pub fn normalize_hash(hash: &str) -> String { + hash.trim().to_ascii_lowercase() +} + +#[must_use] +pub fn to_hex(data: &[u8]) -> String { + data.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha1_bytes_matches_known_vector() { + assert_eq!( + sha1_bytes(b"abc"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!(sha1_bytes(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + } + + #[test] + fn stream_and_oneshot_agree() { + let mut stream = Sha1Stream::new(); + stream.update(b"ab"); + stream.update(b"c"); + assert_eq!(stream.finish(), sha1_bytes(b"abc")); + } + + #[test] + fn normalize_hash_trims_and_lowercases() { + assert_eq!(normalize_hash(" A9B2 "), "a9b2"); + } + + #[test] + fn checksum_streams_match_their_one_shot_digests() { + let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1); + sha1.update(b"ab"); + sha1.update(b"c"); + assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d"); + + let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256); + sha256.update(b"ab"); + sha256.update(b"c"); + assert_eq!( + sha256.finish(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn checksum_comparison_ignores_case_and_padding() { + let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D "); + assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d")); + assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D")); + assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709")); + } + + #[test] + fn a_hash_of_the_wrong_shape_is_not_well_formed() { + assert!(!Checksum::sha256("").is_well_formed()); + assert!(!Checksum::sha256("n/a").is_well_formed()); + assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed()); + + assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!( + Checksum::sha256("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + .is_well_formed() + ); + } +} diff --git a/packages/polyio/src/lib.rs b/packages/polyio/src/lib.rs new file mode 100644 index 00000000..00b33596 --- /dev/null +++ b/packages/polyio/src/lib.rs @@ -0,0 +1,15 @@ +mod archive; +mod error; +mod file; +mod hash; +mod system; + +#[cfg(feature = "testing")] +pub mod testing; + +pub use archive::*; +pub use error::IOError; +pub(crate) use error::PolyIOResult; +pub use file::*; +pub use hash::*; +pub use system::*; diff --git a/packages/polyio/src/system.rs b/packages/polyio/src/system.rs new file mode 100644 index 00000000..61fc8fbd --- /dev/null +++ b/packages/polyio/src/system.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +use crate::{IOError, PolyIOResult}; + +pub fn env_path(name: &str) -> Option { + std::env::var_os(name).map(PathBuf::from) +} + +/// Like [`std::fs::canonicalize`] but on Windows emits the most compatible +/// path form instead of UNC +pub fn canonicalize(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + dunce::canonicalize(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} diff --git a/packages/polyio/src/testing.rs b/packages/polyio/src/testing.rs new file mode 100644 index 00000000..ab7aee91 --- /dev/null +++ b/packages/polyio/src/testing.rs @@ -0,0 +1,44 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// A uniquely-named directory under the system temp dir removed when dropped +#[derive(Debug)] +pub struct ScratchDir(PathBuf); + +impl ScratchDir { + /// `tag` only makes a leaked directory identifiable if the destructor is + /// skipped + #[must_use] + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + + let path = std::env::temp_dir().join(format!("oneclient-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&path) + .unwrap_or_else(|e| panic!("failed to create scratch dir {}: {e}", path.display())); + + Self(path) + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.0 + } + + #[must_use] + pub fn join(&self, name: impl AsRef) -> PathBuf { + self.0.join(name) + } +} + +impl AsRef for ScratchDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +}

    KfsX(BOgRngWUL%(~R@mdh7%(=1 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 + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/alert-triangle.svg b/packages/oneclient_app/assets/icons/alert-triangle.svg new file mode 100644 index 00000000..281d2121 --- /dev/null +++ b/packages/oneclient_app/assets/icons/alert-triangle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/announcement-01.svg b/packages/oneclient_app/assets/icons/announcement-01.svg new file mode 100644 index 00000000..5165fe45 --- /dev/null +++ b/packages/oneclient_app/assets/icons/announcement-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/arrow-left.svg b/packages/oneclient_app/assets/icons/arrow-left.svg new file mode 100644 index 00000000..57c201ac --- /dev/null +++ b/packages/oneclient_app/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/arrow-right.svg b/packages/oneclient_app/assets/icons/arrow-right.svg new file mode 100644 index 00000000..b6817867 --- /dev/null +++ b/packages/oneclient_app/assets/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/bell-01.svg b/packages/oneclient_app/assets/icons/bell-01.svg new file mode 100644 index 00000000..15ea8bfa --- /dev/null +++ b/packages/oneclient_app/assets/icons/bell-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/brush-01.svg b/packages/oneclient_app/assets/icons/brush-01.svg new file mode 100644 index 00000000..41a52dd9 --- /dev/null +++ b/packages/oneclient_app/assets/icons/brush-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/calendar.svg b/packages/oneclient_app/assets/icons/calendar.svg new file mode 100644 index 00000000..29d372a6 --- /dev/null +++ b/packages/oneclient_app/assets/icons/calendar.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/check-circle.svg b/packages/oneclient_app/assets/icons/check-circle.svg new file mode 100644 index 00000000..3f3eaa2d --- /dev/null +++ b/packages/oneclient_app/assets/icons/check-circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/check.svg b/packages/oneclient_app/assets/icons/check.svg new file mode 100644 index 00000000..181c08f2 --- /dev/null +++ b/packages/oneclient_app/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/chevron-down.svg b/packages/oneclient_app/assets/icons/chevron-down.svg new file mode 100644 index 00000000..695ac75e --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/chevron-right.svg b/packages/oneclient_app/assets/icons/chevron-right.svg new file mode 100644 index 00000000..4f862067 --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/chevrons-left.svg b/packages/oneclient_app/assets/icons/chevrons-left.svg new file mode 100644 index 00000000..ef974f1d --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevrons-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/oneclient_app/assets/icons/chevrons-right.svg b/packages/oneclient_app/assets/icons/chevrons-right.svg new file mode 100644 index 00000000..777101a2 --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevrons-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/oneclient_app/assets/icons/clipboard-check.svg b/packages/oneclient_app/assets/icons/clipboard-check.svg new file mode 100644 index 00000000..07f6ce97 --- /dev/null +++ b/packages/oneclient_app/assets/icons/clipboard-check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/clock-rewind.svg b/packages/oneclient_app/assets/icons/clock-rewind.svg new file mode 100644 index 00000000..52c9fdff --- /dev/null +++ b/packages/oneclient_app/assets/icons/clock-rewind.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/code-snippet-02.svg b/packages/oneclient_app/assets/icons/code-snippet-02.svg new file mode 100644 index 00000000..40b3d670 --- /dev/null +++ b/packages/oneclient_app/assets/icons/code-snippet-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/colors.svg b/packages/oneclient_app/assets/icons/colors.svg new file mode 100644 index 00000000..d025a237 --- /dev/null +++ b/packages/oneclient_app/assets/icons/colors.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/copy-01.svg b/packages/oneclient_app/assets/icons/copy-01.svg new file mode 100644 index 00000000..bd55d6e2 --- /dev/null +++ b/packages/oneclient_app/assets/icons/copy-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/curseforge.svg b/packages/oneclient_app/assets/icons/curseforge.svg new file mode 100644 index 00000000..cf2e4d3b --- /dev/null +++ b/packages/oneclient_app/assets/icons/curseforge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/database-01.svg b/packages/oneclient_app/assets/icons/database-01.svg new file mode 100644 index 00000000..c5ff2e7e --- /dev/null +++ b/packages/oneclient_app/assets/icons/database-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/dots-grid.svg b/packages/oneclient_app/assets/icons/dots-grid.svg new file mode 100644 index 00000000..cdd3e0cd --- /dev/null +++ b/packages/oneclient_app/assets/icons/dots-grid.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/dots-vertical.svg b/packages/oneclient_app/assets/icons/dots-vertical.svg new file mode 100644 index 00000000..be024563 --- /dev/null +++ b/packages/oneclient_app/assets/icons/dots-vertical.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/download-01.svg b/packages/oneclient_app/assets/icons/download-01.svg new file mode 100644 index 00000000..2a0b2606 --- /dev/null +++ b/packages/oneclient_app/assets/icons/download-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/download-cloud-02.svg b/packages/oneclient_app/assets/icons/download-cloud-02.svg new file mode 100644 index 00000000..6b022447 --- /dev/null +++ b/packages/oneclient_app/assets/icons/download-cloud-02.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/oneclient_app/assets/icons/eye.svg b/packages/oneclient_app/assets/icons/eye.svg new file mode 100644 index 00000000..9491da74 --- /dev/null +++ b/packages/oneclient_app/assets/icons/eye.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/oneclient_app/assets/icons/file-02.svg b/packages/oneclient_app/assets/icons/file-02.svg new file mode 100644 index 00000000..4c73a639 --- /dev/null +++ b/packages/oneclient_app/assets/icons/file-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/file-plus-02.svg b/packages/oneclient_app/assets/icons/file-plus-02.svg new file mode 100644 index 00000000..0839c52c --- /dev/null +++ b/packages/oneclient_app/assets/icons/file-plus-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/file-x-02.svg b/packages/oneclient_app/assets/icons/file-x-02.svg new file mode 100644 index 00000000..7bd48be3 --- /dev/null +++ b/packages/oneclient_app/assets/icons/file-x-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/folder-check.svg b/packages/oneclient_app/assets/icons/folder-check.svg new file mode 100644 index 00000000..a8e6d11b --- /dev/null +++ b/packages/oneclient_app/assets/icons/folder-check.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/oneclient_app/assets/icons/folder-download.svg b/packages/oneclient_app/assets/icons/folder-download.svg new file mode 100644 index 00000000..4f931279 --- /dev/null +++ b/packages/oneclient_app/assets/icons/folder-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/oneclient_app/assets/icons/folder.svg b/packages/oneclient_app/assets/icons/folder.svg new file mode 100644 index 00000000..d25758e4 --- /dev/null +++ b/packages/oneclient_app/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/globe-01.svg b/packages/oneclient_app/assets/icons/globe-01.svg new file mode 100644 index 00000000..b69f30de --- /dev/null +++ b/packages/oneclient_app/assets/icons/globe-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/help-circle.svg b/packages/oneclient_app/assets/icons/help-circle.svg new file mode 100644 index 00000000..68c90ad0 --- /dev/null +++ b/packages/oneclient_app/assets/icons/help-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/info-circle.svg b/packages/oneclient_app/assets/icons/info-circle.svg new file mode 100644 index 00000000..8ac263ef --- /dev/null +++ b/packages/oneclient_app/assets/icons/info-circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/key-01.svg b/packages/oneclient_app/assets/icons/key-01.svg new file mode 100644 index 00000000..65157a49 --- /dev/null +++ b/packages/oneclient_app/assets/icons/key-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/layout-top.svg b/packages/oneclient_app/assets/icons/layout-top.svg new file mode 100644 index 00000000..b3aef099 --- /dev/null +++ b/packages/oneclient_app/assets/icons/layout-top.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/link-03.svg b/packages/oneclient_app/assets/icons/link-03.svg new file mode 100644 index 00000000..45526d26 --- /dev/null +++ b/packages/oneclient_app/assets/icons/link-03.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/link-external-01.svg b/packages/oneclient_app/assets/icons/link-external-01.svg new file mode 100644 index 00000000..db60113b --- /dev/null +++ b/packages/oneclient_app/assets/icons/link-external-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/loading-02.svg b/packages/oneclient_app/assets/icons/loading-02.svg new file mode 100644 index 00000000..60e67672 --- /dev/null +++ b/packages/oneclient_app/assets/icons/loading-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/maximize-01.svg b/packages/oneclient_app/assets/icons/maximize-01.svg new file mode 100644 index 00000000..12bd5818 --- /dev/null +++ b/packages/oneclient_app/assets/icons/maximize-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/message-text-square-01.svg b/packages/oneclient_app/assets/icons/message-text-square-01.svg new file mode 100644 index 00000000..cbb0ad05 --- /dev/null +++ b/packages/oneclient_app/assets/icons/message-text-square-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/minus.svg b/packages/oneclient_app/assets/icons/minus.svg new file mode 100644 index 00000000..39867488 --- /dev/null +++ b/packages/oneclient_app/assets/icons/minus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/modrinth.svg b/packages/oneclient_app/assets/icons/modrinth.svg new file mode 100644 index 00000000..a8e487dd --- /dev/null +++ b/packages/oneclient_app/assets/icons/modrinth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/onboarding-account.svg b/packages/oneclient_app/assets/icons/onboarding-account.svg new file mode 100644 index 00000000..1c13a38d --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-account.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/onboarding-complete.svg b/packages/oneclient_app/assets/icons/onboarding-complete.svg new file mode 100644 index 00000000..ce9c4b37 --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-complete.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/onboarding-language.svg b/packages/oneclient_app/assets/icons/onboarding-language.svg new file mode 100644 index 00000000..bd37210b --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-language.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/onboarding-preferences.svg b/packages/oneclient_app/assets/icons/onboarding-preferences.svg new file mode 100644 index 00000000..b3bfbbdb --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-preferences.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/onboarding-welcome.svg b/packages/oneclient_app/assets/icons/onboarding-welcome.svg new file mode 100644 index 00000000..095f28d7 --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-welcome.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/paint-pour.svg b/packages/oneclient_app/assets/icons/paint-pour.svg new file mode 100644 index 00000000..19f5388e --- /dev/null +++ b/packages/oneclient_app/assets/icons/paint-pour.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/paragraph-wrap.svg b/packages/oneclient_app/assets/icons/paragraph-wrap.svg new file mode 100644 index 00000000..c6ab0bce --- /dev/null +++ b/packages/oneclient_app/assets/icons/paragraph-wrap.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/pencil-01.svg b/packages/oneclient_app/assets/icons/pencil-01.svg new file mode 100644 index 00000000..5b1df4b7 --- /dev/null +++ b/packages/oneclient_app/assets/icons/pencil-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/play.svg b/packages/oneclient_app/assets/icons/play.svg new file mode 100644 index 00000000..25123712 --- /dev/null +++ b/packages/oneclient_app/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/plus.svg b/packages/oneclient_app/assets/icons/plus.svg new file mode 100644 index 00000000..7339441f --- /dev/null +++ b/packages/oneclient_app/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/refresh-ccw-02.svg b/packages/oneclient_app/assets/icons/refresh-ccw-02.svg new file mode 100644 index 00000000..3352efc5 --- /dev/null +++ b/packages/oneclient_app/assets/icons/refresh-ccw-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/refresh-cw-01.svg b/packages/oneclient_app/assets/icons/refresh-cw-01.svg new file mode 100644 index 00000000..dde1891c --- /dev/null +++ b/packages/oneclient_app/assets/icons/refresh-cw-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/rocket-02.svg b/packages/oneclient_app/assets/icons/rocket-02.svg new file mode 100644 index 00000000..9f5bca64 --- /dev/null +++ b/packages/oneclient_app/assets/icons/rocket-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/search-md.svg b/packages/oneclient_app/assets/icons/search-md.svg new file mode 100644 index 00000000..0ac7a594 --- /dev/null +++ b/packages/oneclient_app/assets/icons/search-md.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/settings-01.svg b/packages/oneclient_app/assets/icons/settings-01.svg new file mode 100644 index 00000000..62d1fbbc --- /dev/null +++ b/packages/oneclient_app/assets/icons/settings-01.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/settings-02.svg b/packages/oneclient_app/assets/icons/settings-02.svg new file mode 100644 index 00000000..6f46f18b --- /dev/null +++ b/packages/oneclient_app/assets/icons/settings-02.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/settings-04.svg b/packages/oneclient_app/assets/icons/settings-04.svg new file mode 100644 index 00000000..e859b2b3 --- /dev/null +++ b/packages/oneclient_app/assets/icons/settings-04.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/sliders-04.svg b/packages/oneclient_app/assets/icons/sliders-04.svg new file mode 100644 index 00000000..4da984c5 --- /dev/null +++ b/packages/oneclient_app/assets/icons/sliders-04.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/square.svg b/packages/oneclient_app/assets/icons/square.svg new file mode 100644 index 00000000..b58884f8 --- /dev/null +++ b/packages/oneclient_app/assets/icons/square.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/terminal.svg b/packages/oneclient_app/assets/icons/terminal.svg new file mode 100644 index 00000000..c7f2b7a5 --- /dev/null +++ b/packages/oneclient_app/assets/icons/terminal.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/trash-01.svg b/packages/oneclient_app/assets/icons/trash-01.svg new file mode 100644 index 00000000..4c97afc4 --- /dev/null +++ b/packages/oneclient_app/assets/icons/trash-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/users-01.svg b/packages/oneclient_app/assets/icons/users-01.svg new file mode 100644 index 00000000..7fe7aad8 --- /dev/null +++ b/packages/oneclient_app/assets/icons/users-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/x-close.svg b/packages/oneclient_app/assets/icons/x-close.svg new file mode 100644 index 00000000..a42153e7 --- /dev/null +++ b/packages/oneclient_app/assets/icons/x-close.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/x.svg b/packages/oneclient_app/assets/icons/x.svg new file mode 100644 index 00000000..0fb0af9e --- /dev/null +++ b/packages/oneclient_app/assets/icons/x.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/logo.svg b/packages/oneclient_app/assets/logo.svg new file mode 100644 index 00000000..3f305562 --- /dev/null +++ b/packages/oneclient_app/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/oneclient_app/assets/steve.png b/packages/oneclient_app/assets/steve.png new file mode 100644 index 0000000000000000000000000000000000000000..1607786076eca0c9cff6bf8fba03e1542855a359 GIT binary patch literal 848 zcmV-W1F!svP)VU|8(g&wax)fR~o(w}5|n8V7*Q z232^zAWm+AZk{iwSp)EV0c$hZ^97qSuWq*O<~5#QqSao_yv`GO0T5Q&X6HPSy)-p5 z6m4Q@>%SL&VU_I^ZO2r@K@OJ$+w7RVzS{BX)i>>!!^p@5u8z}oa$URP=C+QNX=Znq z5;*AXb+xOV)%DvrI5WA#i=n(>?|ia1YAjm1C^*-ft~+n9m*1S7$Ltpkxy-(~(e>x+ z&f}_e?mw?TUw@uge8lfAuJz!1+{s@7$J?o1%lTp8&gmz892PJLw8F2+(k{)`C0~j1|k4 z(O_9)6jEE_p}=4^j7=H?BTE>V01oqjv1r;D2oTtVQ^*htFwU7+h7=0~xEByeFsQZ| zX(gc11Hn0bdWYI{+hT5C9J)1EOScEGz`7Txc_$HJrsIw~v$I~@9afXAHRdb7Cict^ z--msmuibs+(ja}0Tk{vd`|&sS$(I~F&Q<_$Or9gC4S)~M@c$=x-!Xeq2R~>N&FCY( z6$5_ca)N9GvtfkdX#xy@Gn-_1$P16Khf(%V0KCUO+`oR=K_jDukI-6RKVMGJ%)+v) zNTACd3>(;S42GEP86_GR2&l^?W;6r{Siofg3JD?b#9cV6ey-|_zyNkR!58<1HB9!{ aB>fMQEPx|ax^W@^0000 + + + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.cs.allow-dyld-environment-variables + + + com.apple.security.device.audio-input + + + com.apple.security.device.camera + + + diff --git a/packages/oneclient_app/distribution/Info.plist b/packages/oneclient_app/distribution/Info.plist new file mode 100644 index 00000000..52b863f3 --- /dev/null +++ b/packages/oneclient_app/distribution/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleIconName + OneClient + + CFBundleURLTypes + + + CFBundleURLName + OneClient + CFBundleURLSchemes + + oneclient + + + + + NSCameraUsageDescription + A Minecraft mod is requesting access to your camera. + + NSMicrophoneUsageDescription + A Minecraft mod is requesting access to your microphone. + + diff --git a/packages/oneclient_app/distribution/dmg-background.png b/packages/oneclient_app/distribution/dmg-background.png new file mode 100644 index 0000000000000000000000000000000000000000..ef311ae2e0cc0a39747f8eba881c007a15c37a79 GIT binary patch literal 9383 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 literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/distribution/dmg-background.tiff b/packages/oneclient_app/distribution/dmg-background.tiff new file mode 100644 index 0000000000000000000000000000000000000000..c75e71e4611f050fc43e0745b6019c72d1381e4c GIT binary patch literal 62738 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 literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/distribution/dmg-background@2x.png b/packages/oneclient_app/distribution/dmg-background@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4b868d8259b2908abb9169628b6eaf8fa1e0fc8a GIT binary patch literal 20695 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 literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/distribution/linux/org.polyfrost.oneclient.template b/packages/oneclient_app/distribution/linux/org.polyfrost.oneclient.template new file mode 100644 index 00000000..e43eb2ad --- /dev/null +++ b/packages/oneclient_app/distribution/linux/org.polyfrost.oneclient.template @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=OneClient +Comment=Next-generation open source Minecraft launcher +Exec={{exec}} +Icon={{icon}} +Type=Application +Terminal=false +StartupNotify=true +Categories=Game;ActionGame;AdventureGame;Simulation; +Keywords=mc;minecraft;game +StartupWMClass={{exec}} +Version=1.4 diff --git a/packages/oneclient_app/icons/128x128.png b/packages/oneclient_app/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..21b907527ce5ac9a02bef47445d9fe5fbfedd0d1 GIT binary patch literal 5626 zcmVvP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;xNDJpgGwPCcUl000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8p~5ict{zIJ~A02Mw-L_t(|+U=cblvL%J$A9m=wJ*Kz3*F5QE`Wld;!f0~ zMhqH_N+galaY>vxbIjsNG$bbzbBvS8nNdfBS&YUgiCZEXjiRVgAVJyJZn}ZqyXmd= z<=%HbRDmM5pu4KItGn<296nIhb#K-4{GaE2w)ee)Awo)N1uB7szR^e-~}wEF0uk~zz3`aegnKIga`}^0g@Jf zA@F_R4AaW99+9L+xF1+^B99Bf%4}}n&Ln45bQUE6bE0b2Q znDQKz(18uWt-uZ;gghYulFFZ-WY1JnhGPPIft!H!pC*A30Zsy50H&C78&lW=TrGsy zmXZL;HUA}Gfhn&siFLr@PsRkwr=tBIB-eZsV-sfpE0VfCngGdEZDsCIL*C1TbL|z=TNv6D9#nm;^8}PB13gz*0&yO%jXg zM4}q;xXvecKL`Q&xpq`l8RYe#uRly&`}isXuvirAHWjN?K~)7p2$KLKr*w!!HJbJh zuw`dIn|Av6s6K>0B8h3hCn!);WZ~EM&%*6i46pNPJ?gsTx2u|X_U*n;(g>>x9=l-9 zWGAzxdpLDw9(6Td>^2Ldl?$Vi0HJW44>x!7%<6Vt-xi`VEQ!g~e}B$Q8+N;T%pMPb9qlZd>EJ7umoaZnF;0iYB*16w>KhOE zSow$jJoSN}=BON0kTb2$iJ}~9q62=N&hGdz1Q0?{RpBCE6?Dao?-7%*Hze7+F~mz- znpnD^i?82MLtRbIs9G9EO%SN*l6C*=0p;(@ikK`9@=tw7xbw;=)i0iAxoZuq(K zQWvF;!6~veCVB8(Kew*f&HMl8K+~j2fZ>F$OWysUojV?ECeI^0YK{y=ex6d`R)t9~n6yPm0v#o>9^;4my zK&7xCMF!y48Hj*igZ>mAK(#2$p6Vi#vQBGE^608odi%mA0S24%zxGxutLq~fUu*Vc zCswQRPweg2>5oZ*I&^evh8C&_LEQuwmJADfs~e)cx~4TNYXezFfP*c59)7+PUxvZ6 zMZnC-Zd8K+Py(%;aeCvR0sC6xhRP8FYOCCoTQg%oK!=B)?_z(m&m_QcBj=dTs=qYz z!67Z}6ztP8Vl+qW}*qBtCWB6Vur1RF+*pb@8u0uM_ZLt)Apq<4Fb21i`2K;(WKbyB5 zLYf3P7XCntU;U|>y`jv-ldHh>mlfl388GyZ;uG#OXK~GA;VZe z2snLqF%R5P%`9(bVv9<6bafAV8v4dJ0m2cD-#y>V=7IE6BLST3QTW9zWqj?rNjU7r zDO)h4^XnIUZ1<;TT8{?*`(lvxPGfJRDuN3ZR`BSzCvr)pg-nvetv;RKJ=aWlg!=>| zMgS>cfuHpsc8zGCKu9Tj8G&^}}sExic~( zYj+B$aVeZX-NBNJOPGIJF&>XKr4pos^_zm+{)nGFgG@2=RQTD|POiPoi{&_>e>AGG zZD%ikezTod{^6(Ir_--7Btl<5&CbIsP9{HZL=I~08xaENy5zZ+_jBuGt)%k)D&SF| z%poXtDa@#}amrK|lWSZ|smr0N(u3Wm9(NvJO4z<9%v}!+u)cR_W>#&3|N5$%OU`v; zQIBuqvA9lGSBSlh1JpMK*j^uC+x`fx13Fz{NslH;FfBUSIH@XgQCQ%>?Y3e`?#3C+)U~d|)?Hz~ zyVA$IU3yyeg*pfP^o9fptb;59=(v2W2HL^7HK2GTeF< zi%Z_!5ahn60&E(P85Nf*ZScJ-99(m$7k8S&AyOuY5s&Ni4@Bwg4$;;TV&8!PyBb4m zX^imY3-VaLbPB2}#x?=sah?6mKJxSIcwAOgi-Iyps~(J?d;8-2@vnY<{Ewb1F1#LRmyCS^2F3P$#o$ffFYn#=p!0ZCS;xlZ_JK0WMwUr#N zimD`*vr*z5vWEbAQo&M}#5GAcA`^3aew_iIM$dpwdzVIiOPq~OI!!*E_GqHdKGK!6 zLJQQm1+%MFX4YD$C{`)RRmk-!cs&X^9u=oupjrekESp{~Rg4F_Em%wfncwGuPPJvYcy9yR1Q72Ft z9T3TY(JB)E>iya+2=f<5sX) z1&Yd%lvj-RB_NZ6N+c?2ZjZ5bca&GwMOeFEqdoTd27&o1%qvk?yuik3C)uf~wBmLu zD5}Vo^?mH8fl?;&|DE*_URV=i)utFN(a)9$c`95nS>^ggF6NwM$LlfEz^p(dD%r9t z%*q!7yjZUh80KhE1XU}!vb}42dC>_M&It}~!$$PFECM;+;&ef5? zfKTJ4H~c)XI!xm*?Z(Pi;kE@<{^d(v%1f=oR<_oqM2qYAgE4w~Bh*%VQ%*B|Rs{Im z4~bHQ;N1<~JpM`#RrxCOrn{Ip!9`VtlZsLo1^ISdP78`6hTI+%K(1HihAVTJRBh$n zC;V*a&8!(zV~6{`J&V%%*6!@+{YaZIXaU`6tV(=7~MT#TG|3Mw}si=7~rG5VOsh%Izy7~ zc;aZgan}xp!3~56z#*W_E+}#cCY4y3J=Mw7IyciM8_;XugleG&fSp#j!* z4J|0BbHFdY=HY@f-KgsEmz`Klr?oA>?uLH$GzQqRC&;drD4hYFj)=ssr>(o+?66|~zM-8Pf6~Z-G^;szDqKFr&Nr4;Gj~=| z$^c5!Vf7jxcRm@U=LF3Ii-7N)XXCD~=Hal5lpd_{fRD$YNgP<&5=}48sIm#3`c5t9 zo>@Lp8O6wzf`x#&vx~XuY;W45$$c99epiIA{iK1XpKl}>GVTSd3S7L<%`Nk+CoCyn zT%oY+DsM{VYg%F(+buuXdE~&#bd;|EOBZ;Ue_HW~rjsLD!f@DCmM*DcR))K6b_XSQ zKiR>ro^D7<0*6Ck* zV}#Ea9`PF1ztQ+m zCPjfoXS)2^=a*Z!>e9*+j+>7Q0sxd0Ir+wQ zWfWU77f5|r@;^^C)6*MHaW4zko@qPgho3t^<>YC`k$5zw^VACmc(X0@d9y+lzIk0a zr6sOx5PfzKKnTG(3(B}=mUCG8;;-(D^7i}fhI{Z;186zJf7@%V0%_IGEbRNz^#k&7>=cJ!@(F0jr}Pz zg7Z)Lct7EcB8AE_OUi=PU*7j+Jc_0QH(!{`sV5bZO-;nCB7oJZaP1eXII}SG0kD>s zWM{oEztk`VEqBzjvr=c(N(bF>uRIb0W3Y#^%h6l24l%cxP!<|=` zkl`5&XcD&X^`|_AqrAjIz9nIqwUw&j6pxg!zd1lhEHn4-R^ZMh#ne=LvrXLDM*u|; zTylN|S4_#gHomzbobp`0{2YZ`tH7;5X@T)BA6=L1I}l8{vv;t#e3FezFRU1eX3yv& z06<=@o!hRhpvsn+zz+0k9O^Mn2fZGZTzg`4JJ*})U#XG%lM>P~Z}{EdpZ+`M zo?uqbvre6q$8A>>q}?ZoN!ZgEFn%VG5ESJqoM_{kE2^=m zib;S>P+aKbp5-;n%}cK~u%|u7fG=uDfZ}|W@~^n2mz;_RLBG0n855b<_0>0I$hlTj7r)%j5C3P?&JlGi{649_)6m$vkm<|H07C~vTg}5eB4lme3 zqHB`o_Aqnvl%t4YRl#Ey%$e+D)^rc2&dj53f)~5pGHUk;j)~2ohqqqEVmgtiM*Pz= zfr2ADiw1W+=<5$3(@3&d6znz?Yli0-W)a5D?qCz?5kN6zFuq|Dz$AbPlK>`60+=uf zV8SGT36lUOOahoN31DIz5g=-cVca6B0R5&M#w~gk;Gijoaf=oO*kQ_H++wE!tOw$z zEXEz;z&ZtZ5Ad1t7B-EcDNAq2C%`D z%ecWh;7v$Y0)-F(;KxAFl+Bn&0JuM?^P@_^Ym!TWZpvuPLQm>=&EXf0nroI)mH;mS z3rty!S=YZ<2+?`?g`=85$v2h*drVo4IqU_Ne^U9!5Fm-|z_R2AOpFce0d7j_mg5pY z2q8&S2bUyWh8Yu#?lpfMa5b<#sasCu0T4n+A;dOdG4N# zolNuIIGpF72t(f9n)C>jz`~?Qn34Q{K43F-@wr)fKZ#k69m$yOJ)q@87XNAdKQH}= UY<2Kz_W%F@07*qoM6N<$f^>hMtpET3 literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/icons/128x128@2x.png b/packages/oneclient_app/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0786a43c136ccf6b8c4616a74f3f379b2d5fa92d GIT binary patch literal 11645 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 a=v#+y!ZHey*%|s9hl^xwhreq@jr#{EQ&e~W literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/icons/512x512.png b/packages/oneclient_app/icons/512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc4ca47a8eac0aa50392f22cc83d557635e5d3c GIT binary patch literal 19730 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 XvtNe8wYze3VFQ}`*Lwx7jFW!?NpD~= literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/icons/64x64.png b/packages/oneclient_app/icons/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..6c92d512cbc8a7fb845b2577e222b1b7c57ea8fe GIT binary patch literal 2762 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 Qf&c&j07*qoM6N<$f|#ur&;S4c literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/icons/OneClient.icon/Assets/oneclient-mark.svg b/packages/oneclient_app/icons/OneClient.icon/Assets/oneclient-mark.svg new file mode 100644 index 00000000..2f96ddac --- /dev/null +++ b/packages/oneclient_app/icons/OneClient.icon/Assets/oneclient-mark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/oneclient_app/icons/OneClient.icon/icon.json b/packages/oneclient_app/icons/OneClient.icon/icon.json new file mode 100644 index 00000000..b3ad6298 --- /dev/null +++ b/packages/oneclient_app/icons/OneClient.icon/icon.json @@ -0,0 +1,38 @@ +{ + "fill": "automatic", + "groups": [ + { + "layers": [ + { + "blend-mode": "normal", + "fill": "automatic", + "glass": true, + "hidden": false, + "image-name": "oneclient-mark.svg", + "name": "oneclient-mark", + "position": { + "scale": 3.6, + "translation-in-points": [ + 0, + 0 + ] + } + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": [ + "watchOS" + ], + "squares": "shared" + } +} diff --git a/packages/oneclient_app/icons/StoreLogo.png b/packages/oneclient_app/icons/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..431f57e55e5827f4deceb23fc2e33bec6f6de727 GIT binary patch literal 1716 zcmV;l221&gP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;xNDJpgGwPCcUl000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8p~5i%Hi0(*4;00sC-L_t(&-tC%wY*SSfz<>9>u3ux_2HOf7$OaLD0s?~$ z9EwW(!vG1VzNeh{S*b8v~|gLIRaE=MPAZ5owT_58TIfdhXu z;}2;(GTX`AJ93c{S|bvRS+IR)4KICs28UH4f3%&M(=%{Q%*13;dLG2RRlI=`laHf@?>Y_lDZ%+AG^@ERMS1oIv979^FrRQ9$HKMWd)s7GV zIYaCK_ybXVO&S10oc0dSt>4$oGp`(>wKdvvAUk%|@b-abL!C2IpS0H>#p}~2IcT7j z@hKgLeCJ?@?K{u*3`nE5g%$7C80r>`wqUoJVjvm~Ufm)>2=16Nz_87V_iAYLwj>_p zi!G-(6myyX%Jc~-?Vqb_+c;U%*8Y9M7-Nvb0gcbUb|)6(gxk-fWeuI`XI!5dE7o9J zc?(~bw-SlynNY!`j85AXe|e7MCw++l(KN~W4JYX=L0fm{K&}~;4#4lnTi9|+qslKi zcsLBe(Ch(}J}}s@tprLoxY0D({UGK0J-l8XG}OH@XCO<8t_C0yk(6v~YS*p(JV0|u zcg6X4=CR>Er(qu@6+!k^)^!=AOWAL8sEt`m4zj&g_j#*;qKP(KV^b(}WiV=Fnr<}- z@0WRba$Ef3T{XkZvis98sR96ZbrZWQ>#024#HW=(8e>ox}X_Oy1u=Gj3X)8EKdK^76;Kp`qUd9Fn?)`rkv7SpqhVez3-Xow;(60d zr^1Q_$>fi-VKIxuyVm|+>-p0iAa7VQL!368S#~VucvG~tNcP9c`M>`a^D?EVx*Q3S<{^XP1eKDD)xBY&I+ zVDtzJvvXDQQw0;R)hou+H35n~HcmkVu(T)-MG@T(0x%-i!JG3s-*tDEH?)WA+Y8Lh zEijX0(kEAOu=A|BWbPnF=A|aK9+^LDxM8&i@N?CLWO3LK6S+AT7VU4pHyO4WJ3wLK z?A*i`R_SRL);^SDsM~m~h31eR+0q><8R<$anUrkVXPh~&eK3dgG)v+^0A}8p!z1~2 zLtULmujNx63h54guQ`7{%;7-CJnZ2~Hg27f-E)Tz<|Ks|mycnot0T;rS{=lZqT-0T z;Muy42NlIGJ1;H22D4ecB#=u!*Gh9(c9F0;Y0hUOcOat?RgpXMY2=Q1~)WRhhv60000< KMNUMnLSTY3D;YEZ literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/icons/icon.icns b/packages/oneclient_app/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..516ea0e7847b9ad97079e6bbad281067f5b3924f GIT binary patch literal 244223 zcmd42WmFtZ6gD_B3^2I6LvRlg+}+*X2_D=B51tSpxVsZPxVyU(2=2jsdB2r!&)MCx zfA+`zsIIQ=>XLh^Zaw!tw{6WGT>&sHZ?@*_9{~VF!$=h+X%s|4L;wJQA}b@I_MVIU zmmqNO*D_m|jrR=bswOQ4sG1-?d@o2?>d0CtDgqeZ?;!wSge?I4UzPVn_?`d&m>eJg z<~;}g_bdnWf4mjRf%%{J|5cPy6{`UN5ISWgL^ZsCr~2N`8I}ZZr|rXg9iN72C^$L4 zsn9_(Ny&4V-7Oe6KI@B2t4cGmZK=Ge9DA`%T*TXCn(>z)Yh|wJ%>k=P6}rK*9yL@X z^okDYuC5MMdmD$>=|A3xT>>m02nsoL5s(gRU6uNrylr(dMj#Ugw$+C(U5Yf1n=@JA#5bf zU$|1pGZ~z>=Ip*cPbTuFq5~vA<@mztBX!;NX~M_l+4$umO8x^uLKzgja^5chyPA|R z*f>_Z@#pW4Cfr2mCce@?$zHMmZL@#aN~lKVt(@ozS%q*O+`;_WQ3jfV@n=Wv=_sqH+>DSVV-@y*borU@apz+LJvgy;v-LN+=bLTnH{Qckde!0slV?96R5IoY~uXG%{ zX9KpJO-hWel}1?+!j9$LGX_3li8Q`4Pc4}aDSr)ygXIf#uq7QM@{8h+&@b^Jp*7~l z@Bz(DL{&^!BaxP42e1lWyD<;()l4Gp-h43z&+DYZ9?z!4tS?t;DMJ$ROm$oQ*`qxu zg;vxP{JzD`_e+2k9dZ;@#eCls`8^pxWim&ubi}?y(C3rvox_S0%|@~5Vk1O4RPdt$Q~+>UMw@i`o*!ws2|^$ z48qLM;|S5|Qy5@2*kO)Q#Hu!W9-uh}2taUZ&aiI0hYaKz}#26ytZMP`%Slxn%fuRC_D6ZObWIieu3v{>3$ zSg|0)#Xq3ufJk+ABs})!X+;GQ6MpQK8~5LRoKZX=M)>ov>7FO>W0bt9!ID?13Qj9A ziN7X!eiKuh@2I@eYp+hKKep+UQ3rBBKnv`q4=0l5#Kp&@1;H?J#g_vcvfi#O|=@u+)ZgHJ|pUn9VNM+kmWcb55#VF`@j>NmVh!<&=NFoIti? zc6Xz;luHeD2F^JP`=4TE3sc=#O`q9EaQe(`Q7&S6xj6h+T5>>S&C$S8FvkuxYSI9%E;TZ z0uWwL(~b-3f|q<|O$4NB+RzP#Z{I)`37Sp2Tf^?&(^BetcOO+9mzU!>PO%I~psh!z zC6k|WlHosi&aI}1pd1|CZQHHtvse5&#gSh6#f?_(G^%+(eH@Op_U&`) z?EsTsq>+iLe#P>}X%;k_$nP>Z>LUkY^jMAI*gbQ_)BM`tScfsq_{tH}E|W_)g9v_| zeyom*H3j!v2^oWLxYZT`s(wqwR9_OMJq)h|iB)AaoS0p|G@@zK;j+Ag#fhg6Hr6e4 z`7l|Fx5m~VL#KHhMt8yo?k}y70EwCI@dw0fUI>$)YE=`NWjWXyVo%bbJ$E;aY5{=Ye)wT-l zVh3LwF*DeIL}VC0D8M?FxoyFB9`#)+1rV7)TL~nwh}cfNrA1UDv*~VkSy>E4 ztA}Imxf|CLIHnf~2=Hl#7BTtoifBP@e!E?kQeTqEvfBrHRnAU{*XbGCP7wUWtChQy=;A{q-&HyRPLd`rPMiB>8jiIDEKpPf z9V{#12>FZJ5nS!psgOfpL63kX%@8FQx?0IfPLPz#(;*cZm4!3~!%ipil zX0>avMUA{%+Wv>pAY4fAOn7+s!IdWZi6Cigb9uE^S?7&1aO&cF_Z*}{i{tiRT3Xtd z7^$5_{8>!@O0|HZT`p6bW=cv*GvO81aalhx#HWH0T58iVpOe}_PW=7q=$IIRqK})> z8mII$3<3fI*%32DPHn1bYmeC7B1qetLfG^MW))vCQz- z+Czp{1qdmttR%r8BJy3zRk)c}#