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:
parent
bdb9780794
commit
0ab544f265
118 changed files with 9411 additions and 673 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
1
Cargo.lock
generated
|
|
@ -4947,6 +4947,7 @@ dependencies = [
|
|||
"freya-core",
|
||||
"freya-skia-safe",
|
||||
"freya-testing",
|
||||
"futures-util",
|
||||
"image",
|
||||
"mimalloc",
|
||||
"notify",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
packages/oneclient_app/assets/icons/fabric.png
Normal file
BIN
packages/oneclient_app/assets/icons/fabric.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/oneclient_app/assets/icons/forge.png
Normal file
BIN
packages/oneclient_app/assets/icons/forge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
packages/oneclient_app/assets/icons/neo-forge.png
Normal file
BIN
packages/oneclient_app/assets/icons/neo-forge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/oneclient_app/assets/icons/vanilla.png
Normal file
BIN
packages/oneclient_app/assets/icons/vanilla.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
|
|
@ -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()
|
||||
|
|
|
|||
41
packages/oneclient_app/src/components/asset_image.rs
Normal file
41
packages/oneclient_app/src/components/asset_image.rs
Normal 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()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
152
packages/oneclient_app/src/components/instance_card.rs
Normal file
152
packages/oneclient_app/src/components/instance_card.rs
Normal 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()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
53
packages/oneclient_app/src/components/spinner.rs
Normal file
53
packages/oneclient_app/src/components/spinner.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
306
packages/oneclient_app/src/components/version_art_gallery.rs
Normal file
306
packages/oneclient_app/src/components/version_art_gallery.rs
Normal 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;
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
125
packages/oneclient_app/src/view/app/clusters/delete_instance.rs
Normal file
125
packages/oneclient_app/src/view/app/clusters/delete_instance.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
390
packages/oneclient_app/src/view/app/clusters/instance/cards.rs
Normal file
390
packages/oneclient_app/src/view/app/clusters/instance/cards.rs
Normal 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()
|
||||
}
|
||||
107
packages/oneclient_app/src/view/app/clusters/instance/copy.rs
Normal file
107
packages/oneclient_app/src/view/app/clusters/instance/copy.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
171
packages/oneclient_app/src/view/app/clusters/instance/create.rs
Normal file
171
packages/oneclient_app/src/view/app/clusters/instance/create.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
436
packages/oneclient_app/src/view/app/clusters/instance/data.rs
Normal file
436
packages/oneclient_app/src/view/app/clusters/instance/data.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
576
packages/oneclient_app/src/view/app/clusters/instance/details.rs
Normal file
576
packages/oneclient_app/src/view/app/clusters/instance/details.rs
Normal 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, ¤t, MAX_NAME_CHARS)
|
||||
&& no_new_forbidden(&next, ¤t),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.maybe_child(name_problem.map(|problem| {
|
||||
label()
|
||||
.text(problem.message())
|
||||
.font_size(12.)
|
||||
.color(colors::danger())
|
||||
.into_element()
|
||||
}))
|
||||
.into_element(),
|
||||
))
|
||||
.child(field(
|
||||
"Description",
|
||||
TextInput::new(state.description)
|
||||
.placeholder("What is this instance for?")
|
||||
.multiline(true)
|
||||
.width(Size::fill())
|
||||
.height(Size::px(76.))
|
||||
.into_element(),
|
||||
))
|
||||
.child(field("Tags", tag_field(state, &tags, tag_open)))
|
||||
.child(field("Background image", cover_field(state, preview)))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn no_new_forbidden(next: &str, current: &str) -> bool {
|
||||
let forbidden = |text: &str| text.chars().filter(|c| !is_allowed_name_char(*c)).count();
|
||||
forbidden(next) <= forbidden(current)
|
||||
}
|
||||
|
||||
fn within_limit(next: &str, current: &str, limit: usize) -> bool {
|
||||
let count = next.chars().count();
|
||||
count <= limit || count < current.chars().count()
|
||||
}
|
||||
|
||||
fn add_tag(state: DetailsState, tag: &str) {
|
||||
let mut all_tags = state.tags;
|
||||
|
||||
let tag = tag.trim();
|
||||
if validate_tag(tag).is_err() || already_taken(&all_tags.read(), tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut next = all_tags.read().clone();
|
||||
next.push(tag.to_string());
|
||||
all_tags.set(next);
|
||||
}
|
||||
|
||||
fn already_taken(tags: &[String], candidate: &str) -> bool {
|
||||
tags.iter()
|
||||
.any(|tag| tag.eq_ignore_ascii_case(candidate.trim()))
|
||||
}
|
||||
|
||||
fn commit_tag(state: DetailsState) {
|
||||
let mut tag_draft = state.tag_draft;
|
||||
let mut tag_open = state.tag_open;
|
||||
|
||||
let draft = tag_draft.read().clone();
|
||||
add_tag(state, &draft);
|
||||
tag_draft.set(String::new());
|
||||
tag_open.set(false);
|
||||
}
|
||||
|
||||
fn tag_field(state: DetailsState, tags: &[String], open: bool) -> Element {
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(chosen_tags(state, tags, open))
|
||||
.maybe_child(preset_tags(state, tags))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn preset_tags(state: DetailsState, tags: &[String]) -> Option<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()
|
||||
}
|
||||
141
packages/oneclient_app/src/view/app/clusters/instance/edit.rs
Normal file
141
packages/oneclient_app/src/view/app/clusters/instance/edit.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
95
packages/oneclient_app/src/view/app/clusters/instance/mod.rs
Normal file
95
packages/oneclient_app/src/view/app/clusters/instance/mod.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
179
packages/oneclient_app/src/view/app/clusters/instance/model.rs
Normal file
179
packages/oneclient_app/src/view/app/clusters/instance/model.rs
Normal 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,
|
||||
}
|
||||
279
packages/oneclient_app/src/view/app/clusters/instance/rail.rs
Normal file
279
packages/oneclient_app/src/view/app/clusters/instance/rail.rs
Normal 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()
|
||||
}
|
||||
209
packages/oneclient_app/src/view/app/clusters/instance/shell.rs
Normal file
209
packages/oneclient_app/src/view/app/clusters/instance/shell.rs
Normal 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()
|
||||
}
|
||||
399
packages/oneclient_app/src/view/app/clusters/instance/steps.rs
Normal file
399
packages/oneclient_app/src/view/app/clusters/instance/steps.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<'_> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
54
packages/oneclient_cluster/src/identity.rs
Normal file
54
packages/oneclient_cluster/src/identity.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
packages/oneclient_cluster/src/naming.rs
Normal file
110
packages/oneclient_cluster/src/naming.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
110
packages/oneclient_core/src/clusters/java_override.rs
Normal file
110
packages/oneclient_core/src/clusters/java_override.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue