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>
This commit is contained in:
Maciek 2026-09-29 19:08:21 +02:00 • committed by GitHub
parent bdb9780794
commit 0ab544f265
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 9411 additions and 673 deletions

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

1
Cargo.lock generated
View file

@ -4947,6 +4947,7 @@ dependencies = [
"freya-core",
"freya-skia-safe",
"freya-testing",
"futures-util",
"image",
"mimalloc",
"notify",

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -12,7 +12,7 @@ use crate::hooks::{
use crate::routes::Route;
use crate::theme::colors;
use crate::utils::sort_clusters_for_home;
use crate::view::app::launch_button_state;
use crate::view::app::{launch_button_state, launch_syncing};
#[derive(PartialEq)]
pub struct ActiveClusterPanel;
@ -24,7 +24,6 @@ impl Component for ActiveClusterPanel {
let dispatch = use_dispatch();
let game = use_game_snapshot();
let launcher = use_launcher();
let syncing = launcher.fetching || launcher.syncing_bundles;
let clusters = settled_or_loading(&clusters_query).unwrap_or_default();
@ -76,10 +75,15 @@ impl Component for ActiveClusterPanel {
};
let title = format!("{} {}", cluster.mc_version, cluster.mc_loader);
let subtitle = metadata
let subtitle = if cluster.user_created {
cluster.name.clone()
} else {
metadata
.map(|m| m.name)
.unwrap_or_else(|| cluster.name.clone());
.unwrap_or_else(|| cluster.name.clone())
};
let cluster_id = cluster.id;
let syncing = launch_syncing(&launcher, cluster.uses_bundles());
rect()
.vertical()

View file

@ -0,0 +1,41 @@
use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image};
use freya::prelude::*;
use super::local_image::decode;
use crate::AppAssets;
#[derive(PartialEq)]
pub struct AssetImage {
path: &'static str,
size: f32,
}
impl AssetImage {
pub fn new(path: &'static str, size: f32) -> Self {
Self { path, size }
}
}
impl Component for AssetImage {
fn render(&self) -> impl IntoElement {
let path = self.path;
let size = self.size;
let handle: Option<ImageHandle> =
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()
}))
}
}

View file

@ -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<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
cover: Option<PathBuf>,
cover_picked: bool,
max_edge: u32,
preview_edge: Option<u32>,
url: Option<String>,
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<PathBuf>) -> Self {
self.cover = cover;
self
}
#[must_use]
pub fn picked_cover(mut self, cover: Option<PathBuf>) -> 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<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
preview_edge: Option<u32>,
) -> (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<Bytes> {
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())

View file

@ -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<ContentType> {
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>) -> ContentType {
fn infer_content_type(
path: &Path,
route_type: Option<ContentType>,
mod_loader: bool,
) -> Option<ContentType> {
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<PathBuf> = 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<HashMap<PathBuf, ContentType>>,
) -> 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::<Vec<_>>();
field(
@ -535,14 +583,33 @@ fn file_field(
fn file_row(
path: &Path,
content_type: ContentType,
content_type: Option<ContentType>,
mod_loader: bool,
mut overrides: State<HashMap<PathBuf, ContentType>>,
) -> impl IntoElement {
let key = path.to_path_buf();
let options: Vec<String> = IMPORTABLE
let types = importable(mod_loader);
let options: Vec<String> = 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)
}

View file

@ -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<String>,
art: DynamicArt,
selected: bool,
on_press: EventHandler<Event<PressEventData>>,
}
impl InstanceCard {
pub fn new(
cluster: &Cluster,
selected: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> 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()),
),
),
)
}
}

View file

@ -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<Bytes> = settled_or_loading(&query);
let query = use_local_image(self.path.clone(), self.max_edge, self.picked);
let bytes: Option<Bytes> =
settled_or_loading(&query).filter(|bytes: &Bytes| !bytes.is_empty());
let mut cache = use_state(|| None::<(usize, ImageHandle)>);
let holder = bytes.and_then(|bytes| {

View file

@ -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;

View file

@ -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 {},

View file

@ -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)>>,
) -> (EventHandler<(String, String)>, Option<Element>) {
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(),
)

View file

@ -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<Element>, 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()
}

View file

@ -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());
});

View file

@ -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()

View file

@ -213,6 +213,11 @@ impl ScrollArea {
self
}
pub fn append_children(mut self, children: impl IntoIterator<Item = Element>) -> 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

View file

@ -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()
}

View file

@ -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,15 +214,14 @@ 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 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(|| {
let area = area();

View file

@ -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<Option<String>>,
error: State<Option<String>>,
on_pick: EventHandler<String>,
on_close: EventHandler<()>,
}
impl VersionArtGallery {
pub fn new(
pending: State<Option<String>>,
error: State<Option<String>>,
on_pick: impl Into<EventHandler<String>>,
on_close: impl Into<EventHandler<()>>,
) -> 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<String> = 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<String>, 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::<Vec<_>>(),
)
.into_element()
}
#[derive(PartialEq, Clone)]
struct GalleryTile {
url: String,
busy: bool,
dimmed: bool,
on_pick: EventHandler<String>,
}
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<PressEventData>) + '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<String>,
on_close: impl FnMut(Event<PressEventData>) + '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;

View file

@ -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,
}
}

View file

@ -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,

View file

@ -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>
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<Self::Ok, Self::Err> {
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<AvailableBundlesQuery> {
use_query(Query::new(
AvailableBundlesKeys { mc_version, loader },
AvailableBundlesQuery,
))
}
pub fn available_bundles(query: &UseQuery<AvailableBundlesQuery>) -> Option<Arc<[BundleArchive]>> {
super::state::settled_or_loading(query)
}

View file

@ -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);
}
}
}

View file

@ -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,

View file

@ -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::<ListClustersQuery>::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<String> = 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<String>,
description: Option<String>,
tags: Vec<String>,
cover_source: Option<PathBuf>,
bundles: Option<Vec<String>>,
},
UpdateInstance {
cluster_id: ClusterId,
name: String,
description: Option<String>,
tags: Vec<String>,
cover_source: Option<PathBuf>,
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",

View file

@ -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<Self::Ok, Self::Err> {
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))
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()))??,
)
.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<ClusterScreenshotsQu
))
}
pub fn use_local_image(path: PathBuf, max_edge: u32) -> UseQuery<LocalImageQuery> {
pub fn use_local_image(path: PathBuf, max_edge: u32, picked: bool) -> UseQuery<LocalImageQuery> {
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::<ClusterScreenshotsQuery>::invalidate_all().await;
QueriesStorage::<LocalImageQuery>::invalidate_all().await;
QueriesStorage::<LocalImageQuery>::invalidate_matching(LocalImageKeys {
path: PathBuf::new(),
max_edge: 0,
})
.await;
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]

View file

@ -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<String>,
pub fallback: Option<String>,
}
pub fn use_version_art(major: Option<u32>, key: Option<VersionKey>) -> 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::<VersionArtsQuery>::invalidate_matching(VersionArtsKeys).await;
}
pub fn use_version_art_gallery() -> Vec<String> {
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<String> {
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));

View file

@ -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<VersionsMetadataQuery> {
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<VersionArts>;
type Err = LauncherError;
type Keys = VersionArtsKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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<VersionArtsQuery> {
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<VersionsMetadataQuery>) -> Option<Vec<VersionMetadata>> {
@ -60,7 +97,7 @@ pub struct LoaderVersionsKeys {
}
impl QueryCapability for LoaderVersionsQuery {
type Ok = Vec<String>;
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<LoaderVersionsQuery>) -> Vec<String> {
pub fn loader_versions(query: &UseQuery<LoaderVersionsQuery>) -> 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<Self::Ok, Self::Err> {
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<GameVersionsQuery> {
use_query(Query::new(GameVersionsKeys, GameVersionsQuery))
}
pub fn game_versions(query: &UseQuery<GameVersionsQuery>) -> Option<Arc<[GameVersion]>> {
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<GameLoader>;
type Err = LauncherError;
type Keys = VersionLoadersKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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<VersionLoadersQuery> {
use_query(Query::new(
VersionLoadersKeys { mc_version },
VersionLoadersQuery,
))
}
pub fn version_loaders(query: &UseQuery<VersionLoadersQuery>) -> Vec<GameLoader> {
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<Arc<HashSet<String>>>;
impl QueryCapability for LoaderGameVersionsQuery {
type Ok = LoaderVersionSet;
type Err = LauncherError;
type Keys = LoaderGameVersionsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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::<HashSet<String>>())))
})
.await
.map_err(|err| LauncherError::Minecraft(err.to_string()))?
}
}
pub fn use_loader_game_versions(
loader: GameLoader,
with_legacy: bool,
) -> UseQuery<LoaderGameVersionsQuery> {
use_query(Query::new(
LoaderGameVersionsKeys {
loader,
with_legacy,
},
LoaderGameVersionsQuery,
))
}
pub fn loader_game_versions(query: &UseQuery<LoaderGameVersionsQuery>) -> Option<LoaderVersionSet> {
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<String>,
}
impl QueryCapability for JavaMajorsQuery {
type Ok = HashMap<String, u32>;
type Err = LauncherError;
type Keys = JavaMajorsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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<String>) -> UseQuery<JavaMajorsQuery> {
use_query(Query::new(JavaMajorsKeys { versions }, JavaMajorsQuery))
}
pub fn java_majors(query: &UseQuery<JavaMajorsQuery>) -> HashMap<String, u32> {
super::state::settled_or_loading(query).unwrap_or_default()
}

View file

@ -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)

View file

@ -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<i64>| {
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 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),

View file

@ -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<Element>, 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<Element>, cols: usize, mut width: State<f32>, gap: f32) -> Element {
let cols = cols.max(1);

View file

@ -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<VersionKe
sole_version_key(clusters).or_else(|| line.art_key())
}
pub fn group_clusters_by_release(clusters: &[Cluster]) -> ClusterGroups {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GridSelection {
Line(ReleaseLine),
Instance(i64),
}
pub fn split_clusters(clusters: &[Cluster]) -> (ClusterGroups, Vec<Cluster>) {
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<Item = &'a Cluster>,
) -> 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<Cluster>) -> Vec<Cluster> {
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))
}

View file

@ -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<Route>| {
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<String> = types
.iter()
@ -631,7 +661,6 @@ impl Component for ClusterPicker {
.map(|c| format!("{} · {}", c.name, version_name(&metadata, c)))
.collect();
let ids: Vec<i64> = clusters.iter().map(|c| c.id).collect();
let versions: Vec<String> = 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,
});
}

View file

@ -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,

View file

@ -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<Element> = 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<Element> = 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<Element> = 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<String>,
versions: Vec<String>,
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()
}
}

View file

@ -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,

View file

@ -144,6 +144,7 @@ pub(super) fn toolbar_bar(
current_sort: SortMode,
enabled_filter: State<EnabledFilter>,
hidden_filter: State<HiddenFilter>,
uses_bundles: bool,
layout: State<ViewLayout>,
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<EnabledFilter>,
hidden_filter: State<HiddenFilter>,
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<EnabledFilter>,
hidden_filter: State<HiddenFilter>,
uses_bundles: bool,
on_close: EventHandler<()>,
}
@ -540,6 +552,7 @@ impl Component for FilterPopover {
});
}
if self.uses_bundles {
panel = panel.child(section_label("Hidden packages"));
for filter in HiddenFilter::ALL {
let selected = filter == hidden;
@ -553,6 +566,7 @@ impl Component for FilterPopover {
on_press,
});
}
}
rect()
.height(Size::px(0.))
@ -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 });
});

View file

@ -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<EventHandler<()>>) -> 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()
}
}

View file

@ -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<f32>,
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<Element> {
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<String>,
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<String>,
pub corner: Option<Element>,
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()
}

View file

@ -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(),
}
}

View file

@ -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<ClusterAction> {
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<EventHandler<()>>) -> 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::<String>),
filter: use_state(|| 0usize),
query: use_state(String::new),
loader: use_state(|| LoaderChoice::Fabric),
loader_version: use_state(|| None::<String>),
declined: use_state(|| None::<HashSet<String>>),
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(),
})
}
}

View file

@ -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<String>,
pub list: VersionList,
pub loaded: bool,
pub error: Option<String>,
pub filter: usize,
}
pub struct LoaderPicks {
pub chosen: Option<GameLoader>,
pub version: Option<String>,
pub versions: Arc<[String]>,
}
pub struct BundlePicks {
pub archives: Arc<[BundleArchive]>,
pub loaded: bool,
pub names: HashMap<String, String>,
pub declined: HashSet<String>,
}
impl BundlePicks {
pub fn taken(&self) -> Vec<String> {
self.names_of_taken().collect()
}
pub fn taken_count(&self) -> usize {
self.names_of_taken().count()
}
fn names_of_taken(&self) -> impl Iterator<Item = String> + '_ {
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<OneClientVersion>,
oneclient_settled: bool,
vanilla: Option<Arc<[GameVersion]>>,
java: HashMap<String, u32>,
error: Option<String>,
}
impl Catalogue {
fn loader_for(&self, version: &str) -> Option<GameLoader> {
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<OneClientVersion> = 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<LoaderVersionSet>,
error: Option<String>,
}
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<String, String>,
}
fn use_bundle_context(
version: Option<&String>,
loader: Option<GameLoader>,
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<String> {
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<GameLoader>) -> 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<GameLoader>,
) -> 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::<String>()
.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,
},
}
}

View file

@ -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<String>,
pub name_touched: State<bool>,
pub description: State<String>,
pub tags: State<Vec<String>>,
pub tag_draft: State<String>,
pub tag_open: State<bool>,
pub cover: State<Option<PathBuf>>,
pub cover_cleared: State<bool>,
pub gallery_open: State<bool>,
pub gallery_pending: State<Option<String>>,
pub gallery_error: State<Option<String>>,
}
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<String>, 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::<PathBuf>),
cover_cleared: use_state(|| false),
gallery_open: use_state(|| false),
gallery_pending: use_state(|| None::<String>),
gallery_error: use_state(|| None::<String>),
}
}
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<String> {
let text = self.description.read().trim().to_string();
(!text.is_empty()).then_some(text)
}
pub fn preview_cover(&self, existing: Option<PathBuf>) -> 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<PathBuf>,
name_problem: Option<NameProblem>,
) -> 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, &current, MAX_NAME_CHARS)
&& no_new_forbidden(&next, &current),
);
}),
)
.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<Element> {
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<Vec<String>>,
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<String> = 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()
}

View file

@ -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<String>,
pub tags: Vec<String>,
pub cover: Option<PathBuf>,
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<EventHandler<()>>) -> 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(),
})
}
}

View file

@ -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<String, CachedPackageMeta>,
curseforge: &HashMap<String, CachedPackageMeta>,
) -> HashMap<String, String> {
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"
);
}
}

View file

@ -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<GameLoader> {
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<String>,
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<VersionRow> {
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<usize>,
pub choice: State<TypeChoice>,
pub version: State<Option<String>>,
pub filter: State<usize>,
pub query: State<String>,
pub loader: State<LoaderChoice>,
pub loader_version: State<Option<String>>,
pub declined: State<Option<HashSet<String>>>,
pub details: DetailsState,
}

View file

@ -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<String>,
}
pub fn version_art(version: Option<&str>, loader: Option<GameLoader>) -> 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>) -> 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()
}

