feat(ui): port 3D skins tab, offline onboarding and settings, and Forgejo auto-updater to 2.8.0

This commit is contained in:
katR 2026-10-10 18:49:26 +05:30
parent 8d7939c041
commit 0a29f0ab47
14 changed files with 1361 additions and 236 deletions

View file

@ -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"

View file

@ -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)?;

View file

@ -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"),

View file

@ -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());

View file

@ -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";

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -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;

View file

@ -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 });
}
}

View file

@ -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(),
)]) )])
} }

View file

@ -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()

View file

@ -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(&current_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()
} }

View file

@ -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
}
}