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:
Truman Gao
2026-08-18 01:08:09 +00:00
committed by GitHub
co-authored by Prospector
parent d5556271f9
commit e79e0d024b
347 changed files with 12456 additions and 4505 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE instances SET modified = unixepoch() WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "4b8b302590a9ab956823789dfe9b09b1fe8be840b4a14ad2636d7bb558cc62a1"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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);
+10 -1
View File
@@ -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,
};
+338
View File
@@ -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()]
);
}
}
+378 -3
View File
@@ -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()
);
}
}
+13 -2
View File
@@ -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
+12 -1
View File
@@ -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]
+15 -3
View File
@@ -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
+10 -7
View File
@@ -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},
+9
View File
@@ -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(())
}
+31 -1
View File
@@ -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")]
+14
View File
@@ -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",
+4 -1
View File
@@ -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 {
+1 -1
View File
@@ -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?;
+24 -2
View File
@@ -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,
+35 -7
View File
@@ -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>,
}
+3
View File
@@ -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?))
}
+20 -27
View File
@@ -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,