feat(ui): port 3D skins tab, offline onboarding and settings, and Forgejo auto-updater to 2.8.0
This commit is contained in:
parent
8d7939c041
commit
0a29f0ab47
14 changed files with 1361 additions and 236 deletions
|
|
@ -46,6 +46,9 @@ regex.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
dunce.workspace = true
|
dunce.workspace = true
|
||||||
percent-encoding.workspace = true
|
percent-encoding.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
|
||||||
cargo-packager-updater = "=0.2.3"
|
cargo-packager-updater = "=0.2.3"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use freya::elements::image::{ImageHandle, image};
|
use freya::elements::image::{ImageHandle, image};
|
||||||
use freya::engine::prelude::{Paint, SkData, SkImage, raster_n32_premul};
|
use freya::engine::prelude::{Paint, SkData, SkImage, raster_n32_premul};
|
||||||
|
|
@ -10,6 +13,8 @@ const FACE: f32 = 8.;
|
||||||
const HEAD_FACE: (f32, f32) = (8., 8.);
|
const HEAD_FACE: (f32, f32) = (8., 8.);
|
||||||
const HEAD_OVERLAY: (f32, f32) = (40., 8.);
|
const HEAD_OVERLAY: (f32, f32) = (40., 8.);
|
||||||
|
|
||||||
|
static AVATAR_HEAD_CACHE: OnceLock<Mutex<HashMap<usize, ImageHandle>>> = OnceLock::new();
|
||||||
|
|
||||||
#[derive(PartialEq, Clone)]
|
#[derive(PartialEq, Clone)]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
uuid: String,
|
uuid: String,
|
||||||
|
|
@ -34,22 +39,13 @@ impl LayoutExt for Avatar {
|
||||||
impl ContainerSizeExt for Avatar {}
|
impl ContainerSizeExt for Avatar {}
|
||||||
|
|
||||||
impl Component for Avatar {
|
impl Component for Avatar {
|
||||||
|
fn render_key(&self) -> DiffKey {
|
||||||
|
DiffKey::from(&self.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone());
|
let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone());
|
||||||
|
let head = get_or_compose_head(&skin_bytes);
|
||||||
let mut cache = use_state(|| None::<(usize, ImageHandle)>);
|
|
||||||
let src_ptr = skin_bytes.as_ptr() as usize;
|
|
||||||
let cached = cache.read().clone();
|
|
||||||
let head = match cached {
|
|
||||||
Some((ptr, holder)) if ptr == src_ptr => Some(holder),
|
|
||||||
_ => {
|
|
||||||
let holder = compose_head(&skin_bytes);
|
|
||||||
if let Some(holder) = &holder {
|
|
||||||
cache.set(Some((src_ptr, holder.clone())));
|
|
||||||
}
|
|
||||||
holder
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
.width(Size::px(AVATAR_SIZE))
|
.width(Size::px(AVATAR_SIZE))
|
||||||
|
|
@ -67,6 +63,23 @@ impl Component for Avatar {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_or_compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
|
||||||
|
if skin_bytes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let src_ptr = skin_bytes.as_ptr() as usize;
|
||||||
|
let cache = AVATAR_HEAD_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
if let Some(handle) = cache.lock().ok().and_then(|g| g.get(&src_ptr).cloned()) {
|
||||||
|
return Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
let composed = compose_head(skin_bytes)?;
|
||||||
|
if let Ok(mut guard) = cache.lock() {
|
||||||
|
guard.insert(src_ptr, composed.clone());
|
||||||
|
}
|
||||||
|
Some(composed)
|
||||||
|
}
|
||||||
|
|
||||||
fn compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
|
fn compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
|
||||||
let data = unsafe { SkData::new_bytes(skin_bytes) };
|
let data = unsafe { SkData::new_bytes(skin_bytes) };
|
||||||
let skin = SkImage::from_encoded(data)?;
|
let skin = SkImage::from_encoded(data)?;
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,11 @@ fn navbar_center(is_small: bool) -> impl IntoElement {
|
||||||
target: NavTarget::Route(browse_target),
|
target: NavTarget::Route(browse_target),
|
||||||
nav_label: "Browse",
|
nav_label: "Browse",
|
||||||
})
|
})
|
||||||
|
.child(NavLink {
|
||||||
|
active: matches!(route, Route::AccountSkins {}),
|
||||||
|
target: NavTarget::Route(Route::AccountSkins {}),
|
||||||
|
nav_label: "Skins",
|
||||||
|
})
|
||||||
.child(NavLink {
|
.child(NavLink {
|
||||||
active: false,
|
active: false,
|
||||||
target: NavTarget::External("https://store.polyfrost.org"),
|
target: NavTarget::External("https://store.polyfrost.org"),
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ impl PlayerModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for PlayerModel {
|
impl Component for PlayerModel {
|
||||||
|
fn render_key(&self) -> DiffKey {
|
||||||
|
DiffKey::from(&self.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone());
|
let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
pub const WINDOW_APP_ID: &str = "oneclient_app";
|
pub const WINDOW_APP_ID: &str = "oneclient_app";
|
||||||
pub const WINDOW_TITLE: &str = "OneClient";
|
pub const WINDOW_TITLE: &str = "OneClient";
|
||||||
|
|
||||||
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK";
|
pub const FORGEJO_REPO: &str = "oiupoyt/OneLauncher-Cracked";
|
||||||
pub const UPDATER_ENDPOINT: &str =
|
pub const FORGEJO_API_LATEST_RELEASE: &str =
|
||||||
"https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json";
|
"https://git.oiupoyt.space/api/v1/repos/oiupoyt/OneLauncher-Cracked/releases/latest";
|
||||||
pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest";
|
pub const RELEASES_URL: &str = "https://git.oiupoyt.space/oiupoyt/OneLauncher-Cracked/releases";
|
||||||
|
|
@ -67,7 +67,8 @@ pub use queries::{
|
||||||
use_migratable_routes, use_migration, use_mods_folder_sync, use_named_profiles,
|
use_migratable_routes, use_migration, use_mods_folder_sync, use_named_profiles,
|
||||||
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
|
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
|
||||||
use_package_search, use_package_updates, use_package_versions_when, use_player_profile,
|
use_package_search, use_package_updates, use_package_versions_when, use_player_profile,
|
||||||
use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts,
|
use_player_skin, use_custom_skin, delete_account_skin, save_account_skin, invalidate_skin_queries,
|
||||||
|
use_provider_versions, use_refresh_account, use_refresh_all_accounts,
|
||||||
use_remove_account, use_screenshot_action, use_screenshot_folder_watch,
|
use_remove_account, use_screenshot_action, use_screenshot_folder_watch,
|
||||||
use_set_default_account, use_shadowed_mods, use_storage_action, use_storage_report, use_terms,
|
use_set_default_account, use_shadowed_mods, use_storage_action, use_storage_report, use_terms,
|
||||||
use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders,
|
use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders,
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,10 @@ pub use settings_profiles::{
|
||||||
try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile,
|
try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile,
|
||||||
use_named_profiles,
|
use_named_profiles,
|
||||||
};
|
};
|
||||||
pub use skin::use_player_skin;
|
pub use skin::{
|
||||||
|
delete_account_skin, invalidate_skin_queries, save_account_skin, use_custom_skin,
|
||||||
|
use_player_skin,
|
||||||
|
};
|
||||||
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
|
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, StorageAction,
|
DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, StorageAction,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,110 @@
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use freya::prelude::*;
|
use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query};
|
||||||
|
use oneclient_common::paths::{shared_minecraft_dir, skin_file_path, skin_meta_path, skins_dir};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::AppAssets;
|
use crate::AppAssets;
|
||||||
|
|
||||||
|
const SKIN_STALE: Duration = Duration::from_secs(5 * 60);
|
||||||
|
const SKIN_CLEAN: Duration = Duration::from_secs(30 * 60);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct CustomSkinKeys {
|
||||||
|
pub uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct CustomSkinData {
|
||||||
|
pub uuid: String,
|
||||||
|
pub has_custom: bool,
|
||||||
|
pub bytes: Option<Bytes>,
|
||||||
|
pub is_slim: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct SkinMetadata {
|
||||||
|
pub is_slim: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct CustomSkinQuery;
|
||||||
|
|
||||||
|
impl QueryCapability for CustomSkinQuery {
|
||||||
|
type Ok = CustomSkinData;
|
||||||
|
type Err = String;
|
||||||
|
type Keys = CustomSkinKeys;
|
||||||
|
|
||||||
|
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||||
|
let uuid = &keys.uuid;
|
||||||
|
if uuid.is_empty() {
|
||||||
|
return Ok(CustomSkinData {
|
||||||
|
uuid: String::new(),
|
||||||
|
has_custom: false,
|
||||||
|
bytes: None,
|
||||||
|
is_slim: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !skin_path.exists() {
|
||||||
|
return Ok(CustomSkinData {
|
||||||
|
uuid: uuid.clone(),
|
||||||
|
has_custom: false,
|
||||||
|
bytes: None,
|
||||||
|
is_slim: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = polyio::read(&skin_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read custom skin: {e}"))?;
|
||||||
|
|
||||||
|
let is_slim = if meta_path.exists() {
|
||||||
|
polyio::read_to_string(&meta_path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<SkinMetadata>(&s).ok())
|
||||||
|
.map(|m| m.is_slim)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CustomSkinData {
|
||||||
|
uuid: uuid.clone(),
|
||||||
|
has_custom: true,
|
||||||
|
bytes: Some(Bytes::from(bytes)),
|
||||||
|
is_slim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_custom_skin(uuid: String) -> UseQuery<CustomSkinQuery> {
|
||||||
|
use_query(
|
||||||
|
Query::new(CustomSkinKeys { uuid }, CustomSkinQuery)
|
||||||
|
.stale_time(SKIN_STALE)
|
||||||
|
.clean_time(SKIN_CLEAN),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate_skin_queries(uuid: Option<&str>) {
|
||||||
|
if let Some(uuid) = uuid {
|
||||||
|
QueriesStorage::<CustomSkinQuery>::invalidate_matching(CustomSkinKeys {
|
||||||
|
uuid: uuid.to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
QueriesStorage::<CustomSkinQuery>::invalidate_all().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
||||||
|
let custom_query = use_custom_skin(uuid.clone());
|
||||||
|
let custom_data = crate::hooks::settled_or_loading(&custom_query);
|
||||||
|
|
||||||
let profile = super::use_player_profile(uuid.clone(), None::<String>);
|
let profile = super::use_player_profile(uuid.clone(), None::<String>);
|
||||||
|
|
||||||
let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile)
|
let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile)
|
||||||
|
|
@ -11,22 +112,141 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
||||||
|
|
||||||
let skin_query = super::use_cached_image(skin_url.clone(), 256);
|
let skin_query = super::use_cached_image(skin_url.clone(), 256);
|
||||||
|
|
||||||
let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default());
|
let steve = AppAssets::get_bytes("steve.png").unwrap_or_default();
|
||||||
let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default());
|
let alex = AppAssets::get_bytes("alex.png").unwrap_or_default();
|
||||||
|
|
||||||
let default_slim = (java_string_hash(&uuid) & 1) == 1;
|
let default_slim = (java_string_hash(&uuid) & 1) == 1;
|
||||||
|
|
||||||
|
if let Some((bytes, is_slim)) = custom_data
|
||||||
|
.filter(|c| c.has_custom)
|
||||||
|
.and_then(|c| c.bytes.map(|b| (b, c.is_slim)))
|
||||||
|
{
|
||||||
|
return (bytes, is_slim);
|
||||||
|
}
|
||||||
|
|
||||||
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {
|
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {
|
||||||
Some((_, bytes)) => (bytes, is_slim),
|
Some((_, bytes)) => (bytes, is_slim),
|
||||||
None if default_slim => (alex.read().clone(), true),
|
None if default_slim => (alex, true),
|
||||||
None => (steve.read().clone(), false),
|
None => (steve, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn java_string_hash(s: &str) -> i32 {
|
pub fn java_string_hash(s: &str) -> i32 {
|
||||||
let mut h: i32 = 0;
|
let mut h: i32 = 0;
|
||||||
for c in s.encode_utf16() {
|
for c in s.encode_utf16() {
|
||||||
h = h.wrapping_mul(31).wrapping_add(c as i32);
|
h = h.wrapping_mul(31).wrapping_add(c as i32);
|
||||||
}
|
}
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn save_account_skin(
|
||||||
|
uuid: &str,
|
||||||
|
username: Option<&str>,
|
||||||
|
skin_bytes: &[u8],
|
||||||
|
is_slim: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let skins_folder = skins_dir().map_err(|e| e.to_string())?;
|
||||||
|
polyio::create_dir_all(&skins_folder)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let img = image::load_from_memory(skin_bytes)
|
||||||
|
.map_err(|e| format!("Invalid image format: {e}"))?
|
||||||
|
.to_rgba8();
|
||||||
|
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
|
||||||
|
let normalized_img = if w == 64 && h == 32 {
|
||||||
|
let mut new_img = image::RgbaImage::new(64, 64);
|
||||||
|
image::imageops::overlay(&mut new_img, &img, 0, 0);
|
||||||
|
new_img
|
||||||
|
} else if w == 64 && h == 64 {
|
||||||
|
img
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid Minecraft skin dimensions: {w}x{h}. Must be 64x64 or 64x32."
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut png_bytes = Vec::new();
|
||||||
|
let mut cursor = std::io::Cursor::new(&mut png_bytes);
|
||||||
|
normalized_img
|
||||||
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||||
|
.map_err(|e| format!("Failed to encode PNG: {e}"))?;
|
||||||
|
|
||||||
|
polyio::write(&skin_path, &png_bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write skin file: {e}"))?;
|
||||||
|
|
||||||
|
let meta = SkinMetadata { is_slim };
|
||||||
|
let meta_json = serde_json::to_string_pretty(&meta)
|
||||||
|
.map_err(|e| format!("Failed to serialize metadata: {e}"))?;
|
||||||
|
|
||||||
|
polyio::write(&meta_path, meta_json.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write skin metadata: {e}"))?;
|
||||||
|
|
||||||
|
if let Some(uname) = username.filter(|u| !u.is_empty()) {
|
||||||
|
let _ = sync_custom_skin_loader(uname, &png_bytes).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate_skin_queries(Some(uuid)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_account_skin(uuid: &str, username: Option<&str>) -> Result<(), String> {
|
||||||
|
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if skin_path.exists() {
|
||||||
|
let _ = polyio::remove_file(&skin_path).await;
|
||||||
|
}
|
||||||
|
if meta_path.exists() {
|
||||||
|
let _ = polyio::remove_file(&meta_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(uname) = username.filter(|u| !u.is_empty()) {
|
||||||
|
let _ = remove_custom_skin_loader(uname).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate_skin_queries(Some(uuid)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_custom_skin_loader(username: &str, skin_png: &[u8]) -> Result<(), String> {
|
||||||
|
if let Ok(mc_dir) = shared_minecraft_dir() {
|
||||||
|
let cached_dir = mc_dir.join("cachedImages").join("skins");
|
||||||
|
let _ = polyio::create_dir_all(&cached_dir).await;
|
||||||
|
let _ = polyio::write(&cached_dir.join(format!("{username}.png")), skin_png).await;
|
||||||
|
|
||||||
|
let cfg_dir = mc_dir.join("config").join("offlineskins");
|
||||||
|
let _ = polyio::create_dir_all(&cfg_dir).await;
|
||||||
|
let _ = polyio::write(&cfg_dir.join(format!("{username}.png")), skin_png).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_custom_skin_loader(username: &str) -> Result<(), String> {
|
||||||
|
if let Ok(mc_dir) = shared_minecraft_dir() {
|
||||||
|
let target1 = mc_dir
|
||||||
|
.join("cachedImages")
|
||||||
|
.join("skins")
|
||||||
|
.join(format!("{username}.png"));
|
||||||
|
if target1.exists() {
|
||||||
|
let _ = polyio::remove_file(&target1).await;
|
||||||
|
}
|
||||||
|
let target2 = mc_dir
|
||||||
|
.join("config")
|
||||||
|
.join("offlineskins")
|
||||||
|
.join(format!("{username}.png"));
|
||||||
|
if target2.exists() {
|
||||||
|
let _ = polyio::remove_file(&target2).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ use crate::components::{
|
||||||
ReleaseMigrationPopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay,
|
ReleaseMigrationPopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay,
|
||||||
use_provide_tooltips,
|
use_provide_tooltips,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
use crate::hooks::use_start_maximized;
|
|
||||||
use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash};
|
use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash};
|
||||||
use crate::layout::{HomeArtPrefetch, PendingLaunchDriver, use_ipc_commands};
|
use crate::layout::{HomeArtPrefetch, PendingLaunchDriver, use_ipc_commands};
|
||||||
use crate::motion::AnimationClockDriver;
|
use crate::motion::AnimationClockDriver;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
use std::cell::Cell;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cargo_packager_updater::{Config, Update, check_update};
|
|
||||||
use oneclient_events::{Choice, EventBus, Prompt};
|
use oneclient_events::{Choice, EventBus, Prompt};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::constants::{RELEASES_URL, UPDATER_ENDPOINT, UPDATER_PUBKEY};
|
use crate::constants::FORGEJO_API_LATEST_RELEASE;
|
||||||
|
|
||||||
pub const UPDATE_CHOICE_INSTALL: &str = "update.install";
|
pub const UPDATE_CHOICE_INSTALL: &str = "update.install";
|
||||||
|
|
||||||
|
|
@ -15,7 +14,7 @@ enum UpdateAnswer {
|
||||||
fn update_prompt(version: &str) -> Prompt<UpdateAnswer> {
|
fn update_prompt(version: &str) -> Prompt<UpdateAnswer> {
|
||||||
Prompt::new(
|
Prompt::new(
|
||||||
"Update available",
|
"Update available",
|
||||||
format!("OneClient {version} is ready to install. Download and install it now?"),
|
format!("OneLauncher-Cracked {version} is ready to install. Download and install it now?"),
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
Choice::primary(UPDATE_CHOICE_INSTALL, "Install"),
|
Choice::primary(UPDATE_CHOICE_INSTALL, "Install"),
|
||||||
|
|
@ -26,6 +25,99 @@ fn update_prompt(version: &str) -> Prompt<UpdateAnswer> {
|
||||||
|
|
||||||
const PROGRESS_STEP: u64 = 256 * 1024;
|
const PROGRESS_STEP: u64 = 256 * 1024;
|
||||||
|
|
||||||
|
/// SemVer parser supporting cracked revision suffixes like `2.2.3-c1`, `2.2.3.c1`, `v2.2.3-c2`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct CrackedVersion {
|
||||||
|
pub major: u64,
|
||||||
|
pub minor: u64,
|
||||||
|
pub patch: u64,
|
||||||
|
pub rev: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrackedVersion {
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
let trimmed = s.trim().trim_start_matches(['v', 'V']);
|
||||||
|
|
||||||
|
let (base, rev) = if let Some((base_part, rev_part)) = trimmed.split_once("-c").or_else(|| trimmed.split_once(".c")) {
|
||||||
|
let rev_num = rev_part.parse::<u64>().ok()?;
|
||||||
|
(base_part, rev_num)
|
||||||
|
} else if let Some((base_part, rev_part)) = trimmed.split_once("-r").or_else(|| trimmed.split_once(".r")) {
|
||||||
|
let rev_num = rev_part.parse::<u64>().ok()?;
|
||||||
|
(base_part, rev_num)
|
||||||
|
} else if let Some((base_part, rev_part)) = trimmed.split_once('-') {
|
||||||
|
let rev_num = rev_part.trim_start_matches(['c', 'C', 'r', 'R']).parse::<u64>().unwrap_or(0);
|
||||||
|
(base_part, rev_num)
|
||||||
|
} else {
|
||||||
|
(trimmed, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = base.split('.');
|
||||||
|
let major = parts.next()?.parse::<u64>().ok()?;
|
||||||
|
let minor = parts.next()?.parse::<u64>().ok()?;
|
||||||
|
let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
rev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct ForgejoRelease {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub html_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub assets: Vec<ForgejoReleaseAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type GithubRelease = ForgejoRelease;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct ForgejoReleaseAsset {
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type GithubReleaseAsset = ForgejoReleaseAsset;
|
||||||
|
|
||||||
|
impl ForgejoRelease {
|
||||||
|
pub fn find_installer_asset(&self) -> Option<&ForgejoReleaseAsset> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
self.assets.iter().find(|a| a.name.ends_with("_x64-setup.exe") || a.name.ends_with("-setup.exe") || a.name.ends_with(".exe"))
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if std::env::var_os("APPIMAGE").is_some() {
|
||||||
|
self.assets.iter().find(|a| a.name.ends_with(".AppImage"))
|
||||||
|
} else {
|
||||||
|
self.assets.iter().find(|a| a.name.ends_with(".deb") || a.name.ends_with(".AppImage") || a.name.ends_with(".rpm"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
#[cfg(target_arch = "aarch64")]
|
||||||
|
{
|
||||||
|
self.assets.iter().find(|a| a.name.ends_with("aarch64.dmg") || a.name.ends_with("arm64.dmg") || a.name.ends_with(".dmg"))
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "aarch64"))]
|
||||||
|
{
|
||||||
|
self.assets.iter().find(|a| a.name.ends_with("x64.dmg") || a.name.ends_with("x86_64.dmg") || a.name.ends_with(".dmg"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_update_check(auto_install: bool, events: EventBus) {
|
pub fn spawn_update_check(auto_install: bool, events: EventBus) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(err) = run_check(auto_install, events).await {
|
if let Err(err) = run_check(auto_install, events).await {
|
||||||
|
|
@ -44,7 +136,7 @@ pub fn spawn_simulated_update() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_simulated_update() -> anyhow::Result<()> {
|
async fn run_simulated_update() -> anyhow::Result<()> {
|
||||||
const FAKE_VERSION: &str = "9999.9999.9999";
|
const FAKE_VERSION: &str = "2.2.3-c99";
|
||||||
const FAKE_TOTAL: u64 = 48 * 1024 * 1024;
|
const FAKE_TOTAL: u64 = 48 * 1024 * 1024;
|
||||||
|
|
||||||
let events = crate::launcher::state()?.services.events.clone();
|
let events = crate::launcher::state()?.services.events.clone();
|
||||||
|
|
@ -55,7 +147,7 @@ async fn run_simulated_update() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let progress_id = Uuid::new_v4();
|
let progress_id = Uuid::new_v4();
|
||||||
let label = format!("Downloading OneClient {FAKE_VERSION}");
|
let label = format!("Downloading OneLauncher-Cracked {FAKE_VERSION}");
|
||||||
|
|
||||||
let mut downloaded = 0u64;
|
let mut downloaded = 0u64;
|
||||||
events.progress(progress_id, &label, downloaded, FAKE_TOTAL);
|
events.progress(progress_id, &label, downloaded, FAKE_TOTAL);
|
||||||
|
|
@ -68,41 +160,38 @@ async fn run_simulated_update() -> anyhow::Result<()> {
|
||||||
events.finish_progress(
|
events.finish_progress(
|
||||||
progress_id,
|
progress_id,
|
||||||
"Finished Downloading",
|
"Finished Downloading",
|
||||||
format!("OneClient {FAKE_VERSION} is ready. Restart to apply."),
|
format!("OneLauncher-Cracked {FAKE_VERSION} is ready. Restart to apply."),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_check(auto_install: bool, events: EventBus) -> anyhow::Result<()> {
|
async fn run_check(auto_install: bool, events: EventBus) -> anyhow::Result<()> {
|
||||||
// `check_update` performs a blocking HTTP request so offload it to a thread pool
|
let Some(release) = check_for_forgejo_update().await? else {
|
||||||
let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else {
|
|
||||||
tracing::info!("no update available");
|
tracing::info!("no update available");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!("update available: {}", update.version);
|
tracing::info!("update available: {}", release.tag_name);
|
||||||
|
|
||||||
// cargo-packager-updater can only replace an AppImage in place deb/rpm installs live
|
|
||||||
// under a package-managed path that an install would fail on or clobber
|
|
||||||
if !can_self_update() {
|
if !can_self_update() {
|
||||||
tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only");
|
tracing::info!("install is not self-updatable in-place; notifying with release URL");
|
||||||
events
|
events
|
||||||
.notify("Update available")
|
.notify("Update available")
|
||||||
.body(format!(
|
.body(format!(
|
||||||
"OneClient {} is available. Download the latest package from {} to update.",
|
"OneLauncher-Cracked {} is available. Download from {}",
|
||||||
update.version, RELEASES_URL
|
release.tag_name, release.html_url
|
||||||
))
|
))
|
||||||
.send();
|
.send();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auto_install && events.ask(update_prompt(&update.version)).await?.is_none() {
|
if !auto_install && events.ask(update_prompt(&release.tag_name)).await?.is_none() {
|
||||||
tracing::info!("user declined update {}", update.version);
|
tracing::info!("user declined update {}", release.tag_name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
download_and_install(update, events).await
|
download_and_install_forgejo(release, events).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn can_self_update() -> bool {
|
fn can_self_update() -> bool {
|
||||||
|
|
@ -116,70 +205,189 @@ fn can_self_update() -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
std::env::var_os("APPIMAGE").is_some()
|
std::env::var_os("APPIMAGE").is_some()
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
{
|
{
|
||||||
true
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_for_update() -> anyhow::Result<Option<Update>> {
|
async fn check_for_forgejo_update() -> anyhow::Result<Option<ForgejoRelease>> {
|
||||||
let current = env!("CARGO_PKG_VERSION").parse()?;
|
let client = reqwest::Client::builder()
|
||||||
let config = Config {
|
.user_agent("OneLauncher-Cracked-Updater")
|
||||||
endpoints: vec![UPDATER_ENDPOINT.parse()?],
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
pubkey: UPDATER_PUBKEY.into(),
|
.build()?;
|
||||||
..Default::default()
|
|
||||||
|
let resp = client
|
||||||
|
.get(FORGEJO_API_LATEST_RELEASE)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
tracing::debug!("Forgejo releases API returned status: {}", resp.status());
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let release: ForgejoRelease = resp.json().await?;
|
||||||
|
|
||||||
|
let current_str = env!("CARGO_PKG_VERSION");
|
||||||
|
let current_ver = CrackedVersion::parse(current_str).unwrap_or(CrackedVersion {
|
||||||
|
major: 2,
|
||||||
|
minor: 2,
|
||||||
|
patch: 3,
|
||||||
|
rev: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let remote_ver = match CrackedVersion::parse(&release.tag_name) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(check_update(current, config)?)
|
if remote_ver > current_ver {
|
||||||
|
Ok(Some(release))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_and_install(update: Update, events: EventBus) -> anyhow::Result<()> {
|
async fn download_and_install_forgejo(release: ForgejoRelease, events: EventBus) -> anyhow::Result<()> {
|
||||||
|
let Some(asset) = release.find_installer_asset() else {
|
||||||
|
events
|
||||||
|
.notify("Update available")
|
||||||
|
.body(format!(
|
||||||
|
"OneLauncher-Cracked {} is available at {}",
|
||||||
|
release.tag_name, release.html_url
|
||||||
|
))
|
||||||
|
.send();
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let progress_id = Uuid::new_v4();
|
let progress_id = Uuid::new_v4();
|
||||||
let version = update.version.clone();
|
let version = release.tag_name.clone();
|
||||||
let label = format!("Downloading OneClient {version}");
|
let label = format!("Downloading OneLauncher-Cracked {version}");
|
||||||
|
|
||||||
events.progress(progress_id, &label, 0, 0);
|
events.progress(progress_id, &label, 0, asset.size);
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
let client = reqwest::Client::builder()
|
||||||
let downloaded = Cell::new(0u64);
|
.user_agent("OneLauncher-Cracked-Updater")
|
||||||
let last_sent = Cell::new(0u64);
|
.build()?;
|
||||||
|
|
||||||
let bytes = update.download_extended(
|
let resp = client.get(&asset.browser_download_url).send().await?;
|
||||||
|chunk, total| {
|
if !resp.status().is_success() {
|
||||||
let now = downloaded.get() + chunk as u64;
|
anyhow::bail!("Failed to download update asset: HTTP {}", resp.status());
|
||||||
downloaded.set(now);
|
}
|
||||||
let total = total.unwrap_or(0);
|
|
||||||
|
|
||||||
if now == chunk as u64
|
let total_size = asset.size.max(resp.content_length().unwrap_or(0));
|
||||||
|| (total > 0 && now >= total)
|
|
||||||
|| now - last_sent.get() >= PROGRESS_STEP
|
|
||||||
{
|
|
||||||
last_sent.set(now);
|
|
||||||
events.progress(progress_id, &label, now, total);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let total = downloaded.get().max(1);
|
#[cfg(target_os = "windows")]
|
||||||
events.progress(progress_id, &label, total, total);
|
let target_file = std::env::temp_dir().join(&asset.name);
|
||||||
|
|
||||||
update.install(bytes)?;
|
#[cfg(target_os = "linux")]
|
||||||
|
let (target_file, is_appimage, final_appimage_dest) = if let Some(appimage) = std::env::var_os("APPIMAGE") {
|
||||||
|
let dest = PathBuf::from(appimage);
|
||||||
|
let temp_download = dest.with_extension("new_appimage");
|
||||||
|
(temp_download, true, Some(dest))
|
||||||
|
} else {
|
||||||
|
(std::env::temp_dir().join(&asset.name), false, None)
|
||||||
|
};
|
||||||
|
|
||||||
// Converts the same download card into its finished state rather than adding a second
|
#[cfg(target_os = "macos")]
|
||||||
|
let target_file = std::env::temp_dir().join(&asset.name);
|
||||||
|
|
||||||
|
let mut file = tokio::fs::File::create(&target_file).await?;
|
||||||
|
let mut downloaded = 0u64;
|
||||||
|
let mut last_sent = 0u64;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
|
||||||
|
while let Some(chunk_res) = stream.next().await {
|
||||||
|
let chunk = chunk_res?;
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk).await?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
|
||||||
|
if downloaded - last_sent >= PROGRESS_STEP || (total_size > 0 && downloaded >= total_size) {
|
||||||
|
last_sent = downloaded;
|
||||||
|
events.progress(progress_id, &label, downloaded, total_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::io::AsyncWriteExt::flush(&mut file).await?;
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
events.progress(progress_id, &label, total_size, total_size);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
events.finish_progress(
|
events.finish_progress(
|
||||||
progress_id,
|
progress_id,
|
||||||
"Finished Downloading",
|
"Launching Installer",
|
||||||
format!("OneClient {version} is ready. Restart to apply."),
|
format!("OneLauncher-Cracked {version} ready. Opening installer..."),
|
||||||
);
|
);
|
||||||
Ok(())
|
let _ = std::process::Command::new(&target_file).spawn();
|
||||||
})
|
}
|
||||||
.await??;
|
|
||||||
|
|
||||||
tracing::info!("update installed; restart to apply");
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if is_appimage && let Some(dest) = final_appimage_dest {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(&target_file, std::fs::Permissions::from_mode(0o755));
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&target_file, &dest).await?;
|
||||||
|
events.finish_progress(
|
||||||
|
progress_id,
|
||||||
|
"Finished Updating",
|
||||||
|
format!("OneLauncher-Cracked {version} is ready. Restart to apply."),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
events.finish_progress(
|
||||||
|
progress_id,
|
||||||
|
"Download Complete",
|
||||||
|
format!("Downloaded {} to {}", asset.name, target_file.display()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
events.finish_progress(
|
||||||
|
progress_id,
|
||||||
|
"Download Complete",
|
||||||
|
format!("Downloaded {} to {}. Open to install.", asset.name, target_file.display()),
|
||||||
|
);
|
||||||
|
let _ = open::that(&target_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("update download and installation handling completed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cracked_version_ordering() {
|
||||||
|
let v1 = CrackedVersion::parse("2.2.3-c1").unwrap();
|
||||||
|
let v2 = CrackedVersion::parse("2.2.3-c2").unwrap();
|
||||||
|
let v3 = CrackedVersion::parse("v2.2.4-c1").unwrap();
|
||||||
|
let v_base = CrackedVersion::parse("v2.2.3").unwrap();
|
||||||
|
|
||||||
|
assert!(v2 > v1);
|
||||||
|
assert!(v3 > v2);
|
||||||
|
assert!(v1 > v_base);
|
||||||
|
assert_eq!(v1, CrackedVersion { major: 2, minor: 2, patch: 3, rev: 1 });
|
||||||
|
assert_eq!(v2, CrackedVersion { major: 2, minor: 2, patch: 3, rev: 2 });
|
||||||
|
assert_eq!(v3, CrackedVersion { major: 2, minor: 2, patch: 4, rev: 1 });
|
||||||
|
assert_eq!(v_base, CrackedVersion { major: 2, minor: 2, patch: 3, rev: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -584,8 +584,9 @@ mod tests {
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
override_type: OverrideType,
|
override_type: OverrideType,
|
||||||
) -> HashMap<(String, String), String> {
|
) -> HashMap<(String, String), String> {
|
||||||
|
let bundle_name = archive("performance", true, Vec::new()).manifest.name;
|
||||||
HashMap::from([(
|
HashMap::from([(
|
||||||
("performance".to_string(), project_id.to_string()),
|
(bundle_name, project_id.to_string()),
|
||||||
override_type.as_str().to_string(),
|
override_type.as_str().to_string(),
|
||||||
)])
|
)])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ impl Component for SettingsAccounts {
|
||||||
let mut username = use_state(String::new);
|
let mut username = use_state(String::new);
|
||||||
let mut show_offline = use_state(|| false);
|
let mut show_offline = use_state(|| false);
|
||||||
let mut closing_offline = use_state(|| false);
|
let mut closing_offline = use_state(|| false);
|
||||||
|
let edit_account = use_state(|| None::<(Uuid, String)>);
|
||||||
|
let edit_name = use_state(String::new);
|
||||||
|
|
||||||
use_side_effect(move || {
|
use_side_effect(move || {
|
||||||
if !*closing_offline.read() {
|
if !*closing_offline.read() {
|
||||||
|
|
@ -94,6 +96,8 @@ impl Component for SettingsAccounts {
|
||||||
remove,
|
remove,
|
||||||
refresh,
|
refresh,
|
||||||
sign_in: msa.clone(),
|
sign_in: msa.clone(),
|
||||||
|
edit_account,
|
||||||
|
edit_name,
|
||||||
}
|
}
|
||||||
.into_element()
|
.into_element()
|
||||||
})
|
})
|
||||||
|
|
@ -125,6 +129,78 @@ impl Component for SettingsAccounts {
|
||||||
show_offline,
|
show_offline,
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
|
.maybe_child(edit_account.read().as_ref().map(|(id, _)| {
|
||||||
|
let id = *id;
|
||||||
|
let mut edit_account = edit_account;
|
||||||
|
OverlayPopup::new()
|
||||||
|
.on_close(move |()| edit_account.set(None))
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.width(Size::window_percent(100.))
|
||||||
|
.height(Size::window_percent(100.))
|
||||||
|
.center()
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.width(Size::px(380.))
|
||||||
|
.max_width(Size::window_percent(90.))
|
||||||
|
.spacing(16.)
|
||||||
|
.padding(Gaps::new_all(20.))
|
||||||
|
.corner_radius(CornerRadius::new_all(16.))
|
||||||
|
.background(colors::page_elevated())
|
||||||
|
.border(border_all_color(1., colors::component_border()))
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Edit offline username")
|
||||||
|
.font_size(18.)
|
||||||
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.width(Size::fill())
|
||||||
|
.spacing(6.)
|
||||||
|
.child(field_label("New Username"))
|
||||||
|
.child(TextInput::new(edit_name).placeholder("Enter new username")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.width(Size::fill())
|
||||||
|
.main_align(Alignment::End)
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.ghost()
|
||||||
|
.on_press(move |_| edit_account.set(None))
|
||||||
|
.text("Cancel"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.on_press(move |_| {
|
||||||
|
let new_name = edit_name.peek().trim().to_string();
|
||||||
|
if !new_name.is_empty() {
|
||||||
|
let mut edit_account = edit_account;
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(state) = crate::launcher::state() {
|
||||||
|
let _ = state.auth.remove_account(id).await;
|
||||||
|
if let Ok(new_acc) = state.auth.add_offline_account(new_name).await {
|
||||||
|
let _ = state.auth.set_default_account(Some(new_acc.id)).await;
|
||||||
|
crate::hooks::invalidate_auth_queries(Some(new_acc.id)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit_account.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.text("Save"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}))
|
||||||
.maybe_child(msa.popup())
|
.maybe_child(msa.popup())
|
||||||
.into_element()
|
.into_element()
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +208,7 @@ impl Component for SettingsAccounts {
|
||||||
|
|
||||||
fn hero(
|
fn hero(
|
||||||
account: Option<MinecraftAccount>,
|
account: Option<MinecraftAccount>,
|
||||||
has_microsoft: bool,
|
_has_microsoft: bool,
|
||||||
microsoft_pending: bool,
|
microsoft_pending: bool,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
on_open_offline: impl FnMut(Event<PressEventData>) + 'static,
|
on_open_offline: impl FnMut(Event<PressEventData>) + 'static,
|
||||||
|
|
@ -219,16 +295,25 @@ fn hero(
|
||||||
.child(
|
.child(
|
||||||
Button::new()
|
Button::new()
|
||||||
.secondary()
|
.secondary()
|
||||||
.enabled(has_microsoft)
|
.enabled(true)
|
||||||
.on_press(on_open_offline)
|
.on_press(on_open_offline)
|
||||||
.child(Icon::new(IconType::Plus).size(16.))
|
.child(Icon::new(IconType::Plus).size(16.))
|
||||||
.text("Add offline"),
|
.text("Add offline"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.secondary()
|
||||||
|
.on_press(|_| {
|
||||||
|
let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {});
|
||||||
|
})
|
||||||
|
.child(Icon::new(IconType::Colors).size(16.))
|
||||||
|
.text("Change Skin"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map(error, |el, msg| {
|
.map(error, |el, msg| {
|
||||||
el.child(hint_line(IconType::AlertTriangle, msg, colors::danger()))
|
el.child(hint_line(IconType::AlertTriangle, msg, colors::danger()))
|
||||||
})
|
})
|
||||||
.maybe(!has_microsoft, |el| {
|
.maybe(false, |el| {
|
||||||
el.child(hint_line(
|
el.child(hint_line(
|
||||||
IconType::InfoCircle,
|
IconType::InfoCircle,
|
||||||
"Add a Microsoft account before creating offline accounts."
|
"Add a Microsoft account before creating offline accounts."
|
||||||
|
|
@ -417,6 +502,8 @@ struct AccountRow {
|
||||||
remove: crate::hooks::UseRemoveAccount,
|
remove: crate::hooks::UseRemoveAccount,
|
||||||
refresh: crate::hooks::UseRefreshAccount,
|
refresh: crate::hooks::UseRefreshAccount,
|
||||||
sign_in: MicrosoftLogin,
|
sign_in: MicrosoftLogin,
|
||||||
|
edit_account: State<Option<(Uuid, String)>>,
|
||||||
|
edit_name: State<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for AccountRow {
|
impl PartialEq for AccountRow {
|
||||||
|
|
@ -428,6 +515,8 @@ impl PartialEq for AccountRow {
|
||||||
&& self.signed_out == other.signed_out
|
&& self.signed_out == other.signed_out
|
||||||
&& self.is_default == other.is_default
|
&& self.is_default == other.is_default
|
||||||
&& self.sign_in.pending == other.sign_in.pending
|
&& self.sign_in.pending == other.sign_in.pending
|
||||||
|
&& self.edit_account == other.edit_account
|
||||||
|
&& self.edit_name == other.edit_name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,14 +683,43 @@ impl Component for AccountRow {
|
||||||
Button::new()
|
Button::new()
|
||||||
.ghost()
|
.ghost()
|
||||||
.icon()
|
.icon()
|
||||||
.enabled(false)
|
.tooltip("Change Skin")
|
||||||
.tooltip("Edit")
|
.on_press(move |e: Event<PressEventData>| {
|
||||||
|
e.stop_propagation();
|
||||||
|
let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {});
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Icon::new(IconType::Colors)
|
||||||
|
.size(18.)
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child({
|
||||||
|
let is_offline = !is_microsoft;
|
||||||
|
let mut edit_acc_state = self.edit_account;
|
||||||
|
let mut edit_name_state = self.edit_name;
|
||||||
|
let username_val = self.username.clone();
|
||||||
|
let id_val = self.id;
|
||||||
|
Button::new()
|
||||||
|
.ghost()
|
||||||
|
.icon()
|
||||||
|
.enabled(is_offline)
|
||||||
|
.tooltip("Edit username")
|
||||||
|
.on_press(move |e: Event<PressEventData>| {
|
||||||
|
e.stop_propagation();
|
||||||
|
edit_name_state.set(username_val.clone());
|
||||||
|
edit_acc_state.set(Some((id_val, username_val.clone())));
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
Icon::new(IconType::Pencil01)
|
Icon::new(IconType::Pencil01)
|
||||||
.size(18.)
|
.size(18.)
|
||||||
.color(colors::fg_secondary()),
|
.color(if is_offline {
|
||||||
),
|
colors::fg_primary()
|
||||||
)
|
} else {
|
||||||
|
colors::fg_secondary()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
Button::new()
|
Button::new()
|
||||||
.ghost()
|
.ghost()
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,37 @@
|
||||||
use freya::prelude::*;
|
use freya::prelude::*;
|
||||||
|
use oneclient_auth::MinecraftAccount;
|
||||||
|
|
||||||
use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel};
|
use crate::components::{Button, Icon, IconType, PlayerModel};
|
||||||
use crate::hooks::{try_default_account, use_current_account};
|
use crate::hooks::{
|
||||||
|
delete_account_skin, save_account_skin, try_accounts, try_default_account, use_accounts,
|
||||||
|
use_current_account, use_custom_skin,
|
||||||
|
};
|
||||||
use crate::theme::colors;
|
use crate::theme::colors;
|
||||||
use crate::ui::border_all_color;
|
use crate::ui::border_all_color;
|
||||||
|
|
||||||
const ANIMATIONS: &[&str] = &["Idle", "Walking", "Running", "Crouch", "Flying"];
|
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
pub struct AccountSkins;
|
pub struct AccountSkins;
|
||||||
|
|
||||||
impl Component for AccountSkins {
|
impl Component for AccountSkins {
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let account_uuid = try_default_account(&use_current_account()).map(|a| a.id.to_string());
|
let current_account = use_current_account();
|
||||||
|
let default_account = try_default_account(¤t_account);
|
||||||
|
let accounts = try_accounts(&use_accounts()).unwrap_or_default();
|
||||||
|
|
||||||
|
let selected_id = use_state(|| default_account.as_ref().map(|a| a.id));
|
||||||
|
|
||||||
|
// Keep selected account in sync if none is explicitly picked
|
||||||
|
let active_account = selected_id
|
||||||
|
.read()
|
||||||
|
.and_then(|id| accounts.iter().find(|a| a.id == id).cloned())
|
||||||
|
.or_else(|| default_account.clone());
|
||||||
|
|
||||||
|
let account_uuid = active_account.as_ref().map(|a| a.id.to_string());
|
||||||
|
let account_id = active_account.as_ref().map(|a| a.id);
|
||||||
|
let username = active_account.as_ref().map(|a| a.username.clone());
|
||||||
|
|
||||||
|
let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error)
|
||||||
|
let is_loading = use_state(|| false);
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
.horizontal()
|
.horizontal()
|
||||||
|
|
@ -20,150 +39,510 @@ impl Component for AccountSkins {
|
||||||
.height(Size::fill())
|
.height(Size::fill())
|
||||||
.overflow(Overflow::Clip)
|
.overflow(Overflow::Clip)
|
||||||
.padding(40.)
|
.padding(40.)
|
||||||
.spacing(24.)
|
.spacing(28.)
|
||||||
.child(preview_panel(account_uuid))
|
.child(PreviewPanel {
|
||||||
.child(side_panel())
|
account_uuid,
|
||||||
|
account_id,
|
||||||
|
username,
|
||||||
|
status_msg,
|
||||||
|
})
|
||||||
|
.child(side_panel(
|
||||||
|
active_account,
|
||||||
|
accounts,
|
||||||
|
selected_id,
|
||||||
|
status_msg,
|
||||||
|
is_loading,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preview_panel(account_uuid: Option<String>) -> impl IntoElement {
|
#[derive(PartialEq, Clone)]
|
||||||
|
struct PreviewPanel {
|
||||||
|
account_uuid: Option<String>,
|
||||||
|
account_id: Option<uuid::Uuid>,
|
||||||
|
username: Option<String>,
|
||||||
|
status_msg: State<Option<(String, bool)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for PreviewPanel {
|
||||||
|
fn render_key(&self) -> DiffKey {
|
||||||
|
DiffKey::from(&self.account_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self) -> impl IntoElement {
|
||||||
|
let account_uuid = self.account_uuid.clone();
|
||||||
|
let custom_query = use_custom_skin(account_uuid.clone().unwrap_or_default());
|
||||||
|
let custom_data = crate::hooks::settled_or_loading(&custom_query);
|
||||||
|
let is_slim = custom_data.as_ref().is_some_and(|d| d.is_slim);
|
||||||
|
let has_custom = custom_data.as_ref().is_some_and(|d| d.has_custom);
|
||||||
|
|
||||||
|
let acc_id_copy = self.account_id;
|
||||||
|
let username_copy = self.username.clone();
|
||||||
|
let status_msg = self.status_msg;
|
||||||
|
|
||||||
|
let set_classic = {
|
||||||
|
let acc_id = acc_id_copy;
|
||||||
|
let uname = username_copy.clone();
|
||||||
|
let mut status = status_msg;
|
||||||
|
move |_| {
|
||||||
|
let Some(id) = acc_id else { return };
|
||||||
|
let uname = uname.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||||
|
if let Ok(bytes) = polyio::read(&skin_path).await {
|
||||||
|
let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, false)
|
||||||
|
.await;
|
||||||
|
status.set(Some((
|
||||||
|
"Model set to Classic (4px arms)".to_string(),
|
||||||
|
false,
|
||||||
|
)));
|
||||||
|
} else if let Some(steve_bytes) = crate::AppAssets::get_bytes("steve.png") {
|
||||||
|
let _ =
|
||||||
|
save_account_skin(&uuid_str, uname.as_deref(), &steve_bytes, false)
|
||||||
|
.await;
|
||||||
|
status.set(Some(("Model set to Classic".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_slim = {
|
||||||
|
let acc_id = acc_id_copy;
|
||||||
|
let uname = username_copy.clone();
|
||||||
|
let mut status = status_msg;
|
||||||
|
move |_| {
|
||||||
|
let Some(id) = acc_id else { return };
|
||||||
|
let uname = uname.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||||
|
if let Ok(bytes) = polyio::read(&skin_path).await {
|
||||||
|
let _ =
|
||||||
|
save_account_skin(&uuid_str, uname.as_deref(), &bytes, true).await;
|
||||||
|
status.set(Some(("Model set to Slim (3px arms)".to_string(), false)));
|
||||||
|
} else if let Some(alex_bytes) = crate::AppAssets::get_bytes("alex.png") {
|
||||||
|
let _ =
|
||||||
|
save_account_skin(&uuid_str, uname.as_deref(), &alex_bytes, true)
|
||||||
|
.await;
|
||||||
|
status.set(Some(("Model set to Slim".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.width(Size::px(340.))
|
||||||
|
.height(Size::fill())
|
||||||
|
.spacing(16.)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.width(Size::fill())
|
||||||
|
.height(Size::flex(1.0))
|
||||||
|
.corner_radius(CornerRadius::new_all(16.))
|
||||||
|
.background(colors::page_elevated())
|
||||||
|
.border(border_all_color(1., colors::component_border()))
|
||||||
|
.child(match account_uuid {
|
||||||
|
Some(uuid) => PlayerModel::new(uuid)
|
||||||
|
.width(Size::fill())
|
||||||
|
.height(Size::fill())
|
||||||
|
.into_element(),
|
||||||
|
None => Icon::new(IconType::Users01)
|
||||||
|
.size(64.)
|
||||||
|
.color(colors::fg_secondary())
|
||||||
|
.into_element(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.width(Size::fill())
|
||||||
|
.main_align(Alignment::Center)
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.padding(Gaps::new_symmetric(6., 14.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(if !is_slim {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_bg()
|
||||||
|
})
|
||||||
|
.cursor(CursorIcon::Pointer)
|
||||||
|
.on_press(set_classic)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Classic (4px)")
|
||||||
|
.font_size(12.)
|
||||||
|
.font_weight(if !is_slim {
|
||||||
|
FontWeight::SEMI_BOLD
|
||||||
|
} else {
|
||||||
|
FontWeight::NORMAL
|
||||||
|
})
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.padding(Gaps::new_symmetric(6., 14.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(if is_slim {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_bg()
|
||||||
|
})
|
||||||
|
.cursor(CursorIcon::Pointer)
|
||||||
|
.on_press(set_slim)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Slim (3px)")
|
||||||
|
.font_size(12.)
|
||||||
|
.font_weight(if is_slim {
|
||||||
|
FontWeight::SEMI_BOLD
|
||||||
|
} else {
|
||||||
|
FontWeight::NORMAL
|
||||||
|
})
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.maybe_child(has_custom.then(|| {
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Custom Skin Active")
|
||||||
|
.font_size(11.)
|
||||||
|
.color(colors::success()),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
|
}))
|
||||||
|
.into_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn side_panel(
|
||||||
|
active_account: Option<MinecraftAccount>,
|
||||||
|
accounts: Vec<MinecraftAccount>,
|
||||||
|
selected_id: State<Option<uuid::Uuid>>,
|
||||||
|
status_msg: State<Option<(String, bool)>>,
|
||||||
|
is_loading: State<bool>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let username = active_account
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.username.clone())
|
||||||
|
.unwrap_or_else(|| "No Account Selected".to_string());
|
||||||
|
let account_id = active_account.as_ref().map(|a| a.id);
|
||||||
|
let username_opt = active_account.as_ref().map(|a| a.username.clone());
|
||||||
|
|
||||||
|
// Import from local file
|
||||||
|
let id_for_file = account_id;
|
||||||
|
let uname_for_file = username_opt.clone();
|
||||||
|
let mut status_file = status_msg;
|
||||||
|
let mut loading_file = is_loading;
|
||||||
|
|
||||||
|
let import_from_file = move |_| {
|
||||||
|
let Some(id) = id_for_file else { return };
|
||||||
|
let uname = uname_for_file.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
|
||||||
|
loading_file.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
if let Some(handle) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("Select Minecraft Skin PNG")
|
||||||
|
.add_filter("Minecraft Skin (*.png)", &["png"])
|
||||||
|
.pick_file()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let bytes = handle.read().await;
|
||||||
|
match save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await {
|
||||||
|
Ok(()) => {
|
||||||
|
status_file.set(Some((
|
||||||
|
"Custom skin imported successfully!".to_string(),
|
||||||
|
false,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
status_file.set(Some((err, true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading_file.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download/Export skin
|
||||||
|
let id_for_dl = account_id;
|
||||||
|
let uname_for_dl = username_opt.clone();
|
||||||
|
let mut status_dl = status_msg;
|
||||||
|
|
||||||
|
let download_skin = move |_| {
|
||||||
|
let Some(id) = id_for_dl else { return };
|
||||||
|
let uname = uname_for_dl.clone().unwrap_or_else(|| "skin".to_string());
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let skin_bytes =
|
||||||
|
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||||
|
polyio::read(&skin_path).await.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes_to_save = match skin_bytes {
|
||||||
|
Some(b) => b,
|
||||||
|
None => crate::AppAssets::get_bytes("steve.png")
|
||||||
|
.map(|b| b.to_vec())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(handle) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("Save Skin PNG")
|
||||||
|
.set_file_name(format!("{uname}_skin.png"))
|
||||||
|
.add_filter("PNG Image (*.png)", &["png"])
|
||||||
|
.save_file()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if polyio::write(handle.path(), &bytes_to_save).await.is_ok() {
|
||||||
|
status_dl.set(Some(("Skin exported successfully!".to_string(), false)));
|
||||||
|
} else {
|
||||||
|
status_dl.set(Some(("Failed to save skin file".to_string(), true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset skin to default
|
||||||
|
let id_for_reset = account_id;
|
||||||
|
let uname_for_reset = username_opt.clone();
|
||||||
|
let mut status_reset = status_msg;
|
||||||
|
|
||||||
|
let reset_skin = move |_| {
|
||||||
|
let Some(id) = id_for_reset else { return };
|
||||||
|
let uname = uname_for_reset.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
match delete_account_skin(&uuid_str, uname.as_deref()).await {
|
||||||
|
Ok(()) => {
|
||||||
|
status_reset
|
||||||
|
.set(Some(("Skin reset to default skin.".to_string(), false)));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
status_reset.set(Some((err, true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
.vertical()
|
.vertical()
|
||||||
.width(Size::px(320.))
|
.width(Size::flex(1.0))
|
||||||
.height(Size::fill())
|
.height(Size::fill())
|
||||||
.spacing(16.)
|
.spacing(24.)
|
||||||
.child(
|
.child(
|
||||||
rect()
|
rect()
|
||||||
.center()
|
.vertical()
|
||||||
.width(Size::fill())
|
.spacing(4.)
|
||||||
.height(Size::flex(1.0))
|
.child(
|
||||||
.corner_radius(CornerRadius::new_all(16.))
|
label()
|
||||||
.background(colors::page_elevated())
|
.text("Skin Customizer")
|
||||||
.border(border_all_color(1., colors::component_border()))
|
.font_size(24.)
|
||||||
.child(match account_uuid {
|
.font_weight(FontWeight::BOLD)
|
||||||
Some(uuid) => PlayerModel::new(uuid)
|
.color(colors::fg_primary()),
|
||||||
.width(Size::fill())
|
)
|
||||||
.height(Size::fill())
|
.child(
|
||||||
.into_element(),
|
label()
|
||||||
None => Icon::new(IconType::Users01)
|
.text(format!("Managing skin for {username}"))
|
||||||
.size(64.)
|
.font_size(13.)
|
||||||
.color(colors::fg_secondary())
|
.color(colors::fg_secondary()),
|
||||||
.into_element(),
|
),
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.child(
|
// Status message notification banner
|
||||||
|
.maybe_child(status_msg.read().as_ref().map(|(msg, is_err)| {
|
||||||
|
let is_err = *is_err;
|
||||||
rect()
|
rect()
|
||||||
.horizontal()
|
.horizontal()
|
||||||
.width(Size::fill())
|
.width(Size::fill())
|
||||||
.main_align(Alignment::Center)
|
.padding(Gaps::new_symmetric(10., 14.))
|
||||||
.spacing(8.)
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
.children(
|
.background(if is_err {
|
||||||
ANIMATIONS
|
colors::danger().with_a(30)
|
||||||
.iter()
|
} else {
|
||||||
.enumerate()
|
colors::success().with_a(30)
|
||||||
.map(|(i, name)| anim_chip(name, i == 0).into_element()),
|
})
|
||||||
|
.border(border_all_color(
|
||||||
|
1.,
|
||||||
|
if is_err {
|
||||||
|
colors::danger()
|
||||||
|
} else {
|
||||||
|
colors::success()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text(msg.clone())
|
||||||
|
.font_size(13.)
|
||||||
|
.color(if is_err {
|
||||||
|
colors::danger()
|
||||||
|
} else {
|
||||||
|
colors::success()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
|
}))
|
||||||
|
// Account selector dropdown/buttons
|
||||||
|
.child(account_selector(accounts, selected_id))
|
||||||
|
// Action buttons
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(12.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.enabled(!*is_loading.read() && account_id.is_some())
|
||||||
|
.on_press(import_from_file)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(Icon::new(IconType::FilePlus02).size(18.))
|
||||||
|
.child(label().text("Upload PNG from Computer").font_size(14.)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(12.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.ghost()
|
||||||
|
.enabled(!*is_loading.read() && account_id.is_some())
|
||||||
|
.on_press(download_skin)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(Icon::new(IconType::Download01).size(16.))
|
||||||
|
.child(label().text("Save PNG").font_size(13.)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.ghost()
|
||||||
|
.enabled(!*is_loading.read() && account_id.is_some())
|
||||||
|
.on_press(reset_skin)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(
|
||||||
|
Icon::new(IconType::Trash01)
|
||||||
|
.size(16.)
|
||||||
|
.color(colors::danger()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Reset to Default")
|
||||||
|
.font_size(13.)
|
||||||
|
.color(colors::danger()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Information note
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.padding(Gaps::new_all(14.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(colors::component_bg())
|
||||||
|
.spacing(6.)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Offline Skin System")
|
||||||
|
.font_size(13.)
|
||||||
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Custom skins are saved locally to your profile and seamlessly loaded into singleplayer and offline servers.")
|
||||||
|
.font_size(12.)
|
||||||
|
.color(colors::fg_secondary()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.into_element()
|
.into_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn anim_chip(name: &'static str, active: bool) -> impl IntoElement {
|
fn account_selector(
|
||||||
rect()
|
accounts: Vec<MinecraftAccount>,
|
||||||
.center()
|
selected_id: State<Option<uuid::Uuid>>,
|
||||||
.padding(Gaps::new_symmetric(6., 12.))
|
) -> impl IntoElement {
|
||||||
.corner_radius(CornerRadius::new_all(8.))
|
let mut selected_state = selected_id;
|
||||||
.background(if active {
|
|
||||||
colors::brand()
|
|
||||||
} else {
|
|
||||||
colors::component_bg()
|
|
||||||
})
|
|
||||||
.cursor(CursorIcon::Pointer)
|
|
||||||
.child(
|
|
||||||
label()
|
|
||||||
.text(name)
|
|
||||||
.font_size(12.)
|
|
||||||
.color(colors::fg_primary()),
|
|
||||||
)
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn side_panel() -> impl IntoElement {
|
|
||||||
rect()
|
rect()
|
||||||
.vertical()
|
.vertical()
|
||||||
.width(Size::flex(1.0))
|
.spacing(8.)
|
||||||
.height(Size::fill())
|
|
||||||
.overflow(Overflow::Clip)
|
|
||||||
.spacing(20.)
|
|
||||||
.child(
|
.child(
|
||||||
label()
|
label()
|
||||||
.text("Skins")
|
.text("SELECT ACCOUNT")
|
||||||
.font_size(32.)
|
.font_size(11.)
|
||||||
.font_weight(FontWeight::BOLD)
|
.font_weight(FontWeight::BOLD)
|
||||||
.color(colors::fg_primary()),
|
.color(colors::fg_secondary()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
rect()
|
rect()
|
||||||
.horizontal()
|
.horizontal()
|
||||||
.spacing(12.)
|
.spacing(8.)
|
||||||
.child(action_button("Import Skin", IconType::Plus, true))
|
.content(Content::Flex)
|
||||||
.child(action_button("Download", IconType::Download01, false))
|
.children(accounts.into_iter().map(|acc| {
|
||||||
.child(action_button("Remove", IconType::Trash01, false)),
|
let id = acc.id;
|
||||||
|
let uname = acc.username.clone();
|
||||||
|
let is_active = *selected_state.read() == Some(id);
|
||||||
|
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.padding(Gaps::new_symmetric(8., 16.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(if is_active {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_bg()
|
||||||
|
})
|
||||||
|
.border(border_all_color(
|
||||||
|
1.,
|
||||||
|
if is_active {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_border()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.cursor(CursorIcon::Pointer)
|
||||||
|
.on_press(move |_| selected_state.set(Some(id)))
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text(uname)
|
||||||
|
.font_size(13.)
|
||||||
|
.font_weight(if is_active {
|
||||||
|
FontWeight::SEMI_BOLD
|
||||||
|
} else {
|
||||||
|
FontWeight::NORMAL
|
||||||
|
})
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
.child(section_label("SKIN HISTORY"))
|
|
||||||
.child(skin_grid(8))
|
|
||||||
.child(section_label("CAPES"))
|
|
||||||
.child(skin_grid(4))
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement {
|
|
||||||
Button::new()
|
|
||||||
.variant(if primary {
|
|
||||||
ButtonVariant::Primary
|
|
||||||
} else {
|
|
||||||
ButtonVariant::Secondary
|
|
||||||
})
|
|
||||||
.child(Icon::new(icon).size(16.).color(colors::fg_primary()))
|
|
||||||
.text(text)
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn section_label(text: &'static str) -> impl IntoElement {
|
|
||||||
label()
|
|
||||||
.text(text)
|
|
||||||
.font_size(11.)
|
|
||||||
.font_weight(FontWeight::SEMI_BOLD)
|
|
||||||
.color(colors::fg_secondary())
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skin_grid(count: usize) -> impl IntoElement {
|
|
||||||
rect()
|
|
||||||
.horizontal()
|
|
||||||
.width(Size::fill())
|
|
||||||
.spacing(12.)
|
|
||||||
.children((0..count).map(|i| {
|
|
||||||
rect()
|
|
||||||
.center()
|
|
||||||
.width(Size::px(64.))
|
|
||||||
.height(Size::px(80.))
|
|
||||||
.corner_radius(CornerRadius::new_all(8.))
|
|
||||||
.background(colors::page_elevated())
|
|
||||||
.border(border_all_color(
|
|
||||||
if i == 0 { 2. } else { 1. },
|
|
||||||
if i == 0 {
|
|
||||||
colors::brand()
|
|
||||||
} else {
|
|
||||||
colors::component_border()
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.cursor(CursorIcon::Pointer)
|
|
||||||
.child(
|
|
||||||
Icon::new(IconType::Users01)
|
|
||||||
.size(24.)
|
|
||||||
.color(colors::fg_secondary()),
|
|
||||||
)
|
|
||||||
.into_element()
|
|
||||||
}))
|
|
||||||
.into_element()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
use freya::prelude::*;
|
use freya::prelude::*;
|
||||||
|
use freya::query::MutationStateData;
|
||||||
|
use freya::router::RouterContext;
|
||||||
use oneclient_auth::MinecraftAccount;
|
use oneclient_auth::MinecraftAccount;
|
||||||
|
|
||||||
use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login};
|
use crate::components::{
|
||||||
use crate::hooks::{try_default_account, use_current_account};
|
Avatar, Button, Icon, IconType, TextInput, use_microsoft_login,
|
||||||
|
};
|
||||||
|
use crate::hooks::{
|
||||||
|
AddOfflineAccountKeys, try_default_account, use_add_offline_account, use_current_account,
|
||||||
|
};
|
||||||
use crate::routes::Route;
|
use crate::routes::Route;
|
||||||
use crate::theme::colors;
|
use crate::theme::colors;
|
||||||
use crate::view::onboarding::{
|
use crate::view::onboarding::{
|
||||||
onboarding_illustration, onboarding_nav, onboarding_page, step_heading,
|
onboarding_illustration, onboarding_page, step_heading,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
|
|
@ -16,9 +22,30 @@ impl Component for OnboardingAccount {
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let account_query = use_current_account();
|
let account_query = use_current_account();
|
||||||
let msa = use_microsoft_login();
|
let msa = use_microsoft_login();
|
||||||
|
let add_offline = use_add_offline_account();
|
||||||
|
|
||||||
|
let offline_name = use_state(|| "Player".to_string());
|
||||||
|
let is_editing = use_state(|| false);
|
||||||
|
let mut is_navigating = use_state(|| false);
|
||||||
|
|
||||||
|
use_side_effect(move || {
|
||||||
|
if !*is_navigating.read() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match &*add_offline.read().state() {
|
||||||
|
MutationStateData::Settled { res: Ok(_), .. } => {
|
||||||
|
is_navigating.set(false);
|
||||||
|
let _ = RouterContext::get().replace(Route::OnboardingBundles {});
|
||||||
|
}
|
||||||
|
MutationStateData::Settled { res: Err(_), .. } => {
|
||||||
|
is_navigating.set(false);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let account = try_default_account(&account_query);
|
let account = try_default_account(&account_query);
|
||||||
let has_account = account.is_some();
|
let show_entry = account.is_none() || *is_editing.read();
|
||||||
|
|
||||||
let content = rect()
|
let content = rect()
|
||||||
.vertical()
|
.vertical()
|
||||||
|
|
@ -26,26 +53,155 @@ impl Component for OnboardingAccount {
|
||||||
.spacing(24.)
|
.spacing(24.)
|
||||||
.child(step_heading(
|
.child(step_heading(
|
||||||
"Account",
|
"Account",
|
||||||
"Before you continue, we require you to own a copy of Minecraft: Java Edition.",
|
"Enter your offline username to play, or sign in with your Microsoft account.",
|
||||||
))
|
))
|
||||||
.child(match &account {
|
.child(if show_entry {
|
||||||
Some(account) => account_preview(account).into_element(),
|
let start = msa.clone();
|
||||||
None => {
|
let add_offline_click = add_offline;
|
||||||
let start = msa.clone();
|
let mut is_editing_save = is_editing;
|
||||||
sign_in_card(msa.pending, msa.error.clone(), move |_| start.start())
|
let offline_name_read = offline_name;
|
||||||
.into_element()
|
|
||||||
}
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(16.)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Choose Username:")
|
||||||
|
.font_size(14.)
|
||||||
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(
|
||||||
|
TextInput::new(offline_name)
|
||||||
|
.placeholder("Enter username (e.g. Steve)")
|
||||||
|
.width(Size::px(240.)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.large()
|
||||||
|
.on_press(move |_| {
|
||||||
|
let target = sanitize_offline_username(&offline_name_read.read());
|
||||||
|
add_offline_click.mutate(AddOfflineAccountKeys {
|
||||||
|
username: target,
|
||||||
|
});
|
||||||
|
is_editing_save.set(false);
|
||||||
|
})
|
||||||
|
.text("Set & Play"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("3–16 characters (letters, numbers, underscore)")
|
||||||
|
.font_size(11.)
|
||||||
|
.color(colors::fg_secondary()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("— or sign in with Microsoft —")
|
||||||
|
.font_size(12.)
|
||||||
|
.color(colors::fg_secondary()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(sign_in_card(msa.pending, msa.error.clone(), move |_| {
|
||||||
|
start.start()
|
||||||
|
}))
|
||||||
|
.into_element()
|
||||||
|
} else {
|
||||||
|
let mut is_editing_toggle = is_editing;
|
||||||
|
let account = account.as_ref().unwrap();
|
||||||
|
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(16.)
|
||||||
|
.child(account_preview(account))
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.secondary()
|
||||||
|
.on_press(move |_| {
|
||||||
|
is_editing_toggle.set(true);
|
||||||
|
})
|
||||||
|
.text("Change Username"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
})
|
})
|
||||||
.into_element();
|
.into_element();
|
||||||
|
|
||||||
|
let add_offline_nav = add_offline;
|
||||||
|
let offline_name_nav = offline_name;
|
||||||
|
let is_editing_nav = is_editing;
|
||||||
|
let mut is_navigating_nav = is_navigating;
|
||||||
|
let account_clone = account.clone();
|
||||||
|
|
||||||
|
let on_next = move |_| {
|
||||||
|
if *is_navigating_nav.read() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if account_clone.is_none() || *is_editing_nav.read() {
|
||||||
|
let target = sanitize_offline_username(&offline_name_nav.read());
|
||||||
|
add_offline_nav.mutate(AddOfflineAccountKeys {
|
||||||
|
username: target,
|
||||||
|
});
|
||||||
|
is_navigating_nav.set(true);
|
||||||
|
} else {
|
||||||
|
let _ = RouterContext::get().replace(Route::OnboardingBundles {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nav = rect()
|
||||||
|
.horizontal()
|
||||||
|
.width(Size::fill())
|
||||||
|
.main_align(Alignment::End)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.spacing(12.)
|
||||||
|
.padding(Gaps::new(0., 40., 32., 40.))
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.secondary()
|
||||||
|
.width(Size::px(128.))
|
||||||
|
.on_press(move |_| {
|
||||||
|
let _ = RouterContext::get().replace(Route::OnboardingLanguage {});
|
||||||
|
})
|
||||||
|
.text("Back"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.width(Size::px(140.))
|
||||||
|
.enabled(!*is_navigating.read())
|
||||||
|
.on_press(on_next)
|
||||||
|
.text(if *is_navigating.read() {
|
||||||
|
"Saving..."
|
||||||
|
} else {
|
||||||
|
"Next"
|
||||||
|
})
|
||||||
|
.child(Icon::new(IconType::ArrowRight).size(16.)),
|
||||||
|
);
|
||||||
|
|
||||||
let page = onboarding_page(
|
let page = onboarding_page(
|
||||||
onboarding_illustration(IconType::OnboardingAccount),
|
onboarding_illustration(IconType::OnboardingAccount),
|
||||||
content,
|
content,
|
||||||
onboarding_nav(
|
nav,
|
||||||
Some(Route::OnboardingLanguage {}),
|
|
||||||
Route::OnboardingBundles {},
|
|
||||||
has_account,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
|
|
@ -130,3 +286,19 @@ fn sign_in_card(
|
||||||
}))
|
}))
|
||||||
.into_element()
|
.into_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_offline_username(input: &str) -> String {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
let sanitized: String = trimmed
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||||
|
.collect();
|
||||||
|
if sanitized.len() < 3 {
|
||||||
|
"Player".to_string()
|
||||||
|
} else if sanitized.len() > 16 {
|
||||||
|
sanitized[..16].to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue