From 0ab544f2658b5c4807f69d33bd9c12772fae0624 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:08:21 +0200 Subject: [PATCH] feat: custom cluster instances (#859) * feat: added adding new instances of a cluster with 'start from scratch' option enabling user to create a vanilla/modded cluster; fixed couple pre-existing bugs that wouldn't let other loaders boot up * feat: fully implemented cluster creation modal design * chore: refactoring * feat: expanded the tags section on customization step; found that fabric doesnt work with 1.8.9 * feat: added gallery covers handling for new cluster instances * feat: added 'redirecting' from fabric to orinthe on versions pre-1.14 * feat: added java-version-override * feat: added loader types migrations (right now they dont migrate mods because there are no bundles) * chore: wider loader dropdown for multiloader cluster (if it will be used in the future) * chore: fixed more issues with isolated and shared dir, with migrations and instances naming * chore: refactor; taken the loader version dropdown from version picker view back to loader selector * chore: deleted Quilt from UI --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com> --- ...49dda16a5bdf29eb90459ac140d8208db479.json} | 64 +- ...72ec3615954b60bffa33bff7fd3f9bf14210.json} | 66 +- ...5454b1e3fc4bb10c19d1c286d3e45afdd159.json} | 64 +- ...51144a34c01975b898ee749b4e2111856119.json} | 64 +- ...48e4309c2449e181b3b9ae16e7f7b4d9e627.json} | 66 +- ...4f4754dfe057468fe06c22d87c3a062132b7.json} | 66 +- ...892fbc2fa5dc45e63b391176e61f83a7676d.json} | 64 +- ...f8a6c414a9a213fd35c001c957eeebca3ff0.json} | 64 +- ...ff5658afa10db3cf56794b8fa5e12535f3f7.json} | 64 +- Cargo.lock | 1 + packages/oneclient_app/Cargo.toml | 1 + .../oneclient_app/assets/icons/fabric.png | Bin 0 -> 1208 bytes packages/oneclient_app/assets/icons/forge.png | Bin 0 -> 29193 bytes .../oneclient_app/assets/icons/neo-forge.png | Bin 0 -> 1097 bytes .../oneclient_app/assets/icons/vanilla.png | Bin 0 -> 6021 bytes .../src/components/active_cluster_panel.rs | 14 +- .../src/components/asset_image.rs | 41 ++ .../src/components/dynamic_art.rs | 161 ++++- .../oneclient_app/src/components/file_drop.rs | 110 +++- .../src/components/instance_card.rs | 152 +++++ .../src/components/local_image.rs | 13 +- packages/oneclient_app/src/components/mod.rs | 12 +- .../src/components/navbar/app_navbar.rs | 5 +- .../src/components/package_delete_dialog.rs | 2 + .../src/components/package_row.rs | 22 +- .../src/components/recents_row.rs | 22 +- .../src/components/release_migration_popup.rs | 8 +- .../src/components/scrollview.rs | 5 + .../oneclient_app/src/components/spinner.rs | 53 ++ .../src/components/text_input.rs | 22 +- .../src/components/version_art_gallery.rs | 306 ++++++++++ packages/oneclient_app/src/hooks/actions.rs | 5 + packages/oneclient_app/src/hooks/mod.rs | 58 +- .../src/hooks/queries/bundles.rs | 46 +- .../src/hooks/queries/migration.rs | 9 +- .../oneclient_app/src/hooks/queries/mod.rs | 17 +- .../src/hooks/queries/mutations.rs | 209 ++++++- .../src/hooks/queries/screenshots.rs | 37 +- .../src/hooks/queries/version_metadata.rs | 106 ++++ .../src/hooks/queries/versions.rs | 266 +++++++- packages/oneclient_app/src/install.rs | 10 +- .../oneclient_app/src/layout/cluster_shell.rs | 46 +- packages/oneclient_app/src/ui.rs | 31 + packages/oneclient_app/src/utils.rs | 36 +- .../src/view/app/browser/index/mod.rs | 43 +- .../src/view/app/browser/package/mod.rs | 2 +- .../src/view/app/cluster/cluster_settings.rs | 145 ++++- .../view/app/cluster/package_manager/mod.rs | 16 +- .../view/app/cluster/package_manager/views.rs | 42 +- .../src/view/app/clusters/delete_instance.rs | 125 ++++ .../src/view/app/clusters/instance/cards.rs | 390 ++++++++++++ .../src/view/app/clusters/instance/copy.rs | 107 ++++ .../src/view/app/clusters/instance/create.rs | 171 ++++++ .../src/view/app/clusters/instance/data.rs | 436 +++++++++++++ .../src/view/app/clusters/instance/details.rs | 576 ++++++++++++++++++ .../src/view/app/clusters/instance/edit.rs | 141 +++++ .../src/view/app/clusters/instance/mod.rs | 95 +++ .../src/view/app/clusters/instance/model.rs | 179 ++++++ .../src/view/app/clusters/instance/rail.rs | 279 +++++++++ .../src/view/app/clusters/instance/shell.rs | 209 +++++++ .../src/view/app/clusters/instance/steps.rs | 399 ++++++++++++ .../src/view/app/clusters/mod.rs | 4 + .../src/view/app/clusters/page.rs | 292 +++++++-- packages/oneclient_app/src/view/app/mod.rs | 6 +- .../src/view/onboarding/bundles/mod.rs | 43 +- .../src/view/onboarding/migration.rs | 3 +- .../src/view/onboarding/test_support.rs | 6 + packages/oneclient_cluster/src/cluster.rs | 51 +- packages/oneclient_cluster/src/error.rs | 12 + packages/oneclient_cluster/src/identity.rs | 54 ++ packages/oneclient_cluster/src/lib.rs | 6 +- packages/oneclient_cluster/src/manager.rs | 429 ++++++++++++- packages/oneclient_cluster/src/naming.rs | 110 ++++ packages/oneclient_cluster/src/options.rs | 32 + .../oneclient_cluster/src/screenshots/mod.rs | 61 +- packages/oneclient_common/src/domain.rs | 5 + packages/oneclient_common/src/patch.rs | 2 +- packages/oneclient_common/src/paths.rs | 16 +- .../oneclient_content/src/bundles/install.rs | 105 +++- .../src/bundles/polymrpack.rs | 3 + .../oneclient_content/src/bundles/types.rs | 2 + .../oneclient_content/src/bundles/updates.rs | 19 +- .../src/packages/dependencies.rs | 5 + .../src/packages/local_manifest.rs | 27 + .../oneclient_content/src/packages/mod.rs | 2 +- .../src/packages/store/link.rs | 48 +- .../src/packages/store/mod.rs | 40 +- .../oneclient_core/examples/download_game.rs | 4 + .../src/clusters/java_override.rs | 110 ++++ .../oneclient_core/src/clusters/migrate.rs | 349 +++++++++-- packages/oneclient_core/src/clusters/mod.rs | 6 +- .../oneclient_core/src/clusters/prepare.rs | 5 +- .../src/clusters/release_migration.rs | 57 +- packages/oneclient_core/src/game/launch.rs | 28 +- packages/oneclient_core/src/game/mod.rs | 10 +- .../oneclient_core/src/game/shared_dir.rs | 330 +++++++--- packages/oneclient_core/src/images/mod.rs | 175 +++++- packages/oneclient_core/src/lib.rs | 14 +- packages/oneclient_core/src/migration/mod.rs | 25 + .../src/migration/oneclient_v1.rs | 1 + .../oneclient_core/src/migration/vanilla.rs | 1 + packages/oneclient_core/src/recovery.rs | 143 ++++- packages/oneclient_core/src/state.rs | 6 + packages/oneclient_core/src/versions/arts.rs | 248 ++++++++ .../oneclient_core/src/versions/manager.rs | 89 ++- .../oneclient_core/src/versions/manifest.rs | 211 ++++++- packages/oneclient_core/src/versions/mod.rs | 9 +- .../oneclient_core/src/versions/prefetch.rs | 63 ++ .../oneclient_core/tests/bundle_updates.rs | 1 + .../20260921120000_cluster_instances.sql | 6 + packages/oneclient_db/src/dao/artifact.rs | 78 ++- packages/oneclient_db/src/dao/cluster.rs | 237 +++++-- packages/oneclient_db/src/models/cluster.rs | 61 ++ packages/oneclient_db/src/models/mod.rs | 2 +- packages/oneclient_mc/src/arguments.rs | 374 +++++++++++- packages/oneclient_mc/src/error.rs | 9 +- packages/oneclient_mc/src/install.rs | 141 ++++- packages/oneclient_mc/src/manifest.rs | 145 ++++- 118 files changed, 9411 insertions(+), 673 deletions(-) rename .sqlx/{query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json => query-4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479.json} (70%) rename .sqlx/{query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json => query-54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210.json} (65%) rename .sqlx/{query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json => query-5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159.json} (70%) rename .sqlx/{query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json => query-9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119.json} (70%) rename .sqlx/{query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json => query-9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627.json} (65%) rename .sqlx/{query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json => query-aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7.json} (64%) rename .sqlx/{query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json => query-bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d.json} (69%) rename .sqlx/{query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json => query-c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0.json} (69%) rename .sqlx/{query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json => query-d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7.json} (69%) create mode 100644 packages/oneclient_app/assets/icons/fabric.png create mode 100644 packages/oneclient_app/assets/icons/forge.png create mode 100644 packages/oneclient_app/assets/icons/neo-forge.png create mode 100644 packages/oneclient_app/assets/icons/vanilla.png create mode 100644 packages/oneclient_app/src/components/asset_image.rs create mode 100644 packages/oneclient_app/src/components/instance_card.rs create mode 100644 packages/oneclient_app/src/components/spinner.rs create mode 100644 packages/oneclient_app/src/components/version_art_gallery.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/delete_instance.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/cards.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/copy.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/create.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/data.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/details.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/edit.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/mod.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/model.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/rail.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/shell.rs create mode 100644 packages/oneclient_app/src/view/app/clusters/instance/steps.rs create mode 100644 packages/oneclient_cluster/src/identity.rs create mode 100644 packages/oneclient_cluster/src/naming.rs create mode 100644 packages/oneclient_core/src/clusters/java_override.rs create mode 100644 packages/oneclient_core/src/versions/arts.rs create mode 100644 packages/oneclient_core/src/versions/prefetch.rs create mode 100644 packages/oneclient_db/migrations/20260921120000_cluster_instances.sql diff --git a/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json b/.sqlx/query-4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479.json similarity index 70% rename from .sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json rename to .sqlx/query-4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479.json index b1ced123..7b4d834b 100644 --- a/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json +++ b/.sqlx/query-4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479.json @@ -1,6 +1,6 @@ { "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", + "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\tkind, user_created, description, tags, cover_path\n\t\t", "describe": { "columns": [ { @@ -134,6 +134,61 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1" + "hash": "4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479" } diff --git a/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json b/.sqlx/query-54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210.json similarity index 65% rename from .sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json rename to .sqlx/query-54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210.json index 5adaaa3a..159da446 100644 --- a/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json +++ b/.sqlx/query-54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210.json @@ -1,6 +1,6 @@ { "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", + "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\tkind, user_created, description, tags, cover_path\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\tkind, user_created, description, tags, cover_path\n\t\t", "describe": { "columns": [ { @@ -134,10 +134,65 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { - "Right": 8 + "Right": 13 }, "nullable": [ false, @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871" + "hash": "54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210" } diff --git a/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json b/.sqlx/query-5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159.json similarity index 70% rename from .sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json rename to .sqlx/query-5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159.json index 7c99e1d0..00499d44 100644 --- a/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json +++ b/.sqlx/query-5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159.json @@ -1,6 +1,6 @@ { "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", + "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\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE folder_name = ?\n\t\t", "describe": { "columns": [ { @@ -134,6 +134,61 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d" + "hash": "5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159" } diff --git a/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json b/.sqlx/query-9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119.json similarity index 70% rename from .sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json rename to .sqlx/query-9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119.json index ad2eb026..53da235e 100644 --- a/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json +++ b/.sqlx/query-9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119.json @@ -1,6 +1,6 @@ { "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", + "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\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE id = ?\n\t\t", "describe": { "columns": [ { @@ -134,6 +134,61 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782" + "hash": "9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119" } diff --git a/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json b/.sqlx/query-9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627.json similarity index 65% rename from .sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json rename to .sqlx/query-9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627.json index f914a967..0cfc0122 100644 --- a/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json +++ b/.sqlx/query-9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627.json @@ -1,6 +1,6 @@ { "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", + "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\t description = ?,\n\t\t tags = ?,\n\t\t cover_path = ?\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\tkind, user_created, description, tags, cover_path\n\t\t", "describe": { "columns": [ { @@ -134,10 +134,65 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { - "Right": 5 + "Right": 8 }, "nullable": [ false, @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42" + "hash": "9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627" } diff --git a/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json b/.sqlx/query-aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7.json similarity index 64% rename from .sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json rename to .sqlx/query-aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7.json index e2104f28..1336266a 100644 --- a/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json +++ b/.sqlx/query-aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7.json @@ -1,6 +1,6 @@ { "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", + "query": "\n\t\tUPDATE clusters\n\t\tSET mc_version = ?,\n\t\t mc_loader = ?,\n\t\t mc_loader_version = ?,\n\t\t stage = ?,\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\tkind, user_created, description, tags, cover_path\n\t\t", "describe": { "columns": [ { @@ -134,10 +134,65 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { - "Right": 4 + "Right": 7 }, "nullable": [ false, @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61" + "hash": "aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7" } diff --git a/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json b/.sqlx/query-bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d.json similarity index 69% rename from .sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json rename to .sqlx/query-bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d.json index 9f55b11a..96bd69cb 100644 --- a/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json +++ b/.sqlx/query-bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d.json @@ -1,6 +1,6 @@ { "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", + "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\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE mc_version = ? AND mc_loader = ? AND user_created = 0\n\t\tLIMIT 1\n\t\t", "describe": { "columns": [ { @@ -134,6 +134,61 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644" + "hash": "bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d" } diff --git a/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json b/.sqlx/query-c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0.json similarity index 69% rename from .sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json rename to .sqlx/query-c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0.json index 976cb60e..aa5a380b 100644 --- a/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json +++ b/.sqlx/query-c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0.json @@ -1,6 +1,6 @@ { "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", + "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\tkind, user_created, description, tags, cover_path\n\t\t", "describe": { "columns": [ { @@ -134,6 +134,61 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e" + "hash": "c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0" } diff --git a/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json b/.sqlx/query-d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7.json similarity index 69% rename from .sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json rename to .sqlx/query-d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7.json index c5405caf..ce786f92 100644 --- a/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json +++ b/.sqlx/query-d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7.json @@ -1,6 +1,6 @@ { "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", + "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\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tORDER BY last_played IS NULL, last_played DESC, name ASC\n\t\t", "describe": { "columns": [ { @@ -134,6 +134,61 @@ "name": "linked_modpack_hash" } } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } } ], "parameters": { @@ -151,8 +206,13 @@ true, true, true, + true, + false, + false, + true, + false, true ] }, - "hash": "4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22" + "hash": "d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7" } diff --git a/Cargo.lock b/Cargo.lock index 407648c2..95336bde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4947,6 +4947,7 @@ dependencies = [ "freya-core", "freya-skia-safe", "freya-testing", + "futures-util", "image", "mimalloc", "notify", diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 586a6da1..fbf6e0ea 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -33,6 +33,7 @@ rust-embed.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +futures-util.workspace = true tokio.workspace = true uuid.workspace = true bytes.workspace = true diff --git a/packages/oneclient_app/assets/icons/fabric.png b/packages/oneclient_app/assets/icons/fabric.png new file mode 100644 index 0000000000000000000000000000000000000000..c6fb4276951b9922fe5edb9fd08862053ff74216 GIT binary patch literal 1208 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEidQTU}kcv5P?keVO2^DEb zR8SUR@ln)p@NZhZLS0dZfxBtJ1W{7~7RL!)>loVKGI4OqH7c*n7hRZ~6%~2y!~gV2 zv)8}>{KNY5O7rV;itkr3pAcf;5GESkUi*(>+uJuK|7|S)U%zjjFZVvXA~cUdK#gdW zCT5*yb^ibD_0t#s|NrWC_r=QJENOQb6nuzAIop3R-`Mv1@ZlGK1ix|ie3QKLopT4H z!wd#uQACy4FV!2re*d%oUtwqU|D(Hn-#P#N^{;=kkmiodo6qPUESdNJ)%5r7|Ns9x zS-tpk@Hfsichw)TF(fh(jlMDRtgV}0|LWfd1FQJ^>K_jKhXW0|T+2jKxK6vzzTs=- zzpKlBvn-R7pYQg3`R70JHUDnD`18L0kEK<^lG=J{9`RdR2|NM3O z;{W+`Y7DIQSIs|ABHvK?FSnQ^v;OFOyY`)P&+l`$K@Pc1S|S1_=CIl4H^=MWRWGP4 zwYBn}EzQuvNi?dwUAuAf<-Zy84@~*~mn5qh7M$QEIz0fB%|CtyhX4O>b$50Jowz0m QERh&IUHx3vIVCg!0PVtmO#lD@ literal 0 HcmV?d00001 diff --git a/packages/oneclient_app/assets/icons/forge.png b/packages/oneclient_app/assets/icons/forge.png new file mode 100644 index 0000000000000000000000000000000000000000..6fb3de4c94f829aa9528480779510ea24ff70875 GIT binary patch literal 29193 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 j6pFz-r$51-7kXN6bh)0~o>aIh^3 z(}fSTjg^rFx0|=xknmjPjk8HbIszT;T!J1d(Ij6ps4Y`;4>13MT9_e7W+$oqIOCAP zN6iiMKLDQaNL0sgyQhDGB=9^`*JZ)BmnY+%jUw@@vNVTV+J23ZwUJb-fCUBeC)RRt zw=5XG`olsgb3oO^C2}g$bS`i2vXVRr{`^5yF?MjsU@1e1XS*C#RL>ifo0g6x0FE}@ zbHwLtu`L?1W$dClkLc(@Yz(EGs{wm?d-c7tIT}aFal4fn$oDt1B>Asg z0arl{sD_WNOF;QZG>8(?~-?OtAV(M{C$+WJ<%L2 zDApF{6lDp3;q-Zty{Pt~;QgVPIg|Ejy1%VWgy$d?1(xRi3Cxj+@e`%Uqu27;aiXU5*~$~DK{*R!&!ZibQ@_QwrA9AlZ@ zEiMg`I_W6FkLv6Hyf7mxMS>IP3TnlMvTDHI{(-cO;+kjs>IEzZ_)Vk{>LxziS>0Ty zEq*s@uN>M?{C!hp{=5%epEB$+PqNBO$37}HJkNcR&r>aQ_H?q5trnZDHeSxB;?~P}yGs4}*Azb>h!{>r+x$yaaS?X- zumyl-R}$>;L^^kb1ypZ*le&`mHrNZ`UBRJ1wMP51`o-=I|`9|sF$RfZ7MVsPjK8s zT5=z*Tt4!ob8K|1ZtMz_(oU$YlG2X%<+89Fm~GH7k5SVljVc|!j=*7pQ!dmpt}8bH z49GbJ#O@5P9jX<>`7!#`99}lBiVO2kQ0!XwZs%Of1y?+O?8=E@HP+{z4YD?~{BdcR zSGh*8o_&Kn#GRSnahbE2M4dw^yHQKTlP6>DaMDnfJhu9mQ)PbV#Lu>9mU6}-M2z3XPl&T4>QPQZ#Q};x?-(@Se3gHW U-nR(+N3n( Self { + Self { path, size } + } +} + +impl Component for AssetImage { + fn render(&self) -> impl IntoElement { + let path = self.path; + let size = self.size; + + let handle: Option = + use_hook(move || AppAssets::get_bytes(path).as_ref().and_then(decode)); + + rect() + .key(path) + .width(Size::px(size)) + .height(Size::px(size)) + .center() + .maybe_child(handle.map(|handle| { + image(handle) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Min) + .image_cover(ImageCover::Center) + .into_element() + })) + } +} diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 11d108a7..a1c2f6c0 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -1,25 +1,37 @@ +use std::path::PathBuf; + use bytes::Bytes; +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, OnFinish, use_animation_with_dependencies, +}; 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 oneclient_core::images::{DEFAULT_IMAGE_EDGE, PREVIEW_IMAGE_EDGE}; use crate::AppAssets; -use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; +use crate::hooks::{ + loaded_image, resolve_art_url, settled_or_loading, use_cached_image, use_local_image, + use_version_art, use_version_metadata, +}; use crate::layout::HOME_BACKGROUND_ASSET; +use crate::theme::colors; use crate::ui::ImageFallbackExt; -/// The size cluster cards request so it is usually already cached when anything else wants it -pub const ART_PREVIEW_EDGE: u32 = 512; +pub const ART_PREVIEW_EDGE: u32 = PREVIEW_IMAGE_EDGE; #[derive(PartialEq, Clone)] pub struct DynamicArt { major: Option, key: Option, loader: Option, + cover: Option, + cover_picked: bool, max_edge: u32, preview_edge: Option, + url: Option, + skeleton: bool, } impl DynamicArt { @@ -28,8 +40,7 @@ impl DynamicArt { major: Some(major), key, loader, - max_edge: DEFAULT_IMAGE_EDGE, - preview_edge: None, + ..Self::fallback() } } @@ -39,8 +50,15 @@ impl DynamicArt { 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, + cover: cluster.cover_file(), + ..Self::fallback() + } + } + + pub fn for_url(url: String) -> Self { + Self { + url: Some(url), + ..Self::fallback() } } @@ -49,11 +67,34 @@ impl DynamicArt { major: None, key: None, loader: None, + cover: None, + cover_picked: false, max_edge: DEFAULT_IMAGE_EDGE, preview_edge: None, + url: None, + skeleton: false, } } + #[must_use] + pub fn cover(mut self, cover: Option) -> Self { + self.cover = cover; + self + } + + #[must_use] + pub fn picked_cover(mut self, cover: Option) -> Self { + self.cover = cover; + self.cover_picked = true; + self + } + + #[must_use] + pub fn skeleton(mut self, skeleton: bool) -> Self { + self.skeleton = skeleton; + self + } + #[must_use] pub fn max_edge(mut self, max_edge: u32) -> Self { self.max_edge = max_edge; @@ -68,26 +109,55 @@ impl DynamicArt { } pub fn use_bytes(&self) -> (String, Bytes) { - use_art_bytes( - self.major, - self.key, - self.loader, - self.max_edge, - self.preview_edge, - ) + use_art_bytes(self) } } -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()); +pub fn use_art_bytes(art: &DynamicArt) -> (String, Bytes) { + let max_edge = art.max_edge; + let fallback = use_bundled_art(false); + use_resolved_art(art).unwrap_or_else(|| { + ( + format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), + fallback.read().clone(), + ) + }) +} - let art_url = use_version_metadata(major, key, loader).and_then(|m| m.art_url); +fn use_bundled_art(skip: bool) -> Memo { + use_memo(move || { + if skip { + Bytes::new() + } else { + AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default() + } + }) +} + +fn use_resolved_art(art: &DynamicArt) -> Option<(String, Bytes)> { + let DynamicArt { + major, + key, + loader, + cover, + cover_picked, + max_edge, + preview_edge, + url, + skeleton: _, + } = art.clone(); + + let cover_query = use_local_image(cover.clone().unwrap_or_default(), max_edge, cover_picked); + + let cover_bytes = cover.as_ref().and_then(|path| { + settled_or_loading(&cover_query) + .filter(|bytes: &Bytes| !bytes.is_empty()) + .map(|bytes| (format!("{max_edge}|{}", path.display()), bytes)) + }); + + let curated = use_version_metadata(major, key, loader); + let candidates = use_version_art(major, key); + let art_url = url.or_else(|| resolve_art_url(curated.as_ref(), &candidates)); let image_query = use_cached_image(art_url.clone(), max_edge); @@ -105,17 +175,46 @@ pub fn use_art_bytes( .map(|(url, bytes)| (format!("{edge}|{url}"), bytes)) }; - full.or_else(preview).unwrap_or_else(|| { - ( - format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), - fallback.read().clone(), - ) - }) + cover_bytes.or(full).or_else(preview) } impl Component for DynamicArt { fn render(&self) -> impl IntoElement { - let (key, bytes) = self.use_bytes(); + let fallback = use_bundled_art(self.skeleton); + let resolved = use_resolved_art(self); + let show_skeleton = self.skeleton && resolved.is_none(); + + 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(); + + if show_skeleton { + return rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::component_bg()) + .opacity(pulse_v) + .into_element(); + } + + let max_edge = self.max_edge; + let (key, bytes) = resolved.unwrap_or_else(|| { + ( + format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), + fallback.read().clone(), + ) + }); ImageViewer::new((key, bytes)) .width(Size::fill()) diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs index 181d8d11..d70aecbc 100644 --- a/packages/oneclient_app/src/components/file_drop.rs +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -37,6 +37,13 @@ const IMPORTABLE: [ContentType; 4] = [ const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"]; const DATAPACK_HINTS: [&str; 4] = ["datapack", "data pack", "data_pack", "data-pack"]; +fn importable(mod_loader: bool) -> Vec { + IMPORTABLE + .into_iter() + .filter(|ct| mod_loader || !ct.needs_mod_loader()) + .collect() +} + fn content_label(content_type: ContentType) -> &'static str { match content_type { ContentType::ResourcePack => "Textures", @@ -57,20 +64,29 @@ fn file_name(path: &Path) -> 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 { +fn infer_content_type( + path: &Path, + route_type: Option, + mod_loader: bool, +) -> Option { if extension(path).as_deref() == Some("jar") { - return ContentType::Mod; + return mod_loader.then_some(ContentType::Mod); } if let Some(route_type) = route_type { - return route_type; + if mod_loader || !route_type.needs_mod_loader() { + return Some(route_type); + } + if route_type == ContentType::Shader { + return None; + } } let name = file_name(path).to_lowercase(); if DATAPACK_HINTS.iter().any(|hint| name.contains(hint)) { - ContentType::DataPack - } else if SHADER_HINTS.iter().any(|hint| name.contains(hint)) { - ContentType::Shader + Some(ContentType::DataPack) + } else if mod_loader && SHADER_HINTS.iter().any(|hint| name.contains(hint)) { + Some(ContentType::Shader) } else { - ContentType::ResourcePack + Some(ContentType::ResourcePack) } } @@ -340,15 +356,26 @@ fn prompt_body( .position(|c| c.id == cluster_id) .unwrap_or_default(); + let destination = clusters.get(cluster_idx); + let mod_loader = destination.is_none_or(|c| !c.lacks_mod_loader()); + + let mut rejected: Vec = Vec::new(); let resolved: Vec<(PathBuf, ContentType)> = files .iter() - .map(|path| { + .filter_map(|path| { let content_type = overrides .read() .get(path) .copied() - .unwrap_or_else(|| infer_content_type(path, route_type)); - (path.clone(), content_type) + .filter(|ct| mod_loader || !ct.needs_mod_loader()) + .or_else(|| infer_content_type(path, route_type, mod_loader)); + match content_type { + Some(content_type) => Some((path.clone(), content_type)), + None => { + rejected.push(path.clone()); + None + } + } }) .collect(); @@ -359,7 +386,9 @@ fn prompt_body( .filter(|name| worlds.contains(name)) .cloned() .or_else(|| worlds.first().cloned()); - let can_import = !has_datapacks || world.is_some(); + let can_import = !resolved.is_empty() && (!has_datapacks || world.is_some()); + let skipped = rejected.len(); + let destination_name = destination.map(|c| c.name.clone()).unwrap_or_default(); let import_list = resolved.clone(); let import_world = world.clone(); @@ -385,6 +414,16 @@ fn prompt_body( ), ); } + if skipped > 0 { + dispatch + .notify("Some files weren't added") + .body(format!( + "{destination_name} has no mod loader, so {skipped} mod or shader file{} {} skipped.", + if skipped == 1 { "" } else { "s" }, + if skipped == 1 { "was" } else { "were" }, + )) + .send(); + } pending.set(Vec::new()); }; @@ -405,7 +444,7 @@ fn prompt_body( has_datapacks .then(|| world_field(worlds, world.as_deref(), cluster_id, remembered_world)), ) - .child(file_field(&resolved, overrides)) + .child(file_field(&resolved, &rejected, mod_loader, overrides)) .child( rect() .horizontal() @@ -508,12 +547,21 @@ fn world_field( fn file_field( resolved: &[(PathBuf, ContentType)], + rejected: &[PathBuf], + mod_loader: bool, overrides: State>, ) -> impl IntoElement { - let visible = resolved.len().clamp(1, FILE_LIST_MAX_ROWS); + let visible = (resolved.len() + rejected.len()).clamp(1, FILE_LIST_MAX_ROWS); let rows = resolved .iter() - .map(|(path, content_type)| file_row(path, *content_type, overrides).into_element()) + .map(|(path, content_type)| { + file_row(path, Some(*content_type), mod_loader, overrides).into_element() + }) + .chain( + rejected + .iter() + .map(|path| file_row(path, None, mod_loader, overrides).into_element()), + ) .collect::>(); field( @@ -535,14 +583,33 @@ fn file_field( fn file_row( path: &Path, - content_type: ContentType, + content_type: Option, + mod_loader: bool, mut overrides: State>, ) -> impl IntoElement { let key = path.to_path_buf(); - let options: Vec = IMPORTABLE + let types = importable(mod_loader); + let options: Vec = types .iter() .map(|ct| content_label(*ct).to_string()) .collect(); + let control = match content_type { + Some(content_type) => 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) = types.get(idx) { + overrides.write().insert(key.clone(), *content_type); + } + }) + .into_element(), + None => label() + .text("Needs a mod loader") + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + }; rect() .horizontal() @@ -565,14 +632,5 @@ fn file_row( .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); - } - }), - ) + .child(control) } diff --git a/packages/oneclient_app/src/components/instance_card.rs b/packages/oneclient_app/src/components/instance_card.rs new file mode 100644 index 00000000..86f33e5c --- /dev/null +++ b/packages/oneclient_app/src/components/instance_card.rs @@ -0,0 +1,152 @@ +use freya::prelude::*; +use oneclient_core::clusters::Cluster; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_HEIGHT_PX: f32 = 240.; + +pub struct InstanceCard { + id: i64, + title: String, + tags: Vec, + art: DynamicArt, + selected: bool, + on_press: EventHandler>, +} + +impl InstanceCard { + pub fn new( + cluster: &Cluster, + selected: bool, + on_press: impl Into>>, + ) -> Self { + Self { + id: cluster.id, + title: cluster.name.clone(), + tags: cluster.tags.clone(), + art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE), + selected, + on_press: on_press.into(), + } + } +} + +impl PartialEq for InstanceCard { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.title == other.title + && self.tags == other.tags + && self.art == other.art + && self.selected == other.selected + } +} + +impl Component for InstanceCard { + 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 { + 1.0 + } else if hovered || focused { + 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.id) + .width(Size::flex(1.0)) + .height(Size::px(CARD_HEIGHT_PX)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_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(self.art.clone()), + ) + .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(self.title.clone()) + .font_size(28.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/local_image.rs b/packages/oneclient_app/src/components/local_image.rs index 0909fb4c..1ef92136 100644 --- a/packages/oneclient_app/src/components/local_image.rs +++ b/packages/oneclient_app/src/components/local_image.rs @@ -17,6 +17,7 @@ pub struct LocalImage { max_edge: u32, cover: bool, skeleton: bool, + picked: bool, } impl LocalImage { @@ -26,9 +27,15 @@ impl LocalImage { max_edge, cover, skeleton: false, + picked: false, } } + pub fn picked(mut self, picked: bool) -> Self { + self.picked = picked; + self + } + pub fn skeleton(mut self, skeleton: bool) -> Self { self.skeleton = skeleton; self @@ -37,9 +44,9 @@ impl LocalImage { 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 query = use_local_image(self.path.clone(), self.max_edge, self.picked); + let bytes: Option = + settled_or_loading(&query).filter(|bytes: &Bytes| !bytes.is_empty()); let mut cache = use_state(|| None::<(usize, ImageHandle)>); let holder = bytes.and_then(|bytes| { diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 892c0824..99c3b687 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -1,5 +1,6 @@ mod account_switcher; mod active_cluster_panel; +mod asset_image; mod avatar; mod button; mod charts; @@ -16,6 +17,7 @@ mod file_drop; mod flagged_install_popup; mod generic_prompt; mod icons; +mod instance_card; mod java_install_manager; mod java_prompt; mod link_confirm; @@ -40,6 +42,7 @@ mod release_migration_popup; mod screenshot_viewer; mod scrollview; mod segmented_control; +mod spinner; mod splash_curtain; mod status_bar; mod tab_bar; @@ -49,10 +52,12 @@ mod toggle; mod tooltip; mod update_prompt; pub mod upload_mclogs; +mod version_art_gallery; mod version_card; pub use account_switcher::AccountSwitcher; pub use active_cluster_panel::ActiveClusterPanel; +pub use asset_image::AssetImage; pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; @@ -69,6 +74,7 @@ pub use file_drop::FileDropOverlay; pub use flagged_install_popup::FlaggedInstallPopup; pub use generic_prompt::GenericPromptOverlay; pub use icons::{Icon, IconTint, IconType}; +pub use instance_card::InstanceCard; pub use java_install_manager::JavaInstallManager; pub use java_prompt::JavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; @@ -86,8 +92,8 @@ pub use optional_mods_popup::OptionalModsPopup; pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; pub use package_delete_dialog::use_shared_delete; pub(crate) use package_row::{ - CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, grid_card, icon_box, - kebab_button, meta_size, meta_text, on_secondary, package_icon, remote_icon, + CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, filled_pill, grid_card, + icon_box, kebab_button, meta_size, meta_text, on_secondary, package_icon, pill, remote_icon, }; pub use package_row::{ CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, @@ -102,6 +108,7 @@ pub use screenshot_viewer::{ScreenshotViewer, screenshot_context_menu}; pub(crate) use scrollview::corrected_scroll; pub use scrollview::{LazySection, ScrollArea, ScrollAreaCtx}; pub use segmented_control::{Segment, SegmentedControl}; +pub use spinner::centered_spinner; pub use splash_curtain::SplashCurtain; pub use status_bar::StatusBar; pub use tab_bar::{TabBar, TabItem}; @@ -110,4 +117,5 @@ pub use toasts::Toasts; pub use toggle::{toggle, toggle_controlled}; pub use tooltip::{TooltipHost, TooltipPlacement, use_provide_tooltips}; pub use update_prompt::UpdatePromptOverlay; +pub use version_art_gallery::{GALLERY_COVER_EDGE, VersionArtGallery}; 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 index a39c8ae1..4715b2d6 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -164,10 +164,7 @@ fn browse_target() -> Route { match cluster { Some(cluster) => Route::Browser { cluster_id: cluster.id, - package_type: crate::view::app::browser::browsable_type( - &package_type, - &cluster.mc_version, - ), + package_type: crate::view::app::browser::browsable_type(&package_type, &cluster), pick_cluster: true, }, None => Route::Clusters {}, diff --git a/packages/oneclient_app/src/components/package_delete_dialog.rs b/packages/oneclient_app/src/components/package_delete_dialog.rs index 570c8b95..1c6427ba 100644 --- a/packages/oneclient_app/src/components/package_delete_dialog.rs +++ b/packages/oneclient_app/src/components/package_delete_dialog.rs @@ -7,6 +7,7 @@ use crate::ui::border_all_color; const CARD_BG: Color = Color::from_rgb(26, 34, 41); pub fn use_shared_delete( + cluster_id: i64, delete: impl Into>, ) -> (EventHandler<(String, String)>, Option) { let mut pending = use_state(|| None::<(String, String, usize)>); @@ -19,6 +20,7 @@ pub fn use_shared_delete( spawn(async move { let clusters = match crate::launcher::state() { Ok(state) => oneclient_core::clusters_sharing_artifact( + cluster_id, &hash, &state.services.content(), ) diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index ed2af3e1..3c5f228b 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -819,6 +819,10 @@ pub(crate) fn badge(icon: impl IntoElement, text: String) -> Element { } fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element { + pill(Some(icon.into_element()), text, accent) +} + +pub(crate) fn pill(icon: Option, text: String, accent: Color) -> Element { rect() .horizontal() .cross_align(Alignment::Center) @@ -827,7 +831,7 @@ fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element .corner_radius(CornerRadius::new_all(999.)) .border(border_all_color(1., colors::component_border())) .background(colors::component_bg()) - .child(icon) + .maybe_child(icon) .child( label() .text(text) @@ -837,3 +841,19 @@ fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element ) .into_element() } + +pub(crate) fn filled_pill(text: String, background: Color, foreground: Color) -> Element { + rect() + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(background) + .child( + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .letter_spacing(1.) + .color(foreground), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 944b739f..9475ca07 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -151,6 +151,9 @@ impl KeyExt for ClusterCard { impl PartialEq for ClusterCard { fn eq(&self, other: &Self) -> bool { self.cluster.id == other.cluster.id + && self.cluster.name == other.cluster.name + && self.cluster.cover_path == other.cluster.cover_path + && self.cluster.mc_loader == other.cluster.mc_loader && self.index == other.index && self.items == other.items && self.progress == other.progress @@ -171,9 +174,14 @@ impl Component for ClusterCard { 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 title = if self.cluster.user_created { + self.cluster.name.clone() + } else { + format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader) + }; let cluster_id = self.cluster.id; + let mod_tabs = !self.cluster.lacks_mod_loader(); let on_press = move |_| { *active_id.write() = Some(cluster_id); }; @@ -183,7 +191,17 @@ impl Component for ClusterCard { .open_upwards() .title(title.clone()) .on_close(move |_| menu.set(None)); - for (icon, label, route) in cluster_menu_entries(cluster_id) { + for (icon, label, route) in + cluster_menu_entries(cluster_id) + .into_iter() + .filter(|(_, _, route)| { + mod_tabs + || !matches!( + route, + Route::ClusterMods { .. } | Route::ClusterShaders { .. } + ) + }) + { context = context.action(icon, label, move |()| { let _ = RouterContext::get().push(route.clone()); }); diff --git a/packages/oneclient_app/src/components/release_migration_popup.rs b/packages/oneclient_app/src/components/release_migration_popup.rs index e40cd147..13fb81b3 100644 --- a/packages/oneclient_app/src/components/release_migration_popup.rs +++ b/packages/oneclient_app/src/components/release_migration_popup.rs @@ -425,9 +425,13 @@ fn content_panel( let picker_dispatch = dispatch.clone(); let source_title = prompt.source().map(cluster_title).unwrap_or_default(); + let target_title = if prompt.target.user_created { + prompt.target.name.as_str() + } else { + prompt.target.mc_version.as_str() + }; let subtitle = format!( - "Files are copied into {}. Your {source_title} cluster is left exactly as it is.", - prompt.target.mc_version + "Files are copied into {target_title}. Your {source_title} cluster is left exactly as it is." ); rect() diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index 86ec3722..a3590163 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -213,6 +213,11 @@ impl ScrollArea { self } + pub fn append_children(mut self, children: impl IntoIterator) -> Self { + self.children.extend(children); + self + } + pub fn content(mut self, builder: impl Fn(ScrollAreaCtx) -> Element + 'static) -> Self { self.builder = Some(Box::new(builder)); self diff --git a/packages/oneclient_app/src/components/spinner.rs b/packages/oneclient_app/src/components/spinner.rs new file mode 100644 index 00000000..fcff1c9e --- /dev/null +++ b/packages/oneclient_app/src/components/spinner.rs @@ -0,0 +1,53 @@ +use freya::animation::{AnimNum, Function, OnCreation, OnFinish, use_animation}; +use freya::prelude::*; + +use super::{Icon, IconType}; +use crate::theme::colors; + +const SPIN_TIME: u64 = 900; + +#[derive(PartialEq)] +pub struct Spinner { + size: f32, +} + +impl Spinner { + pub fn new(size: f32) -> Self { + Self { size } + } +} + +impl Component for Spinner { + fn render(&self) -> impl IntoElement { + let spin = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::restart()); + AnimNum::new(0., 360.) + .time(SPIN_TIME) + .function(Function::Linear) + }); + + rect().rotate(spin.get().value()).child( + Icon::new(IconType::Loading02) + .size(self.size) + .color(colors::fg_secondary()), + ) + } +} + +pub fn centered_spinner(message: &str) -> Element { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .center() + .spacing(12.) + .child(Spinner::new(22.)) + .child( + label() + .text(message.to_string()) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/text_input.rs b/packages/oneclient_app/src/components/text_input.rs index 52ad2340..b300234d 100644 --- a/packages/oneclient_app/src/components/text_input.rs +++ b/packages/oneclient_app/src/components/text_input.rs @@ -97,6 +97,13 @@ impl TextInput { self } + pub fn compact(mut self) -> Self { + self.input = self + .input + .padding(Gaps::new_symmetric(GAP_VERT / 2., GAP_HORI / 1.5)); + self.font_size(12.) + } + pub fn multiline(mut self, multiline: bool) -> Self { self.input = self.input.multiline(multiline); self.multiline = multiline; @@ -207,14 +214,13 @@ impl Component for TextInput { card.get_style().clone_from(&self.style); let mut input = self.input.clone(); - if self.multiline { - if let Some(on_submit) = self.on_submit.clone() { - input = - input.on_pre_key_down(submit_on_modifier_enter(self.value.clone(), on_submit)); - } - if !matches!(self.layout.height, Size::Inner) { - input = input.height(Size::fill()); - } + if self.multiline + && let Some(on_submit) = self.on_submit.clone() + { + input = input.on_pre_key_down(submit_on_modifier_enter(self.value.clone(), on_submit)); + } + if !matches!(self.layout.height, Size::Inner) { + input = input.height(Size::fill()); } let expanded_input = (self.expandable && expanded()).then(|| { diff --git a/packages/oneclient_app/src/components/version_art_gallery.rs b/packages/oneclient_app/src/components/version_art_gallery.rs new file mode 100644 index 00000000..ef8df418 --- /dev/null +++ b/packages/oneclient_app/src/components/version_art_gallery.rs @@ -0,0 +1,306 @@ +use freya::prelude::*; +use oneclient_core::images::DEFAULT_IMAGE_EDGE; + +use crate::components::overlay_popup::OVERLAY_MAX_LEVEL; +use crate::components::spinner::Spinner; +use crate::components::{ + ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea, +}; +use crate::hooks::{refresh_version_art_gallery, use_version_art_gallery}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const DIALOG_WIDTH: f32 = 880.; +const DIALOG_HEIGHT: f32 = 620.; +const PANE_PADDING: f32 = 24.; +const TILE_W: f32 = 248.; +const TILE_H: f32 = 140.; +const TILE_GAP: f32 = 14.; +const COLUMNS: usize = 3; + +#[derive(PartialEq, Clone)] +pub struct VersionArtGallery { + pending: State>, + error: State>, + on_pick: EventHandler, + on_close: EventHandler<()>, +} + +impl VersionArtGallery { + pub fn new( + pending: State>, + error: State>, + on_pick: impl Into>, + on_close: impl Into>, + ) -> Self { + Self { + pending, + error, + on_pick: on_pick.into(), + on_close: on_close.into(), + } + } +} + +impl Component for VersionArtGallery { + fn render(&self) -> impl IntoElement { + let urls = use_version_art_gallery(); + let mut pending = self.pending; + let mut error = self.error; + let in_flight = pending.read().clone(); + let failure = error.read().clone(); + + let scrim_close = self.on_close.clone(); + let header_close = self.on_close.clone(); + let footer_close = self.on_close.clone(); + let picked = self.on_pick.clone(); + let on_pick: EventHandler = EventHandler::from(move |url: String| { + if pending.read().is_some() { + return; + } + error.set(None); + pending.set(Some(url.clone())); + picked.call(url); + }); + + let rows = urls.len().div_ceil(COLUMNS); + + let scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., PANE_PADDING, 8., PANE_PADDING)) + .scrollbar_gutter(true); + + let scroll = if urls.is_empty() { + scroll.child(empty_state()) + } else { + scroll.lazy(rows, TILE_H, TILE_GAP, move |index| { + let start = index * COLUMNS; + let end = (start + COLUMNS).min(urls.len()); + tile_row(&urls[start..end], &on_pick, in_flight.as_deref()) + }) + }; + + OverlayPopup::new() + .overlay_level(OVERLAY_MAX_LEVEL) + .on_close(move |()| scrim_close.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_WIDTH)) + .height(Size::px(DIALOG_HEIGHT)) + .max_width(Size::window_percent(94.)) + .max_height(Size::window_percent(92.)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(16.)) + .overflow(Overflow::Clip) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(header(move |_| header_close.call(()))) + .child(scroll) + .child(footer(failure, move |_| footer_close.call(()))), + ), + ) + .into_element() + } +} + +fn tile_row(row: &[String], on_pick: &EventHandler, in_flight: Option<&str>) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .spacing(TILE_GAP) + .children( + row.iter() + .map(|url| { + GalleryTile { + busy: in_flight == Some(url.as_str()), + dimmed: in_flight.is_some(), + url: url.clone(), + on_pick: on_pick.clone(), + } + .into_element() + }) + .collect::>(), + ) + .into_element() +} + +#[derive(PartialEq, Clone)] +struct GalleryTile { + url: String, + busy: bool, + dimmed: bool, + on_pick: EventHandler, +} + +impl Component for GalleryTile { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let mut hovering = use_state(|| false); + let hovered = *hovering.read(); + + let on_pick = self.on_pick.clone(); + let picked = self.url.clone(); + + let border_color = if hovered || self.busy { + colors::brand() + } else { + colors::component_border() + }; + + let opacity = if self.dimmed && !self.busy { 0.45 } else { 1.0 }; + + rect() + .width(Size::px(TILE_W)) + .height(Size::px(TILE_H)) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .opacity(opacity) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_press(move |_| on_pick.call(picked.clone())) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child( + DynamicArt::for_url(self.url.clone()) + .max_edge(ART_PREVIEW_EDGE) + .skeleton(true), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(3)) + .corner_radius(CornerRadius::new_all(12.)) + .border(border_all_color(2., border_color).alignment(BorderAlignment::Inner)) + .center() + .maybe_child(self.busy.then(|| Spinner::new(22.).into_element())), + ) + .into_element() + } +} + +fn empty_state() -> Element { + rect() + .vertical() + .width(Size::fill()) + .height(Size::px(220.)) + .center() + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("The art gallery could not be loaded. Check your connection.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| { + spawn(async move { + refresh_version_art_gallery().await; + }); + }) + .text("Try again"), + ) + .into_element() +} + +fn header(on_close: impl FnMut(Event) + 'static) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Start) + .spacing(16.) + .padding(Gaps::new(22., PANE_PADDING, 16., PANE_PADDING)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(5.) + .child( + label() + .text("Art gallery") + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("Pick any of the official version artworks as the background for this instance.") + .font_size(13.) + .line_height(1.35) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .icon() + .alt("Close") + .on_press(on_close) + .child(Icon::new(IconType::XClose).size(16.)), + ) + .into_element() +} + +fn footer( + failure: Option, + on_close: impl FnMut(Event) + 'static, +) -> Element { + let (note, note_color) = match failure { + Some(message) => (message, colors::danger()), + None => ( + "Choosing an artwork pins it to this instance, so it stays put if you change the version later." + .to_string(), + colors::fg_secondary(), + ), + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(20.) + .padding(Gaps::new(16., PANE_PADDING, 16., PANE_PADDING)) + .border( + Border::new() + .fill(colors::component_border()) + .width(BorderWidth { + top: 1., + right: 0., + bottom: 0., + left: 0., + }), + ) + .child( + label() + .text(note) + .width(Size::flex(1.0)) + .font_size(12.) + .line_height(1.35) + .max_lines(2) + .color(note_color), + ) + .child(Button::new().ghost().on_press(on_close).text("Cancel")) + .into_element() +} + +pub const GALLERY_COVER_EDGE: u32 = DEFAULT_IMAGE_EDGE; diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 7bf05724..980e73fd 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -2273,6 +2273,11 @@ mod tests { last_played: None, overall_played: Duration::ZERO, linked_modpack_hash: None, + kind: oneclient_db::models::ClusterKind::OneClient, + user_created: false, + description: None, + tags: Vec::new(), + cover_path: None, } } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 724429f8..01888a1b 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -27,42 +27,48 @@ pub use active_cluster::{ pub use actions::{Actions, NotificationBuilder, PumpSignal}; pub use queries::{ - AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, - CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, - ClusterLogsQuery, ClusterMutation, DisableWarningsQuery, DiscardLeftoversKeys, DiscardLeftoversMutation, - FinishMicrosoftLoginMutation, LeftoversQuery, LogAction, LogContentQuery, MigrationQuery, + AddOfflineAccountKeys, AvailableBundlesQuery, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, + CachedImageQuery, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, + ClusterBundles, ClusterLogsQuery, ClusterMutation, DisableWarningsQuery, DiscardLeftoversKeys, + DiscardLeftoversMutation, FinishMicrosoftLoginMutation, GameVersion, JavaMajorsQuery, + LeftoversQuery, LoaderVersionSet, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, UploadLogKeys, UploadLogMutation, UseDiscardLeftovers, 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, disable_warnings, has_migration_data, invalidate_cluster_content_queries, + VERSIONS_PAGE_SIZE, accounts_have_microsoft, available_bundles, bundle_overrides_map, + bundles_with_status_items, category_list, changelog_error, changelog_groups, + changelog_is_loading, cluster_content_items, content_type_for_slug, disable_warnings, + game_versions, has_migration_data, invalidate_cluster_content_queries, invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_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, mutation_ok, 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, + invalidate_storage_queries, java_majors, java_runtimes, latest_changelog_version, loaded_image, + loader_game_versions, loader_versions, login_code_already_handled, migration_detection, + mutation_error, mutation_is_pending, mutation_is_running, mutation_ok, + onboarding_bundles_items, package_meta_batch, package_updates, pick_version_metadata, + project_detail, provider_versions, query_error, query_is_busy, query_is_loading, + refresh_version_art_gallery, reset_login_code_dedup, resolve_art_url, 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_leftovers, 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_disable_warnings, use_discard_leftovers, use_finish_microsoft_login, use_game_profile, - use_global_analytics, use_java_runtimes, use_leftovers, 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_add_microsoft_account, use_add_offline_account, use_available_bundles, + 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_disable_warnings, use_discard_leftovers, + use_finish_microsoft_login, use_game_profile, use_game_versions, use_global_analytics, + use_java_majors, use_java_runtimes, use_leftovers, use_loader_game_versions, + 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_art, use_version_art_gallery, use_version_loaders, + use_version_metadata, use_versions, version_list, version_loaders, versions_metadata, + versions_total, }; pub use queries::{ add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, diff --git a/packages/oneclient_app/src/hooks/queries/bundles.rs b/packages/oneclient_app/src/hooks/queries/bundles.rs index 911dc881..7658a65b 100644 --- a/packages/oneclient_app/src/hooks/queries/bundles.rs +++ b/packages/oneclient_app/src/hooks/queries/bundles.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; +use std::sync::Arc; use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_common::domain::GameLoader; use oneclient_core::clusters::Cluster; use oneclient_core::{ BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError, @@ -33,7 +35,7 @@ impl QueryCapability for OnboardingBundlesQuery { let clusters = state.clusters.list().await?; let mut out = Vec::with_capacity(clusters.len()); - for cluster in clusters { + for cluster in clusters.into_iter().filter(|cluster| !cluster.user_created) { let archives = state .bundles .archives_for( @@ -182,3 +184,45 @@ pub fn use_bundle_updates(cluster_id: ClusterId) -> UseQuery BundleUpdatesQuery { cluster_id }, )) } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AvailableBundlesQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct AvailableBundlesKeys { + pub mc_version: String, + pub loader: GameLoader, +} + +impl QueryCapability for AvailableBundlesQuery { + type Ok = Arc<[BundleArchive]>; + type Err = LauncherError; + type Keys = AvailableBundlesKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.mc_version.is_empty() { + return Ok(Arc::from([])); + } + let state = crate::launcher::state()?; + Ok(state + .bundles + .archives_for(&state.services.content(), &keys.mc_version, keys.loader) + .await + .unwrap_or_default() + .into()) + } +} + +pub fn use_available_bundles( + mc_version: String, + loader: GameLoader, +) -> UseQuery { + use_query(Query::new( + AvailableBundlesKeys { mc_version, loader }, + AvailableBundlesQuery, + )) +} + +pub fn available_bundles(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/migration.rs b/packages/oneclient_app/src/hooks/queries/migration.rs index 4a9d862c..7299a0d0 100644 --- a/packages/oneclient_app/src/hooks/queries/migration.rs +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -27,10 +27,13 @@ impl QueryCapability for MigrationQuery { if instance.mc_version.is_empty() { continue; } - let resolved = + let (version, loader) = resolve_migration_chain(&instance.mc_version, instance.mc_loader, &rules); - if resolved != instance.mc_version { - instance.target_mc_version = Some(resolved); + if version != instance.mc_version { + instance.target_mc_version = Some(version); + } + if loader != instance.mc_loader { + instance.target_mc_loader = Some(loader); } } } diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index d74a9b67..c404e429 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -50,8 +50,9 @@ pub use auth::{ 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, + AvailableBundlesQuery, ClusterBundles, OnboardingBundlesQuery, available_bundles, + bundle_overrides_map, bundles_with_status_items, onboarding_bundles_items, + use_available_bundles, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_onboarding_bundles, }; pub use changelog::{ @@ -101,8 +102,16 @@ pub use storage::{ use_discard_leftovers, use_leftovers, 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 use version_metadata::{ + pick_version_metadata, refresh_version_art_gallery, resolve_art_url, use_version_art, + use_version_art_gallery, use_version_metadata, +}; +pub use versions::{ + GameVersion, JavaMajorsQuery, LoaderVersionSet, game_versions, java_majors, + loader_game_versions, loader_versions, use_game_versions, use_java_majors, + use_loader_game_versions, use_loader_versions, use_version_loaders, use_versions, + version_loaders, versions_metadata, +}; pub use worlds::{ add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, spawn_world_task, try_cluster_worlds, try_world_datapacks, try_world_size, use_cluster_worlds, diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index cfdaca1b..acc5fa30 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -1,6 +1,10 @@ use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; +use oneclient_common::domain::GameLoader; use oneclient_content::packages::LiveSync; -use oneclient_db::models::ClusterId; +use oneclient_core::BundleArchive; +use oneclient_db::models::{ClusterId, ClusterKind, OverrideType}; +use std::collections::HashSet; +use std::path::PathBuf; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; use super::cluster_content::ClusterContentQuery; @@ -101,6 +105,36 @@ pub async fn invalidate_profile_queries() { QueriesStorage::::invalidate_all().await; } +fn bundle_selection_overrides( + archives: &[BundleArchive], + selected: &[String], +) -> Vec<(String, String, OverrideType)> { + let taken = |archive: &BundleArchive| selected.contains(&archive.manifest.name); + + let mut kept: HashSet = HashSet::new(); + for archive in archives.iter().filter(|archive| taken(archive)) { + for file in archive.manifest.files.iter().filter(|file| file.enabled) { + kept.insert(file.kind.package_id()); + } + } + + let mut overrides = Vec::new(); + for archive in archives.iter().filter(|archive| !taken(archive)) { + for file in archive.manifest.files.iter().filter(|file| file.enabled) { + let package_id = file.kind.package_id(); + if kept.contains(&package_id) { + continue; + } + overrides.push(( + archive.manifest.name.clone(), + package_id, + OverrideType::Removed, + )); + } + } + overrides +} + #[derive(Clone, PartialEq, Eq, Hash)] pub struct ClusterMutation; @@ -135,6 +169,28 @@ pub enum ClusterAction { VerifyFiles { cluster_id: ClusterId, }, + CreateInstance { + kind: ClusterKind, + name: String, + mc_version: String, + mc_loader: GameLoader, + mc_loader_version: Option, + description: Option, + tags: Vec, + cover_source: Option, + bundles: Option>, + }, + UpdateInstance { + cluster_id: ClusterId, + name: String, + description: Option, + tags: Vec, + cover_source: Option, + clear_cover: bool, + }, + DeleteInstance { + cluster_id: ClusterId, + }, } impl MutationCapability for ClusterMutation { @@ -226,6 +282,157 @@ impl MutationCapability for ClusterMutation { }), } } + ClusterAction::CreateInstance { + kind, + name, + mc_version, + mc_loader, + mc_loader_version, + description, + tags, + cover_source, + bundles, + } => { + let global = state.settings.read().global_game_settings.clone(); + let mut options = oneclient_core::clusters::CreateClusterOptions::new( + name.clone(), + mc_version.clone(), + *mc_loader, + ) + .kind(*kind) + .user_created(true) + .tags(tags.clone()); + options.mc_loader_version = mc_loader_version.clone(); + options.description = description.clone(); + + match state.clusters.create(&global, options).await { + Ok(cluster) => { + services + .events + .signal(oneclient_events::Signal::ClustersChanged); + + if let Some(source) = cover_source + && let Err(err) = + state.clusters.set_cover_from_file(cluster.id, source).await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to store the instance cover"); + } + + if cluster.uses_bundles() { + if let Some(selected) = bundles { + let archives = state + .bundles + .archives_for(content, &cluster.mc_version, cluster.mc_loader) + .await + .unwrap_or_default(); + let overrides = bundle_selection_overrides(&archives, selected); + if let Err(err) = oneclient_core::set_bundle_package_overrides( + cluster.id, &overrides, content, + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to record the bundle choices for the new instance"); + } + } + + let session = oneclient_events::GroupedProgressSession::start( + &services.events, + format!("Setting up {}", cluster.name), + ); + if let Err(err) = oneclient_content::bundles::install_cluster_bundles( + cluster.id, + state.bundles.as_ref(), + Some(&session), + content, + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to install bundle content for the new instance"); + } + if let Err(err) = oneclient_core::apply_bundle_java_override( + &state, + cluster.id, + true, + true, + Some(&session), + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to apply the bundle java override for the new instance"); + } + session.finish(); + } + Ok(()) + } + Err(err) => Err(oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }), + } + } + ClusterAction::UpdateInstance { + cluster_id, + name, + description, + tags, + cover_source, + clear_cover, + } => { + let cover = if *clear_cover { + state.clusters.clear_cover(*cluster_id).await.ok(); + oneclient_common::Patch::Clear + } else if let Some(source) = cover_source { + match state.clusters.set_cover_from_file(*cluster_id, source).await { + Ok(_) => oneclient_common::Patch::Unchanged, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to store the instance cover"); + oneclient_common::Patch::Unchanged + } + } + } else { + oneclient_common::Patch::Unchanged + }; + + let update = oneclient_core::clusters::ClusterUpdate { + name: Some(name.clone()), + description: match description { + Some(text) => oneclient_common::Patch::Set(text.clone()), + None => oneclient_common::Patch::Clear, + }, + tags: Some(tags.clone()), + cover_path: cover, + ..Default::default() + }; + + state + .clusters + .update(*cluster_id, update) + .await + .map(|_| ()) + .map_err(|err| oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }) + } + ClusterAction::DeleteInstance { cluster_id } => { + if state.games.is_active(*cluster_id) { + Err(oneclient_content::ContentError::InvalidData { + reason: "Close the game before deleting this instance.".to_string(), + }) + } else { + let outcome = state + .clusters + .delete(*cluster_id, true) + .await + .map_err(|err| oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }); + if outcome.is_ok() { + services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } + outcome + } + } }; tracing::debug!( target: "oneclient_app::perf", diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs index 15830fe7..4c8d7238 100644 --- a/packages/oneclient_app/src/hooks/queries/screenshots.rs +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -42,7 +42,9 @@ pub struct LocalImageKeys { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct LocalImageQuery; +pub struct LocalImageQuery { + picked: bool, +} impl QueryCapability for LocalImageQuery { type Ok = Bytes; @@ -50,6 +52,10 @@ impl QueryCapability for LocalImageQuery { type Keys = LocalImageKeys; async fn run(&self, keys: &Self::Keys) -> Result { + if keys.path.as_os_str().is_empty() { + return Ok(Bytes::new()); + } + let path = keys.path.clone(); let max_edge = (keys.max_edge != 0).then_some(keys.max_edge); @@ -66,11 +72,20 @@ impl QueryCapability for LocalImageQuery { None }; - Ok( - tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge)) - .await - .map_err(|e| LauncherError::Minecraft(e.to_string()))??, - ) + let picked = self.picked; + Ok(tokio::task::spawn_blocking(move || { + if picked { + oneclient_core::load_picked_image(&path, max_edge) + } else { + oneclient_core::load_screenshot(&path, max_edge) + } + }) + .await + .map_err(|e| LauncherError::Minecraft(e.to_string()))??) + } + + fn matches(&self, _keys: &Self::Keys) -> bool { + !self.picked } } @@ -81,10 +96,10 @@ pub fn use_cluster_screenshots(cluster_id: i64) -> UseQuery UseQuery { +pub fn use_local_image(path: PathBuf, max_edge: u32, picked: bool) -> UseQuery { use_query(Query::new( LocalImageKeys { path, max_edge }, - LocalImageQuery, + LocalImageQuery { picked }, )) } @@ -117,7 +132,11 @@ pub fn try_cluster_screenshots( pub async fn invalidate_screenshots_queries() { QueriesStorage::::invalidate_all().await; - QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_matching(LocalImageKeys { + path: PathBuf::new(), + max_edge: 0, + }) + .await; } #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index 755d00a4..48791292 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -2,7 +2,10 @@ use oneclient_common::VersionKey; use oneclient_common::domain::GameLoader; use oneclient_core::VersionMetadata; +use freya::query::QueriesStorage; + use super::use_versions; +use super::versions::{VersionArtsKeys, VersionArtsQuery, use_version_arts}; pub fn pick_version_metadata( list: &[VersionMetadata], @@ -57,6 +60,53 @@ pub fn use_version_metadata( pick_version_metadata(list, major, key, loader) } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct VersionArtCandidates { + pub minor: Option, + pub fallback: Option, +} + +pub fn use_version_art(major: Option, key: Option) -> VersionArtCandidates { + let arts_query = use_version_arts(); + let reader = arts_query.read(); + let state = reader.state(); + + let Some(arts) = state.ok() else { + return VersionArtCandidates::default(); + }; + + VersionArtCandidates { + minor: arts.specific_art_url(major, key), + fallback: arts.fallback_art_url(major), + } +} + +pub async fn refresh_version_art_gallery() { + QueriesStorage::::invalidate_matching(VersionArtsKeys).await; +} + +pub fn use_version_art_gallery() -> Vec { + let arts_query = use_version_arts(); + let reader = arts_query.read(); + let state = reader.state(); + + state.ok().map(|arts| arts.gallery()).unwrap_or_default() +} + +pub fn resolve_art_url( + curated: Option<&VersionMetadata>, + candidates: &VersionArtCandidates, +) -> Option { + let curated_minor = curated + .filter(|m| m.minor_version.is_some()) + .and_then(|m| m.art_url.clone()); + + curated_minor + .or_else(|| candidates.minor.clone()) + .or_else(|| curated.and_then(|m| m.art_url.clone())) + .or_else(|| candidates.fallback.clone()) +} + #[cfg(test)] mod tests { use super::*; @@ -88,6 +138,62 @@ mod tests { ] } + fn candidates(minor: Option<&str>, fallback: Option<&str>) -> VersionArtCandidates { + VersionArtCandidates { + minor: minor.map(str::to_string), + fallback: fallback.map(str::to_string), + } + } + + fn with_art(mut entry: VersionMetadata, art: &str) -> VersionMetadata { + entry.art_url = Some(art.to_string()); + entry + } + + #[test] + fn a_minor_specific_art_beats_the_curated_cluster_row() { + let cluster = with_art(entry(21, None, None, None, "major"), "trials"); + let resolved = resolve_art_url(Some(&cluster), &candidates(Some("garden"), Some("trials"))); + assert_eq!(resolved.as_deref(), Some("garden")); + } + + #[test] + fn a_curated_entry_art_beats_the_arts_manifest() { + let curated = with_art( + entry(21, Some(10), None, Some("fabric"), "copper"), + "copper", + ); + let resolved = resolve_art_url(Some(&curated), &candidates(Some("other"), Some("trials"))); + assert_eq!(resolved.as_deref(), Some("copper")); + } + + #[test] + fn the_cluster_row_still_wins_over_a_line_level_art() { + let cluster = with_art(entry(21, None, None, None, "major"), "trials"); + let resolved = resolve_art_url(Some(&cluster), &candidates(None, Some("line"))); + assert_eq!(resolved.as_deref(), Some("trials")); + } + + #[test] + fn nothing_curated_falls_through_to_the_arts_manifest() { + assert_eq!( + resolve_art_url(None, &candidates(None, Some("dirt"))).as_deref(), + Some("dirt") + ); + assert_eq!( + resolve_art_url(None, &candidates(Some("combat"), Some("dirt"))).as_deref(), + Some("combat") + ); + assert_eq!(resolve_art_url(None, &candidates(None, None)), None); + } + + #[test] + fn a_curated_row_without_art_does_not_shadow_the_manifest() { + let cluster = entry(21, None, None, None, "major"); + let resolved = resolve_art_url(Some(&cluster), &candidates(None, Some("dirt"))); + assert_eq!(resolved.as_deref(), Some("dirt")); + } + #[test] fn patch_and_bare_minor_are_distinct() { let bare = pick_version_metadata(&list(), 26, Some((1, None)), Some(GameLoader::Fabric)); diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index 31d6bd4c..df8d9252 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -1,6 +1,9 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; use oneclient_common::domain::GameLoader; -use oneclient_core::{LauncherError, VersionMetadata}; +use oneclient_core::{GameVersionKind, LauncherError, VersionArts, VersionMetadata}; use crate::launcher::off_ui; @@ -39,6 +42,40 @@ pub fn use_versions() -> UseQuery { use_query(Query::new(VersionsMetadataKeys, VersionsMetadataQuery)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionArtsQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionArtsKeys; + +impl QueryCapability for VersionArtsQuery { + type Ok = Arc; + type Err = LauncherError; + type Keys = VersionArtsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let arts = state + .versions + .arts(&state.services.requester.config().meta_url_base) + .await; + if !arts.is_empty() { + return Ok(Arc::new(arts)); + } + state.versions.sync(&state.services).await?; + Ok(Arc::new( + state + .versions + .arts(&state.services.requester.config().meta_url_base) + .await, + )) + } +} + +pub fn use_version_arts() -> UseQuery { + use_query(Query::new(VersionArtsKeys, VersionArtsQuery)) +} + /// 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> { @@ -60,7 +97,7 @@ pub struct LoaderVersionsKeys { } impl QueryCapability for LoaderVersionsQuery { - type Ok = Vec; + type Ok = Arc<[String]>; type Err = LauncherError; type Keys = LoaderVersionsKeys; @@ -77,7 +114,8 @@ impl QueryCapability for LoaderVersionsQuery { ) .await }) - .await?) + .await? + .into()) } } @@ -91,6 +129,226 @@ pub fn use_loader_versions( )) } -pub fn loader_versions(query: &UseQuery) -> Vec { +pub fn loader_versions(query: &UseQuery) -> Arc<[String]> { + super::state::settled_or_loading(query).unwrap_or_else(|| Arc::from([])) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GameVersionsQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GameVersionsKeys; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameVersion { + pub id: String, + pub kind: GameVersionKind, + pub released: String, +} + +impl QueryCapability for GameVersionsQuery { + type Ok = Arc<[GameVersion]>; + type Err = LauncherError; + type Keys = GameVersionsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + + tokio::spawn(async move { + let raw = { + let mut metadata = state.metadata.lock().await; + oneclient_core::get_version_ids(&mut metadata, &state.services.mc()).await? + }; + + let prepared: Arc<[GameVersion]> = raw + .into_iter() + .map(|info| GameVersion { + id: info.id, + kind: info.kind, + released: info.released.format("%d %b %Y").to_string(), + }) + .collect(); + + Ok(prepared) + }) + .await + .map_err(|err| LauncherError::Minecraft(err.to_string()))? + } +} + +pub fn use_game_versions() -> UseQuery { + use_query(Query::new(GameVersionsKeys, GameVersionsQuery)) +} + +pub fn game_versions(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionLoadersQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct VersionLoadersKeys { + pub mc_version: String, +} + +impl QueryCapability for VersionLoadersQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = VersionLoadersKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let mut metadata = state.metadata.lock().await; + Ok(oneclient_core::get_loaders_for_version( + &mut metadata, + &state.services.mc(), + &keys.mc_version, + ) + .await?) + } +} + +pub fn use_version_loaders(mc_version: String) -> UseQuery { + use_query(Query::new( + VersionLoadersKeys { mc_version }, + VersionLoadersQuery, + )) +} + +pub fn version_loaders(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LoaderGameVersionsQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LoaderGameVersionsKeys { + pub loader: GameLoader, + pub with_legacy: bool, +} + +pub type LoaderVersionSet = Option>>; + +impl QueryCapability for LoaderGameVersionsQuery { + type Ok = LoaderVersionSet; + type Err = LauncherError; + type Keys = LoaderGameVersionsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let keys = *keys; + + tokio::spawn(async move { + let mc = state.services.mc(); + let mut metadata = state.metadata.lock().await; + + let Some(mut ids) = + oneclient_core::get_versions_for_loader(&mut metadata, &mc, keys.loader).await? + else { + return Ok(None); + }; + + if keys.with_legacy + && let Some(legacy) = + oneclient_core::get_versions_for_loader(&mut metadata, &mc, GameLoader::Ornithe) + .await? + { + ids.extend(legacy); + } + + Ok(Some(Arc::new(ids.into_iter().collect::>()))) + }) + .await + .map_err(|err| LauncherError::Minecraft(err.to_string()))? + } +} + +pub fn use_loader_game_versions( + loader: GameLoader, + with_legacy: bool, +) -> UseQuery { + use_query(Query::new( + LoaderGameVersionsKeys { + loader, + with_legacy, + }, + LoaderGameVersionsQuery, + )) +} + +pub fn loader_game_versions(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct JavaMajorsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct JavaMajorsKeys { + pub versions: Vec, +} + +impl QueryCapability for JavaMajorsQuery { + type Ok = HashMap; + type Err = LauncherError; + type Keys = JavaMajorsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.versions.is_empty() { + return Ok(HashMap::new()); + } + + let state = crate::launcher::state()?; + let wanted = keys.versions.clone(); + + tokio::spawn(async move { + let mc = state.services.mc(); + + let resolved = { + let mut metadata = state.metadata.lock().await; + let mut resolved = Vec::with_capacity(wanted.len()); + for id in &wanted { + if let Ok((version, _, _)) = + oneclient_core::game::resolve_minecraft_version(&mut metadata, &mc, id) + .await + { + resolved.push((id.clone(), version)); + } + } + resolved + }; + + let lookups = resolved.into_iter().map(|(id, version)| { + let mc = mc.clone(); + async move { + let info = oneclient_core::game::download_version_info( + &mc, None, &version, None, false, + ) + .await + .ok()?; + Some((id, info.java_version?.major_version)) + } + }); + + let majors = futures_util::future::join_all(lookups) + .await + .into_iter() + .flatten() + .collect(); + + Ok(majors) + }) + .await + .map_err(|err| LauncherError::Minecraft(err.to_string()))? + } +} + +pub fn use_java_majors(versions: Vec) -> UseQuery { + use_query(Query::new(JavaMajorsKeys { versions }, JavaMajorsQuery)) +} + +pub fn java_majors(query: &UseQuery) -> HashMap { super::state::settled_or_loading(query).unwrap_or_default() } diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index 10446150..7dfaf6e9 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -322,8 +322,14 @@ pub async fn install_package( let content = state.services.content(); let bad_mods = oneclient_content::packages::load_bad_mods(&content).await; + let screened = !allow_flagged + && !state + .clusters + .get(cluster_id) + .await + .is_ok_and(|cluster| cluster.user_created); - if !allow_flagged && let Some(entry) = bad_mods.check(&project, &version) { + if screened && let Some(entry) = bad_mods.check(&project, &version) { tracing::warn!(project = %project.name, version = %version.version_number, "refusing to install flagged mod"); return Err(flagged_install(project.name, entry, cluster_id, &content).await); } @@ -346,7 +352,7 @@ pub async fn install_package( } } - if !allow_flagged + if screened && let Some((dependency, entry)) = resolution.install.iter().find_map(|dependency| { bad_mods .check(&dependency.project, &dependency.version) diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 754d1dc1..f1264ef6 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -10,7 +10,7 @@ use crate::hooks::{ use crate::routes::Route; use crate::theme::colors; use crate::ui::entrance_motion_layer; -use crate::view::app::launch_button_state; +use crate::view::app::{launch_button_state, launch_syncing}; const HEADER_HEIGHT: f32 = 64.; const TABS_HEIGHT: f32 = 38.; @@ -111,15 +111,33 @@ impl Component for ClusterShell { 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 syncing = launch_syncing( + &launcher, + cluster.as_ref().is_none_or(|c| c.uses_bundles()), + ); let show_game_log = game.is_active(cluster_id); let show_datapacks = cluster .as_ref() .is_none_or(|c| crate::view::app::cluster::supports_datapacks(&c.mc_version)); + let show_mod_tabs = cluster.as_ref().is_none_or(|c| !c.lacks_mod_loader()); let launch_state = launch_button_state(&game, cluster_id, syncing); + let hidden_tab = !show_mod_tabs + && matches!( + active_tab, + ClusterViewShellTab::Mods | ClusterViewShellTab::Shaders + ); + use_side_effect_with_deps( + &hidden_tab.then_some(cluster_id), + move |redirect: &Option| { + if let Some(cluster_id) = *redirect { + let _ = RouterContext::get().replace(Route::ClusterOverview { cluster_id }); + } + }, + ); + // 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 @@ -132,10 +150,20 @@ impl Component for ClusterShell { ); 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()); + let loader_version = format!("{} {}", cluster.mc_loader, cluster.mc_version); + let (title, subtitle) = if cluster.user_created { + let subtitle = cluster + .description + .clone() + .filter(|d| !d.trim().is_empty()) + .unwrap_or(loader_version); + (cluster.name.clone(), subtitle) + } else { + let subtitle = metadata + .and_then(|m| m.long_description) + .unwrap_or_else(|| cluster.name.clone()); + (loader_version, subtitle) + }; cluster_header( title, subtitle, @@ -170,6 +198,7 @@ impl Component for ClusterShell { cluster_id, show_game_log, show_datapacks, + show_mod_tabs, )), ) .child( @@ -321,13 +350,14 @@ fn cluster_tabs( cluster_id: i64, show_game_log: bool, show_datapacks: bool, + show_mod_tabs: bool, ) -> impl IntoElement { let tabs = [ Some(ClusterViewShellTab::Overview), Some(ClusterViewShellTab::Logs), Some(ClusterViewShellTab::Screenshots), - Some(ClusterViewShellTab::Mods), - Some(ClusterViewShellTab::Shaders), + show_mod_tabs.then_some(ClusterViewShellTab::Mods), + show_mod_tabs.then_some(ClusterViewShellTab::Shaders), Some(ClusterViewShellTab::Textures), Some(ClusterViewShellTab::Worlds), show_datapacks.then_some(ClusterViewShellTab::DataPacks), diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 84eb9ba5..8dbea1e0 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -154,6 +154,37 @@ pub fn columns_for(width: f32, min_cell: f32, max: usize, gap: f32) -> usize { (((width + gap) / (min_cell + gap)).floor() as usize).clamp(1, max) } +pub fn fixed_grid(cards: Vec, columns: usize, card_height: f32, gap: f32) -> Element { + let columns = columns.max(1); + let mut root = rect().vertical().width(Size::fill()).spacing(gap); + + for (index, chunk) in cards.chunks(columns).enumerate() { + let mut row = rect() + .key(index) + .horizontal() + .width(Size::fill()) + .height(Size::px(card_height)) + .content(Content::Flex) + .spacing(gap); + + for card in chunk { + row = row.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(card.clone()), + ); + } + for _ in chunk.len()..columns { + row = row.child(rect().width(Size::flex(1.0)).height(Size::fill())); + } + + root = root.child(row.into_element()); + } + + root.into_element() +} + /// 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); diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 2a2c9f51..ba4ca686 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -4,6 +4,7 @@ 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::BundleArchive; use oneclient_core::clusters::Cluster; use oneclient_common::MEMORY_HEADROOM_GB; @@ -77,7 +78,25 @@ pub fn line_art_key(line: ReleaseLine, clusters: &[Cluster]) -> Option ClusterGroups { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GridSelection { + Line(ReleaseLine), + Instance(i64), +} + +pub fn split_clusters(clusters: &[Cluster]) -> (ClusterGroups, Vec) { + let (instances, provisioned): (Vec<&Cluster>, Vec<&Cluster>) = + clusters.iter().partition(|cluster| cluster.user_created); + + ( + group_clusters_by_release(provisioned), + sort_clusters_for_home(instances.into_iter().cloned().collect()), + ) +} + +pub fn group_clusters_by_release<'a>( + clusters: impl IntoIterator, +) -> ClusterGroups { let mut groups: ClusterGroups = BTreeMap::new(); for cluster in clusters { @@ -320,6 +339,15 @@ pub fn format_day(date: NaiveDate) -> String { format!("{} {}", MONTHS[date.month0() as usize], date.day()) } +pub 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() + } +} + pub fn sort_clusters_for_home(mut clusters: Vec) -> Vec { clusters.sort_by(compare_last_played); clusters @@ -332,12 +360,12 @@ fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering { (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)), + (None, None) => version_sort_key(&b.mc_version).cmp(&version_sort_key(&a.mc_version)), } } -fn version_sort_key(cluster: &Cluster) -> (u32, u32, u32) { - parse_mc_version(&cluster.mc_version) +pub fn version_sort_key(version: &str) -> (u32, u32, u32) { + parse_mc_version(version) .map(|v| (v.major, v.minor.unwrap_or(0), v.patch.unwrap_or(0))) .unwrap_or((0, 0, 0)) } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 715732e2..1b80fec7 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -75,9 +75,16 @@ const BROWSE_TYPES: [(&str, &str); 4] = [ ]; const DATAPACK_SLUG: &str = "datapack"; +const TEXTURE_SLUG: &str = "texture"; +const LOADER_SLUGS: [&str; 2] = ["mod", "shader"]; -pub(crate) fn browsable_type(package_type: &str, mc_version: &str) -> String { - if package_type == DATAPACK_SLUG && !supports_datapacks(mc_version) { +pub(crate) fn browsable_type(package_type: &str, cluster: &Cluster) -> String { + if cluster.lacks_mod_loader() { + let unsupported = LOADER_SLUGS.contains(&package_type) + || (package_type == DATAPACK_SLUG && !supports_datapacks(&cluster.mc_version)); + return if unsupported { TEXTURE_SLUG } else { package_type }.to_string(); + } + if package_type == DATAPACK_SLUG && !supports_datapacks(&cluster.mc_version) { BROWSE_TYPES[0].0.to_string() } else { package_type.to_string() @@ -110,6 +117,21 @@ impl Component for Browser { last_type.set_if_modified(package_type.clone()); }); + let redirect = use_cluster(self.cluster_id) + .filter(Cluster::lacks_mod_loader) + .map(|c| browsable_type(&self.package_type, &c)) + .filter(|package_type| *package_type != self.package_type) + .map(|package_type| Route::Browser { + cluster_id: self.cluster_id, + package_type, + pick_cluster: self.pick_cluster, + }); + use_side_effect_with_deps(&redirect, move |redirect: &Option| { + if let Some(route) = redirect.clone() { + let _ = RouterContext::get().replace(route); + } + }); + BrowserBody { cluster_id: self.cluster_id, package_type: self.package_type.clone(), @@ -560,6 +582,9 @@ impl Component for SortPicker { /// 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 { + if cluster.user_created { + return format!("{} {}", cluster.mc_version, cluster.mc_loader); + } parse_mc_version(&cluster.mc_version) .and_then(|parsed| { pick_version_metadata( @@ -585,11 +610,16 @@ impl Component for TypePicker { let cluster_id = self.cluster_id; let pick_cluster = self.pick_cluster; let current = self.package_type.clone(); - let datapacks = use_cluster(cluster_id).is_none_or(|c| supports_datapacks(&c.mc_version)); + let cluster = use_cluster(cluster_id); + let datapacks = cluster + .as_ref() + .is_none_or(|c| supports_datapacks(&c.mc_version)); + let mod_loader = cluster.as_ref().is_none_or(|c| !c.lacks_mod_loader()); let types: Vec<(&str, &str)> = BROWSE_TYPES .into_iter() .filter(|(slug, _)| datapacks || *slug != DATAPACK_SLUG) + .filter(|(slug, _)| mod_loader || !LOADER_SLUGS.contains(slug)) .collect(); let labels: Vec = types .iter() @@ -631,7 +661,6 @@ impl Component for ClusterPicker { .map(|c| format!("{} · {}", c.name, version_name(&metadata, c))) .collect(); let ids: Vec = clusters.iter().map(|c| c.id).collect(); - let versions: Vec = clusters.iter().map(|c| c.mc_version.clone()).collect(); let selected = ids .iter() @@ -651,12 +680,12 @@ impl Component for ClusterPicker { .width(Size::px(240.)) .height(Size::px(28.)) .on_select(move |idx: usize| { - if let (Some(cluster_id), Some(mc_version)) = - (ids.get(idx).copied(), versions.get(idx)) + if let (Some(cluster_id), Some(cluster)) = + (ids.get(idx).copied(), clusters.get(idx)) { let _ = RouterContext::get().replace(Route::Browser { cluster_id, - package_type: browsable_type(&package_type, mc_version), + package_type: browsable_type(&package_type, cluster), pick_cluster: true, }); } diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index 31520680..8c252900 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -183,7 +183,7 @@ impl Component for BrowserPackage { let remove_id = project.as_ref().map(|p| p.id.clone()); let remove_dispatch = dispatch.clone(); - let (on_remove, remove_dialog) = use_shared_delete(move |(name, hash)| { + let (on_remove, remove_dialog) = use_shared_delete(cluster_id, move |(name, hash)| { if let Some(project_id) = &remove_id { remove_dispatch.remove_package_version( cluster_id, diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 6c38529e..d6b691c3 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use freya::prelude::*; use oneclient_common::Patch; use oneclient_common::domain::GameLoader; @@ -17,7 +19,8 @@ use crate::hooks::{ use crate::layout::cluster_content; use crate::theme::colors; use crate::ui::centered_note; -use crate::view::app::settings::{section_header, settings_row}; +use crate::view::app::clusters::{DeleteInstanceModal, EditInstanceModal, InstanceFacts}; +use crate::view::app::settings::{section_header, settings_row, settings_row_disabled}; use oneclient_core::clusters::rank_migration_sources; use super::cluster_not_found; @@ -64,6 +67,49 @@ impl Component for ClusterSettings { let versions = loader_versions(&versions_query); let runtimes = java_runtimes(&runtimes_query); + let instance_section: Vec = if cluster.user_created { + vec![ + section_header("INSTANCE").into_element(), + InstanceRow { + facts: InstanceFacts { + cluster_id, + name: cluster.name.clone(), + description: cluster.description.clone(), + tags: cluster.tags.clone(), + cover: cluster.cover_file(), + mc_version: cluster.mc_version.clone(), + mc_loader: cluster.mc_loader, + kind: cluster.kind, + }, + } + .into_element(), + ] + } else { + Vec::new() + }; + + let mod_loader = !cluster.lacks_mod_loader(); + let loader_section: Vec = if mod_loader { + vec![ + section_header("LOADER").into_element(), + LoaderRow { + cluster_id, + loader, + selected: cluster.mc_loader_version.clone(), + versions, + locked: cluster.user_created, + } + .into_element(), + ] + } else { + Vec::new() + }; + let migrate_row: Vec = if mod_loader { + vec![MigrateFromRow { cluster_id }.into_element()] + } else { + Vec::new() + }; + cluster_content() .child( ScrollArea::new() @@ -71,6 +117,7 @@ impl Component for ClusterSettings { .height(Size::fill()) .scrollbar_gutter(true) .spacing(4.) + .children(instance_section) .child(section_header("GAME")) .child( ToggleRow { @@ -98,21 +145,13 @@ impl Component for ClusterSettings { } .into_element(), ) - .child(section_header("LOADER")) - .child( - LoaderRow { - cluster_id, - loader, - selected: cluster.mc_loader_version.clone(), - versions, - } - .into_element(), - ) + .append_children(loader_section) .child(section_header("DIRECTORY")) .child( DedicatedDirRow { cluster_id, dedicated: cluster.uses_dedicated_dir(), + locked: cluster.is_isolated(), } .into_element(), ) @@ -142,7 +181,7 @@ impl Component for ClusterSettings { } .into_element(), ) - .child(MigrateFromRow { cluster_id }.into_element()) + .append_children(migrate_row) .child(section_header("REPAIR")) .child(VerifyFilesRow { cluster_id }.into_element()), ) @@ -263,6 +302,57 @@ impl Component for ToggleRow { } } +#[derive(PartialEq)] +struct InstanceRow { + facts: InstanceFacts, +} + +impl Component for InstanceRow { + fn render(&self) -> impl IntoElement { + let mut editing = use_state(|| false); + let mut deleting = use_state(|| false); + let cluster_id = self.facts.cluster_id; + let name = self.facts.name.clone(); + let facts = self.facts.clone(); + + let buttons = rect() + .horizontal() + .spacing(8.) + .child( + Button::new() + .small() + .secondary() + .on_press(move |_| editing.set(true)) + .text("Edit"), + ) + .child( + Button::new() + .small() + .danger() + .on_press(move |_| deleting.set(true)) + .text("Delete"), + ); + + rect() + .vertical() + .width(Size::fill()) + .child(settings_row( + IconType::Pencil01, + "Instance Details", + "Change this instance's name, description, tags and cover image, or remove it from your list.", + buttons, + )) + .maybe_child(editing.read().then(|| { + EditInstanceModal::new(facts.clone(), move |()| editing.set(false)) + .into_element() + })) + .maybe_child(deleting.read().then(|| { + DeleteInstanceModal::new(cluster_id, name.clone(), move |()| deleting.set(false)) + .into_element() + })) + } +} + #[derive(PartialEq)] struct ShortcutRow { cluster_id: i64, @@ -330,6 +420,7 @@ impl Component for VerifyFilesRow { struct DedicatedDirRow { cluster_id: i64, dedicated: bool, + locked: bool, } impl Component for DedicatedDirRow { @@ -338,6 +429,16 @@ impl Component for DedicatedDirRow { let dedicated = self.dedicated; let mutation = use_cluster_mutation(); + if self.locked { + return settings_row_disabled( + IconType::Folder, + "Dedicated Directory", + "Vanilla and modded instances always run in their own folder, so their worlds, settings and packs stay separate.", + toggle_controlled(true, (|()| {}).into()), + ) + .into_element(); + } + let on_toggle: EventHandler<()> = (move |()| { mutation.mutate(ClusterAction::SetDedicatedDir { cluster_id, @@ -352,6 +453,7 @@ impl Component for DedicatedDirRow { "Run this cluster in its own .minecraft instead of the shared one.", toggle_controlled(dedicated, on_toggle), ) + .into_element() } } @@ -852,7 +954,8 @@ struct LoaderRow { cluster_id: i64, loader: GameLoader, selected: Option, - versions: Vec, + versions: Arc<[String]>, + locked: bool, } impl Component for LoaderRow { @@ -865,6 +968,19 @@ impl Component for LoaderRow { .clone() .unwrap_or_else(|| versions.first().cloned().unwrap_or_else(|| "Latest".into())); + if self.locked { + return settings_row_disabled( + IconType::Rocket02, + "Loader Version", + "Set when the instance was created.", + label() + .text(format!("{} {selected}", self.loader)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(); + } + let control: Element = if versions.is_empty() { label() .text("No versions available") @@ -872,7 +988,7 @@ impl Component for LoaderRow { .color(colors::fg_secondary()) .into_element() } else { - let options = versions.clone(); + let options = versions.to_vec(); Dropdown::new(selected, options.clone()) .width(Size::px(220.)) .height(Size::px(34.)) @@ -893,5 +1009,6 @@ impl Component for LoaderRow { ), control, ) + .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 index 34ae4ab7..e4a2fd28 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -454,6 +454,11 @@ impl Component for PackageManager { }); let session_live = use_game_snapshot().is_active(cluster_id); + let cluster = crate::hooks::use_cluster(cluster_id); + let shares_content = cluster + .as_ref() + .map(|cluster| cluster.shares_content(content_type)); + let uses_bundles = cluster.as_ref().is_none_or(|cluster| cluster.uses_bundles()); let active = use_state(|| 0usize); let search = use_state(String::new); @@ -509,8 +514,14 @@ impl Component for PackageManager { }; let mut notices = Vec::new(); - if content_type.is_global() { - notices.push(views::global_notice(noun_plural)); + if content_type.is_global() + && let Some(shares_content) = shares_content + { + notices.push(if shares_content { + views::global_notice(noun_plural) + } else { + views::instance_only_notice(noun_plural) + }); } if session_live { notices.push(views::running_notice(noun_plural, content_type)); @@ -534,6 +545,7 @@ impl Component for PackageManager { sort_mode, enabled_filter, hidden_filter, + uses_bundles, layout, cluster_id, package_type, diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 46f4ad59..3f7f39bf 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -144,6 +144,7 @@ pub(super) fn toolbar_bar( current_sort: SortMode, enabled_filter: State, hidden_filter: State, + uses_bundles: bool, layout: State, cluster_id: i64, package_type: &'static str, @@ -203,6 +204,7 @@ pub(super) fn toolbar_bar( current_sort, enabled_filter, hidden_filter, + uses_bundles, } .into_element(), ]; @@ -356,7 +358,13 @@ pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentTyp pub(super) fn global_notice(noun_plural: &'static str) -> String { format!( - "These {noun_plural} are shared across all your clusters. Adding one here makes it available everywhere, and turning one off removes it everywhere." + "These {noun_plural} are shared across all your OneClient clusters. Adding one here makes it available in all of them, and turning one off removes it from all of them." + ) +} + +pub(super) fn instance_only_notice(noun_plural: &'static str) -> String { + format!( + "These {noun_plural} belong to this instance alone. Adding one here does not touch your other instances." ) } @@ -403,6 +411,7 @@ struct FilterButton { current_sort: SortMode, enabled_filter: State, hidden_filter: State, + uses_bundles: bool, } impl Component for FilterButton { @@ -413,6 +422,7 @@ impl Component for FilterButton { let current_sort = self.current_sort; let enabled_filter = self.enabled_filter; let hidden_filter = self.hidden_filter; + let uses_bundles = self.uses_bundles; // Hiding hidden packages is the default so it does not count as the filters being touched let is_open = open(); @@ -448,6 +458,7 @@ impl Component for FilterButton { current_sort, enabled_filter, hidden_filter, + uses_bundles, on_close, } .into_element() @@ -461,6 +472,7 @@ struct FilterPopover { current_sort: SortMode, enabled_filter: State, hidden_filter: State, + uses_bundles: bool, on_close: EventHandler<()>, } @@ -540,18 +552,20 @@ impl Component for FilterPopover { }); } - 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, - }); + if self.uses_bundles { + 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() @@ -835,7 +849,7 @@ impl Component for ContentBox { let dispatch = use_dispatch(); let cluster = use_cluster_mutation(); let mut menu = use_state(|| None::<(f32, f32, PackageEntry)>); - let (on_delete, delete_dialog) = use_shared_delete(move |(_, hash)| { + let (on_delete, delete_dialog) = use_shared_delete(cluster_id, move |(_, hash)| { cluster.mutate(ClusterAction::RemoveArtifact { cluster_id, hash }); }); diff --git a/packages/oneclient_app/src/view/app/clusters/delete_instance.rs b/packages/oneclient_app/src/view/app/clusters/delete_instance.rs new file mode 100644 index 00000000..ecbecef3 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/delete_instance.rs @@ -0,0 +1,125 @@ +use freya::prelude::*; +use freya::router::RouterContext; + +use crate::components::{Button, OverlayPopup}; +use crate::hooks::{ + ClusterAction, use_active_cluster_id, use_cluster, use_cluster_mutation, use_game_snapshot, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +const DIALOG_WIDTH: f32 = 420.; + +#[derive(PartialEq)] +pub struct DeleteInstanceModal { + cluster_id: i64, + name: String, + on_close: EventHandler<()>, +} + +impl DeleteInstanceModal { + pub fn new(cluster_id: i64, name: String, on_close: impl Into>) -> Self { + Self { + cluster_id, + name, + on_close: on_close.into(), + } + } +} + +impl Component for DeleteInstanceModal { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let mut active_id = use_active_cluster_id(); + let game = use_game_snapshot(); + let cluster_id = self.cluster_id; + let running = game.is_running(cluster_id); + let dedicated = use_cluster(cluster_id).is_none_or(|c| c.uses_dedicated_dir()); + let warning = if dedicated { + format!( + "{} and its files will be deleted, including its worlds, settings and installed content. This cannot be undone.", + self.name + ) + } else { + format!( + "{} and its files will be deleted, including its settings and installed content. Worlds and everything else in the shared game folder are kept. This cannot be undone.", + self.name + ) + }; + + let close_scrim = self.on_close.clone(); + let close_cancel = self.on_close.clone(); + let close_delete = self.on_close.clone(); + + OverlayPopup::new() + .on_close(move |()| close_scrim.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_WIDTH)) + .max_width(Size::window_percent(92.)) + .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("Delete instance") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(warning) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .maybe_child(running.then(|| { + label() + .text("Close the game before deleting this instance.") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| close_cancel.call(())) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .enabled(!running) + .on_press(move |_| { + mutation.mutate(ClusterAction::DeleteInstance { + cluster_id, + }); + if *active_id.peek() == Some(cluster_id) { + *active_id.write() = None; + } + let _ = RouterContext::get().push(Route::Home {}); + close_delete.call(()); + }) + .text("Delete"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/cards.rs b/packages/oneclient_app/src/view/app/clusters/instance/cards.rs new file mode 100644 index 00000000..73c8826d --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/cards.rs @@ -0,0 +1,390 @@ +use freya::prelude::*; + +use super::model::{LoaderChoice, VersionRow}; +use crate::AppAssets; +use crate::components::{AssetImage, Icon, IconType, filled_pill, pill}; +use crate::theme::colors; +use crate::ui::border_all_color; + +pub const CARD_RADIUS: f32 = 12.; +pub const VERSION_ROW_H: f32 = 48.; +pub const VERSION_ROW_SPACING: f32 = 4.; +pub const LOADER_MARK_SIZE: f32 = 22.; +pub const BUNDLE_COLUMNS: usize = 3; +pub const BUNDLE_CARD_H: f32 = 132.; +pub const LOADER_COLUMNS: usize = 2; +pub const LOADER_CARD_H: f32 = 124.; + +const MARKER_SIZE: f32 = 18.; +const MARKER_MARK: f32 = 12.; +const MARKER_DOT: f32 = 7.; +const MARKER_RADIUS: f32 = 5.; + +#[derive(PartialEq)] +pub struct SelectCard { + pub id: String, + pub selected: bool, + pub height: Option, + pub padding: Gaps, + pub content: Element, + pub on_press: EventHandler<()>, +} + +impl Component for SelectCard { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let hovered = *hovering.read(); + let on_press = self.on_press.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let ring = if self.selected || focused { + colors::brand() + } else if hovered { + colors::component_border_hover() + } else { + colors::component_border() + }; + + rect() + .key(self.id.clone()) + .width(Size::fill()) + .height(self.height.map_or_else(Size::auto, Size::px)) + .padding(self.padding) + .corner_radius(CornerRadius::new_all(CARD_RADIUS)) + .overflow(Overflow::Clip) + .background(if hovered || focused { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color(1., ring)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press.call(())) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .child(self.content.clone()) + } +} + +pub fn loader_mark(choice: LoaderChoice, selected: bool, size: f32) -> Option { + let path = choice.mark(); + let drawn = AppAssets::get(path) + .is_some() + .then(|| AssetImage::new(path, size).into_element()); + + if let Some(drawn) = drawn { + return Some( + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(5.)) + .overflow(Overflow::Clip) + .opacity(if selected { 1.0 } else { 0.7 }) + .child(drawn) + .into_element(), + ); + } + + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + Some( + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .center() + .corner_radius(CornerRadius::new_all(5.)) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(choice.short()) + .font_size(9.) + .font_weight(FontWeight::SEMI_BOLD) + .letter_spacing(0.4) + .color(color), + ) + .into_element(), + ) +} + +pub fn marker(selected: bool, checkbox: bool) -> Element { + let radius = if checkbox { MARKER_RADIUS } else { 999. }; + + rect() + .width(Size::px(MARKER_SIZE)) + .height(Size::px(MARKER_SIZE)) + .center() + .corner_radius(CornerRadius::new_all(radius)) + .background(if selected { + colors::brand() + } else { + colors::page_elevated() + }) + .border(border_all_color( + 1., + if selected { + colors::brand() + } else { + colors::component_border() + }, + )) + .maybe_child(selected.then(|| { + if checkbox { + Icon::new(IconType::Check) + .size(MARKER_MARK) + .color(Color::WHITE) + .into_element() + } else { + rect() + .width(Size::px(MARKER_DOT)) + .height(Size::px(MARKER_DOT)) + .corner_radius(CornerRadius::new_all(999.)) + .background(Color::WHITE) + .into_element() + } + })) + .into_element() +} + +pub struct WideCard { + pub icon: IconType, + pub title: String, + pub badge: Option, + pub blurb: String, + pub meta: String, + pub selected: bool, + pub on_press: EventHandler<()>, +} + +pub fn wide_card(card: WideCard) -> Element { + let WideCard { + icon, + title, + badge, + blurb, + meta, + selected, + on_press, + } = card; + let title_id = title.clone(); + + let content = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Start) + .spacing(16.) + .child( + rect() + .width(Size::px(44.)) + .height(Size::px(44.)) + .center() + .corner_radius(CornerRadius::new_all(10.)) + .background(if selected { + colors::brand() + } else { + colors::page_elevated() + }) + .border(border_all_color(1., colors::component_border())) + .child(Icon::new(icon).size(22.).color(if selected { + Color::WHITE + } else { + colors::fg_primary() + })), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(7.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text(title) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .maybe_child( + badge.map(|badge| filled_pill(badge, colors::brand(), Color::WHITE)), + ), + ) + .child( + label() + .text(blurb) + .font_size(12.) + .line_height(1.45) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(meta) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .child(marker(selected, false)) + .into_element(); + + SelectCard { + id: title_id, + selected, + height: None, + padding: Gaps::new_all(16.), + content, + on_press, + } + .into_element() +} + +pub struct CellCard { + pub id: String, + pub title: String, + pub blurb: String, + pub meta: Option, + pub corner: Option, + pub selected: bool, + pub checkbox: bool, + pub on_press: EventHandler<()>, +} + +pub fn cell_card(card: CellCard) -> Element { + let CellCard { + id, + title, + blurb, + meta, + corner, + selected, + checkbox, + on_press, + } = card; + + let content = rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .main_align(Alignment::SpaceBetween) + .spacing(8.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .child(marker(selected, checkbox)) + .child( + label() + .text(title) + .width(Size::flex(1.0)) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe_child(corner), + ) + .child( + label() + .text(blurb) + .width(Size::fill()) + .font_size(12.) + .line_height(1.4) + .max_lines(3) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(meta.map(|meta| { + label() + .text(meta) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element(); + + SelectCard { + id, + selected, + height: None, + padding: Gaps::new_all(14.), + content, + on_press, + } + .into_element() +} + +pub fn version_row(row: &VersionRow, selected: bool, on_press: EventHandler<()>) -> Element { + let content = rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .child(marker(selected, false)) + .child( + label() + .text(row.id.clone()) + .width(Size::px(110.)) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(row.meta.clone()) + .width(Size::flex(1.0)) + .font_size(12.) + .max_lines(1) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()), + ) + .maybe_child( + row.badge + .as_ref() + .map(|badge| pill(None, badge.clone(), colors::fg_secondary())), + ) + .maybe_child((!row.date.is_empty()).then(|| { + label() + .text(row.date.clone()) + .width(Size::px(84.)) + .font_size(12.) + .max_lines(1) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element(); + + SelectCard { + id: row.id.clone(), + selected, + height: Some(VERSION_ROW_H), + padding: Gaps::new_symmetric(0., 14.), + content, + on_press, + } + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/copy.rs b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs new file mode 100644 index 00000000..9e2c6b55 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs @@ -0,0 +1,107 @@ +use oneclient_cluster::naming::validate_instance_name; + +use super::data::Picks; +use super::model::{Step, TypeChoice, Wizard}; + +pub fn heading(picks: &Picks) -> (&'static str, String) { + match picks.step { + Step::Type => ( + "Choose a type", + "Two ways to start. Packages can be added to either of them later.".to_string(), + ), + Step::Loader => ( + "Choose a mod loader", + "Loaders are what packages install into. Pick one, then the Minecraft version it runs on." + .to_string(), + ), + Step::Version => ( + "Choose a version", + match picks.choice { + TypeChoice::OneClient => { + "Only versions OneClient ships a build for are listed.".to_string() + } + TypeChoice::Scratch => format!( + "Versions {} can run. Switch the release type to reach snapshots, betas and alphas.", + picks.loader_label() + ), + }, + ), + Step::Bundles => ( + "Add bundles", + "Curated sets of packages, installed and configured together. Take as many as you like." + .to_string(), + ), + Step::Customize => ( + "Name the instance", + "Everything here can be changed afterwards.".to_string(), + ), + } +} + +pub fn footer_note(picks: &Picks) -> String { + match picks.step { + Step::Type => match picks.choice { + TypeChoice::OneClient => { + "Shares its game folder, worlds and packs with your other OneClient instances." + .to_string() + } + TypeChoice::Scratch => "Keeps its own game folder, worlds and packs.".to_string(), + }, + Step::Loader => picks.loader_label(), + Step::Version => match (&picks.versions.chosen, picks.loader.chosen) { + (Some(version), Some(_)) => { + format!("{version} downloads the first time you launch it.") + } + (Some(version), None) => { + format!("{} has no build for {version}.", picks.loader_label()) + } + (None, _) => "Pick a version to continue.".to_string(), + }, + Step::Bundles => { + let taken = picks.bundles.taken_count(); + let total = picks.bundles.archives.len(); + if taken == 0 { + "Nothing selected. OneConfig is installed either way.".to_string() + } else { + format!("{taken} of {total} selected. Bundles can be changed later.") + } + } + Step::Customize => "The instance is created locally. Nothing is uploaded.".to_string(), + } +} + +pub fn step_value(wizard: Wizard, picks: &Picks, step: Step) -> String { + match step { + Step::Type => match picks.choice { + TypeChoice::OneClient => "OneClient".to_string(), + TypeChoice::Scratch => "From scratch".to_string(), + }, + Step::Loader => picks.loader_label(), + Step::Version => picks + .versions + .chosen + .clone() + .unwrap_or_else(|| "Not chosen".to_string()), + Step::Bundles => { + let taken = picks.bundles.taken_count(); + if taken == 0 { + "None".to_string() + } else { + format!("{taken} selected") + } + } + Step::Customize => match wizard.details.tags.read().len() { + 0 => "Optional".to_string(), + 1 => "1 tag".to_string(), + many => format!("{many} tags"), + }, + } +} + +pub fn is_ready(picks: &Picks) -> bool { + match picks.step { + Step::Type | Step::Loader | Step::Bundles => true, + Step::Version => picks.versions.chosen.is_some() && picks.loader.chosen.is_some(), + Step::Customize => validate_instance_name(&picks.name).is_ok(), + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/create.rs b/packages/oneclient_app/src/view/app/clusters/instance/create.rs new file mode 100644 index 00000000..6914d9b0 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/create.rs @@ -0,0 +1,171 @@ +use std::collections::HashSet; + +use freya::prelude::*; +use oneclient_core::clusters::ClusterKind; + +use super::copy::{footer_note, heading, is_ready, step_value}; +use super::data::{Picks, resolve}; +use super::details::DetailsState; +use super::model::*; +use super::rail::{Rail, RowState, rail, steps_card, version_art}; +use super::shell::{Shell, shell}; +use super::steps; +use crate::hooks::{ClusterAction, use_cluster_mutation}; + +fn create_action(wizard: Wizard, picks: &Picks) -> Option { + let mc_version = picks.versions.chosen.clone()?; + let mc_loader = picks.loader.chosen?; + + Some(ClusterAction::CreateInstance { + kind: picks.kind, + name: picks.name.trim().to_string(), + mc_version, + mc_loader, + mc_loader_version: picks.loader.version.clone(), + description: wizard.details.description_value(), + tags: wizard.details.tags.read().clone(), + cover_source: wizard.details.cover.read().clone(), + bundles: (picks.kind == ClusterKind::OneClient && picks.bundles.loaded) + .then(|| picks.bundles.taken()), + }) +} + +fn wizard_rail(wizard: Wizard, picks: &Picks) -> Element { + let description = wizard.details.description.read().trim().to_string(); + let tags = wizard.details.tags.read().clone(); + + let title = if picks.name.trim().is_empty() { + "New instance".to_string() + } else { + picks.name.clone() + }; + + let subtitle = if description.is_empty() { + match picks.choice { + TypeChoice::OneClient => match &picks.versions.chosen { + Some(version) => format!("OneClient · {version}"), + None => "OneClient".to_string(), + }, + TypeChoice::Scratch => match &picks.versions.chosen { + Some(version) => format!("{version} · {}", picks.loader_label()), + None => picks.loader_label(), + }, + } + } else { + description + }; + + let rows = picks + .steps + .iter() + .enumerate() + .map(|(index, step)| { + let state = if index < picks.index { + RowState::Done + } else if index == picks.index { + RowState::Current + } else { + RowState::Pending + }; + let value = if state == RowState::Pending { + String::new() + } else { + step_value(wizard, picks, *step) + }; + (step.label(), value, state) + }) + .collect(); + + let art = version_art(picks.versions.chosen.as_deref(), picks.loader.chosen) + .picked_cover(wizard.details.cover.read().clone()); + + rail(Rail { + art, + title, + subtitle, + card: steps_card(picks.progress(), rows), + tags: if picks.step == Step::Customize { + tags + } else { + Vec::new() + }, + }) +} + +#[derive(PartialEq)] +pub struct CreateInstanceModal { + on_close: EventHandler<()>, +} + +impl CreateInstanceModal { + pub fn new(on_close: impl Into>) -> Self { + Self { + on_close: on_close.into(), + } + } +} + +impl Component for CreateInstanceModal { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let wizard = Wizard { + step: use_state(|| 0usize), + choice: use_state(|| TypeChoice::OneClient), + version: use_state(|| None::), + filter: use_state(|| 0usize), + query: use_state(String::new), + loader: use_state(|| LoaderChoice::Fabric), + loader_version: use_state(|| None::), + declined: use_state(|| None::>), + details: DetailsState::blank(), + }; + + let picks = resolve(wizard); + let action = create_action(wizard, &picks); + let (title, subtitle) = heading(&picks); + + let first = picks.index == 0; + let last = picks.step == Step::Customize; + let index = picks.index; + let mut step = wizard.step; + let mut query = wizard.query; + + let close_x = self.on_close.clone(); + let close_cancel = self.on_close.clone(); + let close_created = self.on_close.clone(); + + shell(Shell { + rail: wizard_rail(wizard, &picks), + eyebrow: picks.progress(), + title: title.to_string(), + subtitle, + body: steps::body(wizard, &picks), + scrolls_itself: picks.step == Step::Version, + note: footer_note(&picks), + secondary_label: if first { "Cancel" } else { "Back" }.to_string(), + primary_label: if last { "Create instance" } else { "Next" }.to_string(), + primary_enabled: is_ready(&picks), + on_close: (move |()| close_x.call(())).into(), + on_secondary: (move |()| { + if first { + close_cancel.call(()); + } else { + step.set(index.saturating_sub(1)); + } + }) + .into(), + on_primary: (move |()| { + if last { + if let Some(action) = action.clone() { + mutation.mutate(action); + close_created.call(()); + } + } else { + query.set(String::new()); + step.set(index + 1); + } + }) + .into(), + }) + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/data.rs b/packages/oneclient_app/src/view/app/clusters/instance/data.rs new file mode 100644 index 00000000..044f681a --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/data.rs @@ -0,0 +1,436 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use oneclient_cluster::naming::{MAX_NAME_CHARS, validate_instance_name}; +use oneclient_common::domain::GameLoader; +use oneclient_content::packages::ProviderId; +use oneclient_core::clusters::ClusterKind; +use oneclient_core::{BundleArchive, GameVersionKind}; + +use super::model::*; +use super::package_names; +use crate::hooks::{ + GameVersion, LoaderVersionSet, available_bundles, game_versions, java_majors, + loader_game_versions, loader_versions, package_meta_batch, query_error, use_available_bundles, + use_game_versions, use_java_majors, use_loader_game_versions, use_loader_versions, + use_package_meta_batch, use_version_loaders, use_versions, version_loaders, versions_metadata, +}; +use crate::utils::version_sort_key; + +pub struct VersionPicks { + pub chosen: Option, + pub list: VersionList, + pub loaded: bool, + pub error: Option, + pub filter: usize, +} + +pub struct LoaderPicks { + pub chosen: Option, + pub version: Option, + pub versions: Arc<[String]>, +} + +pub struct BundlePicks { + pub archives: Arc<[BundleArchive]>, + pub loaded: bool, + pub names: HashMap, + pub declined: HashSet, +} + +impl BundlePicks { + pub fn taken(&self) -> Vec { + self.names_of_taken().collect() + } + + pub fn taken_count(&self) -> usize { + self.names_of_taken().count() + } + + fn names_of_taken(&self) -> impl Iterator + '_ { + self.archives + .iter() + .map(|archive| archive.manifest.name.clone()) + .filter(|name| !self.declined.contains(name)) + } +} + +pub struct Picks { + pub steps: &'static [Step], + pub index: usize, + pub step: Step, + pub choice: TypeChoice, + pub kind: ClusterKind, + pub name: String, + pub suggested: String, + pub versions: VersionPicks, + pub loader: LoaderPicks, + pub bundles: BundlePicks, +} + +impl Picks { + pub fn loader_label(&self) -> String { + match (self.loader.chosen, self.loader.version.as_deref()) { + (None | Some(GameLoader::Vanilla), _) => "Vanilla".to_string(), + (Some(loader), Some(version)) => format!("{loader} {version}"), + (Some(loader), None) => loader.to_string(), + } + } + + pub fn progress(&self) -> String { + format!("Step {} of {}", self.index + 1, self.steps.len()) + } +} + +fn matches_filter(kind: GameVersionKind, filter: usize) -> bool { + match filter { + 0 => kind == GameVersionKind::Release, + 1 => kind == GameVersionKind::Snapshot, + 2 => kind == GameVersionKind::Beta, + 3 => kind == GameVersionKind::Alpha, + _ => true, + } +} + +struct OneClientVersion { + id: String, + loader: GameLoader, +} + +struct Catalogue { + oneclient: Vec, + oneclient_settled: bool, + vanilla: Option>, + java: HashMap, + error: Option, +} + +impl Catalogue { + fn loader_for(&self, version: &str) -> Option { + self.oneclient + .iter() + .find(|entry| entry.id == version) + .map(|entry| entry.loader) + } + + fn lists(&self, version: &str) -> bool { + self.oneclient.iter().any(|entry| entry.id == version) + } +} + +fn use_catalogue(choice: TypeChoice) -> Catalogue { + let oneclient_query = use_versions(); + let vanilla_query = use_game_versions(); + + let metadata = versions_metadata(&oneclient_query); + let mut oneclient: Vec = metadata + .clone() + .unwrap_or_default() + .into_iter() + .filter_map(|entry| { + Some(OneClientVersion { + id: oneclient_common::version::format_mc_version( + entry.major_version, + entry.minor_version?, + entry.patch_version, + ), + loader: entry.loader.as_deref()?.parse().ok()?, + }) + }) + .collect(); + oneclient.sort_by_key(|entry| std::cmp::Reverse(version_sort_key(&entry.id))); + + let java_query = use_java_majors(if choice == TypeChoice::OneClient { + oneclient.iter().map(|entry| entry.id.clone()).collect() + } else { + Vec::new() + }); + + let error = if choice == TypeChoice::OneClient { + query_error(&oneclient_query) + } else { + query_error(&vanilla_query) + }; + + Catalogue { + oneclient, + oneclient_settled: metadata.is_some(), + vanilla: game_versions(&vanilla_query), + java: java_majors(&java_query), + error, + } +} + +struct LoaderScope { + choice: LoaderChoice, + allowed: Option, + error: Option, +} + +impl LoaderScope { + fn settled(&self) -> bool { + self.allowed.is_some() + } + + fn permits(&self, id: &str) -> bool { + match &self.allowed { + Some(Some(ids)) => ids.contains(id), + _ => true, + } + } +} + +fn use_loader_scope(choice: LoaderChoice) -> LoaderScope { + let query = use_loader_game_versions(choice.primary(), choice == LoaderChoice::Fabric); + + LoaderScope { + choice, + allowed: loader_game_versions(&query), + error: query_error(&query), + } +} + +struct BundleContext { + archives: Arc<[BundleArchive]>, + settled: bool, + names: HashMap, +} + +fn use_bundle_context( + version: Option<&String>, + loader: Option, + takes_bundles: bool, + wants_names: bool, +) -> BundleContext { + let key = match (takes_bundles, version) { + (true, Some(version)) => version.clone(), + _ => String::new(), + }; + + let query = use_available_bundles(key, loader.unwrap_or(GameLoader::Vanilla)); + let bundles = available_bundles(&query); + let archives = bundles.clone().unwrap_or_else(|| Arc::from([])); + + let mut modrinth_ids = Vec::new(); + let mut curseforge_ids = Vec::new(); + for file in archives.iter().flat_map(|archive| &archive.manifest.files) { + match file.kind.metadata_provider() { + ProviderId::Modrinth => modrinth_ids.push(file.kind.package_id()), + ProviderId::CurseForge => curseforge_ids.push(file.kind.package_id()), + ProviderId::Local => {} + } + } + + let modrinth = use_package_meta_batch(ProviderId::Modrinth, modrinth_ids); + let curseforge = use_package_meta_batch(ProviderId::CurseForge, curseforge_ids); + + BundleContext { + names: if wants_names { + package_names( + &archives, + &package_meta_batch(&modrinth), + &package_meta_batch(&curseforge), + ) + } else { + HashMap::new() + }, + archives, + settled: bundles.is_some(), + } +} + +fn build_version_list( + choice: TypeChoice, + catalogue: &Catalogue, + scope: &LoaderScope, + filter: usize, + needle: &str, +) -> VersionList { + let matches_needle = |id: &str| needle.is_empty() || id.to_lowercase().contains(needle); + + if choice == TypeChoice::OneClient { + return VersionList::Curated( + catalogue + .oneclient + .iter() + .filter(|entry| matches_needle(&entry.id)) + .map(|entry| VersionRow { + badge: catalogue + .java + .get(&entry.id) + .map(|major| format!("Java {major}")), + id: entry.id.clone(), + meta: entry.loader.to_string(), + date: String::new(), + }) + .collect(), + ); + } + + let all = catalogue.vanilla.clone().unwrap_or_else(|| Arc::from([])); + let visible = all + .iter() + .enumerate() + .filter(|(_, entry)| in_scope(entry, scope, filter)) + .filter(|(_, entry)| matches_needle(&entry.id)) + .map(|(index, _)| index as u32) + .collect(); + + VersionList::Catalogue { all, visible } +} + +fn in_scope(entry: &GameVersion, scope: &LoaderScope, filter: usize) -> bool { + matches_filter(entry.kind, filter) && scope.permits(&entry.id) +} + +fn default_version( + choice: TypeChoice, + catalogue: &Catalogue, + scope: &LoaderScope, + filter: usize, +) -> Option { + if choice == TypeChoice::OneClient { + return catalogue.oneclient.first().map(|entry| entry.id.clone()); + } + + let all = catalogue.vanilla.as_deref().unwrap_or(&[]); + let mut scoped = all.iter().filter(|entry| in_scope(entry, scope, filter)); + let first = scoped.clone().next(); + scoped + .find(|entry| entry.kind.is_release()) + .or(first) + .map(|entry| entry.id.clone()) +} + +fn kind_for(choice: TypeChoice, loader: Option) -> ClusterKind { + match (choice, loader) { + (TypeChoice::OneClient, _) => ClusterKind::OneClient, + (TypeChoice::Scratch, Some(GameLoader::Vanilla) | None) => ClusterKind::Vanilla, + (TypeChoice::Scratch, Some(_)) => ClusterKind::Modded, + } +} + +fn suggested_name( + version: Option<&String>, + choice: TypeChoice, + kind: ClusterKind, + loader: Option, +) -> String { + let Some(version) = version else { + return String::new(); + }; + + let full = match (choice, kind, loader) { + (TypeChoice::OneClient, _, _) => version.clone(), + (_, ClusterKind::Vanilla, _) => format!("{version} Vanilla"), + (_, _, Some(loader)) => format!("{version} {loader}"), + (_, _, None) => version.clone(), + }; + if validate_instance_name(&full).is_ok() { + return full; + } + + version + .chars() + .take(MAX_NAME_CHARS) + .collect::() + .trim() + .to_string() +} + +pub fn resolve(w: Wizard) -> Picks { + let choice = *w.choice.read(); + let steps = step_order(choice); + let index = (*w.step.read()).min(steps.len().saturating_sub(1)); + let step = steps[index]; + + let catalogue = use_catalogue(choice); + let scope = use_loader_scope(*w.loader.read()); + + let filter = *w.filter.read(); + let needle = w.query.read().trim().to_lowercase(); + let versions = build_version_list(choice, &catalogue, &scope, filter, &needle); + + let oneclient = choice == TypeChoice::OneClient; + let versions_settled = if oneclient { + catalogue.oneclient_settled + } else { + catalogue.vanilla.is_some() && scope.settled() + }; + let versions_error = catalogue.error.clone().or_else(|| scope.error.clone()); + + let still_offered = |chosen: &String| { + if oneclient { + catalogue.lists(chosen) + } else { + catalogue + .vanilla + .iter() + .flat_map(|all| all.iter()) + .any(|entry: &GameVersion| &entry.id == chosen && in_scope(entry, &scope, filter)) + } + }; + let version = (w.version.read().clone()) + .filter(still_offered) + .or_else(|| default_version(choice, &catalogue, &scope, filter)); + + let available = version_loaders(&use_version_loaders(version.clone().unwrap_or_default())); + let loader = match choice { + TypeChoice::OneClient => version.as_ref().and_then(|id| catalogue.loader_for(id)), + TypeChoice::Scratch => scope.choice.resolve(&available), + }; + + let loader_versions = loader_versions(&use_loader_versions( + version.clone().unwrap_or_default(), + loader.unwrap_or(GameLoader::Vanilla), + )); + let loader_version = match (choice, loader) { + (TypeChoice::Scratch, Some(loader)) if loader != GameLoader::Vanilla => { + (w.loader_version.read().clone()) + .filter(|chosen| loader_versions.contains(chosen)) + .or_else(|| loader_versions.first().cloned()) + } + _ => None, + }; + + let bundles = use_bundle_context(version.as_ref(), loader, oneclient, step == Step::Bundles); + + let kind = kind_for(choice, loader); + let suggested = suggested_name(version.as_ref(), choice, kind, loader); + let declined = w.declined.read().clone().unwrap_or_else(|| { + bundles + .archives + .iter() + .filter(|archive| !archive.manifest.enabled) + .map(|archive| archive.manifest.name.clone()) + .collect() + }); + + Picks { + steps, + index, + step, + choice, + kind, + name: w.details.effective_name(&suggested), + suggested, + versions: VersionPicks { + chosen: version, + list: versions, + loaded: versions_settled, + error: versions_error, + filter, + }, + loader: LoaderPicks { + chosen: loader, + version: loader_version, + versions: loader_versions, + }, + bundles: BundlePicks { + archives: bundles.archives, + loaded: bundles.settled, + names: bundles.names, + declined, + }, + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/details.rs b/packages/oneclient_app/src/view/app/clusters/instance/details.rs new file mode 100644 index 00000000..cf91500c --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/details.rs @@ -0,0 +1,576 @@ +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_cluster::naming::{ + MAX_NAME_CHARS, MAX_TAG_CHARS, NameProblem, is_allowed_name_char, validate_tag, +}; + +use crate::components::{ + ART_PREVIEW_EDGE, Button, GALLERY_COVER_EDGE, Icon, IconType, LocalImage, TextInput, + VersionArtGallery, +}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_RADIUS: f32 = 12.; +const TAG_ROW_H: f32 = 28.; +const PREVIEW_W: f32 = 132.; +const PREVIEW_H: f32 = 74.; + +const PRESET_TAGS: [&str; 9] = [ + "PVP", + "Performance", + "QoL", + "Skyblock", + "Bedwars", + "Test", + "Fun", + "Experimental", + "Shenanigans", +]; + +#[derive(Clone, Copy)] +pub struct DetailsState { + pub name: State, + pub name_touched: State, + pub description: State, + pub tags: State>, + pub tag_draft: State, + pub tag_open: State, + pub cover: State>, + pub cover_cleared: State, + pub gallery_open: State, + pub gallery_pending: State>, + pub gallery_error: State>, +} + +impl DetailsState { + pub fn blank() -> Self { + Self::init(String::new(), String::new(), Vec::new(), false) + } + + pub fn seeded(name: &str, description: Option<&str>, tags: &[String]) -> Self { + Self::init( + name.to_string(), + description.unwrap_or_default().to_string(), + tags.to_vec(), + true, + ) + } + + fn init(name: String, description: String, tags: Vec, touched: bool) -> Self { + Self { + name: use_state(move || name.clone()), + name_touched: use_state(move || touched), + description: use_state(move || description.clone()), + tags: use_state(move || tags.clone()), + tag_draft: use_state(String::new), + tag_open: use_state(|| false), + cover: use_state(|| None::), + cover_cleared: use_state(|| false), + gallery_open: use_state(|| false), + gallery_pending: use_state(|| None::), + gallery_error: use_state(|| None::), + } + } + + pub fn effective_name(&self, suggested: &str) -> String { + let typed = self.typed_name(); + if *self.name_touched.read() && !typed.is_empty() { + typed + } else { + suggested.to_string() + } + } + + pub fn typed_name(&self) -> String { + self.name.read().trim().to_string() + } + + pub fn description_value(&self) -> Option { + let text = self.description.read().trim().to_string(); + (!text.is_empty()).then_some(text) + } + + pub fn preview_cover(&self, existing: Option) -> Option<(PathBuf, bool)> { + if let Some(picked) = self.cover.read().clone() { + return Some((picked, true)); + } + if *self.cover_cleared.read() { + return None; + } + existing.map(|path| (path, false)) + } +} + +pub fn details_body( + state: DetailsState, + placeholder: String, + existing_cover: Option, + name_problem: Option, +) -> Element { + let mut touched = state.name_touched; + let name = state.name; + let tags = state.tags.read().clone(); + let tag_open = *state.tag_open.read(); + let preview = state.preview_cover(existing_cover); + + rect() + .vertical() + .width(Size::fill()) + .spacing(18.) + .child(field( + "Name", + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + TextInput::new(state.name) + .placeholder(placeholder) + .width(Size::fill()) + .on_validate(move |validator: InputValidator| { + touched.set(true); + let next = validator.text(); + let current = name.peek(); + validator.set_valid( + within_limit(&next, ¤t, MAX_NAME_CHARS) + && no_new_forbidden(&next, ¤t), + ); + }), + ) + .maybe_child(name_problem.map(|problem| { + label() + .text(problem.message()) + .font_size(12.) + .color(colors::danger()) + .into_element() + })) + .into_element(), + )) + .child(field( + "Description", + TextInput::new(state.description) + .placeholder("What is this instance for?") + .multiline(true) + .width(Size::fill()) + .height(Size::px(76.)) + .into_element(), + )) + .child(field("Tags", tag_field(state, &tags, tag_open))) + .child(field("Background image", cover_field(state, preview))) + .into_element() +} + +fn no_new_forbidden(next: &str, current: &str) -> bool { + let forbidden = |text: &str| text.chars().filter(|c| !is_allowed_name_char(*c)).count(); + forbidden(next) <= forbidden(current) +} + +fn within_limit(next: &str, current: &str, limit: usize) -> bool { + let count = next.chars().count(); + count <= limit || count < current.chars().count() +} + +fn add_tag(state: DetailsState, tag: &str) { + let mut all_tags = state.tags; + + let tag = tag.trim(); + if validate_tag(tag).is_err() || already_taken(&all_tags.read(), tag) { + return; + } + + let mut next = all_tags.read().clone(); + next.push(tag.to_string()); + all_tags.set(next); +} + +fn already_taken(tags: &[String], candidate: &str) -> bool { + tags.iter() + .any(|tag| tag.eq_ignore_ascii_case(candidate.trim())) +} + +fn commit_tag(state: DetailsState) { + let mut tag_draft = state.tag_draft; + let mut tag_open = state.tag_open; + + let draft = tag_draft.read().clone(); + add_tag(state, &draft); + tag_draft.set(String::new()); + tag_open.set(false); +} + +fn tag_field(state: DetailsState, tags: &[String], open: bool) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child(chosen_tags(state, tags, open)) + .maybe_child(preset_tags(state, tags)) + .into_element() +} + +fn preset_tags(state: DetailsState, tags: &[String]) -> Option { + let free: Vec<&'static str> = PRESET_TAGS + .into_iter() + .filter(|preset| !already_taken(tags, preset)) + .collect(); + + if free.is_empty() { + return None; + } + + Some( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Suggested") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(6.)) + .spacing(6.) + .children(free.into_iter().map(|preset| { + PresetChip { + tags: state.tags, + preset, + } + .into_element() + })), + ) + .into_element(), + ) +} + +#[derive(PartialEq)] +struct PresetChip { + tags: State>, + preset: &'static str, +} + +impl Component for PresetChip { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let hovered = *hovering.read(); + + let mut tags = self.tags; + let preset = self.preset; + + rect() + .key(preset) + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .height(Size::px(26.)) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if hovered { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if hovered { + colors::component_border_hover() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + if !already_taken(&tags.read(), preset) { + let mut next = tags.read().clone(); + next.push(preset.to_string()); + tags.set(next); + } + }) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .child( + Icon::new(IconType::Plus) + .size(9.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(preset) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + } +} + +fn chosen_tags(state: DetailsState, tags: &[String], open: bool) -> Element { + let mut tag_draft = state.tag_draft; + let mut tag_open = state.tag_open; + let mut all_tags = state.tags; + let draft_valid = validate_tag(&tag_draft.read()).is_ok(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(8.)) + .cross_align(Alignment::Center) + .spacing(8.) + .children(tags.iter().map(|tag| { + let removed = tag.clone(); + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .height(Size::px(TAG_ROW_H)) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::brand()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let next: Vec = all_tags + .read() + .iter() + .filter(|tag| *tag != &removed) + .cloned() + .collect(); + all_tags.set(next); + }) + .child( + label() + .text(tag.clone()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(Color::WHITE), + ) + .child(Icon::new(IconType::XClose).size(10.).color(Color::WHITE)) + .into_element() + })) + .child(if open { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + rect().width(Size::px(132.)).child( + TextInput::new(tag_draft) + .compact() + .placeholder("Add a tag") + .auto_focus(true) + .width(Size::fill()) + .height(Size::px(TAG_ROW_H)) + .on_validate(move |validator: InputValidator| { + let fits = within_limit( + &validator.text(), + &tag_draft.peek(), + MAX_TAG_CHARS, + ); + validator.set_valid(fits); + }) + .on_submit(move |_| commit_tag(state)) + .into_element(), + ), + ) + .child( + Button::new() + .primary() + .icon() + .alt("Add this tag") + .width(Size::px(TAG_ROW_H)) + .height(Size::px(TAG_ROW_H)) + .enabled(draft_valid) + .on_press(move |_| commit_tag(state)) + .child(Icon::new(IconType::Check).size(13.)), + ) + .into_element() + } else { + Button::new() + .secondary() + .icon() + .alt("Add a tag") + .width(Size::px(TAG_ROW_H)) + .height(Size::px(TAG_ROW_H)) + .on_press(move |_| { + tag_draft.set(String::new()); + tag_open.set(true); + }) + .child(Icon::new(IconType::Plus).size(14.)) + .into_element() + }) + .into_element() +} + +fn cover_field(state: DetailsState, preview: Option<(PathBuf, bool)>) -> Element { + let mut cover = state.cover; + let mut cleared = state.cover_cleared; + + let mut gallery_open = state.gallery_open; + let mut gallery_pending = state.gallery_pending; + let mut gallery_error = state.gallery_error; + + let pick = move |_| { + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Choose a background image") + .add_filter("Image", &["png", "jpg", "jpeg", "webp", "gif"]) + .pick_file() + .await + { + cover.set(Some(handle.path().to_path_buf())); + cleared.set(false); + } + }); + }; + + let pick_from_gallery = move |url: String| { + spawn(async move { + let mut failed = |message: &str| { + gallery_pending.set(None); + gallery_error.set(Some(message.to_string())); + }; + + let state = match crate::launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::warn!(error = %err, "cannot reach the launcher state to pick gallery art"); + failed("The launcher is still starting up. Try again in a moment."); + return; + } + }; + + match state + .images + .ensure_on_disk(&state.services.requester, &url, GALLERY_COVER_EDGE) + .await + { + Ok(path) => { + cover.set(Some(path)); + cleared.set(false); + gallery_pending.set(None); + gallery_error.set(None); + gallery_open.set(false); + } + Err(err) => { + tracing::warn!(error = %err, "failed to cache the chosen gallery art"); + failed("That artwork could not be downloaded. Check your connection."); + } + } + }); + }; + + let has_cover = preview.is_some(); + let gallery = (*gallery_open.read()).then(|| { + rect() + .width(Size::px(0.)) + .height(Size::px(0.)) + .child(VersionArtGallery::new( + gallery_pending, + gallery_error, + pick_from_gallery, + move |()| { + gallery_pending.set(None); + gallery_error.set(None); + gallery_open.set(false); + }, + )) + .into_element() + }); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(CARD_RADIUS)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .width(Size::px(PREVIEW_W)) + .height(Size::px(PREVIEW_H)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .border(border_all_color(1., colors::component_border())) + .center() + .child(match preview { + Some((path, picked)) => LocalImage::new(path, ART_PREVIEW_EDGE, true) + .picked(picked) + .skeleton(true) + .into_element(), + None => label() + .text("Version art") + .font_size(11.) + .color(colors::fg_secondary()) + .into_element(), + }), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(8.) + .child( + label() + .text("Shown behind the instance on the home page and on its card. PNG or JPG, 1280x720 or larger.") + .font_size(12.) + .line_height(1.4) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center) + .child( + Button::new() + .secondary() + .small() + .on_press(pick) + .text("Choose file"), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| gallery_open.set(true)) + .text("Browse gallery"), + ) + .maybe_child(has_cover.then(|| { + Button::new() + .ghost() + .small() + .on_press(move |_| { + cover.set(None); + cleared.set(true); + }) + .text("Use version art") + .into_element() + })), + ), + ) + .maybe_child(gallery) + .into_element() +} + +pub fn field(title: &'static str, control: Element) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(title) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(control) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/edit.rs b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs new file mode 100644 index 00000000..e98082ef --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs @@ -0,0 +1,141 @@ +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_cluster::naming::validate_instance_name; +use oneclient_common::domain::GameLoader; +use oneclient_core::clusters::ClusterKind; + +use super::details::{DetailsState, details_body}; +use super::rail::{Rail, facts_card, rail, version_art}; +use super::shell::{Shell, shell}; +use crate::hooks::{ClusterAction, use_cluster_mutation}; + +#[derive(Clone, PartialEq)] +pub struct InstanceFacts { + pub cluster_id: i64, + pub name: String, + pub description: Option, + pub tags: Vec, + pub cover: Option, + pub mc_version: String, + pub mc_loader: GameLoader, + pub kind: ClusterKind, +} + +#[derive(PartialEq)] +pub struct EditInstanceModal { + facts: InstanceFacts, + on_close: EventHandler<()>, +} + +impl EditInstanceModal { + pub fn new(facts: InstanceFacts, on_close: impl Into>) -> Self { + Self { + facts, + on_close: on_close.into(), + } + } +} + +fn kind_label(kind: ClusterKind) -> &'static str { + match kind { + ClusterKind::OneClient => "OneClient", + ClusterKind::Vanilla => "Vanilla", + ClusterKind::Modded => "Modded", + } +} + +impl Component for EditInstanceModal { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let cluster_id = self.facts.cluster_id; + + let state = DetailsState::seeded( + &self.facts.name, + self.facts.description.as_deref(), + &self.facts.tags, + ); + + let existing = self.facts.cover.clone(); + let preview = state.preview_cover(existing.clone()); + let tags = state.tags.read().clone(); + let typed = state.typed_name(); + let name_problem = if typed == self.facts.name { + None + } else { + validate_instance_name(&typed).err() + }; + let ready = !typed.is_empty() && name_problem.is_none(); + + let description = state.description.read().trim().to_string(); + let subtitle = if description.is_empty() { + format!( + "{} · {}", + kind_label(self.facts.kind), + self.facts.mc_version + ) + } else { + description + }; + + let title = if typed.is_empty() { + self.facts.name.clone() + } else { + typed.clone() + }; + + let art = version_art(Some(&self.facts.mc_version), Some(self.facts.mc_loader)); + let art = match &preview { + Some((path, true)) => art.picked_cover(Some(path.clone())), + Some((path, false)) => art.cover(Some(path.clone())), + None => art, + }; + + let loader = self.facts.mc_loader.to_string(); + + let close_x = self.on_close.clone(); + let close_cancel = self.on_close.clone(); + let close_save = self.on_close.clone(); + + shell(Shell { + rail: rail(Rail { + art, + title, + subtitle, + card: facts_card( + "Instance".to_string(), + vec![ + ("Type", kind_label(self.facts.kind).to_string()), + ("Version", self.facts.mc_version.clone()), + ("Loader", loader), + ], + ), + tags, + }), + eyebrow: "Instance details".to_string(), + title: "Edit the instance".to_string(), + subtitle: "Only how it is presented changes here. The version, loader and installed content stay as they are." + .to_string(), + body: details_body(state, self.facts.name.clone(), existing, name_problem), + scrolls_itself: false, + note: "Changes apply straight away.".to_string(), + secondary_label: "Cancel".to_string(), + primary_label: "Save changes".to_string(), + primary_enabled: ready, + on_close: (move |()| close_x.call(())).into(), + on_secondary: (move |()| close_cancel.call(())).into(), + on_primary: (move |()| { + mutation.mutate(ClusterAction::UpdateInstance { + cluster_id, + name: state.typed_name(), + description: state.description_value(), + tags: state.tags.read().clone(), + cover_source: state.cover.read().clone(), + clear_cover: *state.cover_cleared.read(), + }); + close_save.call(()); + }) + .into(), + }) + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/mod.rs b/packages/oneclient_app/src/view/app/clusters/instance/mod.rs new file mode 100644 index 00000000..c482a4e6 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/mod.rs @@ -0,0 +1,95 @@ +use std::collections::HashMap; + +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; +use oneclient_core::BundleArchive; + +mod cards; +mod copy; +mod create; +mod data; +mod details; +mod edit; +mod model; +mod rail; +mod shell; +mod steps; + +pub use create::CreateInstanceModal; +pub use edit::{EditInstanceModal, InstanceFacts}; + +pub fn tidy_file_name(raw: &str) -> String { + let stem = raw + .rsplit_once('.') + .filter(|(head, ext)| !head.is_empty() && ext.len() <= 8) + .map_or(raw, |(head, _)| head); + + let bytes = stem.as_bytes(); + let cut = bytes.iter().enumerate().position(|(index, byte)| { + matches!(byte, b'-' | b'_' | b'+') && bytes.get(index + 1).is_some_and(u8::is_ascii_digit) + }); + + match cut { + Some(0) | None => stem.to_string(), + Some(cut) => stem[..cut].to_string(), + } +} + +pub fn package_names( + archives: &[BundleArchive], + modrinth: &HashMap, + curseforge: &HashMap, +) -> HashMap { + let mut names = HashMap::new(); + + for file in archives.iter().flat_map(|archive| &archive.manifest.files) { + let package_id = file.kind.package_id(); + let meta = match file.kind.metadata_provider() { + ProviderId::Modrinth => modrinth.get(&package_id), + ProviderId::CurseForge => curseforge.get(&package_id), + ProviderId::Local => None, + }; + + let name = meta + .map(|meta| meta.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| tidy_file_name(&file.display_name())); + + names.insert(package_id, name); + } + + names +} + +#[cfg(test)] +mod tests { + use super::tidy_file_name; + + #[test] + fn a_versioned_jar_keeps_only_its_name() { + assert_eq!( + tidy_file_name("sodium-fabric-0.5.11+mc1.20.1.jar"), + "sodium-fabric" + ); + assert_eq!(tidy_file_name("EvergreenHUD-3.0.0.jar"), "EvergreenHUD"); + assert_eq!(tidy_file_name("Sodium-13.303x012"), "Sodium"); + } + + #[test] + fn a_name_without_a_version_survives_whole() { + assert_eq!(tidy_file_name("OneConfig.jar"), "OneConfig"); + assert_eq!(tidy_file_name("PolyBlur"), "PolyBlur"); + } + + #[test] + fn a_leading_separator_is_not_a_version_cut() { + assert_eq!(tidy_file_name("-1abc"), "-1abc"); + } + + #[test] + fn a_dotted_name_is_not_mistaken_for_an_extension() { + assert_eq!( + tidy_file_name("com.example.longextension"), + "com.example.longextension" + ); + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/model.rs b/packages/oneclient_app/src/view/app/clusters/instance/model.rs new file mode 100644 index 00000000..f732bcc8 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/model.rs @@ -0,0 +1,179 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use freya::prelude::*; +use oneclient_common::domain::GameLoader; + +use super::details::DetailsState; +use crate::hooks::GameVersion; + +pub const FILTERS: [&str; 5] = ["Releases", "Snapshots", "Beta", "Alpha", "All versions"]; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TypeChoice { + OneClient, + Scratch, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LoaderChoice { + Fabric, + Forge, + NeoForge, + Vanilla, +} + +impl LoaderChoice { + pub const ALL: [Self; 4] = [Self::Fabric, Self::Forge, Self::NeoForge, Self::Vanilla]; + + pub fn primary(self) -> GameLoader { + match self { + Self::Fabric => GameLoader::Fabric, + Self::Forge => GameLoader::Forge, + Self::NeoForge => GameLoader::NeoForge, + Self::Vanilla => GameLoader::Vanilla, + } + } + + pub fn name(self) -> String { + self.primary().to_string() + } + + pub fn mark(self) -> &'static str { + match self { + Self::Fabric => "icons/fabric.png", + Self::Forge => "icons/forge.png", + Self::NeoForge => "icons/neo-forge.png", + Self::Vanilla => "icons/vanilla.png", + } + } + + pub fn short(self) -> &'static str { + match self { + Self::Fabric => "FA", + Self::Forge => "FO", + Self::NeoForge => "NF", + Self::Vanilla => "MC", + } + } + + pub fn blurb(self) -> &'static str { + match self { + Self::Fabric => { + "Light and quick to start. The widest package selection on modern versions, and the legacy build covers 1.8.9 and older." + } + Self::Forge => "The oldest loader. Most 1.12.2 and 1.8.9 packages need it.", + Self::NeoForge => "Forge's successor, for 1.20.2 and newer.", + Self::Vanilla => { + "Minecraft exactly as Mojang ships it. Packages cannot be installed without a loader." + } + } + } + + pub fn resolve(self, available: &[GameLoader]) -> Option { + match self { + Self::Vanilla => Some(GameLoader::Vanilla), + Self::Fabric => available + .contains(&GameLoader::Fabric) + .then_some(GameLoader::Fabric) + .or_else(|| { + available + .contains(&GameLoader::Ornithe) + .then_some(GameLoader::Ornithe) + }), + other => { + let loader = other.primary(); + available.contains(&loader).then_some(loader) + } + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Step { + Type, + Loader, + Version, + Bundles, + Customize, +} + +impl Step { + pub fn label(self) -> &'static str { + match self { + Self::Type => "Type", + Self::Loader => "Loader", + Self::Version => "Version", + Self::Bundles => "Bundles", + Self::Customize => "Details", + } + } +} + +const ONECLIENT_STEPS: [Step; 4] = [Step::Type, Step::Version, Step::Bundles, Step::Customize]; +const SCRATCH_STEPS: [Step; 4] = [Step::Type, Step::Loader, Step::Version, Step::Customize]; + +pub fn step_order(choice: TypeChoice) -> &'static [Step] { + match choice { + TypeChoice::OneClient => &ONECLIENT_STEPS, + TypeChoice::Scratch => &SCRATCH_STEPS, + } +} + +#[derive(Clone)] +pub struct VersionRow { + pub id: String, + pub meta: String, + pub badge: Option, + pub date: String, +} + +#[derive(Clone)] +pub enum VersionList { + Curated(Arc<[VersionRow]>), + Catalogue { + all: Arc<[GameVersion]>, + visible: Arc<[u32]>, + }, +} + +impl VersionList { + pub fn len(&self) -> usize { + match self { + Self::Curated(rows) => rows.len(), + Self::Catalogue { visible, .. } => visible.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn row(&self, index: usize) -> Option { + match self { + Self::Curated(rows) => rows.get(index).cloned(), + Self::Catalogue { all, visible } => { + let entry = all.get(*visible.get(index)? as usize)?; + Some(VersionRow { + id: entry.id.clone(), + meta: String::new(), + badge: (!entry.kind.is_release()).then(|| entry.kind.label().to_string()), + date: entry.released.clone(), + }) + } + } + } +} + +#[derive(Clone, Copy)] +pub struct Wizard { + pub step: State, + pub choice: State, + pub version: State>, + pub filter: State, + pub query: State, + pub loader: State, + pub loader_version: State>, + pub declined: State>>, + pub details: DetailsState, +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/rail.rs b/packages/oneclient_app/src/view/app/clusters/instance/rail.rs new file mode 100644 index 00000000..348c8787 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/rail.rs @@ -0,0 +1,279 @@ +use freya::prelude::*; +use oneclient_common::domain::GameLoader; +use oneclient_common::parse_mc_version; + +use super::shell::RAIL_WIDTH; +use crate::components::{DynamicArt, Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_argb(158, 11, 16, 19); +const HAIRLINE: Color = Color::from_argb(31, 255, 255, 255); +const CHIP_BG: Color = Color::from_argb(26, 255, 255, 255); +const SUB: Color = Color::from_argb(184, 213, 219, 255); +const MUTED: Color = Color::from_argb(140, 213, 219, 255); + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RowState { + Done, + Current, + Pending, +} + +pub struct Rail { + pub art: DynamicArt, + pub title: String, + pub subtitle: String, + pub card: Element, + pub tags: Vec, +} + +pub fn version_art(version: Option<&str>, loader: Option) -> DynamicArt { + match version.and_then(parse_mc_version) { + Some(parsed) => DynamicArt::for_version(parsed.major, parsed.key(), loader), + None => DynamicArt::fallback(), + } +} + +pub fn steps_card(progress: String, rows: Vec<(&'static str, String, RowState)>) -> Element { + card( + progress, + rows.into_iter() + .enumerate() + .map(|(index, (name, value, state))| step_row(index, name, &value, state)) + .collect(), + ) +} + +pub fn facts_card(heading: String, rows: Vec<(&'static str, String)>) -> Element { + card( + heading, + rows.into_iter() + .map(|(name, value)| fact_row(name, &value)) + .collect(), + ) +} + +fn card(heading: String, rows: Vec) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(12.) + .child( + label() + .text(heading) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .letter_spacing(1.6) + .color(SUB), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .children(rows), + ) + .into_element() +} + +fn step_row(index: usize, name: &'static str, value: &str, state: RowState) -> Element { + let done = state == RowState::Done; + let now = state == RowState::Current; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(11.) + .child( + rect() + .width(Size::px(20.)) + .height(Size::px(20.)) + .center() + .corner_radius(CornerRadius::new_all(6.)) + .background(if done { + colors::brand() + } else { + Color::TRANSPARENT + }) + .border(border_all_color( + 1., + if done || now { + colors::brand() + } else { + HAIRLINE + }, + )) + .child(if done { + Icon::new(IconType::Check) + .size(12.) + .color(Color::WHITE) + .into_element() + } else { + label() + .text((index + 1).to_string()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(if now { Color::WHITE } else { MUTED }) + .into_element() + }), + ) + .child( + label() + .text(name) + .width(Size::px(76.)) + .font_size(12.) + .max_lines(1) + .color(if now { Color::WHITE } else { SUB }), + ) + .child( + label() + .text(value.to_string()) + .width(Size::flex(1.0)) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_align(TextAlign::Right) + .color(if done { Color::WHITE } else { SUB }), + ) + .into_element() +} + +fn fact_row(name: &'static str, value: &str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(11.) + .child( + label() + .text(name) + .width(Size::px(76.)) + .font_size(12.) + .max_lines(1) + .color(SUB), + ) + .child( + label() + .text(value.to_string()) + .width(Size::flex(1.0)) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_align(TextAlign::Right) + .color(Color::WHITE), + ) + .into_element() +} + +pub fn rail(parts: Rail) -> Element { + let Rail { + art, + title, + subtitle, + card, + tags, + } = parts; + + rect() + .width(Size::px(RAIL_WIDTH)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child(art), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(3)) + .main_align(Alignment::End) + .padding(Gaps::new_all(24.)) + .spacing(18.) + .background( + LinearGradient::new() + .angle(180.) + .stop((Color::from_argb(77, 11, 16, 19), 0.)) + .stop((Color::from_argb(26, 11, 16, 19), 20.)) + .stop((Color::from_argb(204, 11, 16, 19), 56.)) + .stop((Color::from_argb(247, 11, 16, 19), 100.)), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(7.) + .child( + label() + .text(title) + .font_size(30.) + .font_weight(FontWeight::SEMI_BOLD) + .line_height(1.06) + .max_lines(2) + .color(Color::WHITE), + ) + .child( + label() + .text(subtitle) + .font_size(13.) + .line_height(1.45) + .max_lines(3) + .color(SUB), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(12.) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(CARD_BG) + .border(border_all_color(1., HAIRLINE)) + .child(card) + .maybe_child((!tags.is_empty()).then(|| { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(HAIRLINE), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(6.)) + .spacing(6.) + .children(tags.iter().map(|tag| { + rect() + .padding(Gaps::new_symmetric(3., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(CHIP_BG) + .child( + label() + .text(tag.clone()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(Color::WHITE), + ) + .into_element() + })), + ) + .into_element() + })), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/shell.rs b/packages/oneclient_app/src/view/app/clusters/instance/shell.rs new file mode 100644 index 00000000..4a601dcf --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/shell.rs @@ -0,0 +1,209 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::theme::colors; +use crate::ui::border_all_color; + +pub const DIALOG_WIDTH: f32 = 1020.; +pub const DIALOG_HEIGHT: f32 = 660.; +pub const RAIL_WIDTH: f32 = 368.; +pub const PANE_PADDING: f32 = 24.; + +pub struct Shell { + pub rail: Element, + pub eyebrow: String, + pub title: String, + pub subtitle: String, + pub body: Element, + pub scrolls_itself: bool, + pub note: String, + pub secondary_label: String, + pub primary_label: String, + pub primary_enabled: bool, + pub on_close: EventHandler<()>, + pub on_secondary: EventHandler<()>, + pub on_primary: EventHandler<()>, +} + +pub fn shell(parts: Shell) -> Element { + let Shell { + rail, + eyebrow, + title, + subtitle, + body, + scrolls_itself, + note, + secondary_label, + primary_label, + primary_enabled, + on_close, + on_secondary, + on_primary, + } = parts; + + let close_scrim = on_close.clone(); + + OverlayPopup::new() + .on_close(move |()| close_scrim.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .horizontal() + .width(Size::px(DIALOG_WIDTH)) + .height(Size::px(DIALOG_HEIGHT)) + .max_width(Size::window_percent(94.)) + .max_height(Size::window_percent(92.)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(16.)) + .overflow(Overflow::Clip) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(rail) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .child(header(eyebrow, title, subtitle, on_close)) + .child(if scrolls_itself { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., PANE_PADDING, 8., PANE_PADDING)) + .child(body) + .into_element() + } else { + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., PANE_PADDING, 8., PANE_PADDING)) + .scrollbar_gutter(true) + .child(body) + .into_element() + }) + .child(footer( + note, + secondary_label, + primary_label, + primary_enabled, + on_secondary, + on_primary, + )), + ), + ), + ) + .into_element() +} + +fn header(eyebrow: String, title: String, subtitle: String, on_close: EventHandler<()>) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Start) + .spacing(16.) + .padding(Gaps::new(22., PANE_PADDING, 16., PANE_PADDING)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(5.) + .child( + label() + .text(eyebrow) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .letter_spacing(1.6) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(title) + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(13.) + .line_height(1.35) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .icon() + .alt("Close") + .on_press(move |_| on_close.call(())) + .child(Icon::new(IconType::XClose).size(16.)), + ) + .into_element() +} + +fn footer( + note: String, + secondary_label: String, + primary_label: String, + primary_enabled: bool, + on_secondary: EventHandler<()>, + on_primary: EventHandler<()>, +) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(20.) + .padding(Gaps::new(16., PANE_PADDING, 16., PANE_PADDING)) + .border( + Border::new() + .fill(colors::component_border()) + .width(BorderWidth { + top: 1., + right: 0., + bottom: 0., + left: 0., + }), + ) + .child( + label() + .text(note) + .width(Size::flex(1.0)) + .font_size(12.) + .line_height(1.35) + .max_lines(2) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(10.) + .cross_align(Alignment::Center) + .child( + Button::new() + .ghost() + .on_press(move |_| on_secondary.call(())) + .text(secondary_label), + ) + .child( + Button::new() + .primary() + .enabled(primary_enabled) + .on_press(move |_| { + if primary_enabled { + on_primary.call(()); + } + }) + .text(primary_label), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/steps.rs b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs new file mode 100644 index 00000000..277c01b2 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs @@ -0,0 +1,399 @@ +use std::collections::HashMap; + +use freya::prelude::*; +use oneclient_cluster::naming::validate_instance_name; +use oneclient_core::BundleArchive; + +use super::cards::{ + BUNDLE_CARD_H, BUNDLE_COLUMNS, CARD_RADIUS, CellCard, LOADER_CARD_H, LOADER_COLUMNS, + LOADER_MARK_SIZE, VERSION_ROW_H, VERSION_ROW_SPACING, WideCard, cell_card, loader_mark, + version_row, wide_card, +}; +use super::data::Picks; +use super::details::details_body; +use super::model::FILTERS; +use super::model::{LoaderChoice, Step, TypeChoice, Wizard}; +use crate::components::{ + Dropdown, GRID_GAP, Icon, IconType, ScrollArea, TextInput, centered_spinner, +}; +use crate::theme::colors; +use crate::ui::{border_all_color, centered_note, fixed_grid, note}; +use crate::utils::bundle_display_name; + +pub fn body(wizard: Wizard, picks: &Picks) -> Element { + let (key, inner) = match picks.step { + Step::Type => ("step-type", type_step(wizard, picks)), + Step::Loader => ("step-loader", loader_step(wizard, picks)), + Step::Version => ("step-version", version_step(wizard, picks)), + Step::Bundles => ("step-bundles", bundles_step(wizard, picks)), + Step::Customize => ( + "step-details", + details_body( + wizard.details, + picks.suggested.clone(), + None, + validate_instance_name(&picks.name).err(), + ), + ), + }; + + rect() + .key(key) + .width(Size::fill()) + .height(if picks.step == Step::Version { + Size::fill() + } else { + Size::auto() + }) + .child(inner) + .into_element() +} + +fn type_step(mut wizard: Wizard, picks: &Picks) -> Element { + let cards = [ + ( + TypeChoice::OneClient, + IconType::Rocket02, + "OneClient", + Some("RECOMMENDED"), + "Polyfrost's client. OneConfig, the performance stack and cosmetics are wired up, so it is playable the moment it finishes downloading.", + "Shares the game folder, worlds and packs with your other OneClient instances.", + ), + ( + TypeChoice::Scratch, + IconType::Sliders04, + "Start from scratch", + None, + "Pick a loader and a version and build the instance yourself. Packages can be added once it exists.", + "Fabric, Forge, NeoForge, or no loader at all. Keeps its own game folder.", + ), + ]; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .children( + cards + .into_iter() + .map(|(choice, icon, title, badge, blurb, meta)| { + wide_card(WideCard { + icon, + title: title.to_string(), + badge: badge.map(str::to_string), + blurb: blurb.to_string(), + meta: meta.to_string(), + selected: picks.choice == choice, + on_press: (move |()| { + wizard.choice.set(choice); + wizard.version.set(None); + wizard.declined.set(None); + }) + .into(), + }) + }), + ) + .into_element() +} + +fn select_loader(mut wizard: Wizard, choice: LoaderChoice) { + wizard.loader.set(choice); + wizard.loader_version.set(None); + wizard.version.set(None); +} + +fn loader_step(wizard: Wizard, picks: &Picks) -> Element { + let chosen = *wizard.loader.read(); + + let cards: Vec = LoaderChoice::ALL + .into_iter() + .map(|choice| { + cell_card(CellCard { + id: choice.name(), + title: choice.name(), + blurb: choice.blurb().to_string(), + meta: None, + corner: loader_mark(choice, chosen == choice, LOADER_MARK_SIZE), + selected: chosen == choice, + checkbox: false, + on_press: (move |()| select_loader(wizard, choice)).into(), + }) + }) + .collect(); + + let shows_versions = chosen != LoaderChoice::Vanilla && !picks.loader.versions.is_empty(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(GRID_GAP) + .child( + rect() + .key("loader-grid") + .width(Size::fill()) + .child(fixed_grid(cards, LOADER_COLUMNS, LOADER_CARD_H, GRID_GAP)), + ) + .maybe_child(shows_versions.then(|| { + rect() + .key("loader-version") + .width(Size::fill()) + .child(loader_version_card(wizard, picks, chosen)) + .into_element() + })) + .into_element() +} + +fn loader_version_card(mut wizard: Wizard, picks: &Picks, chosen: LoaderChoice) -> Element { + let options = picks.loader.versions.to_vec(); + let selected = picks.loader.version.clone().unwrap_or_default(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(CARD_RADIUS)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(4.) + .child( + label() + .text(format!("{} version", chosen.name())) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The newest build is picked for you. Change it if a package needs an older one.") + .font_size(12.) + .line_height(1.4) + .color(colors::fg_secondary()), + ), + ) + .child( + Dropdown::new(selected, options.clone()) + .width(Size::px(200.)) + .height(Size::px(32.)) + .on_select(move |index: usize| { + if let Some(chosen) = options.get(index) { + wizard.loader_version.set(Some(chosen.clone())); + } + }), + ) + .into_element() +} + +fn version_step(wizard: Wizard, picks: &Picks) -> Element { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .spacing(12.) + .child( + rect() + .key("version-controls") + .width(Size::fill()) + .child(version_controls(wizard, picks)), + ) + .child( + rect() + .key("version-list") + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(version_list(wizard, picks)), + ) + .into_element() +} + +fn version_controls(mut wizard: Wizard, picks: &Picks) -> Element { + let filter = *wizard.filter.read(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(wizard.query) + .placeholder("Search versions") + .width(Size::fill()) + .leading(Icon::new(IconType::SearchMd).size(14.)), + ), + ) + .maybe_child((picks.choice != TypeChoice::OneClient).then(|| { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text("Show") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new( + FILTERS[filter.min(FILTERS.len() - 1)], + FILTERS.iter().map(|f| (*f).to_string()).collect(), + ) + .width(Size::px(168.)) + .height(Size::px(32.)) + .on_select(move |index: usize| { + wizard.filter.set(index); + wizard.version.set(None); + }), + ) + .into_element() + })) + .into_element() +} + +fn version_list(mut wizard: Wizard, picks: &Picks) -> Element { + if let Some(error) = &picks.versions.error { + return centered_failure(error); + } + if !picks.versions.loaded { + return centered_spinner("Loading versions..."); + } + if picks.versions.list.is_empty() { + return centered_note("No versions match that search."); + } + + let versions = picks.versions.list.clone(); + let count = versions.len(); + let selected = picks.versions.chosen.clone(); + + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .scrollbar_gutter(true) + .reset_key(list_reset_key(picks, &wizard.query.read())) + .lazy(count, VERSION_ROW_H, VERSION_ROW_SPACING, move |index| { + let Some(row) = versions.row(index) else { + return rect().into_element(); + }; + let id = row.id.clone(); + let is_selected = selected.as_deref() == Some(row.id.as_str()); + + version_row( + &row, + is_selected, + (move |()| wizard.version.set(Some(id.clone()))).into(), + ) + }) + .into_element() +} + +fn centered_failure(message: &str) -> Element { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .padding(Gaps::new_symmetric(0., 16.)) + .child(note( + format!("Could not load the version list. {message}"), + colors::danger(), + )) + .into_element() +} + +fn list_reset_key(picks: &Picks, needle: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + matches!(picks.choice, TypeChoice::OneClient).hash(&mut hasher); + picks.versions.filter.hash(&mut hasher); + needle.hash(&mut hasher); + hasher.finish() +} + +fn bundle_blurb(archive: &BundleArchive, names: &HashMap) -> String { + let mut listed: Vec = archive + .manifest + .files + .iter() + .filter(|file| file.enabled && !file.hidden) + .map(|file| { + let package_id = file.kind.package_id(); + names + .get(&package_id) + .cloned() + .unwrap_or_else(|| super::tidy_file_name(&file.display_name())) + }) + .collect(); + + let total = listed.len(); + listed.truncate(3); + let head = listed.join(", "); + + match total.saturating_sub(3) { + 0 if head.is_empty() => "Configuration only.".to_string(), + 0 => head, + rest => format!("{head} and {rest} more"), + } +} + +fn bundles_step(mut wizard: Wizard, picks: &Picks) -> Element { + if picks.bundles.archives.is_empty() { + return centered_note(if picks.bundles.loaded { + "No bundles ship for this version yet." + } else { + "Loading bundles..." + }); + } + + let cards: Vec = picks + .bundles + .archives + .iter() + .map(|archive| { + let name = archive.manifest.name.clone(); + let count = archive + .manifest + .files + .iter() + .filter(|file| file.enabled && !file.hidden) + .count(); + + cell_card(CellCard { + id: name.clone(), + title: bundle_display_name(archive), + blurb: bundle_blurb(archive, &picks.bundles.names), + meta: Some(match count { + 1 => "1 package".to_string(), + many => format!("{many} packages"), + }), + corner: None, + selected: !picks.bundles.declined.contains(&name), + checkbox: true, + on_press: { + let defaults = picks.bundles.declined.clone(); + (move |()| { + let mut next = wizard + .declined + .read() + .clone() + .unwrap_or_else(|| defaults.clone()); + if !next.remove(&name) { + next.insert(name.clone()); + } + wizard.declined.set(Some(next)); + }) + .into() + }, + }) + }) + .collect(); + + fixed_grid(cards, BUNDLE_COLUMNS, BUNDLE_CARD_H, GRID_GAP) +} diff --git a/packages/oneclient_app/src/view/app/clusters/mod.rs b/packages/oneclient_app/src/view/app/clusters/mod.rs index 64e98e08..01f88510 100644 --- a/packages/oneclient_app/src/view/app/clusters/mod.rs +++ b/packages/oneclient_app/src/view/app/clusters/mod.rs @@ -1,3 +1,7 @@ +mod delete_instance; +mod instance; mod page; +pub use delete_instance::DeleteInstanceModal; +pub use instance::{CreateInstanceModal, EditInstanceModal, InstanceFacts}; 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 index 3ff0f53c..883a4d07 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -5,7 +5,8 @@ use oneclient_common::domain::GameLoader; use oneclient_core::clusters::Cluster; use crate::components::{ - Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, + ART_PREVIEW_EDGE, Button, ClusterLandscapeArt, Dropdown, DynamicArt, Icon, IconType, + InstanceCard, ScrollArea, VersionCard, }; use crate::hooks::{ settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, @@ -15,10 +16,11 @@ 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, + GridSelection, ReleaseLine, default_line, default_loader, default_version_key, line_title, + loaders_for_line, resolve_cluster, split_clusters, version_keys, version_label, }; -use crate::view::app::launch_button_state; +use crate::view::app::clusters::CreateInstanceModal; +use crate::view::app::{launch_button_state, launch_syncing}; const GRID_GAP_PX: f32 = 12.; const MIN_CARD_WIDTH_PX: f32 = 280.; @@ -35,18 +37,19 @@ 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 show_create = use_state(|| false); + let mut selected = 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); + let (groups, instances) = split_clusters(&clusters); // Newest version first let lines: Vec = groups.keys().rev().copied().collect(); - if lines.is_empty() { + if lines.is_empty() && instances.is_empty() { return rect() .vertical() .width(Size::fill()) @@ -54,7 +57,7 @@ impl Component for Clusters { .overflow(Overflow::Clip) .padding(40.) .spacing(24.) - .child(page_header()) + .child(page_header(show_create)) .child( rect() .vertical() @@ -66,28 +69,54 @@ impl Component for Clusters { .font_size(16.) .color(colors::fg_secondary()), ), - ); + ) + .maybe_child(show_create.read().then(|| { + CreateInstanceModal::new(move |()| show_create.set(false)).into_element() + })); } 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 fallback = || { + lines + .first() + .copied() + .map(GridSelection::Line) + .or_else(|| instances.first().map(|c| GridSelection::Instance(c.id))) + }; + + if selected.read().is_none() { + *selected.write() = match active_cluster.as_ref() { + Some(cluster) if cluster.user_created => Some(GridSelection::Instance(cluster.id)), + other => default_line(&groups, other.cloned()) + .map(GridSelection::Line) + .or_else(fallback), + }; } - let line = (*selected_line.read()) - .filter(|l| groups.contains_key(l)) - .unwrap_or(lines[0]); + let current = (*selected.read()) + .filter(|sel| match sel { + GridSelection::Line(line) => groups.contains_key(line), + GridSelection::Instance(id) => instances.iter().any(|c| c.id == *id), + }) + .or_else(fallback); - if *selected_line.read() != Some(line) { - *selected_line.write() = Some(line); + if *selected.read() != current { + *selected.write() = current; } - let clusters_for_line = groups.get(&line).cloned().unwrap_or_default(); + let line = match current { + Some(GridSelection::Line(line)) => Some(line), + _ => None, + }; - if selected_version.read().is_none() { + let clusters_for_line = line + .and_then(|line| groups.get(&line).cloned()) + .unwrap_or_default(); + + if line.is_some() && selected_version.read().is_none() { let preferred = active_cluster .as_ref() .and_then(|c| oneclient_common::parse_mc_version(&c.mc_version)) @@ -95,7 +124,7 @@ impl Component for Clusters { *selected_version.write() = default_version_key(&clusters_for_line, preferred); } - if selected_loader.read().is_none() { + if line.is_some() && 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); } @@ -107,8 +136,19 @@ impl Component for Clusters { ) .or_else(|| clusters_for_line.first().cloned()); + let instance = match current { + Some(GridSelection::Instance(id)) => instances.iter().find(|c| c.id == id).cloned(), + _ => None, + }; + let columns = *grid_columns.read(); - let grid_rows = chunk_lines(&lines, columns); + let items: Vec = lines + .iter() + .copied() + .map(GridSelection::Line) + .chain(instances.iter().map(|c| GridSelection::Instance(c.id))) + .collect(); + let grid_rows = chunk_items(&items, columns); rect() .vertical() @@ -117,7 +157,7 @@ impl Component for Clusters { .overflow(Overflow::Clip) .padding(Gaps::new(0., 40., 40., 40.)) .spacing(16.) - .child(page_header()) + .child(page_header(show_create)) .child( rect() .horizontal() @@ -150,20 +190,46 @@ impl Component for Clusters { .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; + .children(row.into_iter().map(|item| { + let is_selected = current == Some(item); + let mut selected = selected; 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() + match item { + GridSelection::Line(line) => { + let list = groups + .get(&line) + .cloned() + .unwrap_or_default(); + VersionCard::new( + line, + &list, + is_selected, + move |_| { + *selected.write() = Some(item); + *selected_version.write() = None; + *selected_loader.write() = None; + }, + ) + .into_element() + } + GridSelection::Instance(id) => { + let Some(cluster) = + instances.iter().find(|c| c.id == id) + else { + return rect().into_element(); + }; + InstanceCard::new( + cluster, + is_selected, + move |_| { + *selected.write() = Some(item); + }, + ) + .into_element() + } + } })) .children((row_len..columns).map(|_| { rect() @@ -175,8 +241,19 @@ impl Component for Clusters { })), ), ) - .child(match cluster { - Some(cluster) => DetailSidebar { + .child(match (instance, line, cluster) { + (Some(instance), _, _) => InstanceSidebar { + cluster_id: instance.id, + name: instance.name.clone(), + description: instance.description.clone(), + tags: instance.tags.clone(), + cover: instance.cover_file(), + mc_version: instance.mc_version.clone(), + mc_loader: instance.mc_loader, + uses_bundles: instance.uses_bundles(), + } + .into_element(), + (None, Some(line), Some(cluster)) => DetailSidebar { line, cluster_id: cluster.id, clusters_for_line, @@ -184,9 +261,14 @@ impl Component for Clusters { selected_loader, } .into_element(), - None => sidebar_error(), + _ => sidebar_error(), }), ) + .maybe_child( + show_create.read().then(|| { + CreateInstanceModal::new(move |()| show_create.set(false)).into_element() + }), + ) } } @@ -324,6 +406,117 @@ impl Component for DetailSidebar { } } +#[derive(PartialEq)] +struct InstanceSidebar { + cluster_id: i64, + name: String, + description: Option, + tags: Vec, + cover: Option, + mc_version: String, + mc_loader: GameLoader, + uses_bundles: bool, +} + +impl Component for InstanceSidebar { + fn render(&self) -> impl IntoElement { + let mut sidebar_height = use_state(|| 0f32); + let active_id = use_active_cluster_id(); + let dispatch = use_dispatch(); + let game = use_game_snapshot(); + let launcher = use_launcher(); + + let syncing = launch_syncing(&launcher, self.uses_bundles); + let art_height = art_height_for(*sidebar_height.read()); + let cover = self.cover.clone(); + + rect() + .width(Size::px(SIDEBAR_WIDTH_PX)) + .min_width(Size::px(SIDEBAR_WIDTH_PX)) + .height(Size::fill()) + .vertical() + .spacing(8.) + .padding(8.) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .overflow(Overflow::Clip) + .on_sized(move |event: Event| { + let height = event.data().area.height(); + if (*sidebar_height.peek() - height).abs() > 0.5 { + *sidebar_height.write() = height; + } + }) + .maybe_child(art_height.map(|max| { + let parsed = oneclient_common::parse_mc_version(&self.mc_version); + rect() + .width(Size::fill()) + .max_height(Size::px(max)) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .child( + DynamicArt::for_version( + parsed.as_ref().map_or(0, |p| p.major), + parsed.and_then(|p| p.key()), + Some(self.mc_loader), + ) + .cover(cover.clone()) + .max_edge(ART_PREVIEW_EDGE), + ) + .into_element() + })) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .padding(Gaps::new_all(8.)) + .spacing(8.) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(4.) + .child( + label() + .text(self.name.clone()) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .children(tags_row(&self.tags)) + .child( + label() + .text( + self.description + .clone() + .unwrap_or_else(|| "No description yet.".to_string()), + ) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .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 art_height_for(sidebar_height: f32) -> Option { if sidebar_height <= 0. { return Some(ART_MAX_HEIGHT_PX); @@ -409,6 +602,7 @@ fn loader_rows( Some(info_row( "Mod Loader", Dropdown::new(options[current].clone(), options) + .width(Size::px(120.)) .on_select(move |idx: usize| { if let Some(loader) = loaders.get(idx).copied() { *selected_loader.write() = Some(loader); @@ -467,7 +661,29 @@ fn view_button(cluster_id: i64, mut active_id: State>) -> impl IntoE .child(Icon::new(IconType::ArrowRight).size(14.)) } -fn page_header() -> impl IntoElement { +fn page_header(mut show_create: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(6.) + .child(header_text()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| show_create.set(true)) + .child(Icon::new(IconType::Plus).size(16.)) + .text("New instance"), + ) +} + +fn header_text() -> impl IntoElement { rect() .vertical() .spacing(6.) @@ -516,9 +732,9 @@ fn sidebar_error() -> Element { .into_element() } -fn chunk_lines(lines: &[ReleaseLine], columns: usize) -> Vec> { +fn chunk_items(items: &[GridSelection], columns: usize) -> Vec> { let columns = columns.max(1); - lines.chunks(columns).map(|chunk| chunk.to_vec()).collect() + items.chunks(columns).map(|chunk| chunk.to_vec()).collect() } fn grid_columns_for_width(available_width_px: f32) -> usize { diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index 2f95edd3..f9b4339c 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -9,7 +9,7 @@ pub mod browser; pub mod cluster; pub mod settings; -use crate::state::GameState as GameSnapshot; +use crate::state::{GameState as GameSnapshot, LauncherInit}; use oneclient_events::LaunchStage; pub fn launch_button_state( @@ -33,6 +33,10 @@ pub fn launch_button_state( state } +pub fn launch_syncing(launcher: &LauncherInit, uses_bundles: bool) -> bool { + launcher.fetching || (uses_bundles && launcher.syncing_bundles) +} + pub(crate) use analytics::{analytics_body, analytics_placeholder}; pub use clusters::Clusters; pub use debug::Debug; diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index 0107ae49..ef2e52a8 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -14,7 +14,8 @@ use crate::hooks::{ type MetaMap = HashMap; use crate::routes::Route; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{border_all_color, fixed_grid}; +use crate::utils::bundle_display_name; use crate::view::onboarding::{ archive_selected, choice_row_sized, is_default_bundle, onboarding_nav, onboarding_slide, pkg_key, set_archive_selected, step_heading, version_chip, @@ -66,15 +67,6 @@ fn fps_warning_banner() -> impl IntoElement { .child(text) } -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, @@ -472,29 +464,6 @@ fn extras_section( }) .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()) @@ -506,13 +475,7 @@ fn extras_section( .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(GRID_GAP) - .children(rows), - ) + .child(fixed_grid(cards, MOD_GRID_COLS, CARD_GRID_H, GRID_GAP)) .into_element() } diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index b09f74c0..ca682123 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -32,8 +32,9 @@ pub(crate) fn matching_new_cluster_id( new: &[ClusterBundles], ) -> Option { let want = instance.import_version(); + let loader = instance.import_loader(); new.iter() - .find(|cb| cb.cluster.mc_version == want && cb.cluster.mc_loader == instance.mc_loader) + .find(|cb| cb.cluster.mc_version == want && cb.cluster.mc_loader == loader) .map(|cb| cb.cluster.id) } diff --git a/packages/oneclient_app/src/view/onboarding/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs index ddb4ae3a..55dd6d63 100644 --- a/packages/oneclient_app/src/view/onboarding/test_support.rs +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -50,6 +50,7 @@ pub fn archive(category: &str, enabled: bool, files: Vec) -> BundleA loader: GameLoader::Fabric, loader_version: "0.16.0".to_string(), enabled, + java_version_override: None, files, }, } @@ -69,5 +70,10 @@ pub fn cluster(id: i64) -> Cluster { last_played: None, overall_played: std::time::Duration::ZERO, linked_modpack_hash: None, + kind: oneclient_db::models::ClusterKind::OneClient, + user_created: false, + description: None, + tags: Vec::new(), + cover_path: None, } } diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 777f37d8..3fbe1744 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -3,6 +3,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use oneclient_db::models::ClusterId; +use oneclient_db::models::ClusterKind; use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; @@ -32,6 +33,10 @@ pub async fn remove_mods_link(folder_name: &str) { } } +pub fn encode_tags(tags: &[String]) -> String { + serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { pub id: ClusterId, @@ -47,6 +52,11 @@ pub struct Cluster { #[serde(with = "serde_duration_secs")] pub overall_played: Duration, pub linked_modpack_hash: Option, + pub kind: ClusterKind, + pub user_created: bool, + pub description: Option, + pub tags: Vec, + pub cover_path: Option, } impl PartialEq for Cluster { @@ -74,6 +84,7 @@ impl Cluster { .ok_or(ClusterError::InvalidLoader(row.mc_loader))?; let stage = ClusterStage::from_repr(row.stage).ok_or(ClusterError::InvalidStage(row.stage))?; + let kind = ClusterKind::from_repr(row.kind).ok_or(ClusterError::InvalidKind(row.kind))?; Ok(Self { id: row.id, @@ -88,6 +99,11 @@ impl Cluster { 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, + kind, + user_created: row.user_created != 0, + description: row.description, + tags: serde_json::from_str(&row.tags).unwrap_or_default(), + cover_path: row.cover_path, }) } @@ -100,11 +116,42 @@ impl Cluster { } pub fn uses_dedicated_dir(&self) -> bool { - paths::cluster_uses_dedicated_dir(&self.folder_name) + self.is_isolated() || paths::cluster_uses_dedicated_dir(&self.folder_name) + } + + pub fn is_isolated(&self) -> bool { + self.kind.is_isolated() + } + + pub fn uses_bundles(&self) -> bool { + self.kind.uses_bundles() + } + + pub fn lacks_mod_loader(&self) -> bool { + self.is_isolated() && self.mc_loader == oneclient_common::domain::GameLoader::Vanilla + } + + pub fn shares_content(&self, content_type: oneclient_common::domain::ContentType) -> bool { + content_type.is_global() && !self.is_isolated() + } + + pub fn cover_file(&self) -> Option { + let cover = self.cover_path.as_deref()?; + let mut parts = std::path::Path::new(cover).components(); + let Some(std::path::Component::Normal(name)) = parts.next() else { + return None; + }; + if parts.next().is_some() { + return None; + } + Some(self.dir().ok()?.join(name)) } pub fn game_dir(&self) -> ClusterResult { - Ok(paths::cluster_game_dir(&self.folder_name)?) + Ok(paths::cluster_game_dir( + &self.folder_name, + self.is_isolated(), + )?) } pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs index f08d4f4e..3ec330fe 100644 --- a/packages/oneclient_cluster/src/error.rs +++ b/packages/oneclient_cluster/src/error.rs @@ -70,9 +70,21 @@ pub enum ClusterError { #[error("cluster name is empty after sanitization")] EmptyName, + #[error("invalid name: {0}")] + InvalidName(crate::naming::NameProblem), + + #[error("the loader of a custom instance cannot be changed after it is created")] + LoaderLocked, + #[error("unknown loader id {0} in database")] InvalidLoader(i64), #[error("unknown stage id {0} in database")] InvalidStage(i64), + + #[error("unknown cluster kind id {0} in database")] + InvalidKind(i64), + + #[error("cluster {0} always uses its own game directory")] + DedicatedRequired(i64), } diff --git a/packages/oneclient_cluster/src/identity.rs b/packages/oneclient_cluster/src/identity.rs new file mode 100644 index 00000000..2a5262a0 --- /dev/null +++ b/packages/oneclient_cluster/src/identity.rs @@ -0,0 +1,54 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_db::models::ClusterKind; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstanceIdentity { + pub name: String, + pub mc_version: String, + pub mc_loader: GameLoader, + #[serde(default)] + pub mc_loader_version: Option, + #[serde(default)] + pub kind: ClusterKind, + #[serde(default)] + pub user_created: bool, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub cover_path: Option, +} + +pub async fn read(cluster_dir: &Path) -> Option { + let bytes = polyio::read(cluster_dir.join(paths::INSTANCE_FILE)) + .await + .ok()?; + match serde_json::from_slice(&bytes) { + Ok(identity) => Some(identity), + Err(err) => { + tracing::warn!(dir = %cluster_dir.display(), error = %err, "instance file is unreadable"); + None + } + } +} + +pub async fn write(cluster_dir: &Path, identity: &InstanceIdentity) { + let Ok(bytes) = serde_json::to_vec_pretty(identity) else { + return; + }; + + if !polyio::try_exists(cluster_dir).await.unwrap_or(false) { + tracing::warn!(dir = %cluster_dir.display(), "cluster folder is missing; not writing the instance file"); + return; + } + + if let Err(err) = polyio::write_atomic(cluster_dir.join(paths::INSTANCE_FILE), &bytes).await { + tracing::warn!(dir = %cluster_dir.display(), error = %err, "failed to write the instance file"); + } +} diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs index 3f11c8d2..ff946c5a 100644 --- a/packages/oneclient_cluster/src/lib.rs +++ b/packages/oneclient_cluster/src/lib.rs @@ -9,14 +9,18 @@ mod options; mod profile; mod stage; +pub mod identity; pub mod logs; +pub mod naming; pub mod profiles; pub mod screenshots; pub mod worlds; -pub use cluster::{Cluster, ClusterLinkTarget, remove_mods_link}; +pub use cluster::{Cluster, ClusterLinkTarget, encode_tags, remove_mods_link}; pub use error::{ClusterError, ClusterResult}; +pub use identity::InstanceIdentity; pub use manager::ClusterManager; +pub use oneclient_db::models::ClusterKind; pub use options::{ClusterUpdate, CreateClusterOptions}; pub use profile::{GameSettingsProfile, PackageUpdateMode, SettingsOsExtra}; pub use profiles::ProfileUpdate; diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index 86f28e24..92252a3a 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -1,5 +1,5 @@ use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; -use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; +use oneclient_db::models::{ClusterId, ClusterKind, ClusterPatch, NewCluster}; use crate::error::ClusterResult; use crate::profile::GameSettingsProfile; @@ -11,11 +11,22 @@ use oneclient_common::patch::Patch; use oneclient_db::DbPool; use tokio::sync::Mutex; -use crate::cluster::{Cluster, remove_mods_link}; +use crate::cluster::{Cluster, encode_tags, remove_mods_link}; use crate::error::ClusterError; use crate::options::{ClusterUpdate, CreateClusterOptions}; +use crate::screenshots::thumbnail; use crate::stage::ClusterStage; +const COVER_STEM: &str = "cover"; +const COVER_MAX_EDGE: u32 = 1200; + +fn shrink_cover(raw: Vec) -> (Vec, bool) { + match thumbnail(&raw, COVER_MAX_EDGE) { + Some(out) => (Vec::from(out), true), + None => (raw, false), + } +} + pub struct ClusterManager { db: DbPool, /// Serialises creation so two concurrent creates cannot resolve to the same @@ -40,10 +51,16 @@ impl ClusterManager { 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() + name.retain(crate::naming::is_allowed_name_char); + let name = name.trim().trim_end_matches(['.', ' ']); + + let stem_len = name.find('.').unwrap_or(name.len()); + let stem = name[..stem_len].trim_end(); + if is_reserved_device_name(stem) { + return format!("{stem}_{}", &name[stem.len()..]); + } + + name.to_string() } #[tracing::instrument(level = "debug", skip(self))] @@ -85,8 +102,11 @@ impl ClusterManager { pub async fn create_provisioned( &self, global: &GameSettingsProfile, - options: CreateClusterOptions, + mut options: CreateClusterOptions, ) -> ClusterResult> { + options.kind = ClusterKind::OneClient; + options.user_created = false; + let _guard = self.provisioning.lock().await; if cluster_dao::find_by_version_loader( &self.db, @@ -108,12 +128,26 @@ impl ClusterManager { global: &GameSettingsProfile, options: CreateClusterOptions, ) -> ClusterResult { - let name = Self::sanitize_name(&options.name); - if name.is_empty() { + if options.user_created { + crate::naming::validate_instance_name(&options.name) + .map_err(ClusterError::InvalidName)?; + for tag in &options.tags { + crate::naming::validate_tag(tag).map_err(ClusterError::InvalidName)?; + } + } + + let folder_stem = Self::sanitize_name(&options.name); + if folder_stem.is_empty() { return Err(ClusterError::EmptyName); } - let folder_name = resolve_unique_folder_name(&name).await?; + let name = if options.user_created { + options.name.trim().to_string() + } else { + folder_stem.clone() + }; + + let folder_name = resolve_unique_folder_name(&folder_stem).await?; let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); match create_inner( @@ -144,13 +178,33 @@ impl ClusterManager { cluster_id: ClusterId, update: ClusterUpdate, ) -> ClusterResult { - let _ = self.get(cluster_id).await?; + let existing = 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 existing.user_created { + if matches!(update.mc_loader_version, Patch::Set(_)) { + return Err(ClusterError::LoaderLocked); + } + if let Some(raw) = update.name.as_deref() + && raw.trim() != existing.name + { + crate::naming::validate_instance_name(raw).map_err(ClusterError::InvalidName)?; + } + for tag in update.tags.iter().flatten() { + if !existing.tags.contains(tag) { + crate::naming::validate_tag(tag).map_err(ClusterError::InvalidName)?; + } + } + } + + let name = match update.name.as_deref() { + Some(raw) if existing.user_created => Some(raw.trim().to_string()), + Some(raw) => Some(Self::sanitize_name(raw)), + None => None, + }; if name.as_deref().is_some_and(str::is_empty) { return Err(ClusterError::EmptyName); } @@ -160,23 +214,208 @@ impl ClusterManager { 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(), + description: update.description.into_db_patch(), + tags: update.tags.as_deref().map(encode_tags), + cover_path: update.cover_path.into_db_patch(), }; let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; - Cluster::try_from_row(row) + let cluster = Cluster::try_from_row(row)?; + + if let Ok(dir) = cluster.dir() { + crate::identity::write(&dir, &identity_of(&cluster)).await; + } + + Ok(cluster) + } + + pub async fn ensure_dedicated_marker(&self, cluster: &Cluster) -> ClusterResult<()> { + if !cluster.is_isolated() { + return Ok(()); + } + + let dir = cluster.dir()?; + if !oneclient_common::paths::cluster_uses_dedicated_dir(&cluster.folder_name) { + polyio::create_dir_all(&dir).await?; + polyio::write(dir.join(oneclient_common::paths::DEDICATED_MARKER), b"") + .await + .ok(); + tracing::info!( + cluster_id = cluster.id, + "restored the dedicated directory marker" + ); + } + + if crate::identity::read(&dir).await.is_none() { + crate::identity::write(&dir, &identity_of(cluster)).await; + tracing::info!(cluster_id = cluster.id, "restored the instance file"); + } + Ok(()) + } + + pub async fn refresh_identity(&self, cluster_id: ClusterId) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + let dir = cluster.dir()?; + let recorded = polyio::try_exists(dir.join(oneclient_common::paths::INSTANCE_FILE)) + .await + .unwrap_or(false); + + if recorded || cluster.is_isolated() { + crate::identity::write(&dir, &identity_of(&cluster)).await; + } + Ok(()) + } + + pub async fn sweep_trash(&self) { + let Ok(trash) = oneclient_common::paths::cluster_trash_dir() else { + return; + }; + let Ok(mut entries) = polyio::read_dir(&trash).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + let parsed = name + .split_once('-') + .and_then(|(id, folder)| Some((id.parse::().ok()?, folder))); + + if let Some((id, folder)) = parsed + && let Ok(Some(row)) = cluster_dao::get_by_id(&self.db, id).await + && row.folder_name == folder + { + let Ok(home) = oneclient_common::paths::cluster_dir(folder) else { + continue; + }; + if polyio::symlink_metadata(&home).await.is_err() + && let Err(err) = polyio::rename(&path, &home).await + { + tracing::warn!(cluster_id = id, error = %err, "failed to return a trashed cluster folder whose cluster still exists"); + } + continue; + } + + let removed = match entry.file_type().await { + Ok(kind) if kind.is_dir() => polyio::remove_dir_all(&path).await, + _ => polyio::remove_file(&path).await, + }; + match removed { + Ok(()) => tracing::info!(entry = %name, "cleared a leftover deleted cluster folder"), + Err(err) => { + tracing::warn!(entry = %name, error = %err, "failed to clear a leftover deleted cluster folder") + } + } + } + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_cover_from_file( + &self, + cluster_id: ClusterId, + source: &std::path::Path, + ) -> ClusterResult { + let cluster = self.get(cluster_id).await?; + let dir = cluster.dir()?; + polyio::create_dir_all(&dir).await?; + + let original_extension = source + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .filter(|ext| matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "webp" | "gif")) + .unwrap_or_else(|| "png".to_string()); + + let raw = polyio::read(source).await?; + let (bytes, shrunk) = tokio::task::spawn_blocking(move || shrink_cover(raw)) + .await + .map_err(|err| ClusterError::StdIo(std::io::Error::other(err)))?; + + let extension = if shrunk { + "png".to_string() + } else { + original_extension + }; + let file_name = format!( + "{COVER_STEM}-{}.{extension}", + chrono::Utc::now().timestamp_millis() + ); + let path = dir.join(&file_name); + polyio::write(&path, &bytes).await?; + + let patch = ClusterPatch { + cover_path: Some(Some(file_name.clone())), + ..Default::default() + }; + let row = match cluster_dao::update(&self.db, cluster_id, &patch).await { + Ok(row) => row, + Err(err) => { + polyio::remove_file(&path).await.ok(); + return Err(err.into()); + } + }; + let cluster = Cluster::try_from_row(row)?; + crate::identity::write(&dir, &identity_of(&cluster)).await; + + self.clear_cover_files(&dir, Some(&file_name)).await; + Ok(file_name) + } + + pub async fn clear_cover(&self, cluster_id: ClusterId) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + if let Ok(dir) = cluster.dir() { + self.clear_cover_files(&dir, None).await; + } + Ok(()) + } + + async fn clear_cover_files(&self, dir: &std::path::Path, keep: Option<&str>) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if keep != Some(name.as_ref()) && is_cover_file(&name) { + polyio::remove_file(entry.path()).await.ok(); + } + } } #[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? { + let trashed = if remove_files && cluster.is_isolated() { + move_to_trash(&cluster).await + } else { + None + }; + + let deleted = cluster_dao::delete_by_id(&self.db, cluster_id).await; + if !matches!(deleted, Ok(true)) + && let Some(trashed) = &trashed + && let Ok(home) = cluster.dir() + && let Err(err) = polyio::rename(trashed, &home).await + { + tracing::warn!(cluster_id, error = %err, "failed to put the cluster folder back after its row could not be deleted"); + } + if !deleted? { return Err(ClusterError::NotFound(cluster_id)); } remove_mods_link(&cluster.folder_name).await; - if remove_files { + if let Some(trashed) = trashed { + if let Err(err) = polyio::remove_dir_all(&trashed).await { + tracing::warn!( + cluster_id, + dir = %trashed.display(), + error = %err, + "failed to clear the deleted cluster's folder; it will be retried on the next start" + ); + } + } else if remove_files { let path = cluster.dir()?; if path.exists() { polyio::remove_dir_all(&path).await?; @@ -216,6 +455,10 @@ impl ClusterManager { } let cluster = self.get(cluster_id).await?; + if !dedicated && cluster.is_isolated() { + return Err(ClusterError::DedicatedRequired(cluster_id)); + } + let marker = cluster.dedicated_marker()?; if dedicated { polyio::create_dir_all(cluster.dir()?).await?; @@ -293,7 +536,35 @@ async fn create_inner( 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?; + if options.kind.is_isolated() { + polyio::write( + cluster_path.join(oneclient_common::paths::DEDICATED_MARKER), + b"", + ) + .await + .ok(); + + crate::identity::write( + cluster_path, + &crate::identity::InstanceIdentity { + name: name.to_string(), + mc_version: options.mc_version.clone(), + mc_loader: options.mc_loader, + mc_loader_version: options.mc_loader_version.clone(), + kind: options.kind, + user_created: options.user_created, + description: options.description.clone(), + tags: options.tags.clone(), + cover_path: None, + }, + ) + .await; + } + + let profile = + create_profile_from_global(db, global, folder_name, options.mem_max, None).await?; + + let tags = encode_tags(&options.tags); let row = cluster_dao::insert( db, @@ -305,11 +576,80 @@ async fn create_inner( mc_loader_version: options.mc_loader_version.as_deref(), setting_profile_name: Some(&profile.name), stage: ClusterStage::NotReady as i64, + kind: options.kind.as_i64(), + user_created: i64::from(options.user_created), + description: options.description.as_deref(), + tags: &tags, + cover_path: None, }, ) .await?; - Cluster::try_from_row(row) + let cluster = Cluster::try_from_row(row)?; + crate::identity::write(cluster_path, &identity_of(&cluster)).await; + Ok(cluster) +} + +fn is_reserved_device_name(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + match upper.as_str() { + "CON" | "PRN" | "AUX" | "NUL" => true, + _ => { + upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && matches!(upper.as_bytes()[3], b'1'..=b'9') + } + } +} + +async fn move_to_trash(cluster: &Cluster) -> Option { + let home = cluster.dir().ok()?; + if polyio::symlink_metadata(&home).await.is_err() { + return None; + } + + let trash = oneclient_common::paths::cluster_trash_dir().ok()?; + polyio::create_dir_all(&trash).await.ok()?; + + let dest = trash.join(format!("{}-{}", cluster.id, cluster.folder_name)); + if polyio::symlink_metadata(&dest).await.is_ok() { + polyio::remove_dir_all(&dest).await.ok(); + } + + match polyio::rename(&home, &dest).await { + Ok(()) => Some(dest), + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + error = %err, + "could not move the cluster folder aside; deleting it in place" + ); + None + } + } +} + +fn is_cover_file(name: &str) -> bool { + name.rsplit_once('.').is_some_and(|(stem, _)| { + stem == COVER_STEM + || stem + .strip_prefix(COVER_STEM) + .is_some_and(|rest| rest.starts_with('-')) + }) +} + +fn identity_of(cluster: &Cluster) -> crate::identity::InstanceIdentity { + crate::identity::InstanceIdentity { + name: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + mc_loader: cluster.mc_loader, + mc_loader_version: cluster.mc_loader_version.clone(), + kind: cluster.kind, + user_created: cluster.user_created, + description: cluster.description.clone(), + tags: cluster.tags.clone(), + cover_path: cluster.cover_path.clone(), + } } #[tracing::instrument(level = "debug", skip(pool))] @@ -355,3 +695,58 @@ async fn ensure_content_dirs(cluster_path: &std::path::Path) -> ClusterResult<() } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{COVER_MAX_EDGE, is_cover_file, shrink_cover}; + + fn png(width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height)) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .expect("encode fixture"); + out + } + + #[test] + fn an_oversized_cover_is_shrunk_once_on_assignment() { + let raw = png(COVER_MAX_EDGE * 2, COVER_MAX_EDGE); + let original_len = raw.len(); + + let (bytes, shrunk) = shrink_cover(raw); + + assert!(shrunk); + assert!(bytes.len() < original_len); + let decoded = image::load_from_memory(&bytes).expect("decode result"); + assert_eq!(decoded.width(), COVER_MAX_EDGE); + assert_eq!(decoded.height(), COVER_MAX_EDGE / 2); + } + + #[test] + fn a_cover_that_already_fits_is_stored_untouched() { + let raw = png(64, 64); + let original = raw.clone(); + + let (bytes, shrunk) = shrink_cover(raw); + + assert!(!shrunk); + assert_eq!(bytes, original); + } + + #[test] + fn an_undecodable_cover_falls_back_to_the_original_bytes() { + let (bytes, shrunk) = shrink_cover(b"not an image".to_vec()); + + assert!(!shrunk); + assert_eq!(bytes, b"not an image"); + } + + #[test] + fn legacy_and_unique_cover_names_are_both_cleared() { + assert!(is_cover_file("cover.png")); + assert!(is_cover_file("cover-1759140000000.webp")); + assert!(!is_cover_file("covers.png")); + assert!(!is_cover_file("cover")); + assert!(!is_cover_file("instance.json")); + } +} diff --git a/packages/oneclient_cluster/src/naming.rs b/packages/oneclient_cluster/src/naming.rs new file mode 100644 index 00000000..d0104161 --- /dev/null +++ b/packages/oneclient_cluster/src/naming.rs @@ -0,0 +1,110 @@ +use crate::manager::ClusterManager; + +pub const MAX_NAME_CHARS: usize = 20; +pub const MAX_TAG_CHARS: usize = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameProblem { + Empty, + TooLong, + ForbiddenCharacters, + NoUsableCharacters, +} + +impl NameProblem { + #[must_use] + pub fn message(self) -> &'static str { + match self { + Self::Empty => "Enter a name.", + Self::TooLong => "Use 20 characters or fewer.", + Self::ForbiddenCharacters => "Use only letters A-Z, digits, spaces and _ - . ( )", + Self::NoUsableCharacters => "Include at least one letter (A-Z) or digit.", + } + } +} + +impl std::fmt::Display for NameProblem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +#[must_use] +pub fn is_allowed_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')') +} + +pub fn validate_instance_name(name: &str) -> Result<(), NameProblem> { + let name = name.trim(); + if name.is_empty() { + return Err(NameProblem::Empty); + } + if name.chars().count() > MAX_NAME_CHARS { + return Err(NameProblem::TooLong); + } + if !name.chars().all(is_allowed_name_char) { + return Err(NameProblem::ForbiddenCharacters); + } + if ClusterManager::sanitize_name(name).is_empty() { + return Err(NameProblem::NoUsableCharacters); + } + Ok(()) +} + +pub fn validate_tag(tag: &str) -> Result<(), NameProblem> { + let tag = tag.trim(); + if tag.is_empty() { + return Err(NameProblem::Empty); + } + if tag.chars().count() > MAX_TAG_CHARS { + return Err(NameProblem::TooLong); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_names() { + assert_eq!(validate_instance_name("My Survival"), Ok(())); + assert_eq!(validate_instance_name("Modpack (v2.1)_x-y"), Ok(())); + assert_eq!(validate_instance_name(" "), Err(NameProblem::Empty)); + assert_eq!( + validate_instance_name("Åšwiat"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("世界"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("a/b"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("my:world"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("..."), + Err(NameProblem::NoUsableCharacters) + ); + assert_eq!( + validate_instance_name("abcdefghijklmnopqrstu"), + Err(NameProblem::TooLong) + ); + assert_eq!(validate_instance_name("abcdefghijklmnopqrst"), Ok(())); + } + + #[test] + fn tags() { + assert_eq!(validate_tag("pvp"), Ok(())); + assert_eq!(validate_tag(""), Err(NameProblem::Empty)); + assert_eq!( + validate_tag("abcdefghijklmnopqrstu"), + Err(NameProblem::TooLong) + ); + } +} diff --git a/packages/oneclient_cluster/src/options.rs b/packages/oneclient_cluster/src/options.rs index 3b9ced50..820e6f1c 100644 --- a/packages/oneclient_cluster/src/options.rs +++ b/packages/oneclient_cluster/src/options.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use oneclient_common::domain::GameLoader; use oneclient_common::patch::Patch; +use oneclient_db::models::ClusterKind; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateClusterOptions { @@ -10,6 +11,10 @@ pub struct CreateClusterOptions { pub mc_loader: GameLoader, pub mc_loader_version: Option, pub mem_max: Option, + pub kind: ClusterKind, + pub user_created: bool, + pub description: Option, + pub tags: Vec, } impl CreateClusterOptions { @@ -24,6 +29,10 @@ impl CreateClusterOptions { mc_loader, mc_loader_version: None, mem_max: None, + kind: ClusterKind::OneClient, + user_created: false, + description: None, + tags: Vec::new(), } } @@ -36,6 +45,26 @@ impl CreateClusterOptions { self.mc_loader_version = Some(version.into()); self } + + pub fn kind(mut self, kind: ClusterKind) -> Self { + self.kind = kind; + self + } + + pub fn user_created(mut self, user_created: bool) -> Self { + self.user_created = user_created; + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -44,6 +73,9 @@ pub struct ClusterUpdate { pub setting_profile_name: Patch, pub mc_loader_version: Patch, pub linked_modpack_hash: Patch, + pub description: Patch, + pub tags: Option>, + pub cover_path: Patch, } impl ClusterUpdate { diff --git a/packages/oneclient_cluster/src/screenshots/mod.rs b/packages/oneclient_cluster/src/screenshots/mod.rs index c2a2fcf4..c3f3f19e 100644 --- a/packages/oneclient_cluster/src/screenshots/mod.rs +++ b/packages/oneclient_cluster/src/screenshots/mod.rs @@ -90,16 +90,18 @@ pub fn list_cluster_screenshots(cluster: &Cluster) -> ClusterResult) -> 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) + load_picked_image(&ensure_in_clusters(path)?, max_edge) } -fn thumbnail(raw: &[u8], max_edge: u32) -> Option { +pub fn load_picked_image(path: &Path, max_edge: Option) -> ClusterResult { + let raw = std::fs::read(path).map_err(ScreenshotsError::Io)?; + Ok(match max_edge { + Some(edge) => thumbnail(&raw, edge).unwrap_or_else(|| Bytes::from(raw)), + None => Bytes::from(raw), + }) +} + +pub(crate) 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; @@ -124,3 +126,46 @@ pub fn delete_screenshot(path: &Path) -> ClusterResult<()> { Err(err) => Err(ScreenshotsError::Trash(err.to_string()).into()), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_png(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static NEXT: AtomicU32 = AtomicU32::new(0); + + let unique = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "oneclient-picked-{tag}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cover.png"); + + let image = image::RgbImage::new(4, 3); + image.save(&path).unwrap(); + + path + } + + #[test] + fn a_file_the_user_picked_outside_the_launcher_folders_still_loads() { + let path = scratch_png("outside"); + + let bytes = load_picked_image(&path, None).expect("a picked file reads"); + assert!(!bytes.is_empty()); + + assert!( + load_screenshot(&path, None).is_err(), + "the screenshot loader must keep refusing paths outside the launcher folders" + ); + } + + #[test] + fn a_missing_picked_file_is_an_error_not_an_empty_image() { + let missing = std::env::temp_dir().join("oneclient-picked-missing-does-not-exist.png"); + + assert!(load_picked_image(&missing, None).is_err()); + } +} diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs index 407673b9..7ab8c1bf 100644 --- a/packages/oneclient_common/src/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -62,6 +62,11 @@ impl ContentType { matches!(self, Self::ResourcePack | Self::Shader) } + #[must_use] + pub const fn needs_mod_loader(self) -> bool { + matches!(self, Self::Mod | Self::Shader) + } + #[must_use] pub const fn reloads_in_game(self) -> bool { matches!(self, Self::ResourcePack | Self::Shader) diff --git a/packages/oneclient_common/src/patch.rs b/packages/oneclient_common/src/patch.rs index 5108fe38..828b8a10 100644 --- a/packages/oneclient_common/src/patch.rs +++ b/packages/oneclient_common/src/patch.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Patch { #[default] diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 893b0b8b..d29209de 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -114,6 +114,10 @@ pub fn clusters_dir() -> PathsResult { Ok(data_dir()?.join("clusters")) } +pub fn cluster_trash_dir() -> PathsResult { + Ok(data_dir()?.join(".trash")) +} + pub fn shared_minecraft_dir() -> PathsResult { Ok(data_dir()?.join(".minecraft")) } @@ -121,6 +125,8 @@ pub fn shared_minecraft_dir() -> PathsResult { /// Presence marks a cluster folder as its own game dir instead of the shared `.minecraft` pub const DEDICATED_MARKER: &str = ".dedicated_directory"; +pub const INSTANCE_FILE: &str = ".instance.json"; + pub fn cluster_dir(folder_name: &str) -> PathsResult { Ok(clusters_dir()?.join(folder_name)) } @@ -131,8 +137,8 @@ pub fn cluster_uses_dedicated_dir(folder_name: &str) -> bool { /// 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) { +pub fn cluster_game_dir(folder_name: &str, isolated: bool) -> PathsResult { + if isolated || cluster_uses_dedicated_dir(folder_name) { cluster_dir(folder_name) } else { shared_minecraft_dir() @@ -195,8 +201,12 @@ pub fn versions_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("versions")) } +pub fn metadata_dir() -> PathsResult { + Ok(data_dir()?.join("metadata")) +} + pub fn libraries_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("libraries")) + Ok(metadata_dir()?.join("libraries")) } pub fn natives_dir() -> PathsResult { diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index ca90b16f..ebbe0d81 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -174,6 +174,9 @@ pub async fn install_bundle( ctx: &ContentCtx, ) -> ContentResult> { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(Vec::new()); + } let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -523,7 +526,10 @@ pub async fn enabled_bundle_bytes( bundles: &BundlesManager, ctx: &ContentCtx, ) -> ContentResult { - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let Some(cluster) = bundle_cluster(cluster_id, ctx).await? else { + return Ok(0); + }; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -561,6 +567,10 @@ pub async fn set_bundle_package_override( override_type: Option, ctx: &ContentCtx, ) -> ContentResult<()> { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(()); + } + 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 @@ -613,6 +623,10 @@ pub async fn set_bundle_package_overrides( overrides: &[(String, String, OverrideType)], ctx: &ContentCtx, ) -> ContentResult<()> { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(()); + } + bundle_dao::save_overrides(&ctx.db, cluster_id, overrides).await?; Ok(()) } @@ -652,6 +666,9 @@ pub async fn enabled_bundle_projects( ctx: &ContentCtx, ) -> ContentResult> { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(std::collections::HashSet::new()); + } let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -679,6 +696,14 @@ pub async fn enabled_bundle_projects( Ok(projects) } +pub(crate) async fn bundle_cluster( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + Ok(cluster.uses_bundles().then_some(cluster)) +} + #[tracing::instrument(skip(bundles, progress, ctx))] pub async fn install_cluster_bundles( cluster_id: i64, @@ -687,6 +712,11 @@ pub async fn install_cluster_bundles( ctx: &ContentCtx, ) -> ContentResult<()> { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + tracing::debug!(cluster_id, "instance does not take bundle content"); + return Ok(()); + } + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -748,21 +778,9 @@ pub async fn set_artifact_enabled_to( // which clusters have to record what the user just did async fn override_scope(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult> { - if !is_shared_artifact(hash, ctx).await? { - return Ok(vec![cluster_id]); - } - - let mut ids: Vec = cluster_dao::list_all(&ctx.db) + Ok(sharing_scope(cluster_id, hash, ctx) .await? - .into_iter() - .map(|row| row.id) - .collect(); - - if !ids.contains(&cluster_id) { - ids.push(cluster_id); - } - - Ok(ids) + .unwrap_or_else(|| vec![cluster_id])) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -849,7 +867,8 @@ pub async fn remove_artifact_from_cluster( // Best-effort folder cleanup failure here is not an error let deferred = match (target, link) { (Some(content_type), Some(link)) => { - try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name, &ctx.db) + .await == LiveSync::Deferred } _ => false, @@ -887,37 +906,61 @@ pub async fn remove_artifact_from_cluster( Ok(()) } -async fn is_shared_artifact(hash: &str, ctx: &ContentCtx) -> ContentResult { - Ok(artifact_dao::get_artifact_by_hash(&ctx.db, hash) - .await? - .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) - .is_some_and(ContentType::is_global)) -} - -#[tracing::instrument(level = "debug", skip(ctx))] -pub async fn clusters_sharing_artifact( +async fn sharing_scope( + cluster_id: i64, hash: &str, ctx: &ContentCtx, -) -> ContentResult> { - if !is_shared_artifact(hash, ctx).await? { +) -> ContentResult>> { + let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) + .is_some_and(|content_type| content_type.is_global()); + if !global { return Ok(None); } + let sharing = cluster_dao::list_oneclient_ids(&ctx.db).await?; + + Ok(sharing.contains(&cluster_id).then_some(sharing)) +} + +async fn sharing_clusters( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult>> { + let Some(sharing) = sharing_scope(cluster_id, hash, ctx).await? else { + return Ok(None); + }; + Ok(Some( artifact_dao::list_clusters_linking(&ctx.db, hash) .await? - .len(), + .into_iter() + .filter(|id| sharing.contains(id)) + .collect(), )) } +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn clusters_sharing_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(sharing_clusters(cluster_id, hash, ctx) + .await? + .map(|clusters| clusters.len())) +} + #[tracing::instrument(level = "debug", skip(ctx))] pub async fn delete_artifact(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { - if !is_shared_artifact(hash, ctx).await? { + let Some(linked_clusters) = sharing_clusters(cluster_id, hash, ctx).await? else { return remove_artifact_from_cluster(cluster_id, hash, true, ctx).await; - } + }; let mut first_error = None; - for linked_cluster in artifact_dao::list_clusters_linking(&ctx.db, hash).await? { + for linked_cluster in linked_clusters { if let Err(err) = remove_artifact_from_cluster(linked_cluster, hash, true, ctx).await { tracing::warn!(cluster_id = linked_cluster, hash, error = %err, "failed to delete shared package from a cluster"); first_error.get_or_insert(err); diff --git a/packages/oneclient_content/src/bundles/polymrpack.rs b/packages/oneclient_content/src/bundles/polymrpack.rs index f6fb821b..ffd7117c 100644 --- a/packages/oneclient_content/src/bundles/polymrpack.rs +++ b/packages/oneclient_content/src/bundles/polymrpack.rs @@ -33,6 +33,8 @@ struct PolyMrpackManifest { pub version_id: String, pub name: String, #[serde(default)] + pub java_version_override: Option, + #[serde(default)] pub dependencies: HashMap, #[serde(default)] pub files: Vec, @@ -116,6 +118,7 @@ fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult { loader, loader_version, enabled: manifest.enabled, + java_version_override: manifest.java_version_override, files, }) } diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index e0158249..676385ee 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -19,6 +19,8 @@ pub struct BundleManifest { pub loader: GameLoader, pub loader_version: String, pub enabled: bool, + #[serde(default)] + pub java_version_override: Option, pub files: Vec, } diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index a99c437d..120710b3 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -17,7 +17,7 @@ use tokio::sync::Mutex as AsyncMutex; use futures_util::StreamExt; use crate::bundles::install::{ - BUNDLE_INSTALL_CONCURRENCY, disable_was_deliberate, external_ids_by_sha1, + BUNDLE_INSTALL_CONCURRENCY, bundle_cluster, disable_was_deliberate, external_ids_by_sha1, find_user_suppression, heal_bundle_activity, install_package_from_bundle, remove_artifact_from_cluster, set_artifact_enabled_to, }; @@ -58,6 +58,10 @@ pub async fn check_bundle_updates( /// update so the catalog never has to be fetched for it #[tracing::instrument(level = "debug", skip(ctx))] pub async fn cluster_has_bundle_content(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(false); + } + if !bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) .await? .is_empty() @@ -78,6 +82,12 @@ async fn check_bundle_updates_inner( overrides: &[ClusterBundleOverrideRow], ) -> ContentResult { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(BundleUpdateCheckResult { + cluster_id, + ..Default::default() + }); + } let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -426,6 +436,10 @@ pub async fn apply_bundle_updates_with( session: Option<&oneclient_events::GroupedProgressSession>, deadline: Option, ) -> ContentResult { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(ApplyBundleUpdatesResult::default()); + } + let lock = cluster_lock(cluster_id); let _guard = lock.lock().await; @@ -797,6 +811,9 @@ pub async fn get_bundles_with_update_status( ctx: &ContentCtx, ) -> ContentResult> { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(Vec::new()); + } 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?; diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs index 7b0b6171..9e3d89f2 100644 --- a/packages/oneclient_content/src/packages/dependencies.rs +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -316,6 +316,11 @@ mod tests { last_played: None, overall_played: None, linked_modpack_hash: None, + kind: 0, + user_created: 0, + description: None, + tags: "[]".into(), + cover_path: None, } } diff --git a/packages/oneclient_content/src/packages/local_manifest.rs b/packages/oneclient_content/src/packages/local_manifest.rs index d4046a9b..157431b1 100644 --- a/packages/oneclient_content/src/packages/local_manifest.rs +++ b/packages/oneclient_content/src/packages/local_manifest.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::path::Path; +use oneclient_common::domain::GameLoader; use serde::Deserialize; const MAX_DESCRIPTION: usize = 600; @@ -87,6 +88,32 @@ pub async fn read_jar_manifest(jar: &Path) -> JarManifest { out } +#[tracing::instrument(level = "debug", fields(jar = %jar.display()))] +pub async fn read_jar_loader(jar: &Path) -> Option { + let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)) + .await + .inspect_err(|err| tracing::debug!("could not read {}: {err}", jar.display())) + .ok()?; + let has = |wanted: &str| entries.iter().any(|(name, _)| name == wanted); + + let fabric_family = has(FABRIC) || has(QUILT); + let forge_family = has(FORGE) || has(LEGACY_FORGE) || has(NEOFORGE); + if fabric_family && forge_family { + return None; + } + + [ + (FABRIC, GameLoader::Fabric), + (QUILT, GameLoader::Quilt), + (FORGE, GameLoader::Forge), + (LEGACY_FORGE, GameLoader::Forge), + (NEOFORGE, GameLoader::NeoForge), + ] + .into_iter() + .find(|(wanted, _)| has(wanted)) + .map(|(_, loader)| loader) +} + #[tracing::instrument(level = "debug", fields(jar = %jar.display()))] pub async fn read_jar_icon(jar: &Path, entry: &str) -> Option> { let entry = entry.to_string(); diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index e738832a..5a80acb5 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -25,7 +25,7 @@ pub use dependencies::{ }; pub use error::{PackageError, PackageResult}; pub use file_identity::{FileIdentity, curseforge_fingerprint}; -pub use local_manifest::{JarManifest, read_jar_icon, read_jar_manifest}; +pub use local_manifest::{JarManifest, read_jar_icon, read_jar_loader, read_jar_manifest}; pub use metadata_cache::{ CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, read_cached_package_meta, diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index 9336c2cd..fb0678ae 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -1,6 +1,8 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; +use oneclient_db::DbPool; +use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::{ArtifactRow, ClusterRow}; use crate::error::ContentResult; @@ -12,6 +14,10 @@ use super::paths::artifact_absolute_path; const STAGING_SUFFIX: &str = ".oneclient-tmp"; +pub(crate) fn shares_content(cluster: &ClusterRow, content_type: ContentType) -> bool { + content_type.is_global() && !cluster.is_isolated() +} + /// What a live add actually did, so a caller can say so instead of promising #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LiveSync { @@ -91,7 +97,7 @@ async fn materialized_root( cluster: &ClusterRow, content_type: ContentType, ) -> Option<(PathBuf, &'static str)> { - if content_type.is_global() { + if shares_content(cluster, content_type) { return paths::shared_minecraft_dir() .ok() .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); @@ -104,28 +110,43 @@ async fn materialized_root( return Some((dir, manifest::MODS_MANIFEST_NAME)); } - paths::cluster_game_dir(&cluster.folder_name) + paths::cluster_game_dir(&cluster.folder_name, cluster.is_isolated()) .ok() .map(|dir| (dir, manifest::MANIFEST_NAME)) } -async fn session_owns(cluster: &ClusterRow) -> bool { - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { +async fn session_owns(cluster: &ClusterRow, db: &DbPool) -> bool { + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name, cluster.is_isolated()) else { return false; }; - manifest::load(&game_dir, manifest::MANIFEST_NAME) + let owned = manifest::load(&game_dir, manifest::MANIFEST_NAME) .await - .is_some_and(|session| session.cluster_id == cluster.id) + .is_some_and(|session| session.cluster_id == cluster.id); + + if !owned || !cluster.is_isolated() { + return owned; + } + + match session_dao::unfinished_sessions(db).await { + Ok(sessions) => sessions + .iter() + .any(|session| session.cluster_id == cluster.id), + Err(err) => { + tracing::debug!(error = %err, "cannot tell whether the instance is running; treating it as running"); + true + } + } } -#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +#[tracing::instrument(level = "debug", skip(cluster, db), fields(cluster_id = cluster.id))] pub async fn try_unlink_materialized( cluster: &ClusterRow, content_type: ContentType, file_name: &str, + db: &DbPool, ) -> LiveSync { - if !content_type.reloads_in_game() && session_owns(cluster).await { + if !content_type.reloads_in_game() && session_owns(cluster, db).await { tracing::debug!( file = file_name, "file will be moved at next launch due to an active session" @@ -144,7 +165,7 @@ pub async fn try_unlink_materialized( }; let relative = manifest::entry_path(content_type.folder_name(), file_name); - let ours = if content_type.is_global() { + let ours = if shares_content(cluster, content_type) { loaded.contains(&relative) } else { loaded.owns(cluster.id, &relative) @@ -204,14 +225,14 @@ pub async fn try_link_materialized( return LiveSync::Skipped; }; - if !content_type.is_global() && loaded.cluster_id != cluster.id { + if !shares_content(cluster, content_type) && loaded.cluster_id != cluster.id { return LiveSync::Deferred; } let relative = manifest::entry_path(content_type.folder_name(), file_name); let dest = root.join(&relative); - let ours = if content_type.is_global() { + let ours = if shares_content(cluster, content_type) { loaded.contains(&relative) } else { loaded.owns(cluster.id, &relative) @@ -263,6 +284,11 @@ mod tests { last_played: None, overall_played: None, linked_modpack_hash: None, + kind: 0, + user_created: 0, + description: None, + tags: "[]".into(), + cover_path: None, } } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 0b4a6a06..acc61abb 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -184,7 +184,13 @@ impl PackageStore { artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; if let (Some(content_type), Some(link)) = (content_type, link) - && link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await + && link::try_unlink_materialized( + &cluster, + content_type, + &link.cluster_file_name, + &ctx.db, + ) + .await == LiveSync::Deferred { return Ok(()); @@ -351,7 +357,7 @@ impl PackageStore { ) .await?; - if content_type.is_global() { + if link::shares_content(&cluster, content_type) { artifact_dao::set_enabled_for_hash(&ctx.db, hash, i64::from(enabled)).await?; } @@ -360,9 +366,15 @@ impl PackageStore { let live = if enabled { link::try_link_materialized(&cluster, &artifact, &file_name).await } else { - link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + link::try_unlink_materialized( + &cluster, + content_type, + &link.cluster_file_name, + &ctx.db, + ) + .await; if link.cluster_file_name != file_name { - link::try_unlink_materialized(&cluster, content_type, &file_name).await; + link::try_unlink_materialized(&cluster, content_type, &file_name, &ctx.db).await; } LiveSync::Skipped }; @@ -444,6 +456,8 @@ async fn store_local_file( cluster: &ClusterRow, ctx: &ContentCtx, ) -> ContentResult { + ensure_takes_mods(content_type, cluster)?; + let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -675,6 +689,8 @@ fn ensure_compatible( version: &VersionDetail, cluster: &ClusterRow, ) -> ContentResult<()> { + ensure_takes_mods(project.content_type, cluster)?; + if project.provider == ProviderId::Local { return Ok(()); } @@ -703,6 +719,17 @@ fn ensure_compatible( Ok(()) } +fn ensure_takes_mods(content_type: ContentType, cluster: &ClusterRow) -> ContentResult<()> { + let vanilla = GameLoader::from_repr(cluster.mc_loader as u8) + .is_none_or(|loader| loader == GameLoader::Vanilla); + + if content_type == ContentType::Mod && vanilla && cluster.is_isolated() { + return Err(PackageError::IncompatibleLoader.into()); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -763,6 +790,11 @@ mod tests { last_played: None, overall_played: None, linked_modpack_hash: None, + kind: 0, + user_created: 0, + description: None, + tags: "[]".into(), + cover_path: None, } } diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs index aceeee8b..5f964960 100644 --- a/packages/oneclient_core/examples/download_game.rs +++ b/packages/oneclient_core/examples/download_game.rs @@ -29,6 +29,10 @@ async fn main() -> LauncherResult<()> { mc_loader: loader, mc_loader_version: loader_version.map(str::to_string), mem_max: None, + kind: oneclient_cluster::ClusterKind::OneClient, + user_created: false, + description: None, + tags: Vec::new(), }, ) .await?; diff --git a/packages/oneclient_core/src/clusters/java_override.rs b/packages/oneclient_core/src/clusters/java_override.rs new file mode 100644 index 00000000..dfefa612 --- /dev/null +++ b/packages/oneclient_core/src/clusters/java_override.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use oneclient_cluster::ProfileUpdate; +use oneclient_cluster::profiles::get_profile_or_default; +use oneclient_common::patch::Patch; +use oneclient_db::dao::applied_migration as migration_dao; +use oneclient_events::GroupedProgressSession; + +use crate::LauncherResult; +use crate::state::LauncherState; + +const BUNDLE_JAVA_OVERRIDE: &str = "bundle-java-override"; + +#[tracing::instrument(skip(state, progress))] +pub async fn apply_bundle_java_override( + state: &Arc, + cluster_id: i64, + search_for_java: bool, + auto_install_java: bool, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult<()> { + let db = &state.services.db; + let cluster = state.clusters.get(cluster_id).await?; + if !cluster.uses_bundles() { + return Ok(()); + } + let Some(profile_name) = cluster.setting_profile_name.as_deref() else { + return Ok(()); + }; + + let archives = state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await?; + if archives.is_empty() { + return Ok(()); + } + + let mut overrides = archives.iter().filter_map(|archive| { + archive + .manifest + .java_version_override + .map(|major| (archive.manifest.name.as_str(), major)) + }); + + let Some((bundle, major)) = overrides.next() else { + return Ok(()); + }; + + let marker = format!("{BUNDLE_JAVA_OVERRIDE}:{cluster_id}:{major}"); + if migration_dao::is_applied(db, &marker).await? { + return Ok(()); + } + + for (other, other_major) in overrides { + if other_major != major { + tracing::warn!( + cluster_id, + bundle, + major, + other, + other_major, + "bundles disagree on the java version override; using the first" + ); + } + } + + let global = state.settings.read().global_game_settings.clone(); + let profile = get_profile_or_default(db, &global, Some(profile_name)).await?; + + if profile.java_path.is_none() { + let runtime = state + .java + .prepare(major, search_for_java, auto_install_java, progress) + .await?; + + state + .clusters + .update_profile( + cluster_id, + ProfileUpdate { + java_path: Patch::Set(runtime.absolute_path.clone()), + ..Default::default() + }, + ) + .await?; + + tracing::info!( + cluster_id, + bundle, + major, + java_path = %runtime.absolute_path, + "applied bundle java version override" + ); + } else { + tracing::debug!( + cluster_id, + bundle, + major, + "cluster already has a java runtime; leaving the bundle override unapplied" + ); + } + + migration_dao::mark_applied(db, &marker).await?; + Ok(()) +} diff --git a/packages/oneclient_core/src/clusters/migrate.rs b/packages/oneclient_core/src/clusters/migrate.rs index fd9a07f3..e3829941 100644 --- a/packages/oneclient_core/src/clusters/migrate.rs +++ b/packages/oneclient_core/src/clusters/migrate.rs @@ -1,13 +1,18 @@ use std::path::{Path, PathBuf}; -use std::str::FromStr; -use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_dao}; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::PackageStore; +use oneclient_db::dao::cluster::ClusterMigration; +use oneclient_db::dao::{ + applied_migration as migration_dao, bundle as bundle_catalog_dao, cluster as cluster_dao, + cluster_bundle as bundle_dao, +}; use oneclient_db::models::ClusterRow; use crate::LauncherResult; +use crate::clusters::ClusterStage; use crate::state::LauncherState; -use crate::versions::RemoteMigration; -use oneclient_common::domain::GameLoader; +use crate::versions::{MigrationNode, RemoteMigration, cyclic_migration_ids}; #[tracing::instrument(skip(state))] pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult { @@ -16,9 +21,20 @@ pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult migrated += 1, Ok(false) => {} @@ -41,12 +57,22 @@ pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult LauncherResult { let db = &state.services.db; + let names_loader = rule.to.loader.is_some(); - if migration_dao::is_applied(db, &rule.id).await? { + if !names_loader && migration_dao::is_applied(db, &rule.id).await? { return Ok(false); } - let Ok(loader) = GameLoader::from_str(&rule.from.loader) else { + let Some((from, to)) = rule.endpoints() else { + if names_loader { + tracing::warn!( + migration_id = %rule.id, + from = %rule.from.loader, + to = ?rule.to.loader, + "unknown loader in migration rule; skipping" + ); + return Ok(false); + } tracing::warn!( migration_id = %rule.id, loader = %rule.from.loader, @@ -56,27 +82,28 @@ async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherRe return Ok(false); }; - if rule.from.mc_version == rule.to.mc_version { + if from == to { 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? + cluster_dao::find_by_version_loader(db, &from.mc_version, from.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) + if cluster_dao::find_by_version_loader(db, &to.mc_version, to.loader as i64) .await? .is_some() { tracing::warn!( migration_id = %rule.id, - from = %rule.from.mc_version, - to = %rule.to.mc_version, + from = %from.mc_version, + to = %to.mc_version, + to_loader = %to.loader, "target cluster already exists; skipping migration" ); return Ok(false); @@ -91,33 +118,123 @@ async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherRe return Ok(false); } - migrate_cluster(state, rule, &source).await?; + if from.loader != to.loader + && !(target_loader_supports(state, rule, &to).await + && target_has_bundles(state, rule, &source, &to).await?) + { + return Ok(false); + } + + if !migrate_cluster(state, rule, &source, &from, &to).await? { + return Ok(false); + } 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, + from = %from.mc_version, + to = %to.mc_version, + from_loader = %from.loader, + to_loader = %to.loader, "migrated cluster" ); Ok(true) } +async fn target_loader_supports( + state: &LauncherState, + rule: &RemoteMigration, + to: &MigrationNode, +) -> bool { + let mut metadata = state.metadata.lock().await; + match metadata + .loader_supports_version(&state.services.mc(), to.loader, &to.mc_version) + .await + { + Ok(true) => true, + Ok(false) => { + tracing::warn!( + migration_id = %rule.id, + loader = %to.loader, + mc_version = %to.mc_version, + "target loader has no build for this version; skipping migration" + ); + false + } + Err(err) => { + tracing::warn!( + migration_id = %rule.id, + loader = %to.loader, + error = %err, + "could not read the target loader versions; retrying next launch" + ); + false + } + } +} + +async fn target_has_bundles( + state: &LauncherState, + rule: &RemoteMigration, + source: &ClusterRow, + to: &MigrationNode, +) -> LauncherResult { + if rule.allow_without_bundles || source.is_isolated() { + return Ok(true); + } + + let bundles = bundle_catalog_dao::list_visible_for_version_loader( + &state.services.db, + &to.mc_version, + to.loader as i64, + ) + .await?; + + if bundles.is_empty() { + tracing::warn!( + migration_id = %rule.id, + loader = %to.loader, + mc_version = %to.mc_version, + "target has no bundles; skipping migration" + ); + return Ok(false); + } + + 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; + from: &MigrationNode, + to: &MigrationNode, +) -> LauncherResult { + let current = cluster_dao::get_by_id(&state.services.db, source.id).await?; + if !current + .as_ref() + .is_some_and(|row| row_unchanged(source, row)) + { + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + "cluster changed since the migration pass read it; skipping" + ); + return Ok(false); + } 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 new_folder = resolve_new_folder( + &clusters_dir, + &source.folder_name, + retarget_identity(&source.folder_name, from, to), + ) + .await?; + let new_name = retarget_identity(&source.name, from, to); let mut renamed: Option<(PathBuf, PathBuf)> = None; if let Some(folder) = &new_folder { @@ -139,17 +256,30 @@ async fn migrate_cluster( 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) => { + let changes_loader = from.loader != to.loader; + let migration = if changes_loader { + ClusterMigration { + mc_version: &to.mc_version, + mc_loader: to.loader as i64, + mc_loader_version: None, + stage: ClusterStage::NotReady as i64, + name: new_name.as_deref(), + folder_name: folder_for_db, + } + } else { + ClusterMigration { + mc_version: &to.mc_version, + mc_loader: source.mc_loader, + mc_loader_version: source.mc_loader_version.as_deref(), + stage: source.stage, + name: new_name.as_deref(), + folder_name: folder_for_db, + } + }; + + match cluster_dao::migrate_version_if_unchanged(&state.services.db, source, migration).await { + Ok(Some(_)) => {} + outcome => { if let Some((old_dir, new_dir)) = renamed && let Err(rollback) = polyio::rename(&new_dir, &old_dir).await { @@ -159,12 +289,84 @@ async fn migrate_cluster( from = ?new_dir, to = ?old_dir, error = %rollback, - "failed to roll back cluster directory rename after database error" + "failed to roll back cluster directory rename" ); } - Err(err.into()) + if let Err(err) = outcome { + return Err(err.into()); + } + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + "cluster changed while migrating; rolled back" + ); + return Ok(false); } } + + if let Err(err) = state.clusters.refresh_identity(source.id).await { + tracing::warn!( + migration_id = %rule.id, + cluster_id = source.id, + error = %err, + "could not update the instance file after migrating" + ); + } + + if changes_loader && !to.loader.compatible_with(from.loader) { + match disable_mods(state, source.id).await { + Ok(disabled) => tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + disabled, + "disabled mods built for the previous loader" + ), + Err(err) => tracing::warn!( + migration_id = %rule.id, + cluster_id = source.id, + error = %err, + "could not disable mods built for the previous loader" + ), + } + } + + Ok(true) +} + +fn row_unchanged(expected: &ClusterRow, current: &ClusterRow) -> bool { + current.mc_version == expected.mc_version + && current.mc_loader == expected.mc_loader + && current.folder_name == expected.folder_name +} + +async fn disable_mods(state: &LauncherState, cluster_id: i64) -> LauncherResult { + let ctx = state.services.content(); + let linked = PackageStore::list_linked_artifacts(cluster_id, &ctx).await?; + let mut disabled = 0; + + for info in linked + .iter() + .filter(|info| info.content_type == ContentType::Mod) + { + if info.enabled { + if let Err(err) = + PackageStore::set_artifact_enabled_to(cluster_id, &info.hash, false, &ctx).await + { + tracing::warn!( + cluster_id, + hash = %info.hash, + error = %err, + "could not disable a mod built for the previous loader" + ); + continue; + } + disabled += 1; + } + + bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, &info.hash).await?; + } + + Ok(disabled) } fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: bool) -> &'a str { @@ -177,10 +379,9 @@ fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: boo async fn resolve_new_folder( clusters_dir: &Path, folder_name: &str, - from: &str, - to: &str, + candidate: Option, ) -> LauncherResult> { - let Some(candidate) = retarget_version_prefix(folder_name, from, to) else { + let Some(candidate) = candidate else { tracing::debug!( folder = %folder_name, "folder name is not in generated form; leaving it in place" @@ -200,6 +401,24 @@ async fn resolve_new_folder( Ok(Some(candidate)) } +fn retarget_identity(value: &str, from: &MigrationNode, to: &MigrationNode) -> Option { + if from.loader == to.loader { + return retarget_version_prefix(value, &from.mc_version, &to.mc_version); + } + + let from_prefix = format!("{} {}", from.mc_version, from.loader); + let rest = value + .get(..from_prefix.len()) + .filter(|prefix| prefix.eq_ignore_ascii_case(&from_prefix)) + .map(|_| &value[from_prefix.len()..])?; + if !(rest.is_empty() || rest.starts_with(' ')) { + return None; + } + + let retargeted = format!("{} {}{rest}", to.mc_version, to.loader); + (retargeted != value).then_some(retargeted) +} + fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option { let rest = value.strip_prefix(from)?; if !rest.starts_with(' ') { @@ -211,6 +430,7 @@ fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option #[cfg(test)] mod tests { use super::*; + use oneclient_common::domain::GameLoader; #[test] fn retargets_generated_folder() { @@ -253,6 +473,65 @@ mod tests { assert_eq!(retarget_version_prefix("26.1", "26.1", "26.1.2"), None); } + fn node(mc_version: &str, loader: GameLoader) -> MigrationNode { + MigrationNode { + mc_version: mc_version.to_string(), + loader, + } + } + + #[test] + fn version_rules_keep_the_old_retargeting() { + let from = node("26.1", GameLoader::Fabric); + let to = node("26.1.2", GameLoader::Fabric); + assert_eq!( + retarget_identity("26.1 fabric (1)", &from, &to).as_deref(), + Some("26.1.2 fabric (1)") + ); + assert_eq!( + retarget_identity("26.1 my pack", &from, &to).as_deref(), + Some("26.1.2 my pack") + ); + } + + #[test] + fn loader_rules_retarget_generated_names() { + let from = node("1.20.1", GameLoader::Forge); + let to = node("1.20.1", GameLoader::NeoForge); + assert_eq!( + retarget_identity("1.20.1 Forge", &from, &to).as_deref(), + Some("1.20.1 NeoForge") + ); + assert_eq!( + retarget_identity("1.20.1 forge (2)", &from, &to).as_deref(), + Some("1.20.1 NeoForge (2)") + ); + } + + #[test] + fn loader_rules_leave_custom_names_alone() { + let from = node("1.20.1", GameLoader::Forge); + let to = node("1.20.1", GameLoader::NeoForge); + assert_eq!(retarget_identity("1.20.1 my pack", &from, &to), None); + assert_eq!(retarget_identity("1.20.1 Forgery", &from, &to), None); + assert_eq!(retarget_identity("my cool pack", &from, &to), None); + } + + #[test] + fn loader_rules_with_the_same_display_name_do_not_rename() { + let from = node("1.8.9", GameLoader::Fabric); + let to = node("1.8.9", GameLoader::Ornithe); + assert_eq!(retarget_identity("1.8.9 Fabric", &from, &to), None); + } + + #[test] + fn only_fabric_to_quilt_keeps_mods() { + assert!(GameLoader::Quilt.compatible_with(GameLoader::Fabric)); + assert!(!GameLoader::Fabric.compatible_with(GameLoader::Quilt)); + assert!(!GameLoader::NeoForge.compatible_with(GameLoader::Forge)); + assert!(!GameLoader::Vanilla.compatible_with(GameLoader::Fabric)); + } + #[test] fn row_claims_new_folder_only_when_the_directory_moved() { assert_eq!( diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 6c97c017..3e8f42d2 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -1,9 +1,11 @@ +mod java_override; mod migrate; pub(crate) mod prepare; mod provision; mod release_migration; mod unlink_legacy; +pub use java_override::apply_bundle_java_override; pub use migrate::apply_remote_migrations; pub use prepare::{ estimate_cluster_download, prepare_cluster, prepare_cluster_locked, required_java_major, @@ -16,6 +18,6 @@ pub use release_migration::{ pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; pub use oneclient_cluster::{ - Cluster, ClusterError, ClusterLinkTarget, ClusterManager, ClusterStage, ClusterUpdate, - CreateClusterOptions, + Cluster, ClusterError, ClusterKind, ClusterLinkTarget, ClusterManager, ClusterStage, + ClusterUpdate, CreateClusterOptions, }; diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index 697819bc..8efe18c5 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -50,6 +50,7 @@ pub async fn prepare_cluster( shared_progress: Option<&GroupedProgressSession>, ) -> LauncherResult { let cluster = state.clusters.get(cluster_id).await?; + state.clusters.ensure_dedicated_marker(&cluster).await?; let continuing = cluster.stage == ClusterStage::Downloading; tracing::info!( @@ -336,7 +337,7 @@ async fn run_forge_processors( .join(&version_info.id) .join(format!("{}.jar", version_info.id)); let libraries = paths::libraries_dir()?; - let cluster_dir = cluster.game_dir()?; + let install_root = paths::metadata_dir()?; let Some(data) = &mut version_info.data else { return Ok(()); @@ -367,7 +368,7 @@ async fn run_forge_processors( client => cluster.mc_version.clone(), server => ""; "ROOT": - client => cluster_dir.to_string_lossy(), + client => install_root.to_string_lossy(), server => ""; "LIBRARY_DIR": client => libraries.to_string_lossy(), diff --git a/packages/oneclient_core/src/clusters/release_migration.rs b/packages/oneclient_core/src/clusters/release_migration.rs index 93fd3959..720329a1 100644 --- a/packages/oneclient_core/src/clusters/release_migration.rs +++ b/packages/oneclient_core/src/clusters/release_migration.rs @@ -1,5 +1,4 @@ use std::cmp::Reverse; -use std::str::FromStr; use oneclient_common::domain::GameLoader; @@ -46,10 +45,20 @@ pub fn can_migrate_manually(source: GameLoader, target: GameLoader) -> bool { source == target || (source == GameLoader::Fabric && target == GameLoader::Ornithe) } +fn is_migration_source( + candidate: &Cluster, + target: &Cluster, + loader_ok: impl Fn(GameLoader, GameLoader) -> bool, +) -> bool { + candidate.id != target.id + && !candidate.user_created + && loader_ok(candidate.mc_loader, target.mc_loader) +} + fn is_migration_destination(target: &ReleaseTarget, rules: &[RemoteMigration]) -> bool { rules.iter().any(|rule| { - rule.to.mc_version == target.mc_version - && GameLoader::from_str(&rule.from.loader).is_ok_and(|loader| loader == target.loader) + rule.endpoints() + .is_some_and(|(_, to)| to.mc_version == target.mc_version && to.loader == target.loader) }) } @@ -100,7 +109,9 @@ pub async fn release_migration_offer( let Some(target) = clusters .iter() .filter(|cluster| { - cluster.mc_loader == release.loader && cluster.mc_version == release.mc_version + !cluster.user_created + && cluster.mc_loader == release.loader + && cluster.mc_version == release.mc_version }) .min_by_key(|cluster| cluster.created_at) else { @@ -130,8 +141,7 @@ pub async fn manual_migration_offer( return Ok(OfferLookup::MissingCluster); }; - if source.id == target.id - || !can_migrate_manually(source.mc_loader, target.mc_loader) + if !is_migration_source(source, target, can_migrate_manually) || !has_migratable_packages(source.id, &content).await? { return Ok(OfferLookup::NoSources); @@ -152,9 +162,7 @@ pub fn rank_migration_sources(target: &Cluster, clusters: &[Cluster]) -> Vec = clusters .iter() - .filter(|cluster| { - cluster.id != target.id && can_migrate_manually(cluster.mc_loader, target.mc_loader) - }) + .filter(|cluster| is_migration_source(cluster, target, can_migrate_manually)) .cloned() .collect(); sources.sort_by_key(|cluster| { @@ -180,7 +188,7 @@ async fn offer_for( let mut sources = Vec::new(); for cluster in clusters { - if cluster.id == target.id || cluster.mc_loader != target.mc_loader { + if !is_migration_source(cluster, target, |from, to| from == to) { continue; } if !version_order(&cluster.mc_version).is_some_and(|order| order != target_order) { @@ -249,7 +257,9 @@ mod tests { }, to: crate::versions::MigrationTarget { mc_version: "26.1.2".into(), + loader: None, }, + allow_without_bundles: false, }]; let moved = ReleaseTarget { mc_version: "26.1.2".into(), @@ -269,6 +279,33 @@ mod tests { assert!(!is_migration_destination(&other_loader, &rules)); } + #[test] + fn a_loader_switch_destination_uses_the_target_loader() { + let rules = vec![RemoteMigration { + id: "x".into(), + from: crate::versions::MigrationSource { + mc_version: "1.20.1".into(), + loader: "forge".into(), + }, + to: crate::versions::MigrationTarget { + mc_version: "1.20.1".into(), + loader: Some("neoforge".into()), + }, + allow_without_bundles: false, + }]; + let destination = ReleaseTarget { + mc_version: "1.20.1".into(), + loader: GameLoader::NeoForge, + }; + let source = ReleaseTarget { + mc_version: "1.20.1".into(), + loader: GameLoader::Forge, + }; + + assert!(is_migration_destination(&destination, &rules)); + assert!(!is_migration_destination(&source, &rules)); + } + #[test] fn a_same_version_source_comes_before_older_ones() { let target = version_order("26.2").unwrap(); diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 0e634114..aaf3ab76 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -110,6 +110,19 @@ async fn start( format!("Launching {}", existing.name), ); + if existing.uses_bundles() + && let Err(err) = crate::clusters::apply_bundle_java_override( + state, + cluster_id, + search_for_java, + false, + Some(&progress), + ) + .await + { + tracing::warn!(cluster_id, error = %err, "failed to apply bundle java override"); + } + let cluster = if existing.stage == ClusterStage::Ready { existing } else { @@ -132,13 +145,14 @@ async fn start( } } }; - if let Err(err) = oneclient_content::bundles::install_cluster_bundles( - cluster_id, - state.bundles.as_ref(), - Some(&progress), - &state.services.content(), - ) - .await + if cluster.uses_bundles() + && 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"); } diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index dd4c1cc2..8ce71299 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -28,11 +28,11 @@ pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use heal::clear_zeroed_files; pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair}; pub use oneclient_mc::{ - DownloadPlan, NativesReport, PlannedLibrary, check_natives, 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, natives_missing, plan_downloads, resolve_minecraft_version, validate_rules, - verify_game_files, + DownloadPlan, GameVersionInfo, GameVersionKind, NativesReport, PlannedLibrary, check_natives, + confirm_incomplete_install, download_minecraft, download_version_info, game_files_missing, + get_game_versions, get_loader_version, get_loader_versions, get_loaders_for_version, + get_version_ids, get_versions_for_loader, is_version_updated, libraries_missing, + natives_missing, plan_downloads, resolve_minecraft_version, validate_rules, verify_game_files, }; pub use process::{ GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index a800bca6..a9fdd24b 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -44,6 +44,18 @@ impl Desired { } } +struct Unrestored { + content_type: ContentType, + file_name: String, + hash: String, +} + +impl Unrestored { + fn relative_path(&self) -> String { + manifest::entry_path(self.content_type.folder_name(), &self.file_name) + } +} + #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] pub async fn materialize_content( services: &LauncherServices, @@ -53,7 +65,12 @@ pub async fn materialize_content( ) -> LauncherResult<()> { let dedicated = cluster.uses_dedicated_dir(); let cluster_dir = cluster.dir()?; - let global_root = paths::shared_minecraft_dir()?; + let isolated = cluster.is_isolated(); + let global_root = if isolated { + cluster_dir.clone() + } else { + paths::shared_minecraft_dir()? + }; polyio::create_dir_all(game_dir).await.ok(); polyio::create_dir_all(&global_root).await.ok(); @@ -62,26 +79,39 @@ pub async fn materialize_content( polyio::create_dir_all(paths::cluster_mods_dir(&cluster.folder_name)?) .await .ok(); - ensure_mods_link(cluster).await; + if isolated { + remove_mods_link(&cluster.folder_name).await; + } else { + ensure_mods_link(cluster).await; + } prune_mods_links(services).await; } else { unwind_cluster_mods(cluster, &cluster_dir).await; } - adopt_into_global(&cluster_dir, &global_root).await; - adopt_into_global(game_dir, &global_root).await; - ensure_global_links(game_dir, &global_root).await; + if !isolated { + adopt_into_global(&cluster_dir, &global_root).await; + adopt_into_global(game_dir, &global_root).await; + ensure_global_links(game_dir, &global_root).await; + } let mods_swapped = !dedicated && !mods_in_cluster; drop_stale_notes(&[game_dir, &cluster_dir, &global_root], mods_swapped).await; // In the shared directory this often belongs to another cluster so every // use of it checks the id - let previous = manifest::load(game_dir, manifest::MANIFEST_NAME) - .await - .map(without_global_entries); + let loaded_previous = manifest::load(game_dir, manifest::MANIFEST_NAME).await; + let previous = if isolated { + loaded_previous + } else { + loaded_previous.map(without_global_entries) + }; let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await; - let previous_global = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; + let previous_global = if isolated { + None + } else { + manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await + }; crate::game::heal::clear_zeroed_files(game_dir).await; if mods_in_cluster { crate::game::heal::clear_zeroed_mods(&cluster_dir).await; @@ -105,22 +135,20 @@ pub async fn materialize_content( for content_type in GLOBAL_TYPES { let dir = global_root.join(content_type.folder_name()); - let disabled = disable_hand_removed( - services, - cluster, - &dir, - content_type, - previous_global.as_ref(), - ) - .await; + let seen = if isolated { + previous.as_ref() + } else { + previous_global.as_ref() + }; + let disabled = disable_hand_removed(services, cluster, &dir, content_type, seen).await; if !disabled.is_empty() { - let (title, body) = removal_notice(&disabled); + let (title, body) = removal_notice(&disabled, !isolated); services.events.notify(title).body(body).send(); } } - if mods_in_cluster { + if mods_in_cluster && !cluster.user_created && !cluster.is_isolated() { clear_unlinked_mods_once( services, cluster, @@ -129,7 +157,8 @@ pub async fn materialize_content( .await; } - import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await; + let failed_imports = + import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await; if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity(cluster.id, &services.content()) @@ -181,17 +210,34 @@ pub async fn materialize_content( clear_disabled_mod_files(services, cluster, &active_mods_dir).await; let mut unrestored = Vec::new(); + let wanted: &[ContentType] = if isolated { + &[ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ] + } else { + &[ContentType::Mod] + }; let (mods, rest): (Vec, Vec) = - desired_mods(services, cluster, &mut unrestored) + desired_linked(services, cluster, &mut unrestored, wanted) .await? .into_iter() - .partition(|_| mods_in_cluster); + .partition(|desired| mods_in_cluster && desired.content_type == ContentType::Mod); // read across every cluster rather than this one so a pack installed anywhere is present here too - let packs = desired_global(services, &mut unrestored).await?; + let packs = if isolated { + Vec::new() + } else { + desired_global(services, &mut unrestored).await? + }; if !unrestored.is_empty() { - let (title, body) = unrestored_notice(&unrestored); + let names: Vec = unrestored + .iter() + .map(|item| item.file_name.clone()) + .collect(); + let (title, body) = unrestored_notice(&names); services.events.notify(title).body(body).send(); } @@ -212,10 +258,24 @@ pub async fn materialize_content( sweep_staging_files(&global_root.join(content_type.folder_name())).await; } + let (held_mods, held_rest) = if isolated { + held_in_place(&unrestored, &cluster_dir, game_dir, mods_in_cluster).await + } else { + (Vec::new(), Vec::new()) + }; + // 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 - let mod_paths: HashSet = mods.iter().map(Desired::relative_path).collect(); - let rest_paths: HashSet = rest.iter().map(Desired::relative_path).collect(); + let mod_paths: HashSet = mods + .iter() + .map(Desired::relative_path) + .chain(held_mods.iter().map(|entry| entry.path.clone())) + .collect(); + let rest_paths: HashSet = rest + .iter() + .map(Desired::relative_path) + .chain(held_rest.iter().map(|entry| entry.path.clone())) + .collect(); let pack_paths: HashSet = packs.iter().map(Desired::relative_path).collect(); prune_previous(&cluster_dir, previous_mods.as_ref(), &mod_paths).await; @@ -236,11 +296,18 @@ pub async fn materialize_content( } if mods_in_cluster { - let mod_entries = link_desired(&cluster_dir, &mods).await; + let mut mod_entries = link_desired(&cluster_dir, &mods).await; + mod_entries.extend(held_mods); + let spared = if isolated { + failed_imports + } else { + HashSet::new() + }; drop_unmaterialized( &cluster_dir.join(ContentType::Mod.folder_name()), ContentType::Mod, &mod_entries, + &spared, ) .await; manifest::save( @@ -251,15 +318,18 @@ pub async fn materialize_content( .await; } - let pack_entries = link_desired(&global_root, &packs).await; - manifest::save( - &global_root, - manifest::GLOBAL_MANIFEST_NAME, - &MaterializedManifest::new(cluster.id, pack_entries), - ) - .await; + if !isolated { + let pack_entries = link_desired(&global_root, &packs).await; + manifest::save( + &global_root, + manifest::GLOBAL_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, pack_entries), + ) + .await; + } - let entries = link_desired(game_dir, &rest).await; + let mut entries = link_desired(game_dir, &rest).await; + entries.extend(held_rest); manifest::save( game_dir, manifest::MANIFEST_NAME, @@ -275,7 +345,7 @@ pub async fn materialize_content( // every enabled pack across every cluster async fn desired_global( services: &LauncherServices, - unrestored: &mut Vec, + unrestored: &mut Vec, ) -> LauncherResult> { let mut desired = Vec::new(); @@ -291,8 +361,14 @@ async fn desired_global( continue; }; - let Some(src) = cached_file(services, &row.hash, &artifact.path).await else { - unrestored.push(row.file_name); + let Some(src) = + cached_file(services, &row.hash, &artifact.path, artifact.size_bytes).await + else { + unrestored.push(Unrestored { + content_type, + file_name: row.file_name, + hash: row.hash, + }); continue; }; @@ -475,7 +551,7 @@ async fn disable_hand_removed( let mut disabled = Vec::new(); for (name, hash) in removed { - let outcome = if content_type.is_global() { + let outcome = if cluster.shares_content(content_type) { disable_globally(cluster, &hash, &ctx).await } else { oneclient_content::bundles::set_artifact_enabled_to(cluster.id, &hash, false, &ctx) @@ -534,15 +610,20 @@ fn removal_summary(disabled: &[String]) -> String { } } -fn removal_notice(disabled: &[String]) -> (&'static str, String) { +fn removal_notice(disabled: &[String], shared: bool) -> (&'static str, String) { let names = removal_summary(disabled); + let where_from = if shared { + "your shared folder" + } else { + "this instance" + }; + let scope = if shared { " on every cluster" } else { "" }; if disabled.len() == 1 { return ( "Content disabled", format!( - "{names} is gone from your shared folder, so it has been switched off on every \ - cluster. Turn it back on in OneClient to restore it." + "{names} is gone from {where_from}, so it has been switched off{scope}. Turn it back on in OneClient to restore it." ), ); } @@ -550,8 +631,7 @@ fn removal_notice(disabled: &[String]) -> (&'static str, String) { ( "Content disabled", format!( - "{names} are gone from your shared folder, so they have been switched off on every \ - cluster. Turn them back on in OneClient to restore them." + "{names} are gone from {where_from}, so they have been switched off{scope}. Turn them back on in OneClient to restore them." ), ) } @@ -630,16 +710,17 @@ pub async fn dematerialize_content( Ok(()) } -async fn desired_mods( +async fn desired_linked( services: &LauncherServices, cluster: &Cluster, - unrestored: &mut Vec, + unrestored: &mut Vec, + wanted: &[ContentType], ) -> 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 || link.content_type != ContentType::Mod { + if !link.enabled || !wanted.contains(&link.content_type) { continue; } @@ -648,8 +729,14 @@ async fn desired_mods( continue; }; - let Some(src) = cached_file(services, &link.hash, &artifact.path).await else { - unrestored.push(link.cluster_file_name); + let Some(src) = + cached_file(services, &link.hash, &artifact.path, artifact.size_bytes).await + else { + unrestored.push(Unrestored { + content_type: link.content_type, + file_name: link.cluster_file_name, + hash: link.hash, + }); continue; }; @@ -668,9 +755,10 @@ async fn cached_file( services: &LauncherServices, hash: &str, stored_path: &str, + size_bytes: Option, ) -> Option { let src = artifact_absolute_path(stored_path).ok()?; - if polyio::try_exists(&src).await.unwrap_or(false) { + if cache_intact(&src, hash, size_bytes).await { return Some(src); } @@ -700,6 +788,72 @@ async fn cached_file( .then_some(path) } +async fn cache_intact(src: &Path, hash: &str, size_bytes: Option) -> bool { + let Ok(meta) = polyio::stat(src).await else { + return false; + }; + + let len = meta.len(); + let suspect = len == 0 || size_bytes.is_some_and(|expected| expected as u64 != len); + if !suspect { + return true; + } + + let intact = polyio::sha1_file(src) + .await + .is_ok_and(|disk| polyio::normalize_hash(&disk) == hash); + if !intact { + tracing::warn!( + hash, + file = %src.display(), + size = len, + expected = ?size_bytes, + "cached package file is damaged; treating it as missing" + ); + } + + intact +} + +async fn held_in_place( + unrestored: &[Unrestored], + cluster_dir: &Path, + game_dir: &Path, + mods_in_cluster: bool, +) -> (Vec, Vec) { + let mut mods = Vec::new(); + let mut rest = Vec::new(); + + for item in unrestored { + let in_cluster = mods_in_cluster && item.content_type == ContentType::Mod; + let root = if in_cluster { cluster_dir } else { game_dir }; + let path = item.relative_path(); + + if !polyio::symlink_metadata(root.join(&path)) + .await + .is_ok_and(|meta| meta.file_type().is_file()) + { + continue; + } + + tracing::info!( + file = %item.file_name, + "keeping the instance's own copy of content the cache could not restore" + ); + let entry = ManifestEntry { + path, + hash: item.hash.clone(), + }; + if in_cluster { + mods.push(entry); + } else { + rest.push(entry); + } + } + + (mods, rest) +} + async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { let mut entries = Vec::with_capacity(desired.len()); @@ -780,7 +934,12 @@ async fn mark_repair_applied(services: &LauncherServices, id: &str) { } } -async fn drop_unmaterialized(dir: &Path, content_type: ContentType, entries: &[ManifestEntry]) { +async fn drop_unmaterialized( + dir: &Path, + content_type: ContentType, + entries: &[ManifestEntry], + spared: &HashSet, +) { let kept: HashSet<&str> = entries .iter() .filter_map(|entry| entry.path.rsplit('/').next()) @@ -798,7 +957,7 @@ async fn drop_unmaterialized(dir: &Path, content_type: ContentType, entries: &[M if name.starts_with('.') || is_note(name) || !has_content_extension(content_type, name) { continue; } - if kept.contains(name) { + if kept.contains(name) || spared.contains(name) { continue; } @@ -935,12 +1094,12 @@ async fn import_manual_content_with( game_dir: &Path, mods_in_cluster: bool, discard_originals: bool, -) { +) -> HashSet { 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; + return HashSet::new(); } }; @@ -969,7 +1128,7 @@ async fn import_manual_content_with( ), }; - import_from_dir( + let failed_mods = import_from_dir( services, cluster, &mods_dir, @@ -980,21 +1139,37 @@ async fn import_manual_content_with( ) .await; - let Ok(global_root) = paths::shared_minecraft_dir() else { - return; + let isolated = cluster.is_isolated(); + let global_root = if isolated { + let Ok(dir) = cluster.dir() else { + return failed_mods; + }; + dir + } else { + let Ok(dir) = paths::shared_minecraft_dir() else { + return failed_mods; + }; + dir + }; + let global_manifest = if isolated { + manifest.clone() + } else { + manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await }; - let global_manifest = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; for content_type in GLOBAL_TYPES { let dir = global_root.join(content_type.folder_name()); - let known = + let known = if isolated { + names_linked_here(&linked, content_type) + } else { match artifact_dao::list_global_artifacts(&services.db, content_type as i64).await { Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), Err(err) => { tracing::warn!(error = %err, "cannot list global content; skipping its import"); continue; } - }; + } + }; import_from_dir( services, @@ -1007,6 +1182,8 @@ async fn import_manual_content_with( ) .await; } + + failed_mods } fn names_linked_here( @@ -1028,9 +1205,10 @@ async fn import_from_dir( known: &HashSet, manifest: Option<&MaterializedManifest>, discard_originals: bool, -) { +) -> HashSet { + let mut failed = HashSet::new(); let Ok(mut entries) = polyio::read_dir(dir).await else { - return; + return failed; }; while let Ok(Some(entry)) = entries.next_entry().await { @@ -1057,7 +1235,7 @@ async fn import_from_dir( continue; } - if is_cached_artifact(services, &path).await { + if !cluster.is_isolated() && is_stale_launcher_content(services, &path).await { tracing::debug!( file = name, dir = %dir.display(), @@ -1078,13 +1256,18 @@ async fn import_from_dir( discard_adopted_original(&row, &path, name).await; } } - Err(err) => tracing::warn!( - file = name, - error = %err, - "failed to register manually-added content" - ), + Err(err) => { + tracing::warn!( + file = name, + error = %err, + "failed to register manually-added content" + ); + failed.insert(name.to_owned()); + } } } + + failed } async fn discard_adopted_original( @@ -1108,16 +1291,15 @@ async fn discard_adopted_original( } } -async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { +async fn is_stale_launcher_content(services: &LauncherServices, path: &Path) -> bool { let Ok(hash) = polyio::sha1_file(path).await else { return false; }; + let hash = polyio::normalize_hash(&hash); - artifact_dao::get_artifact_by_hash(&services.db, &polyio::normalize_hash(&hash)) + artifact_dao::is_launcher_owned(&services.db, &hash) .await - .ok() - .flatten() - .is_some() + .unwrap_or(false) } fn has_content_extension(content_type: ContentType, name: &str) -> bool { @@ -2034,7 +2216,7 @@ mod tests { #[test] fn the_global_notice_owns_up_to_its_reach() { - let (_, body) = removal_notice(&["bsl.zip".into()]); + let (_, body) = removal_notice(&["bsl.zip".into()], true); assert!(body.contains("every cluster"), "{body}"); assert!(!body.contains("'s folder"), "{body}"); diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index 81823499..affc3de6 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -9,10 +9,12 @@ use crate::{LauncherError, LauncherResult}; use oneclient_common::paths; use oneclient_net::RequestClient; use oneclient_net::RequestError; +use oneclient_net::{EtagPolicy, fetch_cached}; use polyio::sha1_bytes; pub const DEFAULT_IMAGE_EDGE: u32 = 1200; +pub const PREVIEW_IMAGE_EDGE: u32 = 512; pub const BACKGROUND_IMAGE_EDGE: u32 = 2560; /// Image urls come from untrusted remote descriptions, so cap what is fetched and decoded. @@ -39,6 +41,15 @@ impl MemoryCache { Some(hit) } + fn remove(&mut self, key: &str) { + if let Some(dropped) = self.entries.remove(key) { + self.bytes -= dropped.len(); + if let Some(at) = self.order.iter().position(|k| k == key) { + self.order.remove(at); + } + } + } + fn insert(&mut self, key: String, value: Bytes) { // A single oversized image must not evict the entire cache to store itself. if value.len() > MEMORY_CACHE_BUDGET { @@ -117,10 +128,127 @@ impl ImageCacheStore { Ok(bytes) } + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn prefetch( + &self, + net: &RequestClient, + url: &str, + edges: &[u32], + ) -> LauncherResult<()> { + let mut missing = Vec::new(); + for edge in edges { + let path = Self::disk_path(url, *edge)?; + if !tokio::fs::try_exists(&path).await.unwrap_or(false) { + missing.push((*edge, path)); + } + } + + if missing.is_empty() { + return Ok(()); + } + + let raw = match paths::local_image_path(url) { + Some(source) => Bytes::from(polyio::read(&source).await?), + None => download(net, url).await?, + }; + + let edges: Vec = missing.iter().map(|(edge, _)| *edge).collect(); + let sized = downscale_each_if_oversized(raw, edges).await; + + for ((_, path), bytes) in missing.into_iter().zip(sized) { + 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 prefetched image for {url}: {err}"); + } + } + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn refresh( + &self, + net: &RequestClient, + url: &str, + edges: &[u32], + ) -> LauncherResult<()> { + ensure_fetchable(net, url)?; + + let source = Self::source_path(url)?; + if let Some(parent) = source.parent() { + polyio::create_dir_all(parent).await?; + } + + let Some(fetched) = fetch_cached(net, url, &source, EtagPolicy::CommitNow).await? else { + return self.prefetch(net, url, edges).await; + }; + + if fetched.bytes.len() > MAX_IMAGE_BYTES { + return Err(refused(url, "body is too large")); + } + + let mut stale = Vec::new(); + for edge in edges { + let path = Self::disk_path(url, *edge)?; + if fetched.changed || !tokio::fs::try_exists(&path).await.unwrap_or(false) { + stale.push((*edge, path)); + } + } + + if stale.is_empty() { + return Ok(()); + } + + let raw = Bytes::from(fetched.bytes); + let edges: Vec = stale.iter().map(|(edge, _)| *edge).collect(); + let sized = downscale_each_if_oversized(raw, edges).await; + + for ((edge, path), bytes) in stale.into_iter().zip(sized) { + 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 refreshed image for {url}: {err}"); + continue; + } + self.memory.lock().await.remove(&Self::mem_key(url, edge)); + } + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn ensure_on_disk( + &self, + net: &RequestClient, + url: &str, + max_edge: u32, + ) -> LauncherResult { + let path = Self::disk_path(url, max_edge)?; + if tokio::fs::try_exists(&path).await.unwrap_or(false) { + return Ok(path); + } + + self.prefetch(net, url, &[max_edge]).await?; + + if tokio::fs::try_exists(&path).await.unwrap_or(false) { + Ok(path) + } else { + Err(refused(url, "the image could not be cached on disk")) + } + } + fn mem_key(url: &str, max_edge: u32) -> String { format!("{max_edge}|{url}") } + fn source_path(url: &str) -> LauncherResult { + let name = format!("{}.source", sha1_bytes(url.as_bytes())); + Ok(paths::images_cache_dir()?.join(name)) + } + fn disk_path(url: &str, max_edge: u32) -> LauncherResult { let mut name = sha1_bytes(url.as_bytes()); name.push('_'); @@ -154,7 +282,7 @@ async fn downscale_if_oversized(bytes: Bytes, max_edge: u32) -> Bytes { const MAX_DECODE_EDGE: u32 = 8192; const MAX_DECODE_ALLOC: u64 = 128 * 1024 * 1024; -fn downscale(bytes: &[u8], max_edge: u32) -> Option { +fn decode_limited(bytes: &[u8]) -> Option { let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_DECODE_EDGE); limits.max_image_height = Some(MAX_DECODE_EDGE); @@ -163,12 +291,15 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)); reader.limits(limits); - let img = reader + reader .with_guessed_format() .ok()? .decode() .inspect_err(|err| tracing::warn!("refused to decode an image: {err}")) - .ok()?; + .ok() +} + +fn encode_downscaled(img: &image::DynamicImage, max_edge: u32) -> Option { if img.width().max(img.height()) <= max_edge { return None; } @@ -190,8 +321,38 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { Some(Bytes::from(out)) } +fn downscale(bytes: &[u8], max_edge: u32) -> Option { + encode_downscaled(&decode_limited(bytes)?, max_edge) +} + +fn downscale_each(bytes: &[u8], edges: &[u32]) -> Vec> { + let Some(img) = decode_limited(bytes) else { + return vec![None; edges.len()]; + }; + + edges + .iter() + .map(|edge| encode_downscaled(&img, *edge)) + .collect() +} + +async fn downscale_each_if_oversized(bytes: Bytes, edges: Vec) -> Vec { + let _permit = DECODE_LIMIT.acquire().await; + let candidate = bytes.clone(); + let count = edges.len(); + + let resized = tokio::task::spawn_blocking(move || downscale_each(&candidate, &edges)) + .await + .unwrap_or_else(|_| vec![None; count]); + + resized + .into_iter() + .map(|smaller| smaller.unwrap_or_else(|| bytes.clone())) + .collect() +} + #[tracing::instrument(level = "debug", skip(net))] -async fn download(net: &RequestClient, url: &str) -> LauncherResult { +fn ensure_fetchable(net: &RequestClient, url: &str) -> LauncherResult { let parsed: reqwest::Url = url.parse().map_err(RequestError::from)?; if !matches!(parsed.scheme(), "http" | "https") { @@ -204,6 +365,12 @@ async fn download(net: &RequestClient, url: &str) -> LauncherResult { return Err(refused(url, "host is not public")); } + Ok(parsed) +} + +async fn download(net: &RequestClient, url: &str) -> LauncherResult { + let parsed = ensure_fetchable(net, url)?; + let request = reqwest::Request::new(Method::GET, parsed); let mut res = net.send(request).await?; diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 5d7ba768..ee9bc9ab 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -26,11 +26,15 @@ 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, required_java_major, + apply_bundle_java_override, ensure_from_bundles, ensure_from_versions, + estimate_cluster_download, required_java_major, }; pub use disable_warnings::{DisableWarnings, fetch_disable_warnings}; pub use error::{LauncherError, LauncherResult, SentryExclusion}; -pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; +pub use game::{ + GameError, GameVersionInfo, GameVersionKind, LaunchedGame, get_loader_versions, + get_loaders_for_version, get_version_ids, get_versions_for_loader, launch_cluster, +}; pub use images::ImageCacheStore; pub use migration::{ ImportTarget, MigrationDetection, MigrationSource, SourceInstance, detect as detect_migration, @@ -41,7 +45,8 @@ pub use oneclient_cluster::logs::{ 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, + ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, + load_picked_image, load_screenshot, }; pub use oneclient_cluster::worlds::{ DataPackInfo, LEVEL_DAT, PackIcon, WORLD_ICON, WorldInfo, WorldsError, add_world_datapacks, @@ -76,6 +81,7 @@ pub use state::shutdown; pub use tos::{TermsDocument, fetch_terms}; pub use verify::{ClusterVerifyReport, verify_cluster_files}; pub use versions::{ - ReleaseTarget, RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest, + ReleaseTarget, RemoteMigration, VersionArts, VersionMetadata, VersionsManager, + VersionsManifest, resolve_migration_chain, }; diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index 72f3673b..9014898c 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -47,6 +47,7 @@ pub struct SourceInstance { /// Set for "migrated" clusters (e.g. 26.1 fabric -> 26.1.2 fabric) pub target_mc_version: Option, pub mc_loader: GameLoader, + pub target_mc_loader: Option, /// Empty when the source has no category concept pub categories: Vec, pub has_game_dir: bool, @@ -66,6 +67,11 @@ impl SourceInstance { .as_deref() .unwrap_or(&self.mc_version) } + + #[must_use] + pub fn import_loader(&self) -> GameLoader { + self.target_mc_loader.unwrap_or(self.mc_loader) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -126,6 +132,7 @@ mod tests { mc_version: "26.1".to_string(), target_mc_version: None, mc_loader: GameLoader::Fabric, + target_mc_loader: None, categories: Vec::new(), has_game_dir: true, }; @@ -135,6 +142,24 @@ mod tests { assert_eq!(instance.import_version(), "26.1.2"); } + #[test] + fn import_loader_prefers_resolved_target() { + let mut instance = SourceInstance { + instance_id: 1, + folder_name: "1.20.1 Forge".to_string(), + mc_version: "1.20.1".to_string(), + target_mc_version: None, + mc_loader: GameLoader::Forge, + target_mc_loader: None, + categories: Vec::new(), + has_game_dir: true, + }; + assert_eq!(instance.import_loader(), GameLoader::Forge); + + instance.target_mc_loader = Some(GameLoader::NeoForge); + assert_eq!(instance.import_loader(), GameLoader::NeoForge); + } + #[test] fn ids_round_trip() { for source in MigrationSource::ALL.iter().copied() { diff --git a/packages/oneclient_core/src/migration/oneclient_v1.rs b/packages/oneclient_core/src/migration/oneclient_v1.rs index 4da642d6..2071b2ee 100644 --- a/packages/oneclient_core/src/migration/oneclient_v1.rs +++ b/packages/oneclient_core/src/migration/oneclient_v1.rs @@ -99,6 +99,7 @@ async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult LauncherResult> { mc_version: String::new(), target_mc_version: None, mc_loader: GameLoader::Vanilla, + target_mc_loader: None, categories: Vec::new(), has_game_dir: true, }], diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index da048d0a..4a6011ec 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -4,12 +4,13 @@ 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 oneclient_db::models::{ClusterKind, NewCluster}; use strum::IntoEnumIterator; use crate::LauncherResult; use crate::clusters::ClusterStage; use crate::state::LauncherState; +use oneclient_cluster::encode_tags; use oneclient_cluster::profiles::create_profile_from_global; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_common::paths; @@ -202,11 +203,43 @@ async fn adopt_cluster( folder_name: &str, report: &mut RecoveryReport, ) -> LauncherResult<()> { - let (name, mc_version, loader) = parse_folder_identity(folder_name); + let recorded = match oneclient_common::paths::cluster_dir(folder_name) { + Ok(dir) => oneclient_cluster::identity::read(&dir).await, + Err(_) => None, + }; + + let (name, mc_version, parsed_loader) = match &recorded { + Some(identity) => ( + identity.name.clone(), + identity.mc_version.clone(), + identity.mc_loader, + ), + None => parse_folder_identity(folder_name), + }; + + let guessed = match &recorded { + Some(_) => None, + None => orphan_instance_kind(folder_name).await, + }; + let guessed_kind = guessed.map(|(kind, _)| kind); + let loader = guessed.map_or(parsed_loader, |(_, loader)| loader); + if let Some(kind) = guessed_kind { + tracing::info!( + folder = folder_name, + ?kind, + ?loader, + "orphan folder has no instance file but is marked as its own instance; adopting it as one" + ); + } let global = state.settings.read().global_game_settings.clone(); let profile = - create_profile_from_global(&state.services.db, &global, &name, None, None).await?; + create_profile_from_global(&state.services.db, &global, folder_name, None, None).await?; + + let tags = recorded.as_ref().map_or_else( + || "[]".to_string(), + |identity| encode_tags(&identity.tags), + ); let row = cluster_dao::insert( &state.services.db, @@ -215,9 +248,26 @@ async fn adopt_cluster( folder_name, mc_version: &mc_version, mc_loader: loader as i64, - mc_loader_version: None, + mc_loader_version: recorded + .as_ref() + .and_then(|identity| identity.mc_loader_version.as_deref()), setting_profile_name: Some(&profile.name), stage: ClusterStage::NotReady as i64, + kind: recorded.as_ref().map_or_else( + || guessed_kind.map_or(0, ClusterKind::as_i64), + |identity| identity.kind.as_i64(), + ), + user_created: recorded.as_ref().map_or_else( + || i64::from(guessed_kind.is_some()), + |identity| i64::from(identity.user_created), + ), + description: recorded + .as_ref() + .and_then(|identity| identity.description.as_deref()), + tags: &tags, + cover_path: recorded + .as_ref() + .and_then(|identity| identity.cover_path.as_deref()), }, ) .await?; @@ -231,6 +281,10 @@ async fn adopt_cluster( "adopted cluster folder from disk" ); + if let Ok(cluster) = oneclient_cluster::Cluster::try_from_row(row.clone()) { + state.clusters.ensure_dedicated_marker(&cluster).await.ok(); + } + relink_cluster_files(state, row.id, folder_name, report).await?; Ok(()) } @@ -305,6 +359,10 @@ pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<() let clusters = state.clusters.list().await?; for cluster in clusters { + if !cluster.uses_bundles() { + continue; + } + let archives = match state .bundles .archives_for( @@ -369,7 +427,72 @@ pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<() Ok(()) } -fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) { +async fn orphan_instance_kind(folder_name: &str) -> Option<(ClusterKind, GameLoader)> { + if !paths::cluster_uses_dedicated_dir(folder_name) || looks_provisioned(folder_name) { + return None; + } + + let mods = paths::cluster_dir(folder_name) + .ok()? + .join(ContentType::Mod.folder_name()); + let jars = list_files(&mods) + .await + .unwrap_or_default() + .into_iter() + .filter(|file| { + file.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("jar")) + }); + + let mut found = Vec::new(); + for jar in jars { + if let Some(loader) = oneclient_content::packages::read_jar_loader(&jar).await { + found.push(loader); + } + } + + Some(match loader_from_votes(&found) { + Some(loader) => (ClusterKind::Modded, loader), + None => (ClusterKind::Vanilla, GameLoader::Vanilla), + }) +} + +fn loader_from_votes(found: &[GameLoader]) -> Option { + let count = |loaders: &[GameLoader]| found.iter().filter(|l| loaders.contains(l)).count(); + let fabric_family = count(&[GameLoader::Fabric, GameLoader::Quilt]); + let forge_family = count(&[GameLoader::Forge, GameLoader::NeoForge]); + + if fabric_family == 0 && forge_family == 0 { + None + } else if fabric_family >= forge_family { + Some(if found.contains(&GameLoader::Quilt) { + GameLoader::Quilt + } else { + GameLoader::Fabric + }) + } else { + Some(if found.contains(&GameLoader::NeoForge) { + GameLoader::NeoForge + } else { + GameLoader::Forge + }) + } +} + +fn looks_provisioned(folder_name: &str) -> bool { + let without_copy_suffix = folder_name + .rsplit_once(" (") + .filter(|(_, tail)| { + tail.strip_suffix(')') + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|(stem, _)| stem); + + parse_provisioned_name(folder_name).is_some() + || without_copy_suffix.is_some_and(|stem| parse_provisioned_name(stem).is_some()) +} + +fn parse_provisioned_name(folder_name: &str) -> Option<(String, GameLoader)> { let tokens: Vec<&str> = folder_name.split_whitespace().collect(); for take in [2usize, 1] { @@ -379,12 +502,20 @@ fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) { 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); + return Some((version, loader)); } } } } + None +} + +fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) { + if let Some((version, loader)) = parse_provisioned_name(folder_name) { + 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()); diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 51068bde..fc58bb7e 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -127,6 +127,7 @@ pub fn run_startup_tasks(state: &Arc) { let background = Arc::clone(state); tokio::spawn(async move { sweep_java_scratch_files().await; + background.clusters.sweep_trash().await; let recovery = match crate::recovery::reconstruct_from_disk(&background).await { Ok(report) => report, @@ -154,6 +155,11 @@ pub fn run_startup_tasks(state: &Arc) { tracing::error!("bundle catalog sync failed: {err:#}"); } + let art = Arc::clone(&background); + tokio::spawn(async move { + crate::versions::prefetch_version_art(&art).await; + }); + if recovery.did_recover() && let Err(err) = crate::recovery::restore_bundle_tracking(&background).await { diff --git a/packages/oneclient_core/src/versions/arts.rs b/packages/oneclient_core/src/versions/arts.rs new file mode 100644 index 00000000..c03d1b35 --- /dev/null +++ b/packages/oneclient_core/src/versions/arts.rs @@ -0,0 +1,248 @@ +use std::collections::{HashMap, HashSet}; + +use oneclient_common::version::{VersionKey, parse_mc_version}; + +const DEFAULT_ART_KEY: &str = "default"; + +pub type ArtsManifest = HashMap; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct VersionArts { + by_patch: HashMap<(u32, u32, u32), String>, + by_minor: HashMap<(u32, u32), String>, + by_major: HashMap, + default: Option, +} + +impl VersionArts { + #[must_use] + pub fn new(manifest: &ArtsManifest, meta_url_base: &str) -> Self { + let mut arts = Self::default(); + + for (version, path) in manifest { + let url = format!("{meta_url_base}{path}"); + + if version == DEFAULT_ART_KEY { + arts.default = Some(url); + continue; + } + + let Some(parsed) = parse_mc_version(version) else { + tracing::warn!("ignoring version art under an unparseable key {version}"); + continue; + }; + + match (parsed.minor, parsed.patch) { + (Some(minor), Some(patch)) => { + arts.by_patch.insert((parsed.major, minor, patch), url); + } + (Some(minor), None) => { + arts.by_minor.insert((parsed.major, minor), url); + } + (None, _) => { + arts.by_major.insert(parsed.major, url); + } + } + } + + arts + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_patch.is_empty() + && self.by_minor.is_empty() + && self.by_major.is_empty() + && self.default.is_none() + } + + #[must_use] + pub fn gallery(&self) -> Vec { + let mut ordered: Vec<(u32, Option, Option, &str)> = self + .by_major + .iter() + .map(|(major, url)| (*major, None, None, url.as_str())) + .chain( + self.by_minor + .iter() + .map(|((major, minor), url)| (*major, Some(*minor), None, url.as_str())), + ) + .chain(self.by_patch.iter().map(|((major, minor, patch), url)| { + (*major, Some(*minor), Some(*patch), url.as_str()) + })) + .collect(); + + ordered.sort_unstable_by(|a, b| { + let rank = |v: &(u32, Option, Option, &str)| { + (v.0, v.1.unwrap_or(u32::MAX), v.2.unwrap_or(u32::MAX)) + }; + rank(b).cmp(&rank(a)) + }); + + let mut seen = HashSet::new(); + let mut urls: Vec = ordered + .into_iter() + .filter(|(_, _, _, url)| seen.insert(*url)) + .map(|(_, _, _, url)| url.to_string()) + .collect(); + + if let Some(default) = &self.default + && seen.insert(default.as_str()) + { + urls.push(default.clone()); + } + + urls + } + + #[must_use] + pub fn specific_art_url(&self, major: Option, key: Option) -> Option { + let major = major?; + let (minor, patch) = key?; + + patch + .and_then(|patch| self.by_patch.get(&(major, minor, patch))) + .or_else(|| self.by_minor.get(&(major, minor))) + .cloned() + } + + #[must_use] + pub fn fallback_art_url(&self, major: Option) -> Option { + major + .and_then(|major| self.by_major.get(&major)) + .or(self.default.as_ref()) + .cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "https://example.invalid"; + + fn manifest() -> ArtsManifest { + [ + ("1.9", "/art/combat.webp"), + ("1.16", "/art/nether.webp"), + ("1.21", "/art/trials.webp"), + ("1.21.4", "/art/garden.webp"), + ("1.21.11", "/art/mounts.webp"), + ("26.1", "/art/tiny.webp"), + ("26.1.3", "/art/chaos.webp"), + (DEFAULT_ART_KEY, "/art/dirt.webp"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + fn resolve(version: &str) -> Option { + let arts = VersionArts::new(&manifest(), BASE); + let parsed = parse_mc_version(version)?; + arts.specific_art_url(Some(parsed.major), parsed.key()) + .or_else(|| arts.fallback_art_url(Some(parsed.major))) + } + + #[test] + fn a_patch_key_does_not_collide_with_its_minor_line() { + assert_eq!( + resolve("26.1.3").as_deref(), + Some("https://example.invalid/art/chaos.webp") + ); + assert_eq!( + resolve("26.1").as_deref(), + Some("https://example.invalid/art/tiny.webp") + ); + assert_eq!( + resolve("26.1.9").as_deref(), + Some("https://example.invalid/art/tiny.webp") + ); + } + + #[test] + fn minor_specific_art_wins_over_its_line() { + assert_eq!( + resolve("1.21.4").as_deref(), + Some("https://example.invalid/art/garden.webp") + ); + assert_eq!( + resolve("1.21.11").as_deref(), + Some("https://example.invalid/art/mounts.webp") + ); + } + + #[test] + fn minor_without_its_own_art_falls_back_to_the_line() { + assert_eq!( + resolve("1.21.2").as_deref(), + Some("https://example.invalid/art/trials.webp") + ); + assert_eq!( + resolve("1.16.5").as_deref(), + Some("https://example.invalid/art/nether.webp") + ); + } + + #[test] + fn bare_line_resolves_to_itself() { + assert_eq!( + resolve("1.9").as_deref(), + Some("https://example.invalid/art/combat.webp") + ); + } + + #[test] + fn unknown_versions_and_missing_majors_use_the_default() { + assert_eq!( + resolve("1.20.1").as_deref(), + Some("https://example.invalid/art/dirt.webp") + ); + + let arts = VersionArts::new(&manifest(), BASE); + assert_eq!( + arts.fallback_art_url(None).as_deref(), + Some("https://example.invalid/art/dirt.webp") + ); + } + + #[test] + fn a_manifest_without_a_default_resolves_to_nothing() { + let manifest: ArtsManifest = [("1.9".to_string(), "/art/combat.webp".to_string())] + .into_iter() + .collect(); + let arts = VersionArts::new(&manifest, BASE); + assert_eq!(arts.specific_art_url(Some(20), Some((1, None))), None); + assert_eq!(arts.fallback_art_url(Some(20)), None); + } + + #[test] + fn the_gallery_is_deduped_newest_first_and_ends_with_the_default() { + let mut manifest = manifest(); + manifest.insert("1.21.5".to_string(), "/art/garden.webp".to_string()); + + let arts = VersionArts::new(&manifest, BASE); + + assert_eq!( + arts.gallery(), + vec![ + "https://example.invalid/art/tiny.webp", + "https://example.invalid/art/chaos.webp", + "https://example.invalid/art/trials.webp", + "https://example.invalid/art/mounts.webp", + "https://example.invalid/art/garden.webp", + "https://example.invalid/art/nether.webp", + "https://example.invalid/art/combat.webp", + "https://example.invalid/art/dirt.webp", + ] + ); + } + + #[test] + fn unparseable_keys_are_skipped() { + let manifest: ArtsManifest = [("snapshot".to_string(), "/art/snap.webp".to_string())] + .into_iter() + .collect(); + assert!(VersionArts::new(&manifest, BASE).is_empty()); + } +} diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index 2bfe2134..6298b56f 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -1,7 +1,9 @@ -use tokio::sync::RwLock; +use serde::de::DeserializeOwned; +use tokio::sync::{Mutex, RwLock}; use crate::LauncherResult; use crate::state::LauncherServices; +use crate::versions::arts::{ArtsManifest, VersionArts}; use crate::versions::manifest::{ ReleaseTarget, RemoteMigration, VersionMetadata, VersionsManifest, added_release_targets, }; @@ -10,6 +12,8 @@ use oneclient_net::{EtagPolicy, fetch_cached}; pub struct VersionsManager { manifest: RwLock, + arts: RwLock, + syncing: Mutex<()>, added: std::sync::Mutex>, } @@ -18,6 +22,8 @@ impl VersionsManager { pub fn new() -> Self { Self { manifest: RwLock::new(VersionsManifest::default()), + arts: RwLock::new(ArtsManifest::default()), + syncing: Mutex::new(()), added: std::sync::Mutex::new(Vec::new()), } } @@ -26,23 +32,31 @@ impl VersionsManager { /// 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); + let (manifest, arts) = tokio::join!(Self::cached_manifest(), Self::cached_arts()); Self { - manifest: RwLock::new(manifest), + manifest: RwLock::new(manifest.unwrap_or_default()), + arts: RwLock::new(arts.unwrap_or_default()), + syncing: Mutex::new(()), added: std::sync::Mutex::new(Vec::new()), } } + async fn cached_arts() -> Option { + Self::cached_json("version-arts.json", "version arts are").await + } + async fn cached_manifest() -> Option { - let path = paths::caches_dir().ok()?.join("versions.json"); + Self::cached_json("versions.json", "versions manifest is").await + } + + async fn cached_json(file_name: &str, what: &str) -> Option { + let path = paths::caches_dir().ok()?.join(file_name); let bytes = polyio::read(&path).await.ok()?; match serde_json::from_slice(&bytes) { - Ok(manifest) => Some(manifest), + Ok(value) => Some(value), Err(err) => { - tracing::warn!("cached versions manifest is unreadable: {err}"); + tracing::warn!("cached {what} unreadable: {err}"); None } } @@ -50,9 +64,29 @@ impl VersionsManager { #[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 { + let _guard = self.syncing.lock().await; + + let (manifest, arts) = + tokio::join!(Self::fetch_manifest(services), Self::fetch_arts(services)); + + let mut changed = false; + + match arts { + Ok(Some((arts, arts_changed))) => { + *self.arts.write().await = arts; + changed |= arts_changed; + } + Ok(None) => { + tracing::debug!("skipping version arts sync; no remote or cached arts available"); + } + Err(err) => tracing::warn!( + "version arts sync failed, art falls back to the bundled background: {err}" + ), + } + + let Some((manifest, manifest_changed)) = manifest? else { tracing::debug!("skipping versions sync; no remote or cached manifest available"); - return Ok(false); + return Ok(changed); }; let mut current = self.manifest.write().await; let added = added_release_targets(¤t, &manifest); @@ -68,13 +102,18 @@ impl VersionsManager { } } } - Ok(changed) + Ok(changed || manifest_changed) } pub async fn metadata(&self, meta_url_base: &str) -> Vec { self.manifest.read().await.metadata(meta_url_base) } + pub async fn arts(&self, meta_url_base: &str) -> VersionArts { + let arts = self.arts.read().await; + VersionArts::new(&arts, meta_url_base) + } + pub async fn migrations(&self) -> Vec { self.manifest.read().await.migrations.clone() } @@ -91,19 +130,29 @@ impl VersionsManager { async fn fetch_manifest( services: &LauncherServices, ) -> LauncherResult> { - let manifest_path = paths::caches_dir()?.join("versions.json"); + Self::fetch_json(services, "versions.json", "metadata.json").await + } + + #[tracing::instrument(level = "debug", skip(services))] + async fn fetch_arts( + services: &LauncherServices, + ) -> LauncherResult> { + Self::fetch_json(services, "version-arts.json", "arts.json").await + } + + async fn fetch_json( + services: &LauncherServices, + file_name: &str, + remote_name: &str, + ) -> LauncherResult> { + let path = paths::caches_dir()?.join(file_name); let url = format!( - "{}/oneclient/versions/metadata.json", + "{}/oneclient/versions/{remote_name}", services.requester.config().meta_url_base ); - let Some(fetched) = fetch_cached( - &services.requester, - &url, - &manifest_path, - EtagPolicy::CommitNow, - ) - .await? + let Some(fetched) = + fetch_cached(&services.requester, &url, &path, EtagPolicy::CommitNow).await? else { return Ok(None); }; diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index 2874d747..a109f736 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::str::FromStr; use serde::{Deserialize, Serialize}; @@ -58,6 +59,8 @@ pub struct RemoteMigration { pub id: String, pub from: MigrationSource, pub to: MigrationTarget, + #[serde(default)] + pub allow_without_bundles: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -69,6 +72,82 @@ pub struct MigrationSource { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct MigrationTarget { pub mc_version: String, + #[serde(default)] + pub loader: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MigrationNode { + pub mc_version: String, + pub loader: GameLoader, +} + +impl RemoteMigration { + #[must_use] + pub fn endpoints(&self) -> Option<(MigrationNode, MigrationNode)> { + let from_loader = GameLoader::from_str(&self.from.loader).ok()?; + let to_loader = match &self.to.loader { + Some(loader) => GameLoader::from_str(loader).ok()?, + None => from_loader, + }; + + Some(( + MigrationNode { + mc_version: self.from.mc_version.clone(), + loader: from_loader, + }, + MigrationNode { + mc_version: self.to.mc_version.clone(), + loader: to_loader, + }, + )) + } +} + +#[must_use] +pub fn cyclic_migration_ids(rules: &[RemoteMigration]) -> HashSet { + let edges: Vec<(MigrationNode, MigrationNode)> = rules + .iter() + .filter_map(RemoteMigration::endpoints) + .filter(|(from, to)| from != to) + .collect(); + + rules + .iter() + .filter(|rule| { + let Some((from, to)) = rule.endpoints() else { + return false; + }; + from != to && reaches(&edges, &to, &from) + }) + .map(|rule| rule.id.clone()) + .collect() +} + +fn reaches( + edges: &[(MigrationNode, MigrationNode)], + start: &MigrationNode, + goal: &MigrationNode, +) -> bool { + let mut seen: HashSet<&MigrationNode> = HashSet::new(); + let mut stack = vec![start]; + + while let Some(node) = stack.pop() { + if node == goal { + return true; + } + if !seen.insert(node) { + continue; + } + stack.extend( + edges + .iter() + .filter(|(from, _)| from == node) + .map(|(_, to)| to), + ); + } + + false } #[derive(Debug, Clone, PartialEq, Eq)] @@ -158,33 +237,34 @@ pub fn added_release_targets( added } -/// 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(); +) -> (String, GameLoader) { + let mut current = MigrationNode { + mc_version: mc_version.to_string(), + loader, + }; 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 { + let Some((_, to)) = rules + .iter() + .filter_map(RemoteMigration::endpoints) + .find(|(from, _)| *from == current) + else { break; }; - if rule.to.mc_version == current { + if to == current { break; } - current = rule.to.mc_version.clone(); + current = to; } - current + (current.mc_version, current.loader) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -426,16 +506,28 @@ mod tests { }, to: MigrationTarget { mc_version: to.to_string(), + loader: None, }, + allow_without_bundles: false, } } + fn loader_rule(id: &str, version: &str, from: &str, to: &str) -> RemoteMigration { + let mut rule = rule(id, version, from, version); + rule.to.loader = Some(to.to_string()); + rule + } + + fn fabric(version: &str) -> (String, GameLoader) { + (version.to_string(), GameLoader::Fabric) + } + #[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" + fabric("26.1.2") ); } @@ -447,7 +539,7 @@ mod tests { ]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &rules), - "26.1.3" + fabric("26.1.3") ); } @@ -456,7 +548,7 @@ mod tests { let rules = [rule("a", "26.1", "forge", "26.1.2")]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &rules), - "26.1" + fabric("26.1") ); } @@ -465,7 +557,7 @@ mod tests { let rules = [rule("a", "21.1", "fabric", "21.1.2")]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &rules), - "26.1" + fabric("26.1") ); } @@ -474,7 +566,7 @@ mod tests { let self_rule = [rule("a", "26.1", "fabric", "26.1")]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &self_rule), - "26.1" + fabric("26.1") ); let cycle = [ @@ -482,7 +574,70 @@ mod tests { rule("b", "26.2", "fabric", "26.1"), ]; let out = resolve_migration_chain("26.1", GameLoader::Fabric, &cycle); - assert!(out == "26.1" || out == "26.2"); + assert!(out == fabric("26.1") || out == fabric("26.2")); + } + + #[test] + fn chain_switches_loader() { + let rules = [ + rule("a", "26.1", "fabric", "26.1.2"), + loader_rule("b", "26.1.2", "fabric", "neoforge"), + ]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + ("26.1.2".to_string(), GameLoader::NeoForge) + ); + } + + #[test] + fn missing_target_loader_keeps_the_source_loader() { + let (from, to) = rule("a", "26.1", "forge", "26.1.2").endpoints().unwrap(); + assert_eq!(from.loader, GameLoader::Forge); + assert_eq!(to.loader, GameLoader::Forge); + let (from, to) = loader_rule("a", "1.20.1", "forge", "neoforge") + .endpoints() + .unwrap(); + assert_eq!(from.loader, GameLoader::Forge); + assert_eq!(to.loader, GameLoader::NeoForge); + } + + #[test] + fn unknown_target_loader_invalidates_the_rule() { + let broken = loader_rule("a", "1.20.1", "forge", "rift"); + assert!(broken.endpoints().is_none()); + } + + #[test] + fn opposite_loader_rules_are_a_cycle() { + let rules = [ + loader_rule("there", "1.8.9", "fabric", "ornithe"), + loader_rule("back", "1.8.9", "ornithe", "fabric"), + loader_rule("other", "1.20.1", "forge", "neoforge"), + ]; + let cyclic = cyclic_migration_ids(&rules); + assert!(cyclic.contains("there")); + assert!(cyclic.contains("back")); + assert!(!cyclic.contains("other")); + } + + #[test] + fn a_three_loader_ring_is_a_cycle() { + let rules = [ + loader_rule("a", "1.20.1", "forge", "neoforge"), + loader_rule("b", "1.20.1", "neoforge", "fabric"), + loader_rule("c", "1.20.1", "fabric", "forge"), + ]; + assert_eq!(cyclic_migration_ids(&rules).len(), 3); + } + + #[test] + fn a_chain_is_not_a_cycle() { + let rules = [ + rule("a", "26.1", "fabric", "26.1.2"), + loader_rule("b", "26.1.2", "fabric", "quilt"), + rule("self", "26.3", "fabric", "26.3"), + ]; + assert!(cyclic_migration_ids(&rules).is_empty()); } #[test] @@ -498,5 +653,25 @@ mod tests { 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"); + assert_eq!(manifest.migrations[0].to.loader, None); + assert!(!manifest.migrations[0].allow_without_bundles); + } + + #[test] + fn loader_migrations_parse() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[],"migrations":[ + {"id":"x","from":{"mc_version":"1.20.1","loader":"forge"}, + "to":{"mc_version":"1.20.1","loader":"neoforge"}, + "allow_without_bundles":true} + ]}"#, + ) + .expect("should parse"); + + assert_eq!( + manifest.migrations[0].to.loader.as_deref(), + Some("neoforge") + ); + assert!(manifest.migrations[0].allow_without_bundles); } } diff --git a/packages/oneclient_core/src/versions/mod.rs b/packages/oneclient_core/src/versions/mod.rs index 0e5e45b7..bbb2d017 100644 --- a/packages/oneclient_core/src/versions/mod.rs +++ b/packages/oneclient_core/src/versions/mod.rs @@ -1,8 +1,13 @@ +mod arts; mod manager; mod manifest; +mod prefetch; +pub use arts::VersionArts; pub use manager::VersionsManager; pub use manifest::{ - MigrationSource, MigrationTarget, ReleaseTarget, RemoteMigration, VersionMetadata, - VersionsManifest, added_release_targets, resolve_migration_chain, + MigrationNode, MigrationSource, MigrationTarget, ReleaseTarget, RemoteMigration, + VersionMetadata, VersionsManifest, added_release_targets, cyclic_migration_ids, + resolve_migration_chain, }; +pub use prefetch::prefetch_version_art; diff --git a/packages/oneclient_core/src/versions/prefetch.rs b/packages/oneclient_core/src/versions/prefetch.rs new file mode 100644 index 00000000..ae78a51d --- /dev/null +++ b/packages/oneclient_core/src/versions/prefetch.rs @@ -0,0 +1,63 @@ +use std::collections::BTreeSet; + +use futures_util::StreamExt; + +use crate::images::{DEFAULT_IMAGE_EDGE, PREVIEW_IMAGE_EDGE}; +use crate::state::LauncherState; + +const PREFETCH_CONCURRENCY: usize = 4; + +const PREFETCH_EDGES: [u32; 2] = [PREVIEW_IMAGE_EDGE, DEFAULT_IMAGE_EDGE]; + +#[tracing::instrument(level = "debug", skip(state))] +pub async fn prefetch_version_art(state: &LauncherState) { + let meta_url_base = state.services.requester.config().meta_url_base.clone(); + + let mut urls: BTreeSet = state + .versions + .arts(&meta_url_base) + .await + .gallery() + .into_iter() + .collect(); + + urls.extend( + state + .versions + .metadata(&meta_url_base) + .await + .into_iter() + .filter_map(|entry| entry.art_url), + ); + + if urls.is_empty() { + tracing::debug!("no version art to prefetch"); + return; + } + + let total = urls.len(); + let mut failed = 0usize; + + let mut tasks = futures_util::stream::iter(urls) + .map(|url| async move { + let outcome = state + .images + .refresh(&state.services.requester, &url, &PREFETCH_EDGES) + .await; + (url, outcome) + }) + .buffer_unordered(PREFETCH_CONCURRENCY); + + while let Some((url, outcome)) = tasks.next().await { + if let Err(err) = outcome { + failed += 1; + tracing::debug!("version art prefetch failed for {url}: {err}"); + } + } + + if failed > 0 { + tracing::warn!("version art prefetch finished with {failed} of {total} unavailable"); + } else { + tracing::debug!("version art prefetch warmed {total} images"); + } +} diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index 3497ce61..7a616aa7 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -55,6 +55,7 @@ fn manifest(files: Vec) -> BundleManifest { loader: GameLoader::Fabric, loader_version: "0.16.0".to_string(), enabled: true, + java_version_override: None, files, } } diff --git a/packages/oneclient_db/migrations/20260921120000_cluster_instances.sql b/packages/oneclient_db/migrations/20260921120000_cluster_instances.sql new file mode 100644 index 00000000..bdd46243 --- /dev/null +++ b/packages/oneclient_db/migrations/20260921120000_cluster_instances.sql @@ -0,0 +1,6 @@ +ALTER TABLE `clusters` ADD COLUMN `kind` INTEGER NOT NULL DEFAULT 0; +ALTER TABLE `clusters` ADD COLUMN `user_created` INTEGER NOT NULL DEFAULT 0; +ALTER TABLE `clusters` ADD COLUMN `description` TEXT; +ALTER TABLE `clusters` ADD COLUMN `tags` TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE `clusters` ADD COLUMN `cover_path` TEXT; +CREATE INDEX `clusters_user_created_idx` ON `clusters` (`user_created`); diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index 7a84ee91..8e597ebf 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -1,7 +1,8 @@ use sqlx::SqlitePool; use crate::models::{ - ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, + ArtifactRow, ClusterArtifactRow, ClusterKind, LinkedArtifactRow, ProviderReleaseRow, + SeenStatus, }; pub async fn get_artifact_by_hash( @@ -54,25 +55,20 @@ pub async fn insert_artifact( /// `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?; + let result = sqlx::query( + "DELETE FROM artifacts WHERE hash = ? AND NOT EXISTS (SELECT 1 FROM cluster_artifacts WHERE hash = ?)", + ) + .bind(hash) + .bind(hash) + .execute(pool) + .await?; - if linked.0 > 0 { - return Ok(false); - } - - sqlx::query!("DELETE FROM artifacts WHERE hash = ?", hash) - .execute(pool) - .await?; - - Ok(true) + Ok(result.rows_affected() > 0) } use crate::models::GlobalArtifactRow; -/// One row per hash across every cluster for content that is installed globally +/// One row per hash across the OneClient clusters for content that is installed globally /// /// `enabled` is the OR over the clusters: one cluster still having a pack on is /// enough to keep it in the folder, because there is one folder and it can only @@ -95,16 +91,18 @@ pub async fn list_global_artifacts( MAX(ca.enabled) AS enabled FROM cluster_artifacts ca JOIN artifacts a ON a.hash = ca.hash - WHERE a.content_type = ? + JOIN clusters c ON c.id = ca.cluster_id + WHERE a.content_type = ? AND c.kind = ? GROUP BY ca.hash "#, ) .bind(content_type) + .bind(ClusterKind::OneClient.as_i64()) .fetch_all(pool) .await } -/// Sets the flag on every cluster that has this artifact +/// Sets the flag on every cluster sharing the global folder that has this artifact /// /// Globally installed content has one folder so writing only the row of /// whichever cluster the user happened to be looking at would leave the rest @@ -114,11 +112,17 @@ pub async fn set_enabled_for_hash( hash: &str, enabled: i64, ) -> Result { - let result = sqlx::query("UPDATE cluster_artifacts SET enabled = ? WHERE hash = ?") - .bind(enabled) - .bind(hash) - .execute(pool) - .await?; + let result = sqlx::query( + r#" + UPDATE cluster_artifacts SET enabled = ? + WHERE hash = ? AND cluster_id IN (SELECT id FROM clusters WHERE kind = ?) + "#, + ) + .bind(enabled) + .bind(hash) + .bind(ClusterKind::OneClient.as_i64()) + .execute(pool) + .await?; Ok(result.rows_affected()) } @@ -319,6 +323,31 @@ pub async fn list_clusters_linking(pool: &SqlitePool, hash: &str) -> Result Result { + let owned: i64 = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM artifacts a + WHERE a.hash = ? + AND ( + NOT EXISTS (SELECT 1 FROM cluster_artifacts ca WHERE ca.hash = a.hash) + OR EXISTS ( + SELECT 1 FROM cluster_artifacts ca + LEFT JOIN clusters c ON c.id = ca.cluster_id + WHERE ca.hash = a.hash AND (c.id IS NULL OR c.kind = ?) + ) + ) + ) + "#, + ) + .bind(hash) + .bind(ClusterKind::OneClient.as_i64()) + .fetch_one(pool) + .await?; + + Ok(owned != 0) +} + pub async fn unlink_cluster_artifact( pool: &SqlitePool, cluster_id: i64, @@ -489,6 +518,11 @@ mod tests { mc_loader_version: None, setting_profile_name: None, stage: 0, + kind: 0, + user_created: 0, + description: None, + tags: "[]", + cover_path: None, }, ) .await diff --git a/packages/oneclient_db/src/dao/cluster.rs b/packages/oneclient_db/src/dao/cluster.rs index b2f84eba..3844d3ce 100644 --- a/packages/oneclient_db/src/dao/cluster.rs +++ b/packages/oneclient_db/src/dao/cluster.rs @@ -1,7 +1,7 @@ use chrono::Utc; use sqlx::SqlitePool; -use crate::models::{ClusterPatch, ClusterRow, NewCluster}; +use crate::models::{ClusterKind, ClusterPatch, ClusterRow, NewCluster}; pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result, sqlx::Error> { sqlx::query_as!( @@ -9,7 +9,8 @@ pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result, 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 + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters WHERE id = ? "#, @@ -28,7 +29,8 @@ pub async fn get_by_folder_name( 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 + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters WHERE folder_name = ? "#, @@ -44,7 +46,8 @@ pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> 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 + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters ORDER BY last_played IS NULL, last_played DESC, name ASC "# @@ -53,6 +56,13 @@ pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> .await } +pub async fn list_oneclient_ids(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT id FROM clusters WHERE kind = ?") + .bind(ClusterKind::OneClient.as_i64()) + .fetch_all(pool) + .await +} + pub async fn find_by_version_loader( pool: &SqlitePool, mc_version: &str, @@ -63,9 +73,10 @@ pub async fn find_by_version_loader( 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 + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters - WHERE mc_version = ? AND mc_loader = ? + WHERE mc_version = ? AND mc_loader = ? AND user_created = 0 LIMIT 1 "#, mc_version, @@ -83,12 +94,14 @@ pub async fn insert(pool: &SqlitePool, new: &NewCluster<'_>) -> Result) -> Result { + pub mc_version: &'a str, + pub mc_loader: i64, + pub mc_loader_version: Option<&'a str>, + pub stage: i64, + pub name: Option<&'a str>, + pub folder_name: &'a str, +} + +pub async fn migrate_version_if_unchanged( 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, + expected: &ClusterRow, + migration: ClusterMigration<'_>, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ClusterRow>( r#" UPDATE clusters SET mc_version = ?, - name = ?, + mc_loader = ?, + mc_loader_version = ?, + stage = ?, + name = COALESCE(?, name), folder_name = ? - WHERE id = ? + WHERE id = ? AND mc_version = ? AND mc_loader = ? AND folder_name = ? 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, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path "#, - mc_version, - name, - folder_name, - id ) - .fetch_one(pool) + .bind(migration.mc_version) + .bind(migration.mc_loader) + .bind(migration.mc_loader_version) + .bind(migration.stage) + .bind(migration.name) + .bind(migration.folder_name) + .bind(expected.id) + .bind(&expected.mc_version) + .bind(expected.mc_loader) + .bind(&expected.folder_name) + .fetch_optional(pool) .await } @@ -188,7 +228,8 @@ pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result i64 { + self as i64 + } + + pub fn from_repr(value: i64) -> Option { + match value { + 0 => Some(Self::OneClient), + 1 => Some(Self::Vanilla), + 2 => Some(Self::Modded), + _ => None, + } + } + + pub fn is_isolated(self) -> bool { + self != Self::OneClient + } + + pub fn uses_bundles(self) -> bool { + self == Self::OneClient + } +} + #[derive(Debug, Clone, FromRow)] pub struct ClusterRow { pub id: ClusterId, @@ -16,6 +50,25 @@ pub struct ClusterRow { pub last_played: Option, pub overall_played: Option, pub linked_modpack_hash: Option, + pub kind: i64, + pub user_created: i64, + pub description: Option, + pub tags: String, + pub cover_path: Option, +} + +impl ClusterRow { + pub fn kind(&self) -> ClusterKind { + ClusterKind::from_repr(self.kind).unwrap_or(ClusterKind::Modded) + } + + pub fn is_isolated(&self) -> bool { + self.kind().is_isolated() + } + + pub fn uses_bundles(&self) -> bool { + self.kind().uses_bundles() + } } pub struct NewCluster<'a> { @@ -26,6 +79,11 @@ pub struct NewCluster<'a> { pub mc_loader_version: Option<&'a str>, pub setting_profile_name: Option<&'a str>, pub stage: i64, + pub kind: i64, + pub user_created: i64, + pub description: Option<&'a str>, + pub tags: &'a str, + pub cover_path: Option<&'a str>, } #[derive(Debug, Clone, Default)] @@ -34,4 +92,7 @@ pub struct ClusterPatch { pub setting_profile_name: Option>, pub mc_loader_version: Option>, pub linked_modpack_hash: Option>, + pub description: Option>, + pub tags: Option, + pub cover_path: Option>, } diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index cb7dd3ca..0a0561f4 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -16,7 +16,7 @@ pub use artifact::{ }; pub use browser_package_update::BrowserPackageUpdateRow; pub use bundle::{BundleRow, NewBundle}; -pub use cluster::{ClusterId, ClusterPatch, ClusterRow, NewCluster}; +pub use cluster::{ClusterId, ClusterKind, ClusterPatch, ClusterRow, NewCluster}; pub use cluster_bundle::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; pub use cluster_optional_mod::{ClusterOptionalModRow, OptionalModStatus}; pub use game_session::{ diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs index eae11731..1e7936cb 100644 --- a/packages/oneclient_mc/src/arguments.rs +++ b/packages/oneclient_mc/src/arguments.rs @@ -226,9 +226,42 @@ pub fn minecraft_arguments( )?; } + drop_repeated_arguments(&mut parsed); + Ok(parsed) } +fn drop_repeated_arguments(args: &mut Vec) { + let mut seen: HashSet<(String, Option)> = HashSet::new(); + let mut kept: Vec = Vec::with_capacity(args.len()); + let mut index = 0; + + while index < args.len() { + let token = &args[index]; + if !token.starts_with("--") { + kept.push(token.clone()); + index += 1; + continue; + } + + let value = args + .get(index + 1) + .filter(|next| !next.starts_with("--")) + .cloned(); + + if seen.insert((token.clone(), value.clone())) { + kept.push(token.clone()); + if let Some(value) = &value { + kept.push(value.clone()); + } + } + + index += if value.is_some() { 2 } else { 1 }; + } + + *args = kept; +} + pub fn append_profile_game_arguments( args: &mut Vec, force_fullscreen: Option, @@ -251,29 +284,58 @@ pub fn processor_arguments, S: std::hash::BuildHasher>( 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()); + let arg = arg.as_ref(); + + if let Some(coordinate) = arg.strip_prefix('[').and_then(|a| a.strip_suffix(']')) { + parsed.push(get_library(libraries_path, coordinate, true)?); + continue; } + + parsed.push(expand_data_placeholders(libraries_path, arg, data)?); } Ok(parsed) } +fn expand_data_placeholders( + libraries_path: &Path, + arg: &str, + data: &HashMap, +) -> McResult { + let mut out = String::with_capacity(arg.len()); + let mut rest = arg; + + while let Some(open) = rest.find('{') { + let Some(close) = rest[open..].find('}').map(|at| open + at) else { + break; + }; + + out.push_str(&rest[..open]); + let key = &rest[open + 1..close]; + + match data.get(key) { + Some(entry) => { + match entry + .client + .strip_prefix('[') + .and_then(|c| c.strip_suffix(']')) + { + Some(coordinate) => { + out.push_str(&get_library(libraries_path, coordinate, true)?); + } + None => out.push_str(&entry.client), + } + } + None => out.push_str(&rest[open..=close]), + } + + rest = &rest[close + 1..]; + } + + out.push_str(rest); + Ok(out) +} + #[tracing::instrument(skip_all, level = "debug")] pub async fn main_class(path: impl AsRef) -> McResult> { let file = tokio::fs::File::open(path.as_ref()).await?; @@ -379,7 +441,7 @@ pub fn classpaths( java_arch: &str, updated: bool, ) -> McResult { - let mut chosen: HashMap, &str)> = HashMap::new(); + let mut chosen: HashMap = HashMap::new(); for lib in libraries { if let Some(rules) = &lib.rules && !validate_rules(rules, java_arch, updated) @@ -395,17 +457,20 @@ pub fn classpaths( } 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 => { + let bundled = is_legacy_asm_bundle(&artifact); + let slot = classpath_slot(artifact); + let rank = (!bundled, version_key(version)); + + match chosen.get(&slot) { + Some((existing, existing_name)) if *existing >= rank => { tracing::debug!( skipped = %lib.name, kept = %existing_name, - "classpath: dropping older duplicate library" + "classpath: dropping superseded library" ); } _ => { - chosen.insert(artifact, (ver_key, &lib.name)); + chosen.insert(slot, (rank, &lib.name)); } } } @@ -425,6 +490,25 @@ pub fn classpaths( .join(constants::CLASSPATH_SEPARATOR)) } +type ClasspathRank = (bool, Vec); + +const ASM_SLOT: &str = "org.ow2.asm:asm"; + +fn is_legacy_asm_bundle(artifact: &str) -> bool { + matches!( + artifact, + "org.ow2.asm:asm-all" | "org.ow2.asm:asm-debug-all" | "asm:asm-all" | "asm:asm-debug-all" + ) +} + +fn classpath_slot(artifact: String) -> String { + if is_legacy_asm_bundle(&artifact) { + return ASM_SLOT.to_string(); + } + + artifact +} + 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()) => { @@ -453,14 +537,21 @@ pub fn get_classpath_library>( Ok(classpaths.join(constants::CLASSPATH_SEPARATOR)) } -pub fn get_library(libraries_path: &Path, library: &str, error_exist: bool) -> McResult { +pub fn get_library(libraries_path: &Path, library: &str, allow_missing: 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()); + if !path.exists() { + if allow_missing { + return Ok(path.display().to_string()); + } + + return Err(McError::MissingLibrary { + library: library.to_string(), + path: path.display().to_string(), + }); } Ok(polyio::canonicalize(&path)?.display().to_string()) @@ -509,8 +600,9 @@ where #[cfg(test)] mod tests { use super::{ - Library, ZGC_MIN_HEAP_MB, classpaths, is_collector_flag, java_arguments, - minecraft_arguments, performance_flags, split_custom_args, + HashMap, Library, Path, SidedDataEntry, ZGC_MIN_HEAP_MB, classpaths, + drop_repeated_arguments, get_library, is_collector_flag, java_arguments, + minecraft_arguments, performance_flags, processor_arguments, split_custom_args, }; use oneclient_common::Resolution; @@ -764,6 +856,135 @@ mod tests { assert_eq!(flags(7, "amd64", 4096), vec!["-Xms512M"]); } + fn install_jars(tag: &str, jars: &[&str]) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(tag); + for jar in jars { + let path = dir.join(jar); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"").unwrap(); + } + let client = dir.join("client.jar"); + std::fs::write(&client, b"").unwrap(); + (dir, client) + } + + #[test] + fn a_legacy_asm_bundle_loses_to_the_split_modules() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "org.ow2.asm:asm-all:4.1" }, + { "name": "org.ow2.asm:asm:9.10.1" }, + { "name": "org.ow2.asm:asm-tree:9.10.1" }, + { "name": "net.minecraft:launchwrapper:1.5" } + ]"#, + ) + .unwrap(); + + let (dir, client) = install_jars( + "oneclient-classpath-asm-all", + &[ + "org/ow2/asm/asm-all/4.1/asm-all-4.1.jar", + "org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "net/minecraft/launchwrapper/1.5/launchwrapper-1.5.jar", + ], + ); + + let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap(); + + assert!( + !cp.contains("asm-all"), + "fabric aborts on two copies of org/objectweb/asm/ClassReader.class: {cp}" + ); + assert!(cp.contains("asm-9.10.1.jar"), "{cp}"); + assert!(cp.contains("asm-tree-9.10.1.jar"), "{cp}"); + assert!(cp.contains("launchwrapper-1.5.jar"), "{cp}"); + } + + #[test] + fn a_legacy_asm_bundle_stays_when_nothing_supersedes_it() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "org.ow2.asm:asm-all:4.1" }, + { "name": "net.minecraft:launchwrapper:1.5" } + ]"#, + ) + .unwrap(); + + let (dir, client) = install_jars( + "oneclient-classpath-asm-only", + &[ + "org/ow2/asm/asm-all/4.1/asm-all-4.1.jar", + "net/minecraft/launchwrapper/1.5/launchwrapper-1.5.jar", + ], + ); + + let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap(); + + assert!(cp.contains("asm-all-4.1.jar"), "{cp}"); + } + + #[test] + fn a_newer_bundle_does_not_outrank_an_older_split_module() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "org.ow2.asm:asm-debug-all:5.2" }, + { "name": "org.ow2.asm:asm:5.0.3" } + ]"#, + ) + .unwrap(); + + let (dir, client) = install_jars( + "oneclient-classpath-asm-debug", + &[ + "org/ow2/asm/asm-debug-all/5.2/asm-debug-all-5.2.jar", + "org/ow2/asm/asm/5.0.3/asm-5.0.3.jar", + ], + ); + + let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap(); + + assert!(!cp.contains("asm-debug-all"), "{cp}"); + assert!(cp.contains("asm-5.0.3.jar"), "{cp}"); + } + + #[test] + fn a_missing_classpath_jar_names_the_library_it_could_not_find() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "net.fabricmc:fabric-loader:0.19.5" }, + { "name": "net.fabricmc:intermediary:1.8.9" } + ]"#, + ) + .unwrap(); + + let dir = std::env::temp_dir().join("oneclient-classpath-missing"); + let present = dir.join("net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"); + std::fs::create_dir_all(present.parent().unwrap()).unwrap(); + std::fs::write(&present, b"").unwrap(); + let client = dir.join("client.jar"); + std::fs::write(&client, b"").unwrap(); + + let err = classpaths(&dir, &libraries, &client, "x86", false) + .expect_err("a jar that never downloaded must not reach the jvm as a silent gap"); + let message = err.to_string(); + + assert!( + message.contains("net.fabricmc:intermediary:1.8.9"), + "{message}" + ); + assert!(message.contains("intermediary-1.8.9.jar"), "{message}"); + } + + #[test] + fn a_tolerated_missing_library_keeps_its_expected_path() { + let dir = std::env::temp_dir().join("oneclient-library-tolerated"); + let path = get_library(&dir, "net.fabricmc:intermediary:1.8.9", true) + .expect("allow_missing hands back the path it would have used"); + + assert!(path.ends_with("intermediary-1.8.9.jar"), "{path}"); + } + #[test] fn a_natives_only_library_is_not_a_classpath_entry() { let libraries: Vec = serde_json::from_str( @@ -853,4 +1074,101 @@ mod tests { assert_eq!(args[1], "player"); assert_eq!(args.last().unwrap(), "--fabric"); } + + #[test] + fn a_loader_repeating_the_legacy_string_is_not_passed_twice() { + let loader_game: Vec = serde_json::from_str( + r#"["--username", "${auth_player_name}", "--gameDir", "${game_directory}", + "--tweakClass", "net.minecraftforge.fml.common.launcher.FMLTweaker"]"#, + ) + .unwrap(); + let dir = std::env::temp_dir(); + + let args = minecraft_arguments( + false, + Some(&loader_game), + Some( + "--username ${auth_player_name} --gameDir ${game_directory} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker", + ), + "token", + "player", + uuid::Uuid::nil(), + "1.11", + "1.11", + &dir, + &dir, + interfrost::api::minecraft::VersionType::Release, + Resolution::default(), + "arm64", + ) + .unwrap(); + + assert_eq!(args.iter().filter(|a| *a == "--gameDir").count(), 1); + assert_eq!(args.iter().filter(|a| *a == "--username").count(), 1); + assert_eq!(args.iter().filter(|a| *a == "--tweakClass").count(), 1); + } + + #[test] + fn an_embedded_placeholder_keeps_its_surrounding_text() { + let mut data = HashMap::new(); + data.insert( + "ROOT".to_string(), + SidedDataEntry { + client: "/meta".to_string(), + server: String::new(), + }, + ); + data.insert( + "BINPATCH".to_string(), + SidedDataEntry { + client: "/meta/libraries/patch.lzma".to_string(), + server: String::new(), + }, + ); + + let args = [ + "--task", + "PROCESS_MINECRAFT_JAR", + "--extract-libraries-to", + "{ROOT}/libraries/", + "--apply-patches", + "{BINPATCH}", + ]; + + let parsed = processor_arguments(Path::new("/libs"), &args, &data).unwrap(); + + assert_eq!(parsed.len(), args.len()); + assert_eq!(parsed[3], "/meta/libraries/"); + assert_eq!(parsed[4], "--apply-patches"); + assert_eq!(parsed[5], "/meta/libraries/patch.lzma"); + } + + #[test] + fn an_unknown_placeholder_is_left_alone_rather_than_dropped() { + let data: HashMap = HashMap::new(); + let args = ["--flag", "{NOPE}", "--after"]; + + let parsed = processor_arguments(Path::new("/libs"), &args, &data).unwrap(); + + assert_eq!(parsed, vec!["--flag", "{NOPE}", "--after"]); + } + + #[test] + fn distinct_tweakers_both_survive() { + let mut args = vec![ + "--tweakClass".to_string(), + "forge.FMLTweaker".to_string(), + "--tweakClass".to_string(), + "optifine.OptiFineTweaker".to_string(), + "--gameDir".to_string(), + "/games".to_string(), + "--gameDir".to_string(), + "/games".to_string(), + ]; + + drop_repeated_arguments(&mut args); + + assert_eq!(args.iter().filter(|a| *a == "--tweakClass").count(), 2); + assert_eq!(args.iter().filter(|a| *a == "--gameDir").count(), 1); + } } diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs index 57cd78f1..598bf54f 100644 --- a/packages/oneclient_mc/src/error.rs +++ b/packages/oneclient_mc/src/error.rs @@ -11,6 +11,11 @@ pub enum McError { #[error("failed to resolve library artifact path for {0}")] LibraryPath(String), + #[error( + "library {library} is missing from the install; the game cannot start without it (expected at {path})" + )] + MissingLibrary { library: String, path: String }, + #[error("invalid game version {0}")] InvalidVersion(String), @@ -29,8 +34,8 @@ pub enum McError { #[error("requested loader version '{requested}' was not found")] RequestedLoaderVersionNotFound { requested: String }, - #[error("no matching version found")] - NoMatchingVersion, + #[error("{loader} has no build for Minecraft {version}")] + NoMatchingVersion { loader: GameLoader, version: String }, #[error( "{version} ships no native libraries for {platform}; this platform is not supported \ diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index b2e8d10f..0c3c89d3 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -12,7 +12,7 @@ use crate::rules::validate_rules; use crate::McCtx; use crate::error::{McError, McResult}; -use crate::manifest::MetadataStore; +use crate::manifest::{MetadataStore, entry_matches_version, manifest_supports_version}; use oneclient_common::domain::GameLoader; use oneclient_common::os_ext::OsExt; use oneclient_common::paths; @@ -1306,12 +1306,12 @@ pub async fn get_loader_versions( } let manifest = metadata.get_modded_or_fetch(ctx, loader).await?; + if !manifest_supports_version(manifest, mc_version) { + return Ok(Vec::new()); + } + 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 { + if entry_matches_version(&entry.id, mc_version) && !entry.loaders.is_empty() { return Ok(entry.loaders.iter().map(|l| l.id.clone()).collect()); } } @@ -1323,15 +1323,14 @@ fn resolve_loader_from_manifest( mc_version: &str, loader_version: Option<&str>, ) -> (bool, Option) { + if !manifest_supports_version(manifest, mc_version) { + return (false, None); + } + 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 - { + if !entry_matches_version(&entry.id, mc_version) { continue; } @@ -1407,9 +1406,14 @@ pub async fn get_loader_version( } } + let no_matching_version = || McError::NoMatchingVersion { + loader, + version: mc_version.to_string(), + }; + if let Some(requested) = loader_version { if !saw_matching { - return Err(McError::NoMatchingVersion); + return Err(no_matching_version()); } return Err(McError::RequestedLoaderVersionNotFound { requested: requested.to_string(), @@ -1419,7 +1423,7 @@ pub async fn get_loader_version( if saw_matching { Err(McError::NoMatchingLoader) } else { - Err(McError::NoMatchingVersion) + Err(no_matching_version()) } } @@ -1458,6 +1462,61 @@ pub async fn get_game_versions( Ok(manifest.versions.clone()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GameVersionKind { + Release, + Snapshot, + Beta, + Alpha, +} + +impl GameVersionKind { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Release => "Release", + Self::Snapshot => "Snapshot", + Self::Beta => "Beta", + Self::Alpha => "Alpha", + } + } + + #[must_use] + pub const fn is_release(self) -> bool { + matches!(self, Self::Release) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameVersionInfo { + pub id: String, + pub kind: GameVersionKind, + pub released: chrono::DateTime, +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_version_ids( + metadata: &mut MetadataStore, + ctx: &McCtx, +) -> McResult> { + use interfrost::api::minecraft::VersionType; + + Ok(get_game_versions(metadata, ctx) + .await? + .into_iter() + .map(|version| GameVersionInfo { + id: version.id, + kind: match version.type_ { + VersionType::Release => GameVersionKind::Release, + VersionType::Snapshot => GameVersionKind::Snapshot, + VersionType::OldBeta => GameVersionKind::Beta, + VersionType::OldAlpha => GameVersionKind::Alpha, + }, + released: version.release_time, + }) + .collect()) +} + #[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn get_loaders_for_version( metadata: &mut MetadataStore, @@ -1467,6 +1526,15 @@ pub async fn get_loaders_for_version( metadata.get_loaders_for_version(ctx, mc_version).await } +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_versions_for_loader( + metadata: &mut MetadataStore, + ctx: &McCtx, + loader: GameLoader, +) -> McResult>> { + metadata.get_versions_for_loader(ctx, loader).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) @@ -1556,6 +1624,51 @@ mod tests { assert!(!saw && resolved.is_none()); } + #[test] + fn a_wildcard_only_supplies_loaders_for_versions_the_manifest_lists() { + let manifest = serde_json::from_str::( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/0.19.5.json", "stable": true }, + { "id": "0.19.4", "url": "https://meta.example/0.19.4.json", "stable": false } + ]}, + { "id": "1.21.1", "stable": true, "loaders": [] }, + { "id": "1.14", "stable": true, "loaders": [] } + ]}"#, + ) + .unwrap(); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.21.1", None); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.5"); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.14", Some("0.19.4")); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.4"); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.8.9", None); + assert!( + !saw && resolved.is_none(), + "fabric publishes no intermediary below 1.14 so the wildcard must not claim 1.8.9" + ); + } + + #[test] + fn a_manifest_that_is_only_a_wildcard_still_covers_every_version() { + let manifest = serde_json::from_str::( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/0.19.5.json", "stable": true } + ]} + ]}"#, + ) + .unwrap(); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.8.9", None); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.5"); + } + #[test] fn a_complete_assets_tree_needs_no_repair() { let dir = scratch("assets-ok"); diff --git a/packages/oneclient_mc/src/manifest.rs b/packages/oneclient_mc/src/manifest.rs index 648c2357..aabce86d 100644 --- a/packages/oneclient_mc/src/manifest.rs +++ b/packages/oneclient_mc/src/manifest.rs @@ -207,15 +207,7 @@ impl MetadataStore { continue; }; - 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 { + if manifest_supports_version(manifest, mc_version) { loaders.push(*loader); } } @@ -225,6 +217,91 @@ impl MetadataStore { Ok(loaders) } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn loader_supports_version( + &mut self, + ctx: &McCtx, + loader: GameLoader, + mc_version: &str, + ) -> McResult { + if !loader.is_modded() { + return Ok(true); + } + + let manifest = self.get_modded_or_fetch(ctx, loader).await?; + Ok(manifest_supports_version(manifest, mc_version)) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_versions_for_loader( + &mut self, + ctx: &McCtx, + loader: GameLoader, + ) -> McResult>> { + if !self.initialized() { + self.initialize(ctx).await?; + } + + if loader == GameLoader::Vanilla { + return Ok(None); + } + + let Ok(manifest) = self.get_modded(loader) else { + return Ok(None); + }; + + Ok(concrete_version_ids(manifest)) + } +} + +const LEGACY_DUMMY_REPLACE_STRING: &str = "${interpulse.gameVersion}"; + +#[must_use] +pub(crate) fn is_version_placeholder(entry_id: &str) -> bool { + entry_id.contains(LEGACY_DUMMY_REPLACE_STRING) + || entry_id.contains(interfrost::api::modded::DUMMY_REPLACE_STRING) +} + +#[must_use] +pub(crate) fn entry_matches_version(entry_id: &str, mc_version: &str) -> bool { + entry_id + .replace(LEGACY_DUMMY_REPLACE_STRING, mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) + == mc_version +} + +#[must_use] +pub(crate) fn concrete_version_ids(manifest: &ModdedManifest) -> Option> { + let mut ids = Vec::with_capacity(manifest.game_versions.len()); + + for entry in &manifest.game_versions { + if is_version_placeholder(&entry.id) { + continue; + } + ids.push(entry.id.clone()); + } + + (!ids.is_empty()).then_some(ids) +} + +#[must_use] +pub(crate) fn manifest_supports_version(manifest: &ModdedManifest, mc_version: &str) -> bool { + let mut saw_concrete = false; + + for entry in &manifest.game_versions { + if is_version_placeholder(&entry.id) { + continue; + } + + saw_concrete = true; + + if entry.id == mc_version { + return true; + } + } + + !saw_concrete } fn keep_fetched(slot: &mut Option, fetched: McResult) { @@ -275,3 +352,53 @@ async fn fetch_manifest(ctx: &McCtx, loader: GameLoader) -> .await .map_err(McError::from) } + +#[cfg(test)] +mod tests { + use super::{ModdedManifest, concrete_version_ids, manifest_supports_version}; + + fn manifest(raw: &str) -> ModdedManifest { + serde_json::from_str(raw).unwrap() + } + + fn fabric_shaped() -> ModdedManifest { + manifest( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/0.19.5.json", "stable": true } + ]}, + { "id": "1.21.1", "stable": true, "loaders": [] }, + { "id": "1.14", "stable": true, "loaders": [] } + ]}"#, + ) + } + + #[test] + fn the_concrete_entries_are_the_supported_set() { + let manifest = fabric_shaped(); + + assert!(manifest_supports_version(&manifest, "1.21.1")); + assert!(manifest_supports_version(&manifest, "1.14")); + assert!(!manifest_supports_version(&manifest, "1.8.9")); + } + + #[test] + fn a_manifest_of_nothing_but_placeholders_covers_everything() { + let manifest = manifest( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [] } + ]}"#, + ); + + assert!(manifest_supports_version(&manifest, "1.8.9")); + assert_eq!(concrete_version_ids(&manifest), None); + } + + #[test] + fn the_version_list_drops_the_placeholder_rather_than_going_unbounded() { + assert_eq!( + concrete_version_ids(&fabric_shaped()), + Some(vec!["1.21.1".to_string(), "1.14".to_string()]) + ); + } +}