View file

@ -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()
}

View file

@ -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<Element> = 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, String>) -> String {
let mut listed: Vec<String> = 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<Element> = 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)
}

View file

@ -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;

View file

@ -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::<ReleaseLine>);
let mut show_create = use_state(|| false);
let mut selected = use_state(|| None::<GridSelection>);
let mut selected_version = use_state(|| None::<VersionKey>);
let mut selected_loader = use_state(|| None::<GameLoader>);
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<ReleaseLine> = 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<GridSelection> = 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);
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,7 +261,12 @@ 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<String>,
tags: Vec<String>,
cover: Option<std::path::PathBuf>,
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<SizedEventData>| {
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<f32> {
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<Option<i64>>) -> impl IntoE
.child(Icon::new(IconType::ArrowRight).size(14.))
}
fn page_header() -> impl IntoElement {
fn page_header(mut show_create: State<bool>) -> 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<Vec<ReleaseLine>> {
fn chunk_items(items: &[GridSelection], columns: usize) -> Vec<Vec<GridSelection>> {
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 {

View file

@ -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;

View file

@ -14,7 +14,8 @@ use crate::hooks::{
type MetaMap = HashMap<String, oneclient_content::packages::CachedPackageMeta>;
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<Element> = 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()
}

View file

@ -32,8 +32,9 @@ pub(crate) fn matching_new_cluster_id(
new: &[ClusterBundles],
) -> Option<i64> {
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)
}

View file

@ -50,6 +50,7 @@ pub fn archive(category: &str, enabled: bool, files: Vec<BundleFile>) -> 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,
}
}

View file

@ -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<String>,
pub kind: ClusterKind,
pub user_created: bool,
pub description: Option<String>,
pub tags: Vec<String>,
pub cover_path: Option<String>,
}
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<PathBuf> {
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<PathBuf> {
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<'_> {

View file

@ -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),
}

View file

@ -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<String>,
#[serde(default)]
pub kind: ClusterKind,
#[serde(default)]
pub user_created: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub cover_path: Option<String>,
}
pub async fn read(cluster_dir: &Path) -> Option<InstanceIdentity> {
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");
}
}

View file

@ -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;

View file

@ -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<u8>) -> (Vec<u8>, 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<Option<Cluster>> {
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<Cluster> {
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<Cluster> {
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::<ClusterId>().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<String> {
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<std::path::PathBuf> {
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<u8> {
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"));
}
}

View file

@ -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)
);
}
}

View file

@ -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<String>,
pub mem_max: Option<u32>,
pub kind: ClusterKind,
pub user_created: bool,
pub description: Option<String>,
pub tags: Vec<String>,
}
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<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -44,6 +73,9 @@ pub struct ClusterUpdate {
pub setting_profile_name: Patch<String>,
pub mc_loader_version: Patch<String>,
pub linked_modpack_hash: Patch<String>,
pub description: Patch<String>,
pub tags: Option<Vec<String>>,
pub cover_path: Patch<String>,
}
impl ClusterUpdate {

View file

@ -90,16 +90,18 @@ pub fn list_cluster_screenshots(cluster: &Cluster) -> ClusterResult<Vec<Screensh
#[tracing::instrument(level = "debug")]
pub fn load_screenshot(path: &Path, max_edge: Option<u32>) -> ClusterResult<Bytes> {
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<Bytes> {
pub fn load_picked_image(path: &Path, max_edge: Option<u32>) -> ClusterResult<Bytes> {
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<Bytes> {
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());
}
}

View file

@ -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)

View file

@ -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<T> {
#[default]

View file

@ -114,6 +114,10 @@ pub fn clusters_dir() -> PathsResult<PathBuf> {
Ok(data_dir()?.join("clusters"))
}
pub fn cluster_trash_dir() -> PathsResult<PathBuf> {
Ok(data_dir()?.join(".trash"))
}
pub fn shared_minecraft_dir() -> PathsResult<PathBuf> {
Ok(data_dir()?.join(".minecraft"))
}
@ -121,6 +125,8 @@ pub fn shared_minecraft_dir() -> PathsResult<PathBuf> {
/// 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<PathBuf> {
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<PathBuf> {
if cluster_uses_dedicated_dir(folder_name) {
pub fn cluster_game_dir(folder_name: &str, isolated: bool) -> PathsResult<PathBuf> {
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<PathBuf> {
Ok(data_dir()?.join("metadata").join("versions"))
}
pub fn metadata_dir() -> PathsResult<PathBuf> {
Ok(data_dir()?.join("metadata"))
}
pub fn libraries_dir() -> PathsResult<PathBuf> {
Ok(data_dir()?.join("metadata").join("libraries"))
Ok(metadata_dir()?.join("libraries"))
}
pub fn natives_dir() -> PathsResult<PathBuf> {

View file

@ -174,6 +174,9 @@ pub async fn install_bundle(
ctx: &ContentCtx,
) -> ContentResult<Vec<String>> {
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<u64> {
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<OverrideType>,
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<std::collections::HashSet<String>> {
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<Option<ClusterRow>> {
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<Vec<i64>> {
if !is_shared_artifact(hash, ctx).await? {
return Ok(vec![cluster_id]);
}
let mut ids: Vec<i64> = 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<bool> {
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<Option<usize>> {
if !is_shared_artifact(hash, ctx).await? {
) -> ContentResult<Option<Vec<i64>>> {
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<Option<Vec<i64>>> {
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<Option<usize>> {
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);

View file

@ -33,6 +33,8 @@ struct PolyMrpackManifest {
pub version_id: String,
pub name: String,
#[serde(default)]
pub java_version_override: Option<u32>,
#[serde(default)]
pub dependencies: HashMap<String, String>,
#[serde(default)]
pub files: Vec<PolyMrpackFile>,
@ -116,6 +118,7 @@ fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult<BundleManifest> {
loader,
loader_version,
enabled: manifest.enabled,
java_version_override: manifest.java_version_override,
files,
})
}

View file

@ -19,6 +19,8 @@ pub struct BundleManifest {
pub loader: GameLoader,
pub loader_version: String,
pub enabled: bool,
#[serde(default)]
pub java_version_override: Option<u32>,
pub files: Vec<BundleFile>,
}

View file

@ -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<bool> {
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<BundleUpdateCheckResult> {
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<Instant>,
) -> ContentResult<ApplyBundleUpdatesResult> {
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<Vec<BundleWithUpdateStatus>> {
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?;

View file

@ -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,
}
}

View file

@ -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<GameLoader> {
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<Vec<u8>> {
let entry = entry.to_string();

View file

@ -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,

View file

@ -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,
}
}

View file

@ -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<ArtifactRow> {
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,
}
}

View file

@ -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?;

View file

@ -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<LauncherState>,
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(())
}

View file

@ -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<usize> {
@ -16,9 +21,20 @@ pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult<us
return Ok(0);
}
let _guard = state.clusters.provisioning_guard().await;
let cyclic = cyclic_migration_ids(&rules);
let mut migrated = 0;
for rule in rules {
if cyclic.contains(&rule.id) {
tracing::warn!(
migration_id = %rule.id,
"migration rule is part of a cycle; skipping"
);
continue;
}
match apply_rule(state, &rule).await {
Ok(true) => migrated += 1,
Ok(false) => {}
@ -41,12 +57,22 @@ pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult<us
async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherResult<bool> {
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<bool> {
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<bool> {
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<usize> {
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<String>,
) -> LauncherResult<Option<String>> {
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<String> {
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<String> {
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<String>
#[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!(

View file

@ -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,
};

View file

@ -50,6 +50,7 @@ pub async fn prepare_cluster(
shared_progress: Option<&GroupedProgressSession>,
) -> LauncherResult<Cluster> {
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(),

View file

@ -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<Clu
let target_order = version_order(&target.mc_version);
let mut sources: Vec<Cluster> = 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();

View file

@ -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,7 +145,8 @@ async fn start(
}
}
};
if let Err(err) = oneclient_content::bundles::install_cluster_bundles(
if cluster.uses_bundles()
&& let Err(err) = oneclient_content::bundles::install_cluster_bundles(
cluster_id,
state.bundles.as_ref(),
Some(&progress),

View file

@ -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,

View file

@ -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();
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;
}
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,6 +157,7 @@ pub async fn materialize_content(
.await;
}
let failed_imports =
import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await;
if let Err(err) =
@ -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<Desired>, Vec<Desired>) =
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<String> = 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<String> = mods.iter().map(Desired::relative_path).collect();
let rest_paths: HashSet<String> = rest.iter().map(Desired::relative_path).collect();
let mod_paths: HashSet<String> = mods
.iter()
.map(Desired::relative_path)
.chain(held_mods.iter().map(|entry| entry.path.clone()))
.collect();
let rest_paths: HashSet<String> = rest
.iter()
.map(Desired::relative_path)
.chain(held_rest.iter().map(|entry| entry.path.clone()))
.collect();
let pack_paths: HashSet<String> = 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,6 +318,7 @@ pub async fn materialize_content(
.await;
}
if !isolated {
let pack_entries = link_desired(&global_root, &packs).await;
manifest::save(
&global_root,
@ -258,8 +326,10 @@ pub async fn materialize_content(
&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<String>,
unrestored: &mut Vec<Unrestored>,
) -> LauncherResult<Vec<Desired>> {
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<String>,
unrestored: &mut Vec<Unrestored>,
wanted: &[ContentType],
) -> LauncherResult<Vec<Desired>> {
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<i64>,
) -> Option<PathBuf> {
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<i64>) -> 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<ManifestEntry>, Vec<ManifestEntry>) {
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<ManifestEntry> {
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<String>,
) {
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<String> {
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,20 +1139,36 @@ 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(
@ -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<String>,
manifest: Option<&MaterializedManifest>,
discard_originals: bool,
) {
) -> HashSet<String> {
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!(
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}");

View file

@ -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<u32> = 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<u32> = 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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<Bytes> {
fn decode_limited(bytes: &[u8]) -> Option<image::DynamicImage> {
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<Bytes> {
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<Bytes> {
if img.width().max(img.height()) <= max_edge {
return None;
}
@ -190,8 +321,38 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option<Bytes> {
Some(Bytes::from(out))
}
fn downscale(bytes: &[u8], max_edge: u32) -> Option<Bytes> {
encode_downscaled(&decode_limited(bytes)?, max_edge)
}
fn downscale_each(bytes: &[u8], edges: &[u32]) -> Vec<Option<Bytes>> {
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<u32>) -> Vec<Bytes> {
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<Bytes> {
fn ensure_fetchable(net: &RequestClient, url: &str) -> LauncherResult<reqwest::Url> {
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<Bytes> {
return Err(refused(url, "host is not public"));
}
Ok(parsed)
}
async fn download(net: &RequestClient, url: &str) -> LauncherResult<Bytes> {
let parsed = ensure_fetchable(net, url)?;
let request = reqwest::Request::new(Method::GET, parsed);
let mut res = net.send(request).await?;

View file

@ -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,
};

View file

@ -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<String>,
pub mc_loader: GameLoader,
pub target_mc_loader: Option<GameLoader>,
/// Empty when the source has no category concept
pub categories: Vec<String>,
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() {

View file

@ -99,6 +99,7 @@ async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult<MigrationDe
mc_version,
target_mc_version: None,
mc_loader,
target_mc_loader: None,
categories,
has_game_dir,
});

Some files were not shown because too many files have changed in this diff Show more