mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 17:14:50 +00:00
feat: app onboarding and library (#6923)
* refactor: remove unused legacy onboarded flag * sqlx prepare * feat: add onboarding checklist app db and add vue provider * feat: add welcome page * feat: add onboarding checklist component * feat: move checklist visibility source of truth in app db * feat: split up import modpack button * feat-small: style tweak * feat: remove discover section from homepage and add library * refactor: move welcome screen to component * refactor: move grid display into /library * refactor: rename existing components * refactor: split up the current library page files * feat: add create group modal * feat: add instance group definitions * feat: update accordian for instance groups * feat: add delete group * feat: implement rename instance groups * refactor: move instance group api out * feat: add context menu for groups * feat: style instance items in library * feat: add selected instance item state * feat: implement action bar for selection * feat: drag and drop to move between groups * feat: show ungrouped with header * feat: improve edit group name * small fix * small style fixes * feat: improve selection behaviour * fix: dont toggle group when context menu open * feat: drag and drop multiple selected * small * feat: add remove from group in action bar * feat: add delay on icon hiding in inline input * feat: improve icon hiding delay * fix: bad routes.js * feat: implement sort and filter options * refactor: dont prefix filenames * feat: change open/collapse group target * feat: improve ungrouped behaviour * feat: add shift to select multiple instances * fix: icon disappear transition * feat: add esc while dragging to cancel * feat: update sorting and moved label * feat: update context menu * fix: multiple context menus can be opened * refactor: instance groups to be identified by ID instead of name, names become display data * feat: creating new group always adds instance * feat: create group button, fix instance selection, and increase group name max length * feat: strokes * fix: show getting started checklist in all sidebar * feat: set size on microsoft login screen * fix: use ButtonStyled in Chips component * feat: creating groups with selected instances and fix some moving selection and duplication bugs * pnpm prepr * feat: add tooltip for instance already in group * feat: create instance with content in creation flow modal - search modpack becomes search projects - creation flow modal context receives prepareProjectInstall and createProjectInstall callbacks, from content-install.ts (shared with ContentInstallModal) * feat: update instance card styles * refactor: separate out instance card content * update styles * refactor: instance card * fix: width of instance * feat: update empty state * feat: update sort and grouping dropdowns * feat: better context menu options * feat: add group action buttons * feat: add/remove favourites group * remove manual sort * feat: improve the "New group" jank * feat: add group by instance type and change group sorting * feat: welcome screen styles match figma * feat: not signed in skins demo * feat: new hosting empty state * feat: new instance card style * feat: add tooltip and spice up animation more * feat: improve the transitions for groups after dropping many instances * feat: set instance group membership for bulk moving * feat: remove fade out * fix: allow dragging from buttons inside instance card * fix: library page load in behaviour * small fix * feat: add group ordering and reordering * feat: add drag to reorder groups * fix: switching page not clearing selected instances * style fixes * style fix * fix spacing * feat: jump in changes * feat: add styled newly created instances * feat: improve jump in cards styles * fix: welcome screen style * fix: empty state styles * feat: max 5 jump in items * feat: max new instances to 3 * fix: search icon * feat: clear selection after move * feat: skins list use check icon styles * feat: list will be deleted instances and default sort last played * fix: stroke * remove: no more group settings in instance general settings * fix: resolving modpack server install * feat: icon editor * feat: add three hue shifted variants * fix: surprise me doesnt pick symbol * feat: add customize icon to create instance modal * feat: skins loading state style * fix: welcome logo * fix: copy * fix: left over usage of ButtonStyled from main's refactor * feat: some leaked merge stuff * feat: server empty state styles * remove: instance helper text * remove: auto link in set up stage * feat: auto focus on search when modal open * fix: delete instance modal style * remove: modpack meta repeating * fix: icon * fix: font weight * fix: dropdown filter preview heights * pnpm prepr * fix: sign into modrinth flashing change in position * feat: add avatar transparent corner gets padding * feat: drag and hold over group opens group * feat: add random icon for custom instance * feat: update loading icon * polish: empty state for search * polish: jump in cards * fix: avatar could skip checking for image if tauri loads it first * feat: add icon customize and randomizer in content install modal for add to new instances * fix: avatar cannot read image pixels due to cors * refactor: popup notifications toast type for instance sharing notifications * feat: apply random icons modal and add popup notification for it * polish: style * fix: set_webview_visible * QA * qa * feat: add inner white 15 opacity stroke around avatar * add to catalog * pnpm prepr * fix: app event import * feat: add new icons and backgrounds for icon editor * feat: recents list generate from randomized icons and save icon in editor + icon editor modal fixes * qa * refactor: recipe -> config * fix: event bus * polish * feat: add safe guard against randomizing empty icons * feat: navigate to instance after creation * fix: icon file names * i18n on welcome screen * i18n pass on PR * fix: inconsistent max group name length * refactor: use sqlx macro * feat: add instance icon background selection drag * feat: update catalog order * fix: remove gradient for welcome screen * remove: frog * fix: min width * feat: update corner size * fix: context menu styles * prepr * fix: instance name trimming * reorder * fix: instance card loading state stayed until hovering * feat: add icon randomizer blacklist * blog * prepr --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@ mod content;
|
||||
mod content_set_diff;
|
||||
mod export_mrpack;
|
||||
mod get;
|
||||
mod groups;
|
||||
mod icon;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
@@ -23,7 +24,15 @@ pub use self::export_mrpack::{
|
||||
get_pack_export_candidates, get_pack_export_candidates_for_parent,
|
||||
};
|
||||
pub use self::get::{get, get_many, list};
|
||||
pub use self::icon::edit_icon;
|
||||
pub use self::groups::{
|
||||
FAVORITES_GROUP_ID, InstanceGroup, InstanceGroupMembershipUpdate,
|
||||
create_group, delete_group, list_groups, rename_group,
|
||||
set_group_memberships, set_group_order,
|
||||
};
|
||||
pub use self::icon::{
|
||||
cache_generated_icon, edit_generated_icon, edit_generated_icon_if_empty,
|
||||
edit_icon, get_recent_icon_configs,
|
||||
};
|
||||
pub(crate) use self::icon::{
|
||||
cache_icon, cache_icon_from_path, migrate_legacy_icons,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
use crate::event::emit::emit_instance_groups_changed;
|
||||
use crate::state::State;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH: usize = 256;
|
||||
pub const FAVORITES_GROUP_ID: &str = "group:favorites";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroupMembershipUpdate {
|
||||
pub instance_id: String,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn validate_group_name(name: &str) -> crate::Result<&str> {
|
||||
let name = name.trim();
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be empty".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.eq_ignore_ascii_case("none") {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be None".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn normalize_membership_updates(
|
||||
mut updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<Vec<InstanceGroupMembershipUpdate>> {
|
||||
let mut instance_ids = HashSet::new();
|
||||
for update in &mut updates {
|
||||
if !instance_ids.insert(update.instance_id.clone()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Duplicate instance {} in group membership update",
|
||||
update.instance_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut group_ids = HashSet::new();
|
||||
update
|
||||
.group_ids
|
||||
.retain(|group_id| group_ids.insert(group_id.clone()));
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
pub async fn list_groups() -> crate::Result<Vec<InstanceGroup>> {
|
||||
let state = State::get().await?;
|
||||
Ok(instance_rows::list_instance_groups(&state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, name)| InstanceGroup { id, name })
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn create_group(name: String) -> crate::Result<InstanceGroup> {
|
||||
let name = validate_group_name(&name)?;
|
||||
let state = State::get().await?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
instance_rows::create_instance_group(&id, name, &state.pool).await?;
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_group_order(group_ids: Vec<String>) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
instance_rows::set_instance_group_order(&group_ids, &state.pool).await
|
||||
}
|
||||
|
||||
pub async fn set_group_memberships(
|
||||
updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<()> {
|
||||
if updates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let updates = normalize_membership_updates(updates)?;
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let unique_group_ids = updates
|
||||
.iter()
|
||||
.flat_map(|update| update.group_ids.iter())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for group_id in unique_group_ids {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM instance_groups WHERE id = ?) AS "exists!: bool""#,
|
||||
group_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {group_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
for update in &updates {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE instances SET modified = unixepoch() WHERE id = ?",
|
||||
update.instance_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance {}",
|
||||
update.instance_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
instance_rows::replace_instance_groups(
|
||||
&update.instance_id,
|
||||
&update.group_ids,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let instance_ids = updates
|
||||
.into_iter()
|
||||
.map(|update| update.instance_id)
|
||||
.collect::<Vec<_>>();
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename_group(
|
||||
id: String,
|
||||
new_name: String,
|
||||
) -> crate::Result<InstanceGroup> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be renamed".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_name = validate_group_name(&new_name)?;
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
let instance_ids = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT instance_id
|
||||
FROM instance_group_memberships
|
||||
WHERE group_id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
UPDATE instance_groups
|
||||
SET name = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
new_name,
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: new_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_group(id: String) -> crate::Result<()> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be deleted".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let instance_ids = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT instance_id
|
||||
FROM instance_group_memberships
|
||||
WHERE group_id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_groups
|
||||
WHERE id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FAVORITES_GROUP_ID, InstanceGroupMembershipUpdate,
|
||||
MAX_GROUP_NAME_LENGTH, normalize_membership_updates,
|
||||
validate_group_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_trims_valid_names() {
|
||||
assert_eq!(validate_group_name(" My group ").unwrap(), "My group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_empty_names() {
|
||||
assert!(validate_group_name(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_reserved_name() {
|
||||
assert!(validate_group_name("NoNe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_allows_favorites() {
|
||||
assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_long_names() {
|
||||
assert!(
|
||||
validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_group_id_is_stable() {
|
||||
assert_eq!(FAVORITES_GROUP_ID, "group:favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_reject_duplicate_instances() {
|
||||
let updates = vec![
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["first".to_string()],
|
||||
},
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["second".to_string()],
|
||||
},
|
||||
];
|
||||
|
||||
assert!(normalize_membership_updates(updates).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_deduplicate_groups() {
|
||||
let updates =
|
||||
normalize_membership_updates(vec![InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec![
|
||||
"first".to_string(),
|
||||
"second".to_string(),
|
||||
"first".to_string(),
|
||||
],
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
updates[0].group_ids,
|
||||
vec!["first".to_string(), "second".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{EditInstance, State};
|
||||
use crate::state::{
|
||||
EditInstance, InstanceIconBackground, InstanceIconConfig, State,
|
||||
};
|
||||
use crate::util::fetch::{sha1_async, write};
|
||||
use crate::util::io;
|
||||
use bytes::Bytes;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ImageFormat, ImageReader, Rgba, RgbaImage};
|
||||
use std::fs::File as StdFile;
|
||||
use std::io::{BufRead, BufReader, Cursor, Seek};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -13,6 +17,10 @@ const INSTANCE_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const INSTANCE_ICON_MAX_DIMENSION: u32 = 512;
|
||||
const INSTANCE_ICON_MAX_SOURCE_DIMENSION: u32 = 8_192;
|
||||
const INSTANCE_ICON_MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const GENERATED_ICON_SIZE: u32 = 256;
|
||||
const MAX_ICON_CONFIG_ID_LENGTH: usize = 64;
|
||||
const MAX_SYMBOL_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_SYMBOL_DIMENSION: u32 = 4096;
|
||||
|
||||
enum LegacyIconAction {
|
||||
Keep,
|
||||
@@ -20,6 +28,15 @@ enum LegacyIconAction {
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum ValidatedIconBackground {
|
||||
Color([u8; 3]),
|
||||
LinearTopDownGradient {
|
||||
top_color: [u8; 3],
|
||||
bottom_color: [u8; 3],
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn edit_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<&Path>,
|
||||
@@ -36,7 +53,115 @@ pub async fn edit_icon(
|
||||
None
|
||||
};
|
||||
|
||||
apply_instance_icon(instance_id, icon_path, &state).await
|
||||
apply_instance_icon(instance_id, icon_path, None, &state).await
|
||||
}
|
||||
|
||||
pub async fn edit_generated_icon(
|
||||
instance_id: &str,
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
|
||||
apply_instance_icon(
|
||||
instance_id,
|
||||
Some(icon_path.clone()),
|
||||
Some(config),
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
pub async fn edit_generated_icon_if_empty(
|
||||
instance_id: &str,
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
) -> crate::Result<Option<String>> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
let applied = instance_rows::update_instance_icon_if_empty(
|
||||
instance_id,
|
||||
&icon_path,
|
||||
&config,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
if !applied {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Err(error) = super::shared::sync_shared_instance_icon(
|
||||
instance_id,
|
||||
Some(&icon_path),
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
error = %error,
|
||||
"Failed to sync shared instance icon"
|
||||
);
|
||||
}
|
||||
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(Some(icon_path))
|
||||
}
|
||||
|
||||
pub async fn cache_generated_icon(
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
add_to_recents: bool,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
|
||||
if add_to_recents {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::update_recent_instance_icon_config(&config, &mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
pub async fn get_recent_icon_configs() -> crate::Result<Vec<InstanceIconConfig>>
|
||||
{
|
||||
let state = State::get().await?;
|
||||
instance_rows::get_recent_instance_icon_configs(&state.pool).await
|
||||
}
|
||||
|
||||
async fn cache_generated_icon_with_state(
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let background = validate_icon_config(&config)?;
|
||||
let icon_bytes = tokio::task::spawn_blocking(move || {
|
||||
render_generated_icon(background, &symbol_bytes)
|
||||
})
|
||||
.await??;
|
||||
let file = write_cached_icon(Bytes::from(icon_bytes), state).await?;
|
||||
|
||||
Ok(file.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn cache_icon(
|
||||
@@ -125,7 +250,7 @@ pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
}
|
||||
LegacyIconAction::Remove => {
|
||||
if let Err(error) =
|
||||
apply_instance_icon(&instance.id, None, &state).await
|
||||
apply_instance_icon(&instance.id, None, None, &state).await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
@@ -144,6 +269,7 @@ pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
async fn apply_instance_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let instance =
|
||||
@@ -156,6 +282,7 @@ async fn apply_instance_icon(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path.clone()),
|
||||
icon_config: Some(icon_config),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
@@ -287,6 +414,152 @@ fn validate_normalized_icon(normalized: Vec<u8>) -> crate::Result<Bytes> {
|
||||
Ok(Bytes::from(normalized))
|
||||
}
|
||||
|
||||
fn validate_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
) -> crate::Result<ValidatedIconBackground> {
|
||||
let background = match &config.background {
|
||||
InstanceIconBackground::Color { value } => {
|
||||
ValidatedIconBackground::Color(parse_background_color(value)?)
|
||||
}
|
||||
InstanceIconBackground::LinearTopDownGradient {
|
||||
top_color,
|
||||
bottom_color,
|
||||
} => ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color: parse_background_color(top_color)?,
|
||||
bottom_color: parse_background_color(bottom_color)?,
|
||||
},
|
||||
};
|
||||
validate_icon_config_id("symbol", &config.symbol)?;
|
||||
Ok(background)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_generated_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
) -> crate::Result<()> {
|
||||
validate_icon_config(config).map(drop)
|
||||
}
|
||||
|
||||
fn parse_background_color(value: &str) -> crate::Result<[u8; 3]> {
|
||||
if value.len() != 7 || !value.starts_with('#') {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon background must be a hexadecimal color".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let color = u32::from_str_radix(&value[1..], 16).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance icon background must be a hexadecimal color".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok([
|
||||
((color >> 16) & 0xff) as u8,
|
||||
((color >> 8) & 0xff) as u8,
|
||||
(color & 0xff) as u8,
|
||||
])
|
||||
}
|
||||
|
||||
fn validate_icon_config_id(kind: &str, value: &str) -> crate::Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_ICON_CONFIG_ID_LENGTH
|
||||
|| !value.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
|
||||
})
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Instance icon {kind} ID is invalid"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_generated_icon(
|
||||
background: ValidatedIconBackground,
|
||||
symbol_bytes: &[u8],
|
||||
) -> crate::Result<Vec<u8>> {
|
||||
if symbol_bytes.is_empty() || symbol_bytes.len() > MAX_SYMBOL_BYTES {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon symbol must be a PNG smaller than 4 MiB".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let reader =
|
||||
ImageReader::with_format(Cursor::new(symbol_bytes), ImageFormat::Png);
|
||||
let (width, height) =
|
||||
reader.into_dimensions().map_err(image_input_error)?;
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_SYMBOL_DIMENSION
|
||||
|| height > MAX_SYMBOL_DIMENSION
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Instance icon symbol dimensions must be between 1 and {MAX_SYMBOL_DIMENSION} pixels"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let symbol =
|
||||
image::load_from_memory_with_format(symbol_bytes, ImageFormat::Png)
|
||||
.map_err(image_input_error)?
|
||||
.resize_exact(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
FilterType::Lanczos3,
|
||||
)
|
||||
.to_rgba8();
|
||||
let mut icon = match background {
|
||||
ValidatedIconBackground::Color(color) => RgbaImage::from_pixel(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
Rgba([color[0], color[1], color[2], 255]),
|
||||
),
|
||||
ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color,
|
||||
bottom_color,
|
||||
} => RgbaImage::from_fn(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
|_, y| {
|
||||
let interpolate = |top: u8, bottom: u8| {
|
||||
let distance = i32::from(bottom) - i32::from(top);
|
||||
(i32::from(top)
|
||||
+ distance * y as i32
|
||||
/ (GENERATED_ICON_SIZE - 1) as i32)
|
||||
as u8
|
||||
};
|
||||
Rgba([
|
||||
interpolate(top_color[0], bottom_color[0]),
|
||||
interpolate(top_color[1], bottom_color[1]),
|
||||
interpolate(top_color[2], bottom_color[2]),
|
||||
255,
|
||||
])
|
||||
},
|
||||
),
|
||||
};
|
||||
image::imageops::overlay(&mut icon, &symbol, 0, 0);
|
||||
for pixel in icon.pixels_mut() {
|
||||
pixel[3] = 255;
|
||||
}
|
||||
|
||||
let mut encoded = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(icon)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.map_err(image_input_error)?;
|
||||
|
||||
Ok(encoded.into_inner())
|
||||
}
|
||||
|
||||
fn image_input_error(error: image::ImageError) -> crate::Error {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid instance icon symbol: {error}"
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn looks_like_svg(bytes: &[u8]) -> bool {
|
||||
if image::guess_format(bytes).is_ok() {
|
||||
return false;
|
||||
@@ -316,3 +589,105 @@ fn svg_not_supported_error() -> crate::Error {
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
GENERATED_ICON_SIZE, InstanceIconBackground, InstanceIconConfig,
|
||||
ValidatedIconBackground, render_generated_icon, validate_icon_config,
|
||||
};
|
||||
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn png(pixel: Rgba<u8>) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(RgbaImage::from_pixel(1, 1, pixel))
|
||||
.write_to(&mut bytes, ImageFormat::Png)
|
||||
.unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_renders_background_and_symbol() {
|
||||
let bytes = render_generated_icon(
|
||||
ValidatedIconBackground::Color([10, 20, 30]),
|
||||
&png(Rgba([200, 100, 50, 128])),
|
||||
)
|
||||
.unwrap();
|
||||
let icon =
|
||||
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(
|
||||
icon.dimensions(),
|
||||
(GENERATED_ICON_SIZE, GENERATED_ICON_SIZE)
|
||||
);
|
||||
assert_eq!(icon.get_pixel(0, 0), &Rgba([105, 60, 40, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_rejects_invalid_symbol_data() {
|
||||
assert!(
|
||||
render_generated_icon(
|
||||
ValidatedIconBackground::Color([0, 0, 0]),
|
||||
b"not a png",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_renders_linear_top_down_gradient() {
|
||||
let bytes = render_generated_icon(
|
||||
ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color: [10, 20, 30],
|
||||
bottom_color: [110, 120, 130],
|
||||
},
|
||||
&png(Rgba([0, 0, 0, 0])),
|
||||
)
|
||||
.unwrap();
|
||||
let icon =
|
||||
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(icon.get_pixel(0, 0), &Rgba([10, 20, 30, 255]));
|
||||
assert_eq!(
|
||||
icon.get_pixel(0, GENERATED_ICON_SIZE - 1),
|
||||
&Rgba([110, 120, 130, 255])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_config_validates_color_and_symbol_id() {
|
||||
assert_eq!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "#c78aff".to_string(),
|
||||
},
|
||||
symbol: "dusk_block".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
ValidatedIconBackground::Color([199, 138, 255])
|
||||
);
|
||||
assert!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "purple".to_string(),
|
||||
},
|
||||
symbol: "dusk_block".to_string(),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "#c78aff".to_string(),
|
||||
},
|
||||
symbol: "dusk-block".to_string(),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{
|
||||
CreateInstance, EditInstance, InstanceLink, InstanceMetadata, ModLoader,
|
||||
State,
|
||||
CreateInstance, EditInstance, InstanceIconConfig, InstanceLink,
|
||||
InstanceMetadata, ModLoader, State,
|
||||
};
|
||||
|
||||
#[tracing::instrument]
|
||||
@@ -14,9 +14,13 @@ pub(crate) async fn create(
|
||||
modloader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
link: InstanceLink,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
if let Some(icon_config) = &icon_config {
|
||||
super::icon::validate_generated_icon_config(icon_config)?;
|
||||
}
|
||||
let instance = crate::state::create_instance(
|
||||
CreateInstance {
|
||||
name,
|
||||
@@ -25,6 +29,7 @@ pub(crate) async fn create(
|
||||
loader: modloader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
},
|
||||
&state,
|
||||
@@ -47,6 +52,12 @@ pub(crate) async fn create(
|
||||
|
||||
if result.is_err() {
|
||||
let _ = crate::state::remove_instance(&instance.id, &state).await;
|
||||
} else if let Err(error) =
|
||||
crate::onboarding_checklist::mark_created_instance().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark instance creation in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -33,7 +33,18 @@ pub async fn finish_login(
|
||||
) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
|
||||
crate::state::login_finish(code, flow, &state.pool).await
|
||||
let credentials =
|
||||
crate::state::login_finish(code, flow, &state.pool).await?;
|
||||
|
||||
if let Err(error) =
|
||||
crate::onboarding_checklist::mark_logged_into_minecraft().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark Minecraft login in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
|
||||
@@ -306,9 +306,21 @@ pub async fn get_available_capes() -> crate::Result<Vec<Cape>> {
|
||||
pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let selected_credentials = Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
let Some(selected_credentials) =
|
||||
Credentials::get_default_credential(&state.pool).await?
|
||||
else {
|
||||
let fallback_default_skin = get_fallback_default_skin()?;
|
||||
|
||||
return Ok(assets::DEFAULT_SKINS
|
||||
.iter()
|
||||
.map(|skin| Skin {
|
||||
is_equipped: skin.texture_key
|
||||
== fallback_default_skin.texture_key
|
||||
&& skin.variant == fallback_default_skin.variant,
|
||||
..skin.clone()
|
||||
})
|
||||
.collect());
|
||||
};
|
||||
|
||||
let online_profile = selected_credentials.online_profile_fresh().await;
|
||||
let profile_id = online_profile
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod metadata;
|
||||
pub mod minecraft_auth;
|
||||
pub mod minecraft_skins;
|
||||
pub mod mr_auth;
|
||||
pub mod onboarding_checklist;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
pub mod reports;
|
||||
@@ -23,13 +24,15 @@ pub mod data {
|
||||
AppliedContentSetPatch, CacheBehaviour, CacheValueType, ContentFile,
|
||||
ContentItem, ContentItemOwner, ContentItemProject, ContentItemVersion,
|
||||
CreateInstance, Credentials, Dependency, DirectoryInfo, EditInstance,
|
||||
Hooks, InstanceInstallCandidate, InstanceInstallTarget,
|
||||
Hooks, InstanceIconBackground, InstanceIconConfig,
|
||||
InstanceInstallCandidate, InstanceInstallTarget,
|
||||
InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata,
|
||||
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
|
||||
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
|
||||
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
|
||||
Theme, User, UserFriend, Version, WindowSize,
|
||||
ModrinthCredentials, OnboardingChecklist, Organization, OwnerType,
|
||||
ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult,
|
||||
SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
|
||||
SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
|
||||
WindowSize,
|
||||
};
|
||||
pub use ariadne::users::UserStatus;
|
||||
pub use modrinth_content_management::{
|
||||
@@ -43,8 +46,8 @@ pub mod prelude {
|
||||
State,
|
||||
data::*,
|
||||
event::CommandPayload,
|
||||
install, instance, jre, metadata, minecraft_auth, mr_auth, pack,
|
||||
process, settings,
|
||||
install, instance, jre, metadata, minecraft_auth, mr_auth,
|
||||
onboarding_checklist, pack, process, settings,
|
||||
state::{ReleaseChannel, db_backup::app_db_backup_dir},
|
||||
util::{
|
||||
io::{IOError, canonicalize},
|
||||
|
||||
@@ -30,6 +30,15 @@ pub async fn authenticate_finish_flow(
|
||||
.await?;
|
||||
|
||||
creds.upsert(&state.pool).await?;
|
||||
|
||||
if let Err(error) =
|
||||
crate::onboarding_checklist::mark_logged_into_modrinth().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark Modrinth login in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
state.friends_socket.disconnect().await?;
|
||||
state
|
||||
.friends_socket
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::State;
|
||||
use crate::event::emit::emit_onboarding_checklist;
|
||||
use crate::state::{
|
||||
OnboardingChecklist, OnboardingChecklistItem, get_onboarding_checklist,
|
||||
mark_onboarding_checklist_item,
|
||||
};
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get() -> crate::Result<OnboardingChecklist> {
|
||||
let state = State::get().await?;
|
||||
get_onboarding_checklist(&state.pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_created_instance() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::CreatedInstance).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_logged_into_minecraft() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::LoggedIntoMinecraft).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_logged_into_modrinth() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::LoggedIntoModrinth).await
|
||||
}
|
||||
|
||||
async fn mark(item: OnboardingChecklistItem) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
if let Some(checklist) =
|
||||
mark_onboarding_checklist_item(item, &state.pool).await?
|
||||
{
|
||||
emit_onboarding_checklist(checklist).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user