From 0a29f0ab47d5e1c52d36e75fceace9a5c0c66470 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 10 Oct 2026 18:49:26 +0530 Subject: [PATCH] feat(ui): port 3D skins tab, offline onboarding and settings, and Forgejo auto-updater to 2.8.0 --- packages/oneclient_app/Cargo.toml | 3 + .../oneclient_app/src/components/avatar.rs | 41 +- .../src/components/navbar/app_navbar.rs | 5 + .../src/components/player_model.rs | 4 + packages/oneclient_app/src/constants.rs | 8 +- packages/oneclient_app/src/hooks/mod.rs | 3 +- .../oneclient_app/src/hooks/queries/mod.rs | 5 +- .../oneclient_app/src/hooks/queries/skin.rs | 232 ++++++- .../oneclient_app/src/layout/root_layout.rs | 2 - packages/oneclient_app/src/updater.rs | 326 +++++++-- .../oneclient_app/src/view/app/browser/mod.rs | 3 +- .../src/view/app/settings/accounts.rs | 134 +++- packages/oneclient_app/src/view/app/skins.rs | 625 ++++++++++++++---- .../src/view/onboarding/account.rs | 206 +++++- 14 files changed, 1361 insertions(+), 236 deletions(-) diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index df8c60a6..8559aab8 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -46,6 +46,9 @@ regex.workspace = true directories.workspace = true dunce.workspace = true percent-encoding.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +reqwest.workspace = true cargo-packager-updater = "=0.2.3" diff --git a/packages/oneclient_app/src/components/avatar.rs b/packages/oneclient_app/src/components/avatar.rs index e1d5c488..1e62d72f 100644 --- a/packages/oneclient_app/src/components/avatar.rs +++ b/packages/oneclient_app/src/components/avatar.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + use bytes::Bytes; use freya::elements::image::{ImageHandle, image}; 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_OVERLAY: (f32, f32) = (40., 8.); +static AVATAR_HEAD_CACHE: OnceLock>> = OnceLock::new(); + #[derive(PartialEq, Clone)] pub struct Avatar { uuid: String, @@ -34,22 +39,13 @@ impl LayoutExt for Avatar { impl ContainerSizeExt for Avatar {} impl Component for Avatar { + fn render_key(&self) -> DiffKey { + DiffKey::from(&self.uuid) + } + fn render(&self) -> impl IntoElement { let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone()); - - 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 - } - }; + let head = get_or_compose_head(&skin_bytes); rect() .width(Size::px(AVATAR_SIZE)) @@ -67,6 +63,23 @@ impl Component for Avatar { } } +fn get_or_compose_head(skin_bytes: &Bytes) -> Option { + 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 { let data = unsafe { SkData::new_bytes(skin_bytes) }; let skin = SkImage::from_encoded(data)?; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index f2c2b036..c938a1fe 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -147,6 +147,11 @@ fn navbar_center(is_small: bool) -> impl IntoElement { target: NavTarget::Route(browse_target), nav_label: "Browse", }) + .child(NavLink { + active: matches!(route, Route::AccountSkins {}), + target: NavTarget::Route(Route::AccountSkins {}), + nav_label: "Skins", + }) .child(NavLink { active: false, target: NavTarget::External("https://store.polyfrost.org"), diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs index 9fa0690c..002a9b92 100644 --- a/packages/oneclient_app/src/components/player_model.rs +++ b/packages/oneclient_app/src/components/player_model.rs @@ -78,6 +78,10 @@ impl PlayerModel { } impl Component for PlayerModel { + fn render_key(&self) -> DiffKey { + DiffKey::from(&self.uuid) + } + fn render(&self) -> impl IntoElement { let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone()); diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs index 0bfe6067..5d1ef894 100644 --- a/packages/oneclient_app/src/constants.rs +++ b/packages/oneclient_app/src/constants.rs @@ -1,7 +1,7 @@ pub const WINDOW_APP_ID: &str = "oneclient_app"; pub const WINDOW_TITLE: &str = "OneClient"; -pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK"; -pub const UPDATER_ENDPOINT: &str = - "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"; -pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest"; +pub const FORGEJO_REPO: &str = "oiupoyt/OneLauncher-Cracked"; +pub const FORGEJO_API_LATEST_RELEASE: &str = + "https://git.oiupoyt.space/api/v1/repos/oiupoyt/OneLauncher-Cracked/releases/latest"; +pub const RELEASES_URL: &str = "https://git.oiupoyt.space/oiupoyt/OneLauncher-Cracked/releases"; \ No newline at end of file diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index c529c335..feab502b 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -67,7 +67,8 @@ pub use queries::{ 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_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_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, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 27e788f9..1c0939b2 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -99,7 +99,10 @@ pub use settings_profiles::{ try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile, 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 storage::{ DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, StorageAction, diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs index 10ab6609..6c92ff60 100644 --- a/packages/oneclient_app/src/hooks/queries/skin.rs +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -1,9 +1,110 @@ 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; +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, + 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 { + 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::(&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 { + 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::::invalidate_matching(CustomSkinKeys { + uuid: uuid.to_string(), + }) + .await; + } else { + QueriesStorage::::invalidate_all().await; + } +} + 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::); 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 steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default()); - let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default()); + let steve = AppAssets::get_bytes("steve.png").unwrap_or_default(); + let alex = AppAssets::get_bytes("alex.png").unwrap_or_default(); 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) { Some((_, bytes)) => (bytes, is_slim), - None if default_slim => (alex.read().clone(), true), - None => (steve.read().clone(), false), + None if default_slim => (alex, true), + None => (steve, false), } } -fn java_string_hash(s: &str) -> i32 { +pub fn java_string_hash(s: &str) -> i32 { let mut h: i32 = 0; for c in s.encode_utf16() { h = h.wrapping_mul(31).wrapping_add(c as i32); } 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(()) +} diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 1a42a8c1..ffafd1e5 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -8,8 +8,6 @@ use crate::components::{ ReleaseMigrationPopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, 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::layout::{HomeArtPrefetch, PendingLaunchDriver, use_ipc_commands}; use crate::motion::AnimationClockDriver; diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index 20691f2f..cad976f5 100644 --- a/packages/oneclient_app/src/updater.rs +++ b/packages/oneclient_app/src/updater.rs @@ -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 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"; @@ -15,7 +14,7 @@ enum UpdateAnswer { fn update_prompt(version: &str) -> Prompt { Prompt::new( "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( Choice::primary(UPDATE_CHOICE_INSTALL, "Install"), @@ -26,6 +25,99 @@ fn update_prompt(version: &str) -> Prompt { 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 { + 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::().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::().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::().unwrap_or(0); + (base_part, rev_num) + } else { + (trimmed, 0) + }; + + let mut parts = base.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next().unwrap_or("0").parse::().ok()?; + + Some(Self { + major, + minor, + patch, + rev, + }) + } +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ForgejoRelease { + pub tag_name: String, + pub name: Option, + pub body: Option, + pub html_url: String, + #[serde(default)] + pub assets: Vec, +} + +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) { tokio::spawn(async move { 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<()> { - const FAKE_VERSION: &str = "9999.9999.9999"; + const FAKE_VERSION: &str = "2.2.3-c99"; const FAKE_TOTAL: u64 = 48 * 1024 * 1024; 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 label = format!("Downloading OneClient {FAKE_VERSION}"); + let label = format!("Downloading OneLauncher-Cracked {FAKE_VERSION}"); let mut downloaded = 0u64; events.progress(progress_id, &label, downloaded, FAKE_TOTAL); @@ -68,41 +160,38 @@ async fn run_simulated_update() -> anyhow::Result<()> { events.finish_progress( progress_id, "Finished Downloading", - format!("OneClient {FAKE_VERSION} is ready. Restart to apply."), + format!("OneLauncher-Cracked {FAKE_VERSION} is ready. Restart to apply."), ); Ok(()) } 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(update) = tokio::task::spawn_blocking(check_for_update).await?? else { + let Some(release) = check_for_forgejo_update().await? else { tracing::info!("no update available"); 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() { - 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 .notify("Update available") .body(format!( - "OneClient {} is available. Download the latest package from {} to update.", - update.version, RELEASES_URL + "OneLauncher-Cracked {} is available. Download from {}", + release.tag_name, release.html_url )) .send(); return Ok(()); } - if !auto_install && events.ask(update_prompt(&update.version)).await?.is_none() { - tracing::info!("user declined update {}", update.version); + if !auto_install && events.ask(update_prompt(&release.tag_name)).await?.is_none() { + tracing::info!("user declined update {}", release.tag_name); return Ok(()); } - download_and_install(update, events).await + download_and_install_forgejo(release, events).await } fn can_self_update() -> bool { @@ -116,70 +205,189 @@ fn can_self_update() -> bool { return false; } + #[cfg(target_os = "windows")] + { + true + } #[cfg(target_os = "linux")] { 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> { - let current = env!("CARGO_PKG_VERSION").parse()?; - let config = Config { - endpoints: vec![UPDATER_ENDPOINT.parse()?], - pubkey: UPDATER_PUBKEY.into(), - ..Default::default() +async fn check_for_forgejo_update() -> anyhow::Result> { + let client = reqwest::Client::builder() + .user_agent("OneLauncher-Cracked-Updater") + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + 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 version = update.version.clone(); - let label = format!("Downloading OneClient {version}"); + let version = release.tag_name.clone(); + 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 downloaded = Cell::new(0u64); - let last_sent = Cell::new(0u64); + let client = reqwest::Client::builder() + .user_agent("OneLauncher-Cracked-Updater") + .build()?; - let bytes = update.download_extended( - |chunk, total| { - let now = downloaded.get() + chunk as u64; - downloaded.set(now); - let total = total.unwrap_or(0); + let resp = client.get(&asset.browser_download_url).send().await?; + if !resp.status().is_success() { + anyhow::bail!("Failed to download update asset: HTTP {}", resp.status()); + } - if now == chunk as u64 - || (total > 0 && now >= total) - || now - last_sent.get() >= PROGRESS_STEP - { - last_sent.set(now); - events.progress(progress_id, &label, now, total); - } - }, - || {}, - )?; + let total_size = asset.size.max(resp.content_length().unwrap_or(0)); - let total = downloaded.get().max(1); - events.progress(progress_id, &label, total, total); + #[cfg(target_os = "windows")] + 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( progress_id, - "Finished Downloading", - format!("OneClient {version} is ready. Restart to apply."), + "Launching Installer", + format!("OneLauncher-Cracked {version} ready. Opening installer..."), ); - Ok(()) - }) - .await??; + let _ = std::process::Command::new(&target_file).spawn(); + } - 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(()) } + +#[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 }); + } +} + diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 7f5aaa93..4d33353d 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -584,8 +584,9 @@ mod tests { project_id: &str, override_type: OverrideType, ) -> HashMap<(String, String), String> { + let bundle_name = archive("performance", true, Vec::new()).manifest.name; HashMap::from([( - ("performance".to_string(), project_id.to_string()), + (bundle_name, project_id.to_string()), override_type.as_str().to_string(), )]) } diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 3434360d..332b6c64 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -42,6 +42,8 @@ impl Component for SettingsAccounts { let mut username = use_state(String::new); let mut show_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 || { if !*closing_offline.read() { @@ -94,6 +96,8 @@ impl Component for SettingsAccounts { remove, refresh, sign_in: msa.clone(), + edit_account, + edit_name, } .into_element() }) @@ -125,6 +129,78 @@ impl Component for SettingsAccounts { 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()) .into_element() } @@ -132,7 +208,7 @@ impl Component for SettingsAccounts { fn hero( account: Option, - has_microsoft: bool, + _has_microsoft: bool, microsoft_pending: bool, error: Option, on_open_offline: impl FnMut(Event) + 'static, @@ -219,16 +295,25 @@ fn hero( .child( Button::new() .secondary() - .enabled(has_microsoft) + .enabled(true) .on_press(on_open_offline) .child(Icon::new(IconType::Plus).size(16.)) .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| { el.child(hint_line(IconType::AlertTriangle, msg, colors::danger())) }) - .maybe(!has_microsoft, |el| { + .maybe(false, |el| { el.child(hint_line( IconType::InfoCircle, "Add a Microsoft account before creating offline accounts." @@ -417,6 +502,8 @@ struct AccountRow { remove: crate::hooks::UseRemoveAccount, refresh: crate::hooks::UseRefreshAccount, sign_in: MicrosoftLogin, + edit_account: State>, + edit_name: State, } impl PartialEq for AccountRow { @@ -428,6 +515,8 @@ impl PartialEq for AccountRow { && self.signed_out == other.signed_out && self.is_default == other.is_default && 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() .ghost() .icon() - .enabled(false) - .tooltip("Edit") + .tooltip("Change Skin") + .on_press(move |e: Event| { + 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| { + e.stop_propagation(); + edit_name_state.set(username_val.clone()); + edit_acc_state.set(Some((id_val, username_val.clone()))); + }) .child( Icon::new(IconType::Pencil01) .size(18.) - .color(colors::fg_secondary()), - ), - ) + .color(if is_offline { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ) + }) .child( Button::new() .ghost() diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs index 5ea92368..caed7352 100644 --- a/packages/oneclient_app/src/view/app/skins.rs +++ b/packages/oneclient_app/src/view/app/skins.rs @@ -1,18 +1,37 @@ use freya::prelude::*; +use oneclient_auth::MinecraftAccount; -use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel}; -use crate::hooks::{try_default_account, use_current_account}; +use crate::components::{Button, Icon, IconType, PlayerModel}; +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::ui::border_all_color; -const ANIMATIONS: &[&str] = &["Idle", "Walking", "Running", "Crouch", "Flying"]; - #[derive(PartialEq)] pub struct AccountSkins; impl Component for AccountSkins { 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() .horizontal() @@ -20,150 +39,510 @@ impl Component for AccountSkins { .height(Size::fill()) .overflow(Overflow::Clip) .padding(40.) - .spacing(24.) - .child(preview_panel(account_uuid)) - .child(side_panel()) + .spacing(28.) + .child(PreviewPanel { + 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) -> impl IntoElement { +#[derive(PartialEq, Clone)] +struct PreviewPanel { + account_uuid: Option, + account_id: Option, + username: Option, + status_msg: State>, +} + +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, + accounts: Vec, + selected_id: State>, + status_msg: State>, + is_loading: State, +) -> 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() .vertical() - .width(Size::px(320.)) + .width(Size::flex(1.0)) .height(Size::fill()) - .spacing(16.) + .spacing(24.) .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(), - }), + .vertical() + .spacing(4.) + .child( + label() + .text("Skin Customizer") + .font_size(24.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("Managing skin for {username}")) + .font_size(13.) + .color(colors::fg_secondary()), + ), ) - .child( + // Status message notification banner + .maybe_child(status_msg.read().as_ref().map(|(msg, is_err)| { + let is_err = *is_err; rect() .horizontal() .width(Size::fill()) - .main_align(Alignment::Center) - .spacing(8.) - .children( - ANIMATIONS - .iter() - .enumerate() - .map(|(i, name)| anim_chip(name, i == 0).into_element()), + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if is_err { + colors::danger().with_a(30) + } else { + colors::success().with_a(30) + }) + .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() } -fn anim_chip(name: &'static str, active: bool) -> impl IntoElement { - rect() - .center() - .padding(Gaps::new_symmetric(6., 12.)) - .corner_radius(CornerRadius::new_all(8.)) - .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 account_selector( + accounts: Vec, + selected_id: State>, +) -> impl IntoElement { + let mut selected_state = selected_id; -fn side_panel() -> impl IntoElement { rect() .vertical() - .width(Size::flex(1.0)) - .height(Size::fill()) - .overflow(Overflow::Clip) - .spacing(20.) + .spacing(8.) .child( label() - .text("Skins") - .font_size(32.) + .text("SELECT ACCOUNT") + .font_size(11.) .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), + .color(colors::fg_secondary()), ) .child( rect() .horizontal() - .spacing(12.) - .child(action_button("Import Skin", IconType::Plus, true)) - .child(action_button("Download", IconType::Download01, false)) - .child(action_button("Remove", IconType::Trash01, false)), + .spacing(8.) + .content(Content::Flex) + .children(accounts.into_iter().map(|acc| { + 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() } diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs index 1d1f44f9..48238069 100644 --- a/packages/oneclient_app/src/view/onboarding/account.rs +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -1,12 +1,18 @@ use freya::prelude::*; +use freya::query::MutationStateData; +use freya::router::RouterContext; use oneclient_auth::MinecraftAccount; -use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login}; -use crate::hooks::{try_default_account, use_current_account}; +use crate::components::{ + 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::theme::colors; use crate::view::onboarding::{ - onboarding_illustration, onboarding_nav, onboarding_page, step_heading, + onboarding_illustration, onboarding_page, step_heading, }; #[derive(PartialEq)] @@ -16,9 +22,30 @@ impl Component for OnboardingAccount { fn render(&self) -> impl IntoElement { let account_query = use_current_account(); 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 has_account = account.is_some(); + let show_entry = account.is_none() || *is_editing.read(); let content = rect() .vertical() @@ -26,26 +53,155 @@ impl Component for OnboardingAccount { .spacing(24.) .child(step_heading( "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 { - Some(account) => account_preview(account).into_element(), - None => { - let start = msa.clone(); - sign_in_card(msa.pending, msa.error.clone(), move |_| start.start()) - .into_element() - } + .child(if show_entry { + let start = msa.clone(); + let add_offline_click = add_offline; + let mut is_editing_save = is_editing; + let offline_name_read = offline_name; + + 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(); + 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( onboarding_illustration(IconType::OnboardingAccount), content, - onboarding_nav( - Some(Route::OnboardingLanguage {}), - Route::OnboardingBundles {}, - has_account, - ), + nav, ); rect() @@ -130,3 +286,19 @@ fn sign_in_card( })) .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 + } +} +