mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
feat: app onboarding and library (#6923)
* refactor: remove unused legacy onboarded flag * sqlx prepare * feat: add onboarding checklist app db and add vue provider * feat: add welcome page * feat: add onboarding checklist component * feat: move checklist visibility source of truth in app db * feat: split up import modpack button * feat-small: style tweak * feat: remove discover section from homepage and add library * refactor: move welcome screen to component * refactor: move grid display into /library * refactor: rename existing components * refactor: split up the current library page files * feat: add create group modal * feat: add instance group definitions * feat: update accordian for instance groups * feat: add delete group * feat: implement rename instance groups * refactor: move instance group api out * feat: add context menu for groups * feat: style instance items in library * feat: add selected instance item state * feat: implement action bar for selection * feat: drag and drop to move between groups * feat: show ungrouped with header * feat: improve edit group name * small fix * small style fixes * feat: improve selection behaviour * fix: dont toggle group when context menu open * feat: drag and drop multiple selected * small * feat: add remove from group in action bar * feat: add delay on icon hiding in inline input * feat: improve icon hiding delay * fix: bad routes.js * feat: implement sort and filter options * refactor: dont prefix filenames * feat: change open/collapse group target * feat: improve ungrouped behaviour * feat: add shift to select multiple instances * fix: icon disappear transition * feat: add esc while dragging to cancel * feat: update sorting and moved label * feat: update context menu * fix: multiple context menus can be opened * refactor: instance groups to be identified by ID instead of name, names become display data * feat: creating new group always adds instance * feat: create group button, fix instance selection, and increase group name max length * feat: strokes * fix: show getting started checklist in all sidebar * feat: set size on microsoft login screen * fix: use ButtonStyled in Chips component * feat: creating groups with selected instances and fix some moving selection and duplication bugs * pnpm prepr * feat: add tooltip for instance already in group * feat: create instance with content in creation flow modal - search modpack becomes search projects - creation flow modal context receives prepareProjectInstall and createProjectInstall callbacks, from content-install.ts (shared with ContentInstallModal) * feat: update instance card styles * refactor: separate out instance card content * update styles * refactor: instance card * fix: width of instance * feat: update empty state * feat: update sort and grouping dropdowns * feat: better context menu options * feat: add group action buttons * feat: add/remove favourites group * remove manual sort * feat: improve the "New group" jank * feat: add group by instance type and change group sorting * feat: welcome screen styles match figma * feat: not signed in skins demo * feat: new hosting empty state * feat: new instance card style * feat: add tooltip and spice up animation more * feat: improve the transitions for groups after dropping many instances * feat: set instance group membership for bulk moving * feat: remove fade out * fix: allow dragging from buttons inside instance card * fix: library page load in behaviour * small fix * feat: add group ordering and reordering * feat: add drag to reorder groups * fix: switching page not clearing selected instances * style fixes * style fix * fix spacing * feat: jump in changes * feat: add styled newly created instances * feat: improve jump in cards styles * fix: welcome screen style * fix: empty state styles * feat: max 5 jump in items * feat: max new instances to 3 * fix: search icon * feat: clear selection after move * feat: skins list use check icon styles * feat: list will be deleted instances and default sort last played * fix: stroke * remove: no more group settings in instance general settings * fix: resolving modpack server install * feat: icon editor * feat: add three hue shifted variants * fix: surprise me doesnt pick symbol * feat: add customize icon to create instance modal * feat: skins loading state style * fix: welcome logo * fix: copy * fix: left over usage of ButtonStyled from main's refactor * feat: some leaked merge stuff * feat: server empty state styles * remove: instance helper text * remove: auto link in set up stage * feat: auto focus on search when modal open * fix: delete instance modal style * remove: modpack meta repeating * fix: icon * fix: font weight * fix: dropdown filter preview heights * pnpm prepr * fix: sign into modrinth flashing change in position * feat: add avatar transparent corner gets padding * feat: drag and hold over group opens group * feat: add random icon for custom instance * feat: update loading icon * polish: empty state for search * polish: jump in cards * fix: avatar could skip checking for image if tauri loads it first * feat: add icon customize and randomizer in content install modal for add to new instances * fix: avatar cannot read image pixels due to cors * refactor: popup notifications toast type for instance sharing notifications * feat: apply random icons modal and add popup notification for it * polish: style * fix: set_webview_visible * QA * qa * feat: add inner white 15 opacity stroke around avatar * add to catalog * pnpm prepr * fix: app event import * feat: add new icons and backgrounds for icon editor * feat: recents list generate from randomized icons and save icon in editor + icon editor modal fixes * qa * refactor: recipe -> config * fix: event bus * polish * feat: add safe guard against randomizing empty icons * feat: navigate to instance after creation * fix: icon file names * i18n on welcome screen * i18n pass on PR * fix: inconsistent max group name length * refactor: use sqlx macro * feat: add instance icon background selection drag * feat: update catalog order * fix: remove gradient for welcome screen * remove: frog * fix: min width * feat: update corner size * fix: context menu styles * prepr * fix: instance name trimming * reorder * fix: instance card loading state stayed until hovering * feat: add icon randomizer blacklist * blog * prepr --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET has_created_instance = TRUE\n WHERE id = 0 AND has_created_instance = FALSE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "04b62cdfd635acb3d700211a104706fce514b070987a4ff2047afa18ee60d237"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tDELETE FROM instance_groups\n\t\tWHERE instance_id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "073e4c6fbf434f94c8015076b50c099d57f217b4c1f4a63761b5e07129eb1f84"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT group_name\n\t\tFROM instance_groups\n\t\tWHERE instance_id = ?\n\t\tORDER BY group_name\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "group_name",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0812e019f53d81d527ea15cbf7a6416ad15182131cfb6de3f2dab153c74a553c"
|
||||
}
|
||||
+67
-55
File diff suppressed because one or more lines are too long
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n onboarded = $13,\n\n extra_launch_args = jsonb($14),\n custom_env_vars = jsonb($15),\n mc_memory_max = $16,\n mc_force_fullscreen = $17,\n mc_game_resolution_x = $18,\n mc_game_resolution_y = $19,\n hide_on_process_start = $20,\n\n hook_pre_launch = $21,\n hook_wrapper = $22,\n hook_post_exit = $23,\n\n custom_dir = $24,\n prev_custom_dir = $25,\n migrated = $26,\n\n toggle_sidebar = $27,\n feature_flags = $28,\n hide_nametag_skins_page = $29,\n\n skipped_update = $30,\n pending_update_toast_for_version = $31,\n auto_download_updates = $32,\n\n version = $33\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 33
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n DELETE FROM instance_groups\n WHERE id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1a1d2e6b837d5f2b43069f4de9f5f60241f1526302015b8098ec8fd68c9743d3"
|
||||
}
|
||||
+67
-55
File diff suppressed because one or more lines are too long
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tUPDATE instance_groups\n\t\tSET name = ?\n\t\tWHERE id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "29d7a7578c6bf4a446eff7f68d1ebe2080733c323ec58f86969d9d1fdb4ba076"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE instances SET modified = unixepoch() WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4b8b302590a9ab956823789dfe9b09b1fe8be840b4a14ad2636d7bb558cc62a1"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET show_checklist = FALSE\n WHERE id = 0\n AND show_checklist = TRUE\n AND has_created_instance = TRUE\n AND has_logged_into_minecraft = TRUE\n AND has_logged_into_modrinth = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5b33d55c05ca5157e50e43ca611f3472d6e2f5349dabef91a203d8a4d938e4aa"
|
||||
}
|
||||
+67
-55
File diff suppressed because one or more lines are too long
+21
-27
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -68,104 +68,99 @@
|
||||
"ordinal": 12,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "onboarded",
|
||||
"ordinal": 13,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "extra_launch_args",
|
||||
"ordinal": 14,
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "custom_env_vars",
|
||||
"ordinal": 15,
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mc_memory_max",
|
||||
"ordinal": 16,
|
||||
"ordinal": 15,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "mc_force_fullscreen",
|
||||
"ordinal": 17,
|
||||
"ordinal": 16,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "mc_game_resolution_x",
|
||||
"ordinal": 18,
|
||||
"ordinal": 17,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "mc_game_resolution_y",
|
||||
"ordinal": 19,
|
||||
"ordinal": 18,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "hide_on_process_start",
|
||||
"ordinal": 20,
|
||||
"ordinal": 19,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "hook_pre_launch",
|
||||
"ordinal": 21,
|
||||
"ordinal": 20,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hook_wrapper",
|
||||
"ordinal": 22,
|
||||
"ordinal": 21,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hook_post_exit",
|
||||
"ordinal": 23,
|
||||
"ordinal": 22,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "custom_dir",
|
||||
"ordinal": 24,
|
||||
"ordinal": 23,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "prev_custom_dir",
|
||||
"ordinal": 25,
|
||||
"ordinal": 24,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "migrated",
|
||||
"ordinal": 26,
|
||||
"ordinal": 25,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "feature_flags",
|
||||
"ordinal": 27,
|
||||
"ordinal": 26,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "toggle_sidebar",
|
||||
"ordinal": 28,
|
||||
"ordinal": 27,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "skipped_update",
|
||||
"ordinal": 29,
|
||||
"ordinal": 28,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pending_update_toast_for_version",
|
||||
"ordinal": 30,
|
||||
"ordinal": 29,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "auto_download_updates",
|
||||
"ordinal": 31,
|
||||
"ordinal": 30,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"ordinal": 32,
|
||||
"ordinal": 31,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
@@ -186,7 +181,6 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
@@ -208,5 +202,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8e62fba05f331f91822ec204695ecb40567541c547181a4ef847318845cf3110"
|
||||
"hash": "6f8b76b839db48539a70664af549c12be71df20b8cf520019413f6ea751cd594"
|
||||
}
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT groups.id\n\t\tFROM instance_group_memberships memberships\n\t\tINNER JOIN instance_groups groups\n\t\t\tON groups.id = memberships.group_id\n\t\tWHERE memberships.instance_id = ?\n\t\tORDER BY groups.name\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "73a5b7a4b61a75705f6ed3ea370ec66677b42cdd09b8ae87cee0ef89d3875681"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT OR IGNORE INTO instance_groups (instance_id, group_name)\n VALUES (?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7790039c6bfeba24c9a0f515b12d06e8ca910f05a73f2b31fe80eef9033873d7"
|
||||
}
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM instance_groups WHERE id = ?) AS \"exists!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "exists!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7b17b6dc7e42a064a0f6e501d8b45eeeca9734a94f958f43a1605a38235b5fe0"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tDELETE FROM instance_group_memberships\n\t\tWHERE instance_id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "85ae4979fb862c60859e2a6694d8288cd69f7defc9f9990b8c18ecf2d4f76ef3"
|
||||
}
|
||||
Generated
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n has_created_instance,\n has_logged_into_minecraft,\n has_logged_into_modrinth,\n show_checklist\n FROM onboarding_checklist\n WHERE id = 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "has_created_instance",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "has_logged_into_minecraft",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "has_logged_into_modrinth",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "show_checklist",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "baa15329484896237f57ee6b585c4c7b4a5c56ddb12b7fbe2d8d63936853312e"
|
||||
}
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT instance_id\n\t\tFROM instance_group_memberships\n\t\tWHERE group_id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "instance_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c264c05f91394756ef724b6cbb0c4ab5534a7f105cf386b53ac44c5527d2e2e7"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET has_logged_into_minecraft = TRUE\n WHERE id = 0 AND has_logged_into_minecraft = FALSE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c715a78bfe15b58f7a14c9eaf3bb59c6d9d83486ccadae7351b1516d3387834f"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n version = $32\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 32
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dfdc99cb5ec60bb08c566920628deec7e741eca9490811ebad6594abe8a6126d"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\t\tINSERT OR IGNORE INTO instance_groups (instance_id, group_name)\n\t\t\tVALUES (?, ?)\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e26d2ce1fdaaef4ef4648b57faf0ea2940ba9ada7b39f3c2fa973a36ee4cba2a"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET has_logged_into_modrinth = TRUE\n WHERE id = 0 AND has_logged_into_modrinth = FALSE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fa1954f9c9c998fe4c66cbc82bab087901087b5580012e68f96c6836b5a146ca"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings DROP COLUMN onboarded;
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE onboarding_checklist (
|
||||
id INTEGER NOT NULL CHECK (id = 0),
|
||||
has_created_instance INTEGER NOT NULL DEFAULT FALSE CHECK (has_created_instance IN (FALSE, TRUE)),
|
||||
has_logged_into_minecraft INTEGER NOT NULL DEFAULT FALSE CHECK (
|
||||
has_logged_into_minecraft IN (FALSE, TRUE)
|
||||
),
|
||||
has_logged_into_modrinth INTEGER NOT NULL DEFAULT FALSE CHECK (
|
||||
has_logged_into_modrinth IN (FALSE, TRUE)
|
||||
),
|
||||
show_checklist INTEGER NOT NULL DEFAULT TRUE CHECK (show_checklist IN (FALSE, TRUE)),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
INSERT INTO onboarding_checklist
|
||||
(
|
||||
id,
|
||||
has_created_instance,
|
||||
has_logged_into_minecraft,
|
||||
has_logged_into_modrinth,
|
||||
show_checklist
|
||||
)
|
||||
SELECT
|
||||
0,
|
||||
EXISTS (SELECT 1 FROM instances),
|
||||
EXISTS (SELECT 1 FROM minecraft_users),
|
||||
EXISTS (SELECT 1 FROM modrinth_users),
|
||||
NOT (
|
||||
EXISTS (SELECT 1 FROM instances)
|
||||
AND EXISTS (SELECT 1 FROM minecraft_users)
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
ALTER TABLE instance_groups RENAME TO legacy_instance_group_memberships;
|
||||
|
||||
CREATE TABLE instance_groups (
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE instance_group_memberships (
|
||||
instance_id TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (instance_id, group_id),
|
||||
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES instance_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO instance_groups (id, name, display_order)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
group_name,
|
||||
ROW_NUMBER() OVER (ORDER BY group_name) - 1
|
||||
FROM (
|
||||
SELECT DISTINCT group_name
|
||||
FROM legacy_instance_group_memberships
|
||||
);
|
||||
|
||||
INSERT INTO instance_group_memberships (instance_id, group_id)
|
||||
SELECT
|
||||
legacy.instance_id,
|
||||
groups.id
|
||||
FROM legacy_instance_group_memberships legacy
|
||||
INNER JOIN instance_groups groups ON groups.name = legacy.group_name;
|
||||
|
||||
UPDATE install_jobs
|
||||
SET state = json_remove(
|
||||
json_set(
|
||||
state,
|
||||
'$.rollback.instance.group_ids',
|
||||
json(COALESCE((
|
||||
SELECT json_group_array(groups.id)
|
||||
FROM json_each(state, '$.rollback.instance.groups') legacy_groups
|
||||
INNER JOIN instance_groups groups
|
||||
ON groups.name = legacy_groups.value
|
||||
), '[]'))
|
||||
),
|
||||
'$.rollback.instance.groups'
|
||||
)
|
||||
WHERE json_type(state, '$.rollback.instance.groups') = 'array';
|
||||
|
||||
DROP TABLE legacy_instance_group_memberships;
|
||||
|
||||
CREATE INDEX instance_groups_name ON instance_groups(name);
|
||||
CREATE INDEX instance_groups_display_order ON instance_groups(display_order);
|
||||
CREATE INDEX instance_group_memberships_group_id
|
||||
ON instance_group_memberships(group_id);
|
||||
|
||||
INSERT OR IGNORE INTO instance_groups (id, name, display_order)
|
||||
VALUES ('group:favorites', 'Favorites', -1);
|
||||
|
||||
UPDATE instance_groups
|
||||
SET name = 'Favorites', display_order = -1
|
||||
WHERE id = 'group:favorites';
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE instance_icon_configs (
|
||||
instance_id TEXT NOT NULL,
|
||||
background TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (instance_id),
|
||||
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE recent_instance_icon_configs (
|
||||
background TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
used_at INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (background, symbol)
|
||||
);
|
||||
|
||||
CREATE INDEX recent_instance_icon_configs_used_at
|
||||
ON recent_instance_icon_configs(used_at DESC);
|
||||
@@ -4,6 +4,7 @@ mod content;
|
||||
mod content_set_diff;
|
||||
mod export_mrpack;
|
||||
mod get;
|
||||
mod groups;
|
||||
mod icon;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
@@ -23,7 +24,15 @@ pub use self::export_mrpack::{
|
||||
get_pack_export_candidates, get_pack_export_candidates_for_parent,
|
||||
};
|
||||
pub use self::get::{get, get_many, list};
|
||||
pub use self::icon::edit_icon;
|
||||
pub use self::groups::{
|
||||
FAVORITES_GROUP_ID, InstanceGroup, InstanceGroupMembershipUpdate,
|
||||
create_group, delete_group, list_groups, rename_group,
|
||||
set_group_memberships, set_group_order,
|
||||
};
|
||||
pub use self::icon::{
|
||||
cache_generated_icon, edit_generated_icon, edit_generated_icon_if_empty,
|
||||
edit_icon, get_recent_icon_configs,
|
||||
};
|
||||
pub(crate) use self::icon::{
|
||||
cache_icon, cache_icon_from_path, migrate_legacy_icons,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
use crate::event::emit::emit_instance_groups_changed;
|
||||
use crate::state::State;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH: usize = 256;
|
||||
pub const FAVORITES_GROUP_ID: &str = "group:favorites";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroupMembershipUpdate {
|
||||
pub instance_id: String,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn validate_group_name(name: &str) -> crate::Result<&str> {
|
||||
let name = name.trim();
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be empty".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.eq_ignore_ascii_case("none") {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be None".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn normalize_membership_updates(
|
||||
mut updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<Vec<InstanceGroupMembershipUpdate>> {
|
||||
let mut instance_ids = HashSet::new();
|
||||
for update in &mut updates {
|
||||
if !instance_ids.insert(update.instance_id.clone()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Duplicate instance {} in group membership update",
|
||||
update.instance_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut group_ids = HashSet::new();
|
||||
update
|
||||
.group_ids
|
||||
.retain(|group_id| group_ids.insert(group_id.clone()));
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
pub async fn list_groups() -> crate::Result<Vec<InstanceGroup>> {
|
||||
let state = State::get().await?;
|
||||
Ok(instance_rows::list_instance_groups(&state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, name)| InstanceGroup { id, name })
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn create_group(name: String) -> crate::Result<InstanceGroup> {
|
||||
let name = validate_group_name(&name)?;
|
||||
let state = State::get().await?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
instance_rows::create_instance_group(&id, name, &state.pool).await?;
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_group_order(group_ids: Vec<String>) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
instance_rows::set_instance_group_order(&group_ids, &state.pool).await
|
||||
}
|
||||
|
||||
pub async fn set_group_memberships(
|
||||
updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<()> {
|
||||
if updates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let updates = normalize_membership_updates(updates)?;
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let unique_group_ids = updates
|
||||
.iter()
|
||||
.flat_map(|update| update.group_ids.iter())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for group_id in unique_group_ids {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM instance_groups WHERE id = ?) AS "exists!: bool""#,
|
||||
group_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {group_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
for update in &updates {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE instances SET modified = unixepoch() WHERE id = ?",
|
||||
update.instance_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance {}",
|
||||
update.instance_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
instance_rows::replace_instance_groups(
|
||||
&update.instance_id,
|
||||
&update.group_ids,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let instance_ids = updates
|
||||
.into_iter()
|
||||
.map(|update| update.instance_id)
|
||||
.collect::<Vec<_>>();
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename_group(
|
||||
id: String,
|
||||
new_name: String,
|
||||
) -> crate::Result<InstanceGroup> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be renamed".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_name = validate_group_name(&new_name)?;
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
let instance_ids = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT instance_id
|
||||
FROM instance_group_memberships
|
||||
WHERE group_id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
UPDATE instance_groups
|
||||
SET name = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
new_name,
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: new_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_group(id: String) -> crate::Result<()> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be deleted".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let instance_ids = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT instance_id
|
||||
FROM instance_group_memberships
|
||||
WHERE group_id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_groups
|
||||
WHERE id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FAVORITES_GROUP_ID, InstanceGroupMembershipUpdate,
|
||||
MAX_GROUP_NAME_LENGTH, normalize_membership_updates,
|
||||
validate_group_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_trims_valid_names() {
|
||||
assert_eq!(validate_group_name(" My group ").unwrap(), "My group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_empty_names() {
|
||||
assert!(validate_group_name(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_reserved_name() {
|
||||
assert!(validate_group_name("NoNe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_allows_favorites() {
|
||||
assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_long_names() {
|
||||
assert!(
|
||||
validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_group_id_is_stable() {
|
||||
assert_eq!(FAVORITES_GROUP_ID, "group:favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_reject_duplicate_instances() {
|
||||
let updates = vec![
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["first".to_string()],
|
||||
},
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["second".to_string()],
|
||||
},
|
||||
];
|
||||
|
||||
assert!(normalize_membership_updates(updates).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_deduplicate_groups() {
|
||||
let updates =
|
||||
normalize_membership_updates(vec![InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec![
|
||||
"first".to_string(),
|
||||
"second".to_string(),
|
||||
"first".to_string(),
|
||||
],
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
updates[0].group_ids,
|
||||
vec!["first".to_string(), "second".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{EditInstance, State};
|
||||
use crate::state::{
|
||||
EditInstance, InstanceIconBackground, InstanceIconConfig, State,
|
||||
};
|
||||
use crate::util::fetch::{sha1_async, write};
|
||||
use crate::util::io;
|
||||
use bytes::Bytes;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ImageFormat, ImageReader, Rgba, RgbaImage};
|
||||
use std::fs::File as StdFile;
|
||||
use std::io::{BufRead, BufReader, Cursor, Seek};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -13,6 +17,10 @@ const INSTANCE_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const INSTANCE_ICON_MAX_DIMENSION: u32 = 512;
|
||||
const INSTANCE_ICON_MAX_SOURCE_DIMENSION: u32 = 8_192;
|
||||
const INSTANCE_ICON_MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const GENERATED_ICON_SIZE: u32 = 256;
|
||||
const MAX_ICON_CONFIG_ID_LENGTH: usize = 64;
|
||||
const MAX_SYMBOL_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_SYMBOL_DIMENSION: u32 = 4096;
|
||||
|
||||
enum LegacyIconAction {
|
||||
Keep,
|
||||
@@ -20,6 +28,15 @@ enum LegacyIconAction {
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum ValidatedIconBackground {
|
||||
Color([u8; 3]),
|
||||
LinearTopDownGradient {
|
||||
top_color: [u8; 3],
|
||||
bottom_color: [u8; 3],
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn edit_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<&Path>,
|
||||
@@ -36,7 +53,115 @@ pub async fn edit_icon(
|
||||
None
|
||||
};
|
||||
|
||||
apply_instance_icon(instance_id, icon_path, &state).await
|
||||
apply_instance_icon(instance_id, icon_path, None, &state).await
|
||||
}
|
||||
|
||||
pub async fn edit_generated_icon(
|
||||
instance_id: &str,
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
|
||||
apply_instance_icon(
|
||||
instance_id,
|
||||
Some(icon_path.clone()),
|
||||
Some(config),
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
pub async fn edit_generated_icon_if_empty(
|
||||
instance_id: &str,
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
) -> crate::Result<Option<String>> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
let applied = instance_rows::update_instance_icon_if_empty(
|
||||
instance_id,
|
||||
&icon_path,
|
||||
&config,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
if !applied {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Err(error) = super::shared::sync_shared_instance_icon(
|
||||
instance_id,
|
||||
Some(&icon_path),
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
error = %error,
|
||||
"Failed to sync shared instance icon"
|
||||
);
|
||||
}
|
||||
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(Some(icon_path))
|
||||
}
|
||||
|
||||
pub async fn cache_generated_icon(
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
add_to_recents: bool,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
|
||||
if add_to_recents {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::update_recent_instance_icon_config(&config, &mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
pub async fn get_recent_icon_configs() -> crate::Result<Vec<InstanceIconConfig>>
|
||||
{
|
||||
let state = State::get().await?;
|
||||
instance_rows::get_recent_instance_icon_configs(&state.pool).await
|
||||
}
|
||||
|
||||
async fn cache_generated_icon_with_state(
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let background = validate_icon_config(&config)?;
|
||||
let icon_bytes = tokio::task::spawn_blocking(move || {
|
||||
render_generated_icon(background, &symbol_bytes)
|
||||
})
|
||||
.await??;
|
||||
let file = write_cached_icon(Bytes::from(icon_bytes), state).await?;
|
||||
|
||||
Ok(file.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn cache_icon(
|
||||
@@ -125,7 +250,7 @@ pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
}
|
||||
LegacyIconAction::Remove => {
|
||||
if let Err(error) =
|
||||
apply_instance_icon(&instance.id, None, &state).await
|
||||
apply_instance_icon(&instance.id, None, None, &state).await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
@@ -144,6 +269,7 @@ pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
async fn apply_instance_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let instance =
|
||||
@@ -156,6 +282,7 @@ async fn apply_instance_icon(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path.clone()),
|
||||
icon_config: Some(icon_config),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
@@ -287,6 +414,152 @@ fn validate_normalized_icon(normalized: Vec<u8>) -> crate::Result<Bytes> {
|
||||
Ok(Bytes::from(normalized))
|
||||
}
|
||||
|
||||
fn validate_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
) -> crate::Result<ValidatedIconBackground> {
|
||||
let background = match &config.background {
|
||||
InstanceIconBackground::Color { value } => {
|
||||
ValidatedIconBackground::Color(parse_background_color(value)?)
|
||||
}
|
||||
InstanceIconBackground::LinearTopDownGradient {
|
||||
top_color,
|
||||
bottom_color,
|
||||
} => ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color: parse_background_color(top_color)?,
|
||||
bottom_color: parse_background_color(bottom_color)?,
|
||||
},
|
||||
};
|
||||
validate_icon_config_id("symbol", &config.symbol)?;
|
||||
Ok(background)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_generated_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
) -> crate::Result<()> {
|
||||
validate_icon_config(config).map(drop)
|
||||
}
|
||||
|
||||
fn parse_background_color(value: &str) -> crate::Result<[u8; 3]> {
|
||||
if value.len() != 7 || !value.starts_with('#') {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon background must be a hexadecimal color".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let color = u32::from_str_radix(&value[1..], 16).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance icon background must be a hexadecimal color".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok([
|
||||
((color >> 16) & 0xff) as u8,
|
||||
((color >> 8) & 0xff) as u8,
|
||||
(color & 0xff) as u8,
|
||||
])
|
||||
}
|
||||
|
||||
fn validate_icon_config_id(kind: &str, value: &str) -> crate::Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_ICON_CONFIG_ID_LENGTH
|
||||
|| !value.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
|
||||
})
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Instance icon {kind} ID is invalid"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_generated_icon(
|
||||
background: ValidatedIconBackground,
|
||||
symbol_bytes: &[u8],
|
||||
) -> crate::Result<Vec<u8>> {
|
||||
if symbol_bytes.is_empty() || symbol_bytes.len() > MAX_SYMBOL_BYTES {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon symbol must be a PNG smaller than 4 MiB".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let reader =
|
||||
ImageReader::with_format(Cursor::new(symbol_bytes), ImageFormat::Png);
|
||||
let (width, height) =
|
||||
reader.into_dimensions().map_err(image_input_error)?;
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_SYMBOL_DIMENSION
|
||||
|| height > MAX_SYMBOL_DIMENSION
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Instance icon symbol dimensions must be between 1 and {MAX_SYMBOL_DIMENSION} pixels"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let symbol =
|
||||
image::load_from_memory_with_format(symbol_bytes, ImageFormat::Png)
|
||||
.map_err(image_input_error)?
|
||||
.resize_exact(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
FilterType::Lanczos3,
|
||||
)
|
||||
.to_rgba8();
|
||||
let mut icon = match background {
|
||||
ValidatedIconBackground::Color(color) => RgbaImage::from_pixel(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
Rgba([color[0], color[1], color[2], 255]),
|
||||
),
|
||||
ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color,
|
||||
bottom_color,
|
||||
} => RgbaImage::from_fn(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
|_, y| {
|
||||
let interpolate = |top: u8, bottom: u8| {
|
||||
let distance = i32::from(bottom) - i32::from(top);
|
||||
(i32::from(top)
|
||||
+ distance * y as i32
|
||||
/ (GENERATED_ICON_SIZE - 1) as i32)
|
||||
as u8
|
||||
};
|
||||
Rgba([
|
||||
interpolate(top_color[0], bottom_color[0]),
|
||||
interpolate(top_color[1], bottom_color[1]),
|
||||
interpolate(top_color[2], bottom_color[2]),
|
||||
255,
|
||||
])
|
||||
},
|
||||
),
|
||||
};
|
||||
image::imageops::overlay(&mut icon, &symbol, 0, 0);
|
||||
for pixel in icon.pixels_mut() {
|
||||
pixel[3] = 255;
|
||||
}
|
||||
|
||||
let mut encoded = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(icon)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.map_err(image_input_error)?;
|
||||
|
||||
Ok(encoded.into_inner())
|
||||
}
|
||||
|
||||
fn image_input_error(error: image::ImageError) -> crate::Error {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid instance icon symbol: {error}"
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn looks_like_svg(bytes: &[u8]) -> bool {
|
||||
if image::guess_format(bytes).is_ok() {
|
||||
return false;
|
||||
@@ -316,3 +589,105 @@ fn svg_not_supported_error() -> crate::Error {
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
GENERATED_ICON_SIZE, InstanceIconBackground, InstanceIconConfig,
|
||||
ValidatedIconBackground, render_generated_icon, validate_icon_config,
|
||||
};
|
||||
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn png(pixel: Rgba<u8>) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(RgbaImage::from_pixel(1, 1, pixel))
|
||||
.write_to(&mut bytes, ImageFormat::Png)
|
||||
.unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_renders_background_and_symbol() {
|
||||
let bytes = render_generated_icon(
|
||||
ValidatedIconBackground::Color([10, 20, 30]),
|
||||
&png(Rgba([200, 100, 50, 128])),
|
||||
)
|
||||
.unwrap();
|
||||
let icon =
|
||||
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(
|
||||
icon.dimensions(),
|
||||
(GENERATED_ICON_SIZE, GENERATED_ICON_SIZE)
|
||||
);
|
||||
assert_eq!(icon.get_pixel(0, 0), &Rgba([105, 60, 40, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_rejects_invalid_symbol_data() {
|
||||
assert!(
|
||||
render_generated_icon(
|
||||
ValidatedIconBackground::Color([0, 0, 0]),
|
||||
b"not a png",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_renders_linear_top_down_gradient() {
|
||||
let bytes = render_generated_icon(
|
||||
ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color: [10, 20, 30],
|
||||
bottom_color: [110, 120, 130],
|
||||
},
|
||||
&png(Rgba([0, 0, 0, 0])),
|
||||
)
|
||||
.unwrap();
|
||||
let icon =
|
||||
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(icon.get_pixel(0, 0), &Rgba([10, 20, 30, 255]));
|
||||
assert_eq!(
|
||||
icon.get_pixel(0, GENERATED_ICON_SIZE - 1),
|
||||
&Rgba([110, 120, 130, 255])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_config_validates_color_and_symbol_id() {
|
||||
assert_eq!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "#c78aff".to_string(),
|
||||
},
|
||||
symbol: "dusk_block".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
ValidatedIconBackground::Color([199, 138, 255])
|
||||
);
|
||||
assert!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "purple".to_string(),
|
||||
},
|
||||
symbol: "dusk_block".to_string(),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "#c78aff".to_string(),
|
||||
},
|
||||
symbol: "dusk-block".to_string(),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{
|
||||
CreateInstance, EditInstance, InstanceLink, InstanceMetadata, ModLoader,
|
||||
State,
|
||||
CreateInstance, EditInstance, InstanceIconConfig, InstanceLink,
|
||||
InstanceMetadata, ModLoader, State,
|
||||
};
|
||||
|
||||
#[tracing::instrument]
|
||||
@@ -14,9 +14,13 @@ pub(crate) async fn create(
|
||||
modloader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
link: InstanceLink,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
if let Some(icon_config) = &icon_config {
|
||||
super::icon::validate_generated_icon_config(icon_config)?;
|
||||
}
|
||||
let instance = crate::state::create_instance(
|
||||
CreateInstance {
|
||||
name,
|
||||
@@ -25,6 +29,7 @@ pub(crate) async fn create(
|
||||
loader: modloader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
},
|
||||
&state,
|
||||
@@ -47,6 +52,12 @@ pub(crate) async fn create(
|
||||
|
||||
if result.is_err() {
|
||||
let _ = crate::state::remove_instance(&instance.id, &state).await;
|
||||
} else if let Err(error) =
|
||||
crate::onboarding_checklist::mark_created_instance().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark instance creation in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -33,7 +33,18 @@ pub async fn finish_login(
|
||||
) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
|
||||
crate::state::login_finish(code, flow, &state.pool).await
|
||||
let credentials =
|
||||
crate::state::login_finish(code, flow, &state.pool).await?;
|
||||
|
||||
if let Err(error) =
|
||||
crate::onboarding_checklist::mark_logged_into_minecraft().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark Minecraft login in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
|
||||
@@ -306,9 +306,21 @@ pub async fn get_available_capes() -> crate::Result<Vec<Cape>> {
|
||||
pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let selected_credentials = Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
let Some(selected_credentials) =
|
||||
Credentials::get_default_credential(&state.pool).await?
|
||||
else {
|
||||
let fallback_default_skin = get_fallback_default_skin()?;
|
||||
|
||||
return Ok(assets::DEFAULT_SKINS
|
||||
.iter()
|
||||
.map(|skin| Skin {
|
||||
is_equipped: skin.texture_key
|
||||
== fallback_default_skin.texture_key
|
||||
&& skin.variant == fallback_default_skin.variant,
|
||||
..skin.clone()
|
||||
})
|
||||
.collect());
|
||||
};
|
||||
|
||||
let online_profile = selected_credentials.online_profile_fresh().await;
|
||||
let profile_id = online_profile
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod metadata;
|
||||
pub mod minecraft_auth;
|
||||
pub mod minecraft_skins;
|
||||
pub mod mr_auth;
|
||||
pub mod onboarding_checklist;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
pub mod reports;
|
||||
@@ -23,13 +24,15 @@ pub mod data {
|
||||
AppliedContentSetPatch, CacheBehaviour, CacheValueType, ContentFile,
|
||||
ContentItem, ContentItemOwner, ContentItemProject, ContentItemVersion,
|
||||
CreateInstance, Credentials, Dependency, DirectoryInfo, EditInstance,
|
||||
Hooks, InstanceInstallCandidate, InstanceInstallTarget,
|
||||
Hooks, InstanceIconBackground, InstanceIconConfig,
|
||||
InstanceInstallCandidate, InstanceInstallTarget,
|
||||
InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata,
|
||||
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
|
||||
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
|
||||
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
|
||||
Theme, User, UserFriend, Version, WindowSize,
|
||||
ModrinthCredentials, OnboardingChecklist, Organization, OwnerType,
|
||||
ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult,
|
||||
SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
|
||||
SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
|
||||
WindowSize,
|
||||
};
|
||||
pub use ariadne::users::UserStatus;
|
||||
pub use modrinth_content_management::{
|
||||
@@ -43,8 +46,8 @@ pub mod prelude {
|
||||
State,
|
||||
data::*,
|
||||
event::CommandPayload,
|
||||
install, instance, jre, metadata, minecraft_auth, mr_auth, pack,
|
||||
process, settings,
|
||||
install, instance, jre, metadata, minecraft_auth, mr_auth,
|
||||
onboarding_checklist, pack, process, settings,
|
||||
state::{ReleaseChannel, db_backup::app_db_backup_dir},
|
||||
util::{
|
||||
io::{IOError, canonicalize},
|
||||
|
||||
@@ -30,6 +30,15 @@ pub async fn authenticate_finish_flow(
|
||||
.await?;
|
||||
|
||||
creds.upsert(&state.pool).await?;
|
||||
|
||||
if let Err(error) =
|
||||
crate::onboarding_checklist::mark_logged_into_modrinth().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark Modrinth login in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
state.friends_socket.disconnect().await?;
|
||||
state
|
||||
.friends_socket
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::State;
|
||||
use crate::event::emit::emit_onboarding_checklist;
|
||||
use crate::state::{
|
||||
OnboardingChecklist, OnboardingChecklistItem, get_onboarding_checklist,
|
||||
mark_onboarding_checklist_item,
|
||||
};
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get() -> crate::Result<OnboardingChecklist> {
|
||||
let state = State::get().await?;
|
||||
get_onboarding_checklist(&state.pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_created_instance() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::CreatedInstance).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_logged_into_minecraft() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::LoggedIntoMinecraft).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_logged_into_modrinth() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::LoggedIntoModrinth).await
|
||||
}
|
||||
|
||||
async fn mark(item: OnboardingChecklistItem) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
if let Some(checklist) =
|
||||
mark_onboarding_checklist_item(item, &state.pool).await?
|
||||
{
|
||||
emit_onboarding_checklist(checklist).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
use super::{FriendPayload, LoadingBarId};
|
||||
#[cfg(feature = "tauri")]
|
||||
use crate::event::{
|
||||
AppEvent, InstancePayload, LoadingPayload, ProcessPayload, WarningPayload,
|
||||
AppEvent, InstanceGroupsChangedPayload, InstancePayload, LoadingPayload,
|
||||
ProcessPayload, WarningPayload,
|
||||
};
|
||||
use crate::event::{
|
||||
CommandPayload, EventError, InstanceBulkUpdateProgressPayload,
|
||||
InstancePayloadType, LoadingBar, LoadingBarType, ProcessPayloadType,
|
||||
};
|
||||
use crate::state::OnboardingChecklist;
|
||||
use futures::prelude::*;
|
||||
use serde_json::Value;
|
||||
#[cfg(feature = "tauri")]
|
||||
@@ -232,6 +234,34 @@ pub async fn emit_instance(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_instance_groups_changed(
|
||||
instance_ids: &[String],
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get();
|
||||
event_state.send(AppEvent::InstanceGroupsChanged(
|
||||
InstanceGroupsChangedPayload {
|
||||
instance_ids: instance_ids.to_vec(),
|
||||
},
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_onboarding_checklist(
|
||||
checklist: OnboardingChecklist,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get();
|
||||
event_state.send(AppEvent::OnboardingChecklist(checklist))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
|
||||
@@ -114,6 +114,8 @@ pub enum AppEvent {
|
||||
Loading(LoadingPayload),
|
||||
Process(ProcessPayload),
|
||||
Instance(InstancePayload),
|
||||
InstanceGroupsChanged(InstanceGroupsChangedPayload),
|
||||
OnboardingChecklist(crate::state::OnboardingChecklist),
|
||||
InstanceBulkUpdateProgress(InstanceBulkUpdateProgressPayload),
|
||||
InstallJob(std::sync::Arc<InstallJobSnapshot>),
|
||||
Command(CommandPayload),
|
||||
@@ -161,6 +163,8 @@ pub fn export_app_event_bindings(
|
||||
ProcessPayloadType,
|
||||
InstancePayload,
|
||||
InstancePayloadType,
|
||||
InstanceGroupsChangedPayload,
|
||||
crate::state::OnboardingChecklist,
|
||||
FriendPayload,
|
||||
FriendStatusPayload,
|
||||
LogEvent,
|
||||
@@ -446,6 +450,16 @@ pub struct InstancePayload {
|
||||
pub event: InstancePayloadType,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(
|
||||
feature = "export-ts",
|
||||
derive(ts_rs::TS, postcard_bindgen::PostcardBindings)
|
||||
)]
|
||||
#[serde_binhum::serde_binhum]
|
||||
pub struct InstanceGroupsChangedPayload {
|
||||
pub instance_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(
|
||||
feature = "export-ts",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::api::pack::import::ImportLauncherType;
|
||||
use crate::api::pack::install_from::{CreatePackInstance, CreatePackLocation};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, InstanceLink, InstanceMetadata, ModLoader,
|
||||
InstanceIconConfig, InstanceInstallStage, InstanceLink, InstanceMetadata,
|
||||
ModLoader,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -161,6 +162,8 @@ pub enum InstallRequest {
|
||||
loader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
#[serde(default)]
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
link: InstanceLink,
|
||||
},
|
||||
CreateModpackInstance {
|
||||
|
||||
@@ -154,7 +154,7 @@ async fn restore_instance_metadata(
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(
|
||||
&metadata.instance.id,
|
||||
&metadata.groups,
|
||||
&metadata.group_ids,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -22,8 +22,10 @@ use crate::api::pack::install_mrpack::install_zipped_mrpack_files_with_reporter;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::content_rows;
|
||||
use crate::state::instances::commands::resolve_icon_path;
|
||||
use crate::state::{
|
||||
ContentSourceKind, InstanceInstallStage, InstanceLink, ModLoader, State,
|
||||
ContentSourceKind, InstanceIconConfig, InstanceInstallStage, InstanceLink,
|
||||
ModLoader, State,
|
||||
};
|
||||
use crate::util::fetch::DownloadReason;
|
||||
use std::collections::HashSet;
|
||||
@@ -36,6 +38,7 @@ pub async fn create_instance(
|
||||
loader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
link: InstanceLink,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
start(InstallRequest::CreateInstance {
|
||||
@@ -44,6 +47,7 @@ pub async fn create_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
})
|
||||
.await
|
||||
@@ -435,6 +439,7 @@ async fn prepare_initial_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
} => {
|
||||
let metadata = crate::api::instance::create(
|
||||
@@ -443,6 +448,7 @@ async fn prepare_initial_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
)
|
||||
.await?;
|
||||
@@ -489,6 +495,7 @@ async fn prepare_initial_instance(
|
||||
preview.modloader,
|
||||
preview.loader_version,
|
||||
icon_path,
|
||||
None,
|
||||
link,
|
||||
)
|
||||
.await?;
|
||||
@@ -534,6 +541,7 @@ async fn prepare_initial_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
None,
|
||||
shared_link,
|
||||
)
|
||||
.await?;
|
||||
@@ -556,6 +564,7 @@ async fn prepare_initial_instance(
|
||||
ModLoader::Vanilla,
|
||||
Some("latest".to_string()),
|
||||
None,
|
||||
None,
|
||||
InstanceLink::Unmanaged,
|
||||
)
|
||||
.await?;
|
||||
@@ -581,6 +590,7 @@ async fn prepare_initial_instance(
|
||||
metadata.applied_content_set.loader,
|
||||
metadata.applied_content_set.loader_version,
|
||||
metadata.instance.icon_path,
|
||||
None,
|
||||
metadata.link,
|
||||
)
|
||||
.await?;
|
||||
@@ -805,6 +815,7 @@ async fn run_request(
|
||||
loader,
|
||||
loader_version: _,
|
||||
icon_path: _,
|
||||
icon_config: _,
|
||||
link: _,
|
||||
} => {
|
||||
let Some(instance_id) = current_instance_id(job_state) else {
|
||||
@@ -1084,7 +1095,7 @@ async fn apply_post_install_edit(
|
||||
instance_id: &str,
|
||||
edit: Option<InstallPostInstallEdit>,
|
||||
) -> crate::Result<()> {
|
||||
let Some(edit) = edit else {
|
||||
let Some(mut edit) = edit else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -1092,6 +1103,17 @@ async fn apply_post_install_edit(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(icon_path) = edit.icon_path.take() {
|
||||
let icon_path = match icon_path {
|
||||
Some(icon_path) => {
|
||||
let state = State::get().await?;
|
||||
resolve_icon_path(Some(&icon_path), false, &state).await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
edit.icon_path = Some(icon_path);
|
||||
}
|
||||
|
||||
crate::api::instance::edit(
|
||||
instance_id,
|
||||
crate::state::instances::commands::EditInstance {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use crate::state::instances::{
|
||||
ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind,
|
||||
Instance, InstanceLaunchContext, InstanceLaunchOverrides,
|
||||
Instance, InstanceIconBackground, InstanceIconConfig,
|
||||
InstanceLaunchContext, InstanceLaunchOverrides,
|
||||
InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment,
|
||||
SharedInstanceRole, playtime_to_storage,
|
||||
};
|
||||
@@ -12,6 +13,7 @@ use crate::state::{
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::de::DeserializeOwned;
|
||||
use sqlx::{Executor, Sqlite, SqlitePool, Transaction};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
@@ -161,10 +163,11 @@ pub(crate) struct InstanceLaunchOverridesRow {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InstanceMetadataRecord {
|
||||
pub instance: Instance,
|
||||
pub icon_config: Option<InstanceIconConfig>,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub shared_instance: Option<SharedInstanceAttachment>,
|
||||
pub groups: Vec<String>,
|
||||
pub group_ids: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
|
||||
@@ -184,6 +187,8 @@ struct InstanceMetadataRow {
|
||||
update_channel: String,
|
||||
name: String,
|
||||
icon_path: Option<String>,
|
||||
icon_config_background: Option<String>,
|
||||
icon_config_symbol: Option<String>,
|
||||
created: i64,
|
||||
modified: i64,
|
||||
last_played: Option<i64>,
|
||||
@@ -221,7 +226,7 @@ struct InstanceMetadataRow {
|
||||
imported_name: Option<String>,
|
||||
imported_version_number: Option<String>,
|
||||
imported_filename: Option<String>,
|
||||
groups: String,
|
||||
group_ids: String,
|
||||
launch_overrides: Option<String>,
|
||||
}
|
||||
|
||||
@@ -333,16 +338,31 @@ impl InstanceMetadataRow {
|
||||
self.shared_sync_applied_update_id,
|
||||
self.shared_sync_latest_available_update_id,
|
||||
)?;
|
||||
let groups = parse_groups(self.groups)?;
|
||||
let group_ids = parse_group_ids(self.group_ids)?;
|
||||
let launch_overrides =
|
||||
launch_overrides_from_json(instance_id, self.launch_overrides)?;
|
||||
let icon_config =
|
||||
match (self.icon_config_background, self.icon_config_symbol) {
|
||||
(Some(background), Some(symbol)) => Some(InstanceIconConfig {
|
||||
background: deserialize_icon_background(background)?,
|
||||
symbol,
|
||||
}),
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon config is incomplete".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(InstanceMetadataRecord {
|
||||
instance,
|
||||
icon_config,
|
||||
applied_content_set,
|
||||
link,
|
||||
shared_instance,
|
||||
groups,
|
||||
group_ids,
|
||||
launch_overrides,
|
||||
})
|
||||
}
|
||||
@@ -538,6 +558,8 @@ macro_rules! query_instance_metadata {
|
||||
i.update_channel AS "update_channel!: String",
|
||||
i.name AS "name!: String",
|
||||
i.icon_path AS "icon_path?: String",
|
||||
config.background AS "icon_config_background?: String",
|
||||
config.symbol AS "icon_config_symbol?: String",
|
||||
i.created AS "created!: i64",
|
||||
i.modified AS "modified!: i64",
|
||||
i.last_played AS "last_played?: i64",
|
||||
@@ -576,14 +598,16 @@ macro_rules! query_instance_metadata {
|
||||
link.imported_version_number AS "imported_version_number?: String",
|
||||
link.imported_filename AS "imported_filename?: String",
|
||||
COALESCE((
|
||||
SELECT json_group_array(group_name)
|
||||
SELECT json_group_array(id)
|
||||
FROM (
|
||||
SELECT group_name
|
||||
FROM instance_groups
|
||||
WHERE instance_id = i.id
|
||||
ORDER BY group_name
|
||||
SELECT groups.id
|
||||
FROM instance_group_memberships memberships
|
||||
INNER JOIN instance_groups groups
|
||||
ON groups.id = memberships.group_id
|
||||
WHERE memberships.instance_id = i.id
|
||||
ORDER BY groups.name
|
||||
)
|
||||
), '[]') AS "groups!: String",
|
||||
), '[]') AS "group_ids!: String",
|
||||
json(overrides.overrides) AS "launch_overrides?: String"
|
||||
"#
|
||||
+ $from
|
||||
@@ -598,6 +622,8 @@ macro_rules! query_instance_metadata {
|
||||
AND sync.provider = 'shared_instance'
|
||||
LEFT JOIN instance_launch_overrides overrides
|
||||
ON overrides.instance_id = i.id
|
||||
LEFT JOIN instance_icon_configs config
|
||||
ON config.instance_id = i.id
|
||||
"#
|
||||
+ $suffix,
|
||||
$arg,
|
||||
@@ -605,6 +631,157 @@ macro_rules! query_instance_metadata {
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) async fn update_instance_icon_config(
|
||||
instance_id: &str,
|
||||
config: Option<&InstanceIconConfig>,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
if let Some(config) = config {
|
||||
let background = serde_json::to_string(&config.background)?;
|
||||
let symbol = &config.symbol;
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_icon_configs (instance_id, background, symbol)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
background = excluded.background,
|
||||
symbol = excluded.symbol
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(&background)
|
||||
.bind(symbol)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
update_recent_instance_icon_config(config, tx).await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"
|
||||
DELETE FROM instance_icon_configs
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_instance_icon_if_empty(
|
||||
instance_id: &str,
|
||||
icon_path: &str,
|
||||
config: &InstanceIconConfig,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<bool> {
|
||||
let modified = Utc::now().timestamp();
|
||||
let mut tx = pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"
|
||||
UPDATE instances
|
||||
SET icon_path = ?, modified = ?
|
||||
WHERE id = ? AND (icon_path IS NULL OR icon_path = '')
|
||||
",
|
||||
)
|
||||
.bind(icon_path)
|
||||
.bind(modified)
|
||||
.bind(instance_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
update_instance_icon_config(instance_id, Some(config), &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_recent_instance_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let background = serde_json::to_string(&config.background)?;
|
||||
let used_at = Utc::now().timestamp_millis();
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO recent_instance_icon_configs (background, symbol, used_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (background, symbol) DO UPDATE SET
|
||||
used_at = excluded.used_at
|
||||
",
|
||||
)
|
||||
.bind(&background)
|
||||
.bind(&config.symbol)
|
||||
.bind(used_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
DELETE FROM recent_instance_icon_configs
|
||||
WHERE rowid NOT IN (
|
||||
SELECT rowid
|
||||
FROM recent_instance_icon_configs
|
||||
ORDER BY used_at DESC, background, symbol
|
||||
LIMIT 8
|
||||
)
|
||||
",
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_recent_instance_icon_configs(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceIconConfig>> {
|
||||
let rows = sqlx::query_as::<_, InstanceIconConfigRow>(
|
||||
"
|
||||
SELECT background, symbol
|
||||
FROM recent_instance_icon_configs
|
||||
ORDER BY used_at DESC, background, symbol
|
||||
LIMIT 8
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(InstanceIconConfig {
|
||||
background: deserialize_icon_background(row.background)?,
|
||||
symbol: row.symbol,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct InstanceIconConfigRow {
|
||||
background: String,
|
||||
symbol: String,
|
||||
}
|
||||
|
||||
fn deserialize_icon_background(
|
||||
background: String,
|
||||
) -> crate::Result<InstanceIconBackground> {
|
||||
match serde_json::from_str(&background) {
|
||||
Ok(background) => Ok(background),
|
||||
Err(_) if background.starts_with('#') => {
|
||||
Ok(InstanceIconBackground::Color { value: background })
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_metadata_by_id(
|
||||
id: &str,
|
||||
pool: &SqlitePool,
|
||||
@@ -746,10 +923,12 @@ where
|
||||
{
|
||||
let rows = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT group_name
|
||||
FROM instance_groups
|
||||
WHERE instance_id = ?
|
||||
ORDER BY group_name
|
||||
SELECT groups.id
|
||||
FROM instance_group_memberships memberships
|
||||
INNER JOIN instance_groups groups
|
||||
ON groups.id = memberships.group_id
|
||||
WHERE memberships.instance_id = ?
|
||||
ORDER BY groups.name
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
@@ -759,6 +938,122 @@ where
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_instance_groups(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<(String, String)>> {
|
||||
let groups = sqlx::query_as::<_, (String, String)>(
|
||||
"
|
||||
SELECT id, name
|
||||
FROM instance_groups
|
||||
ORDER BY display_order, name, id
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_instance_group(
|
||||
id: &str,
|
||||
name: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE instance_groups
|
||||
SET display_order = display_order + 1
|
||||
WHERE id != ?
|
||||
",
|
||||
)
|
||||
.bind(crate::api::instance::FAVORITES_GROUP_ID)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_groups (id, name, display_order)
|
||||
VALUES (?, ?, 0)
|
||||
",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_group_order(
|
||||
group_ids: &[String],
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let existing_group_ids = sqlx::query_scalar::<_, String>(
|
||||
"
|
||||
SELECT id
|
||||
FROM instance_groups
|
||||
WHERE id != ?
|
||||
ORDER BY display_order, name, id
|
||||
",
|
||||
)
|
||||
.bind(crate::api::instance::FAVORITES_GROUP_ID)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let existing_group_id_set = existing_group_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut seen_group_ids = HashSet::new();
|
||||
let mut ordered_group_ids = Vec::with_capacity(existing_group_ids.len());
|
||||
|
||||
for group_id in group_ids {
|
||||
if !seen_group_ids.insert(group_id.as_str()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Duplicate instance group {group_id} in group order"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
if !existing_group_id_set.contains(group_id.as_str()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {group_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
ordered_group_ids.push(group_id.as_str());
|
||||
}
|
||||
|
||||
for group_id in &existing_group_ids {
|
||||
if seen_group_ids.insert(group_id.as_str()) {
|
||||
ordered_group_ids.push(group_id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (display_order, group_id) in ordered_group_ids.into_iter().enumerate() {
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE instance_groups
|
||||
SET display_order = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
)
|
||||
.bind(display_order as i64)
|
||||
.bind(group_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_launch_overrides<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
@@ -1032,12 +1327,12 @@ pub(crate) async fn set_shared_instance_attachment(
|
||||
|
||||
pub(crate) async fn replace_instance_groups(
|
||||
instance_id: &str,
|
||||
groups: &[String],
|
||||
group_ids: &[String],
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_groups
|
||||
DELETE FROM instance_group_memberships
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
instance_id,
|
||||
@@ -1045,15 +1340,18 @@ pub(crate) async fn replace_instance_groups(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for group in groups {
|
||||
sqlx::query!(
|
||||
for group_id in group_ids {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT OR IGNORE INTO instance_groups (instance_id, group_name)
|
||||
INSERT OR IGNORE INTO instance_group_memberships (
|
||||
instance_id,
|
||||
group_id
|
||||
)
|
||||
VALUES (?, ?)
|
||||
",
|
||||
instance_id,
|
||||
group,
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(group_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
@@ -1264,7 +1562,7 @@ fn required_i64(value: Option<i64>, column: &str) -> crate::Result<i64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_groups(value: String) -> crate::Result<Vec<String>> {
|
||||
fn parse_group_ids(value: String) -> crate::Result<Vec<String>> {
|
||||
serde_json::from_str(&value).map_err(|err| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid instance groups JSON: {err}"
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::state::instances::{
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
|
||||
State,
|
||||
InstanceIconConfig, InstanceInstallStage, LauncherFeatureVersion,
|
||||
ModLoader, ReleaseChannel, State,
|
||||
};
|
||||
use crate::util::fetch;
|
||||
use crate::util::io;
|
||||
@@ -23,6 +23,7 @@ pub struct CreateInstance {
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
pub icon_config: Option<InstanceIconConfig>,
|
||||
pub link: InstanceLink,
|
||||
}
|
||||
|
||||
@@ -97,6 +98,14 @@ pub(crate) async fn create_instance(
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::insert_instance(&instance, &mut tx).await?;
|
||||
if let Some(icon_config) = &input.icon_config {
|
||||
instance_rows::update_instance_icon_config(
|
||||
&instance_id,
|
||||
Some(icon_config),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
content_rows::insert_content_set(&content_set, &mut tx).await?;
|
||||
instance_rows::upsert_instance_link(&instance_id, &input.link, &mut tx)
|
||||
.await?;
|
||||
@@ -170,7 +179,7 @@ async fn path_available(
|
||||
.is_none())
|
||||
}
|
||||
|
||||
async fn resolve_icon_path(
|
||||
pub(crate) async fn resolve_icon_path(
|
||||
icon_path: Option<&str>,
|
||||
ignore_missing_remote_icon: bool,
|
||||
state: &State,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::state::instances::{
|
||||
ContentSourceKind, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
ContentSourceKind, Instance, InstanceIconConfig, InstanceLaunchOverrides,
|
||||
InstanceLink,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
@@ -21,8 +22,14 @@ pub struct EditInstance {
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub icon_path: Option<Option<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub icon_config: Option<Option<InstanceIconConfig>>,
|
||||
pub update_channel: Option<ReleaseChannel>,
|
||||
pub groups: Option<Vec<String>>,
|
||||
pub group_ids: Option<Vec<String>>,
|
||||
pub link: Option<InstanceLink>,
|
||||
pub launch_overrides: Option<InstanceLaunchOverridesPatch>,
|
||||
pub content_set_patch: Option<AppliedContentSetPatch>,
|
||||
@@ -174,9 +181,13 @@ pub(crate) async fn edit_instance(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(groups) = &patch.groups {
|
||||
instance_rows::replace_instance_groups(&instance.id, groups, &mut tx)
|
||||
.await?;
|
||||
if let Some(group_ids) = &patch.group_ids {
|
||||
instance_rows::replace_instance_groups(
|
||||
&instance.id,
|
||||
group_ids,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(overrides) = launch_overrides.as_mut() {
|
||||
@@ -184,6 +195,18 @@ pub(crate) async fn edit_instance(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(icon_config) = &patch.icon_config {
|
||||
instance_rows::update_instance_icon_config(
|
||||
&instance.id,
|
||||
icon_config.as_ref(),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
} else if patch.icon_path.is_some() {
|
||||
instance_rows::update_instance_icon_config(&instance.id, None, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
if should_mark_shared_instance_stale {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::state::instances::{
|
||||
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
SharedInstanceAttachment, adapters::sqlite::instance_rows,
|
||||
ContentSet, Instance, InstanceIconConfig, InstanceLaunchOverrides,
|
||||
InstanceLink, SharedInstanceAttachment, adapters::sqlite::instance_rows,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -8,12 +8,14 @@ use sqlx::SqlitePool;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceMetadata {
|
||||
pub instance: Instance,
|
||||
#[serde(default)]
|
||||
pub icon_config: Option<InstanceIconConfig>,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub shared_instance: Option<SharedInstanceAttachment>,
|
||||
#[serde(default)]
|
||||
pub quarantined: bool,
|
||||
pub groups: Vec<String>,
|
||||
pub group_ids: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
|
||||
@@ -79,11 +81,12 @@ fn instance_metadata(
|
||||
) -> InstanceMetadata {
|
||||
InstanceMetadata {
|
||||
instance: record.instance,
|
||||
icon_config: record.icon_config,
|
||||
applied_content_set: record.applied_content_set,
|
||||
link: record.link,
|
||||
shared_instance: record.shared_instance,
|
||||
quarantined,
|
||||
groups: record.groups,
|
||||
group_ids: record.group_ids,
|
||||
launch_overrides: record.launch_overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod create_instance;
|
||||
pub use self::create_instance::CreateInstance;
|
||||
pub(crate) use self::create_instance::create_instance;
|
||||
pub(crate) use self::create_instance::{create_instance, resolve_icon_path};
|
||||
|
||||
mod edit_instance;
|
||||
pub(crate) use self::edit_instance::edit_instance;
|
||||
|
||||
@@ -4,6 +4,25 @@ use crate::state::{
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstanceIconBackground {
|
||||
Color {
|
||||
value: String,
|
||||
},
|
||||
#[serde(rename = "linear-top-down-gradient")]
|
||||
LinearTopDownGradient {
|
||||
top_color: String,
|
||||
bottom_color: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstanceIconConfig {
|
||||
pub background: InstanceIconBackground,
|
||||
pub symbol: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub id: String,
|
||||
|
||||
@@ -63,7 +63,6 @@ where
|
||||
settings.telemetry = !legacy_settings.opt_out_analytics;
|
||||
settings.discord_rpc = !legacy_settings.disable_discord_rpc;
|
||||
settings.developer_mode = legacy_settings.developer_mode;
|
||||
settings.onboarded = legacy_settings.fully_onboarded;
|
||||
settings.extra_launch_args = legacy_settings.custom_java_args;
|
||||
settings.custom_env_vars = legacy_settings.custom_env_args;
|
||||
settings.memory.maximum = legacy_settings.memory.maximum;
|
||||
@@ -588,14 +587,45 @@ where
|
||||
.await?;
|
||||
|
||||
for group in input.groups {
|
||||
sqlx::query!(
|
||||
let group_id = match sqlx::query_scalar::<_, String>(
|
||||
"
|
||||
INSERT OR IGNORE INTO instance_groups (instance_id, group_name)
|
||||
SELECT id
|
||||
FROM instance_groups
|
||||
WHERE name = ?
|
||||
",
|
||||
)
|
||||
.bind(&group)
|
||||
.fetch_optional(exec)
|
||||
.await?
|
||||
{
|
||||
Some(group_id) => group_id,
|
||||
None => {
|
||||
let group_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_groups (id, name)
|
||||
VALUES (?, ?)
|
||||
",
|
||||
)
|
||||
.bind(&group_id)
|
||||
.bind(&group)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
group_id
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT OR IGNORE INTO instance_group_memberships (
|
||||
instance_id,
|
||||
group_id
|
||||
)
|
||||
VALUES (?, ?)
|
||||
",
|
||||
instance_id_str,
|
||||
group,
|
||||
)
|
||||
.bind(instance_id_str)
|
||||
.bind(group_id)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
}
|
||||
@@ -659,8 +689,6 @@ struct LegacySettings {
|
||||
pub opt_out_analytics: bool,
|
||||
#[serde(default)]
|
||||
pub advanced_rendering: bool,
|
||||
#[serde(default)]
|
||||
pub fully_onboarded: bool,
|
||||
#[serde(default = "default_settings_dir")]
|
||||
pub loaded_config_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ pub use self::instances::*;
|
||||
mod settings;
|
||||
pub use self::settings::*;
|
||||
|
||||
mod onboarding_checklist;
|
||||
pub use self::onboarding_checklist::*;
|
||||
|
||||
mod process;
|
||||
pub use self::process::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use sqlx::{Executor, Sqlite};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(
|
||||
feature = "export-ts",
|
||||
derive(ts_rs::TS, postcard_bindgen::PostcardBindings)
|
||||
)]
|
||||
#[serde_binhum::serde_binhum]
|
||||
pub struct OnboardingChecklist {
|
||||
pub has_created_instance: bool,
|
||||
pub has_logged_into_minecraft: bool,
|
||||
pub has_logged_into_modrinth: bool,
|
||||
pub show_checklist: bool,
|
||||
}
|
||||
|
||||
pub(crate) enum OnboardingChecklistItem {
|
||||
CreatedInstance,
|
||||
LoggedIntoMinecraft,
|
||||
LoggedIntoModrinth,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_onboarding_checklist(
|
||||
exec: impl Executor<'_, Database = Sqlite>,
|
||||
) -> crate::Result<OnboardingChecklist> {
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT
|
||||
has_created_instance,
|
||||
has_logged_into_minecraft,
|
||||
has_logged_into_modrinth,
|
||||
show_checklist
|
||||
FROM onboarding_checklist
|
||||
WHERE id = 0
|
||||
",
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
||||
Ok(OnboardingChecklist {
|
||||
has_created_instance: row.has_created_instance == 1,
|
||||
has_logged_into_minecraft: row.has_logged_into_minecraft == 1,
|
||||
has_logged_into_modrinth: row.has_logged_into_modrinth == 1,
|
||||
show_checklist: row.show_checklist == 1,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_onboarding_checklist_item(
|
||||
item: OnboardingChecklistItem,
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> crate::Result<Option<OnboardingChecklist>> {
|
||||
let result = match item {
|
||||
OnboardingChecklistItem::CreatedInstance => {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET has_created_instance = TRUE
|
||||
WHERE id = 0 AND has_created_instance = FALSE
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?
|
||||
}
|
||||
OnboardingChecklistItem::LoggedIntoMinecraft => {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET has_logged_into_minecraft = TRUE
|
||||
WHERE id = 0 AND has_logged_into_minecraft = FALSE
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?
|
||||
}
|
||||
OnboardingChecklistItem::LoggedIntoModrinth => {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET has_logged_into_modrinth = TRUE
|
||||
WHERE id = 0 AND has_logged_into_modrinth = FALSE
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET show_checklist = FALSE
|
||||
WHERE id = 0
|
||||
AND show_checklist = TRUE
|
||||
AND has_created_instance = TRUE
|
||||
AND has_logged_into_minecraft = TRUE
|
||||
AND has_logged_into_modrinth = TRUE
|
||||
"
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(Some(get_onboarding_checklist(pool).await?))
|
||||
}
|
||||
@@ -24,8 +24,6 @@ pub struct Settings {
|
||||
pub discord_rpc: bool,
|
||||
pub personalized_ads: bool,
|
||||
|
||||
pub onboarded: bool,
|
||||
|
||||
pub extra_launch_args: Vec<String>,
|
||||
pub custom_env_vars: Vec<(String, String)>,
|
||||
pub memory: MemorySettings,
|
||||
@@ -85,7 +83,6 @@ impl Settings {
|
||||
max_concurrent_writes, max_concurrent_downloads,
|
||||
theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
|
||||
discord_rpc, developer_mode, telemetry, personalized_ads,
|
||||
onboarded,
|
||||
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
|
||||
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
|
||||
hook_pre_launch, hook_wrapper, hook_post_exit,
|
||||
@@ -113,7 +110,6 @@ impl Settings {
|
||||
discord_rpc: res.discord_rpc == 1,
|
||||
developer_mode: res.developer_mode == 1,
|
||||
personalized_ads: res.personalized_ads == 1,
|
||||
onboarded: res.onboarded == 1,
|
||||
extra_launch_args: res
|
||||
.extra_launch_args
|
||||
.as_ref()
|
||||
@@ -186,33 +182,31 @@ impl Settings {
|
||||
telemetry = $11,
|
||||
personalized_ads = $12,
|
||||
|
||||
onboarded = $13,
|
||||
extra_launch_args = jsonb($13),
|
||||
custom_env_vars = jsonb($14),
|
||||
mc_memory_max = $15,
|
||||
mc_force_fullscreen = $16,
|
||||
mc_game_resolution_x = $17,
|
||||
mc_game_resolution_y = $18,
|
||||
hide_on_process_start = $19,
|
||||
|
||||
extra_launch_args = jsonb($14),
|
||||
custom_env_vars = jsonb($15),
|
||||
mc_memory_max = $16,
|
||||
mc_force_fullscreen = $17,
|
||||
mc_game_resolution_x = $18,
|
||||
mc_game_resolution_y = $19,
|
||||
hide_on_process_start = $20,
|
||||
hook_pre_launch = $20,
|
||||
hook_wrapper = $21,
|
||||
hook_post_exit = $22,
|
||||
|
||||
hook_pre_launch = $21,
|
||||
hook_wrapper = $22,
|
||||
hook_post_exit = $23,
|
||||
custom_dir = $23,
|
||||
prev_custom_dir = $24,
|
||||
migrated = $25,
|
||||
|
||||
custom_dir = $24,
|
||||
prev_custom_dir = $25,
|
||||
migrated = $26,
|
||||
toggle_sidebar = $26,
|
||||
feature_flags = $27,
|
||||
hide_nametag_skins_page = $28,
|
||||
|
||||
toggle_sidebar = $27,
|
||||
feature_flags = $28,
|
||||
hide_nametag_skins_page = $29,
|
||||
skipped_update = $29,
|
||||
pending_update_toast_for_version = $30,
|
||||
auto_download_updates = $31,
|
||||
|
||||
skipped_update = $30,
|
||||
pending_update_toast_for_version = $31,
|
||||
auto_download_updates = $32,
|
||||
|
||||
version = $33
|
||||
version = $32
|
||||
",
|
||||
max_concurrent_writes,
|
||||
max_concurrent_downloads,
|
||||
@@ -226,7 +220,6 @@ impl Settings {
|
||||
self.developer_mode,
|
||||
self.telemetry,
|
||||
self.personalized_ads,
|
||||
self.onboarded,
|
||||
extra_launch_args,
|
||||
custom_env_vars,
|
||||
self.memory.maximum,
|
||||
|
||||
Reference in New Issue
Block a user