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