From c34b6a63d8b98c6a44a9bbdc98a33e6f670b0e57 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Mon, 7 Sep 2026 17:37:54 +0530 Subject: [PATCH] feat: update base to official upstream 2.3.1 with all cracked features intact (2.3.1-c1) - Rebased onto official Polyfrost OneLauncher v2.3.1 (commit 0e49c691) - Preserved 100% offline authentication without requiring Microsoft accounts - Integrated custom 3D offline skin changer with UUID character slot targeting - Maintained CustomSkinLoader auto-configuration for Ely.by and SkinRestorer - Maintained direct GitHub Releases auto-updater pointing to oiupoyt/OneLauncher-Cracked - Maintained mods filter popover fix eliminating text ghosting/duplication - Configured dev profile to prevent debug symbol bloat --- .github/workflows/release.yml | 166 +++++ Cargo.lock | 34 +- Cargo.toml | 14 +- README.md | 67 +- packages/oneclient_app/Cargo.toml | 4 + .../distribution/linux/oneclient_app.desktop | 11 + .../src/components/account_switcher.rs | 17 +- .../oneclient_app/src/components/avatar.rs | 41 +- .../src/components/microsoft_login.rs | 67 +- .../src/components/navbar/app_navbar.rs | 7 +- .../src/components/player_model.rs | 4 + packages/oneclient_app/src/constants.rs | 7 +- packages/oneclient_app/src/hooks/mod.rs | 5 +- .../oneclient_app/src/hooks/queries/auth.rs | 2 +- .../oneclient_app/src/hooks/queries/mod.rs | 7 +- .../src/hooks/queries/player_profile.rs | 21 +- .../oneclient_app/src/hooks/queries/skin.rs | 222 ++++++- .../oneclient_app/src/layout/root_layout.rs | 8 +- packages/oneclient_app/src/updater.rs | 332 ++++++++-- .../view/app/cluster/package_manager/views.rs | 54 +- .../src/view/app/settings/accounts.rs | 175 ++++- .../src/view/app/settings/java.rs | 3 +- packages/oneclient_app/src/view/app/skins.rs | 625 ++++++++++++++---- .../src/view/onboarding/account.rs | 206 +++++- packages/oneclient_auth/src/store.rs | 81 ++- packages/oneclient_auth/tests/offline.rs | 17 +- packages/oneclient_common/src/paths.rs | 12 + packages/oneclient_core/src/auth/mod.rs | 13 + packages/oneclient_core/src/auth/offline.rs | 52 ++ packages/oneclient_core/src/game/launch.rs | 463 +++++++++++++ 30 files changed, 2318 insertions(+), 419 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packages/oneclient_app/distribution/linux/oneclient_app.desktop create mode 100644 packages/oneclient_core/src/auth/mod.rs create mode 100644 packages/oneclient_core/src/auth/offline.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b4999407 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +name: Release Multiplatform Binaries + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release Tag (e.g. v2.3.1)' + required: false + default: 'v2.3.1-c1' + type: string + +permissions: + contents: write + +env: + SQLX_OFFLINE: true + SQLX_OFFLINE_DIR: packages/oneclient_db/.sqlx + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + # Linux x86_64 + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + name: linux-x86_64 + formats: deb,appimage + + # Windows x86_64 + - os: windows-latest + target: x86_64-pc-windows-msvc + name: windows-x86_64 + formats: nsis + + # macOS Apple Silicon + - os: macos-14 + target: aarch64-apple-darwin + name: macos-arm64 + formats: dmg,app + + # macOS Intel (cross-compiled on macos-14) + - os: macos-14 + target: x86_64-apple-darwin + name: macos-x86_64 + formats: dmg,app + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + cache-all-crates: true + + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libgl1-mesa-dev \ + libvulkan-dev \ + libasound2-dev + + - name: Install cargo-packager + run: cargo install cargo-packager --locked || true + + - name: Build Release Binary + run: cargo build --release --bin oneclient_app --target ${{ matrix.target }} + + - name: Package Application + run: cargo packager --release --target ${{ matrix.target }} --formats ${{ matrix.formats }} + continue-on-error: true + + - name: Generate RPM Package (Linux) + if: runner.os == 'Linux' + run: | + cargo install cargo-generate-rpm --locked || true + cargo generate-rpm --target ${{ matrix.target }} -p oneclient_app || true + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: binaries-${{ matrix.name }} + if-no-files-found: warn + path: | + target/${{ matrix.target }}/release/*.deb + target/${{ matrix.target }}/release/*.AppImage + target/${{ matrix.target }}/release/*.dmg + target/${{ matrix.target }}/release/*-setup.exe + target/${{ matrix.target }}/generate-rpm/*.rpm + target/generate-rpm/*.rpm + target/release/*.deb + target/release/*.AppImage + target/release/*.dmg + target/release/*-setup.exe + + create-release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + merge-multiple: true + + - name: Determine Release Tag + id: release_tag + run: | + if [[ "${{ github.ref_name }}" =~ ^v ]]; then + echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT + elif [ -n "${{ inputs.tag }}" ]; then + echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT + else + echo "tag=v2.3.1-c1" >> $GITHUB_OUTPUT + fi + + - name: Clean up previous release if exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release delete "${{ steps.release_tag.outputs.tag }}" -y --cleanup-tag=false || true + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: ./artifacts/**/* + tag_name: ${{ steps.release_tag.outputs.tag }} + name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}" + body: | + ### What's New in ${{ steps.release_tag.outputs.tag }} + - **Rebased onto Official Upstream 2.3.1**: Integrated all upstream OneLauncher 2.3.1 updates, fixes, and performance improvements. + - **Offline / Cracked Accounts**: Full offline account support with customizable username and avatar caching. + - **Custom Offline Skins & targeted multiplayer injection**: Custom 3D skin changer, per-account UUID hash targeting preventing multiplayer skin cloning, and automatic CustomSkinLoader config generation. + - **Integrated Auto-Updater**: Native background check and direct GitHub Releases binary downloads. + draft: false + prerelease: false + generate_release_notes: false + make_latest: true + fail_on_unmatched_files: false diff --git a/Cargo.lock b/Cargo.lock index 7bcffb27..61b19605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4464,7 +4464,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneclient_app" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "anyhow", "arboard", @@ -4477,6 +4477,7 @@ dependencies = [ "freya-core", "freya-skia-safe", "freya-testing", + "futures-util", "image", "mimalloc", "notify", @@ -4497,8 +4498,11 @@ dependencies = [ "polyio", "pulldown-cmark", "regex", + "reqwest 0.13.3", "rfd", "rust-embed", + "serde", + "serde_json", "sysinfo", "tokio", "tracing", @@ -4510,7 +4514,7 @@ dependencies = [ [[package]] name = "oneclient_auth" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4534,7 +4538,7 @@ dependencies = [ [[package]] name = "oneclient_cluster" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "bytes 1.11.1", "chrono", @@ -4557,7 +4561,7 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "directories", "interfrost", @@ -4570,7 +4574,7 @@ dependencies = [ [[package]] name = "oneclient_content" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "async-trait", "async_zip", @@ -4598,7 +4602,7 @@ dependencies = [ [[package]] name = "oneclient_core" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "async-compression", "async-stream", @@ -4662,7 +4666,7 @@ dependencies = [ [[package]] name = "oneclient_db" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "async-tempfile", "chrono", @@ -4677,7 +4681,7 @@ dependencies = [ [[package]] name = "oneclient_discord" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "chrono", "discord-rich-presence", @@ -4689,7 +4693,7 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "thiserror 2.0.18", "tokio", @@ -4699,7 +4703,7 @@ dependencies = [ [[package]] name = "oneclient_java" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "astral-tokio-tar", "async-tempfile", @@ -4721,7 +4725,7 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "proc-macro2", "quote", @@ -4730,7 +4734,7 @@ dependencies = [ [[package]] name = "oneclient_mc" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4752,7 +4756,7 @@ dependencies = [ [[package]] name = "oneclient_net" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "arc-swap", "async-trait", @@ -4774,7 +4778,7 @@ dependencies = [ [[package]] name = "oneclient_polyplus" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "async-tungstenite", "bytes 1.11.1", @@ -5051,7 +5055,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.3.1" +version = "2.3.1-c1" dependencies = [ "astral-tokio-tar", "async-compression", diff --git a/Cargo.toml b/Cargo.toml index 5b2592db..30a932f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,13 @@ authors = [ "Polyfrost", "Polyfrost Contributors", ] -documentation = "https://github.com/Polyfrost/OneLauncher" +documentation = "https://github.com/oiupoyt/OneLauncher-Cracked" edition = "2024" homepage = "https://polyfrost.org/projects/oneclient" license = "GPL-3.0-only" readme = "README.md" -repository = "https://github.com/Polyfrost/OneLauncher" -version = "2.3.1" +repository = "https://github.com/oiupoyt/OneLauncher-Cracked" +version = "2.3.1-c1" [workspace.dependencies] # core @@ -159,3 +159,11 @@ inherits = "release" debug = true strip = false + +[profile.dev] +debug = 1 + +[profile.dev.package."*"] +opt-level = 3 +debug = false + diff --git a/README.md b/README.md index d0134c83..70ea5635 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,16 @@
-Repository Banner + OneLauncher Logo -# OneClient | OneLauncher -The monorepo containing the code for OneLauncher, OneClient, and their core backend. - -OneClient is a Minecraft client featuring fully 100% open-source components, offering many packaged and pre-configured mods in one click. -OneLauncher is a WIP Minecraft launcher giving power-users the greatest customization whilst featuring a clean UI. +forked from [OneLauncher](https://github.com/Polyfrost/OneLauncher) allowing for uh offline authentication
-## Installing - -You can install the latest release of OneClient from our website: [https://polyfrost.org/projects/oneclient](https://polyfrost.org/projects/oneclient) -as well as our [GitHub releases](https://github.com/Polyfrost/OneLauncher/releases/latest). - -| Windows (x86_64) | macOS (Intel & Apple Silicon) | Linux (x86_64) | -|------------------|-------------------------------|---------------------------------------------------------| -| Installer 🔄 | DMG 🔄 | AppImage 🔄 | -| | App Bundle 🔄 | DEB | -| | | RPM | -| | | [AUR](https://aur.archlinux.org/packages/oneclient-bin) | - -> 🔄 = Has support for autoupdating built-in +## download from [**Releases Page**](https://github.com/oiupoyt/OneLauncher-Cracked/releases). +yes the actual client performs normally aswell -## Contributing -We welcome contributions! Please read our [contributing guidelines](CONTRIBUTING.md) before getting started. +intended for offline usage only. -### Requirements - -The project targets **Rust 1.97** or later. You can install Rust via [rustup](https://rustup.rs/). - - -### Building & Running - -```sh -# Run the app -cargo run -p oneclient_app - -# Build a release binary -cargo build -p oneclient_app --release -``` - - -### Packaging / Releasing - -Installers are produced with [**cargo-packager**](https://github.com/crabnebula-dev/cargo-packager) -(the standalone bundler spun out of the Tauri bundler). Config lives in -[`packages/oneclient_app/Cargo.toml`](./packages/oneclient_app/Cargo.toml) under -`[package.metadata.packager]`. - -```sh -cargo install cargo-packager --locked - -# Build the binary, then bundle it for the current OS: -cargo build --release -p oneclient_app -cargo packager --release -p oneclient_app --formats -# Windows: nsis macOS: app,dmg Linux: deb,appimage -``` - - -### Versioning - -The workspace shares a single version, defined in the root [`Cargo.toml`](./Cargo.toml) under `[workspace.package]`. - - -## Code signing - -This program uses free code signing provided by [SignPath.io](https://signpath.io?utm_source=foundation&utm_medium=github&utm_campaign=0install), and a certificate by the [SignPath Foundation](https://signpath.org?utm_source=foundation&utm_medium=github&utm_campaign=0install). We thank them very much to their contributions to OSS software! diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 6f7d3a0c..332d3406 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -45,6 +45,10 @@ 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 +futures-util = { workspace = true, features = ["std"] } cargo-packager-updater = "=0.2.3" diff --git a/packages/oneclient_app/distribution/linux/oneclient_app.desktop b/packages/oneclient_app/distribution/linux/oneclient_app.desktop new file mode 100644 index 00000000..f02aabbc --- /dev/null +++ b/packages/oneclient_app/distribution/linux/oneclient_app.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=OneClient +Comment=Next-generation open source Minecraft launcher +Exec=/usr/bin/oneclient_app +Icon=oneclient_app +Type=Application +Terminal=false +StartupNotify=true +Categories=Game;ActionGame;AdventureGame;Simulation; +Keywords=mc;minecraft;game; +StartupWMClass=oneclient_app diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs index 673b7dbf..11d9c827 100644 --- a/packages/oneclient_app/src/components/account_switcher.rs +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -6,14 +6,14 @@ use freya::{ use oneclient_auth::MinecraftAccount; use crate::{ - ui::{divider}, Route, components::{Avatar, Button, Icon, IconType, OverlayPopup}, hooks::{ - SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open, - use_accounts, use_current_account, use_dispatch, use_set_default_account, + try_accounts, try_default_account, use_account_switcher_open, use_accounts, + use_current_account, use_dispatch, }, theme::colors, + ui::divider, }; #[derive(PartialEq)] @@ -90,8 +90,7 @@ impl Component for AccountPanel { .spacing(8.) .opacity(progress) .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) - .background(colors::page_elevated().with_a(220)) - .blur(12.) + .background(colors::page_elevated()) .corner_radius(CornerRadius::new_all(12.)) .border( Border::new() @@ -157,15 +156,19 @@ impl Component for AccountRow { let active = self.active; let dispatch = use_dispatch(); - let set_default = use_set_default_account(); let mut hovered = use_state(|| false); let switch = move |_| { if active { return; } - set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); dispatch.close_account_switcher(); + spawn(async move { + if let Ok(state) = crate::launcher::state() { + let _ = state.auth.set_default_account(Some(id)).await; + crate::hooks::invalidate_auth_queries(Some(id)).await; + } + }); }; rect() 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/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs index aabe3044..47cb3bc7 100644 --- a/packages/oneclient_app/src/components/microsoft_login.rs +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -1,9 +1,9 @@ +use crate::state::LoginProgress; use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; use freya::text_edit::Clipboard; -use oneclient_core::LauncherError; use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession}; -use crate::state::LoginProgress; +use oneclient_core::LauncherError; use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::hooks::{ @@ -185,7 +185,7 @@ pub(crate) fn login_dialog( on_close: impl FnMut() + Clone + 'static, ) -> impl IntoElement { let mut close_scrim = on_close.clone(); - let mut close_cancel = on_close; + let mut close_cancel = on_close.clone(); OverlayPopup::new() .on_close(move |()| close_scrim()) .child( @@ -225,10 +225,9 @@ pub(crate) fn login_dialog( .spacing(18.) .child(browser_dialog_body(auth_url)) .child(dialog_divider()) - .child(device_code_dialog_body( - user_code, - verification_uri, - )) + .child(device_code_dialog_body(user_code, verification_uri)) + .child(dialog_divider()) + .child(offline_mode_dialog_body(on_close.clone())) .child(status_row(status, error)) .maybe_child(guidance.map(guidance_block)), ), @@ -314,17 +313,13 @@ fn status_row(status: Option, error: Option) -> impl Into } fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { - let mut steps = rect() - .vertical() - .width(Size::fill()) - .spacing(6.) - .child( - label() - .text("What you can do:") - .font_size(12.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ); + let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child( + label() + .text("What you can do:") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { steps = steps.child( @@ -443,3 +438,39 @@ fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoE ) .into_element() } + +fn offline_mode_dialog_body(on_close: impl FnMut() + Clone + 'static) -> impl IntoElement { + let on_close_btn = on_close; + + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text("Or Play Offline (LAN / Offline Servers)") + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let mut close_cb = on_close_btn.clone(); + spawn(async move { + if let Ok(state) = crate::launcher::state() { + let res = state.auth.add_offline_account("Player".to_string()).await; + if let Ok(acc) = res { + let _ = state.auth.set_default_account(Some(acc.id)).await; + } + } + close_cb(); + }); + }) + .text("Play Offline as Player") + .into_element(), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index a49a39fa..d5dfcd04 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -100,9 +100,9 @@ fn navbar_center() -> impl IntoElement { nav_label: "Versions", }) .child(NavLink { - active: false, - target: NavTarget::External("https://store.polyfrost.org"), - nav_label: "Cosmetics", + active: route == Route::AccountSkins {}, + target: NavTarget::Route(Route::AccountSkins {}), + nav_label: "Skins", }) .child(NavLink { active: matches!( @@ -142,6 +142,7 @@ fn browse_target() -> Route { } #[derive(PartialEq, Clone)] +#[allow(dead_code)] enum NavTarget { Route(Route), External(&'static str), diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs index 23524feb..46d178ea 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 b4762806..6fa12a2a 100644 --- a/packages/oneclient_app/src/constants.rs +++ b/packages/oneclient_app/src/constants.rs @@ -1,6 +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"; \ No newline at end of file +pub const GITHUB_REPO: &str = "oiupoyt/OneLauncher-Cracked"; +pub const GITHUB_API_LATEST_RELEASE: &str = + "https://api.github.com/repos/oiupoyt/OneLauncher-Cracked/releases/latest"; +pub const RELEASES_URL: &str = "https://github.com/oiupoyt/OneLauncher-Cracked/releases/latest"; \ 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 2a119519..f0c5e986 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -60,12 +60,15 @@ pub use queries::{ use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, use_package_search, use_package_versions, 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_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; +pub(crate) use queries::invalidate_auth_queries; + use crate::notifications::NotificationSnapshot; use crate::state::{ AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState, diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index fb5dccf6..527feba8 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -112,7 +112,7 @@ pub fn try_account(query: &UseQuery) -> Option { super::state::settled_or_loading(query).flatten() } -async fn invalidate_auth_queries(account_id: Option) { +pub(crate) async fn invalidate_auth_queries(account_id: Option) { QueriesStorage::::invalidate_matching(ListAccountsKeys).await; for fallback in [false, true] { QueriesStorage::::invalidate_matching(DefaultAccountKeys { fallback }) diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index d6efb2f9..e65694c6 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -95,8 +95,13 @@ 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 tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; pub use version_metadata::{pick_version_metadata, use_version_metadata}; pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata}; + +pub(crate) use auth::invalidate_auth_queries; diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs index 742b5ad7..58ef5892 100644 --- a/packages/oneclient_app/src/hooks/queries/player_profile.rs +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -22,12 +22,29 @@ impl QueryCapability for FetchPlayerProfileQuery { type Keys = PlayerProfileQueryKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let access_token = keys.access_token.clone(); + let uuid_str = &keys.uuid; + if uuid_str.is_empty() { + return Ok(PlayerProfileView::default()); + } + if let Ok(u) = uuid::Uuid::parse_str(uuid_str) { + let is_offline = u.get_version() == Some(uuid::Version::Md5) || u.is_nil(); + if is_offline { + return Ok(PlayerProfileView { + uuid: uuid_str.clone(), + ..Default::default() + }); + } + } + + let access_token = keys.access_token.clone(); let state = crate::launcher::state()?; let client = &state.services.requester; - Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?) + Ok( + minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()) + .await?, + ) } // TODO Cache diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs index 10ab6609..0f4cd28a 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,131 @@ 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 csl_skins_dir = mc_dir.join("CustomSkinLoader").join("skins"); + let _ = polyio::create_dir_all(&csl_skins_dir).await; + let target = csl_skins_dir.join(format!("{username}.png")); + let _ = polyio::write(&target, skin_png).await; + } + Ok(()) +} + +async fn remove_custom_skin_loader(username: &str) -> Result<(), String> { + if let Ok(mc_dir) = shared_minecraft_dir() { + let target = mc_dir + .join("CustomSkinLoader") + .join("skins") + .join(format!("{username}.png")); + if target.exists() { + let _ = polyio::remove_file(&target).await; + } + } + Ok(()) +} diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 92bc474c..b891bad3 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -47,10 +47,10 @@ impl Component for RootLayout { ctx.windows.get(&id).map(|w| w.window().is_maximized()) }) .await; - if let Ok(Some(is_max)) = is_max { - if *maximized.peek() != is_max { - maximized.set(is_max); - } + if let Ok(Some(is_max)) = is_max + && *maximized.peek() != is_max + { + maximized.set(is_max); } }); }); diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index f46d288a..1e16a71a 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::GITHUB_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,95 @@ 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 GithubRelease { + pub tag_name: String, + pub name: Option, + pub body: Option, + pub html_url: String, + #[serde(default)] + pub assets: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct GithubReleaseAsset { + pub name: String, + pub size: u64, + pub browser_download_url: String, +} + +impl GithubRelease { + pub fn find_installer_asset(&self) -> Option<&GithubReleaseAsset> { + #[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 +132,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 +143,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,116 +156,230 @@ 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_github_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_github(release, events).await } fn can_self_update() -> bool { - if cfg!(debug_assertions) { - return false; - } + if cfg!(debug_assertions) { + return false; + } - if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE").is_some_and(|val| val.eq_ignore_ascii_case("1")) { - return false; - } + if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE") + .is_some_and(|val| val.eq_ignore_ascii_case("1")) + { + 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_github_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(GITHUB_API_LATEST_RELEASE) + .send() + .await?; + + if !resp.status().is_success() { + tracing::debug!("GitHub releases API returned status: {}", resp.status()); + return Ok(None); + } + + let release: GithubRelease = 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_github(release: GithubRelease, 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 mut 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; + + while let Some(chunk) = resp.chunk().await? { + 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/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 0c3548c4..b9ff7074 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -347,8 +347,7 @@ impl Component for FilterPopover { .opacity(progress) .offset_y((progress - 1.0) * 6.) .corner_radius(CornerRadius::new_all(10.)) - .background(colors::page_elevated().with_a(230)) - .blur(12.) + .background(colors::page_elevated()) .border(crate::ui::border_all_color(1., colors::component_border())) .shadow(Shadow::from(( 0., @@ -372,11 +371,15 @@ impl Component for FilterPopover { sort.set(Some(mode.key().to_string())); }) .into(); - panel = panel.child(ChoiceRow { - text: mode.label(), - selected, - on_press, - }); + panel = panel.child( + ChoiceRow { + text: mode.label(), + selected, + on_press, + key: DiffKey::None, + } + .key(mode.key()), + ); } panel = panel.child(section_label("Show")); @@ -386,11 +389,15 @@ impl Component for FilterPopover { enabled_filter.set(filter); }) .into(); - panel = panel.child(ChoiceRow { - text: filter.label(), - selected, - on_press, - }); + panel = panel.child( + ChoiceRow { + text: filter.label(), + selected, + on_press, + key: DiffKey::None, + } + .key(filter.label()), + ); } panel = panel.child(section_label("Hidden packages")); @@ -400,11 +407,15 @@ impl Component for FilterPopover { hidden_filter.set(filter); }) .into(); - panel = panel.child(ChoiceRow { - text: filter.label(), - selected, - on_press, - }); + panel = panel.child( + ChoiceRow { + text: filter.label(), + selected, + on_press, + key: DiffKey::None, + } + .key(filter.label()), + ); } rect() @@ -442,6 +453,13 @@ struct ChoiceRow { text: &'static str, selected: bool, on_press: EventHandler>, + key: DiffKey, +} + +impl KeyExt for ChoiceRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } } impl Component for ChoiceRow { @@ -483,7 +501,7 @@ impl Component for ChoiceRow { el.border(crate::ui::border_all_color(1., colors::brand())) }) .cursor(CursorIcon::Pointer) - .on_press(self.on_press.clone()) + .on_all_press(self.on_press.clone()) .child( label() .text(text) diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 79765e6c..70587634 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -38,26 +38,10 @@ impl Component for SettingsAccounts { let remove = use_remove_account(); let refresh = use_refresh_account(); - let mut username = use_state(String::new); + let username = use_state(String::new); let mut show_offline = use_state(|| false); - let mut closing_offline = use_state(|| false); - - use_side_effect(move || { - if !*closing_offline.read() { - return; - } - match &*add_offline.read().state() { - MutationStateData::Settled { res: Ok(_), .. } => { - closing_offline.set(false); - show_offline.set(false); - username.set(String::new()); - } - MutationStateData::Settled { res: Err(_), .. } => { - closing_offline.set(false); - } - _ => {} - } - }); + let edit_account = use_state(|| None::<(Uuid, String)>); + let edit_name = use_state(String::new); let accounts = try_accounts(&accounts_query).unwrap_or_default(); let default_account = try_default_account(&default_query); @@ -75,8 +59,20 @@ impl Component for SettingsAccounts { if name.is_empty() { return; } - add_offline.mutate(AddOfflineAccountKeys { username: name }); - closing_offline.set(true); + let mut show_offline = show_offline; + let mut username = username; + spawn(async move { + let reader = add_offline + .mutate_async(AddOfflineAccountKeys { username: name }) + .await; + if matches!( + &*reader.state(), + MutationStateData::Settled { res: Ok(_), .. } + ) { + show_offline.set(false); + username.set(String::new()); + } + }); }; let mut rows: Vec = accounts @@ -91,6 +87,8 @@ impl Component for SettingsAccounts { set_default, remove, refresh, + edit_account, + edit_name, } .into_element() }) @@ -122,6 +120,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() } @@ -129,7 +199,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, @@ -212,16 +282,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." @@ -331,7 +410,7 @@ fn offline_dialog( .child(field_label("UUID")) .child( label() - .text(uuid_preview.unwrap_or_else(|| "-".to_string())) + .text(uuid_preview.unwrap_or_else(|| "—".to_string())) .font_size(12.) .color(colors::fg_secondary()), ), @@ -405,6 +484,8 @@ struct AccountRow { set_default: crate::hooks::UseSetDefaultAccount, remove: crate::hooks::UseRemoveAccount, refresh: crate::hooks::UseRefreshAccount, + edit_account: State>, + edit_name: State, } impl PartialEq for AccountRow { @@ -414,6 +495,8 @@ impl PartialEq for AccountRow { && self.kind == other.kind && self.expires == other.expires && self.is_default == other.is_default + && self.edit_account == other.edit_account + && self.edit_name == other.edit_name } } @@ -548,12 +631,44 @@ impl Component for AccountRow { .into_element() })) .child( - Button::new().ghost().icon().enabled(false).child( - Icon::new(IconType::Pencil01) - .size(18.) - .color(colors::fg_secondary()), - ), + Button::new() + .ghost() + .icon() + .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) + .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(if is_offline { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ) + }) .child( Button::new() .ghost() diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 84089146..3bf11880 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -23,6 +23,7 @@ impl Component for SettingsJava { let mut show_manager = use_state(|| false); let pending_remove = use_state(|| None::); + #[allow(dead_code)] fn invalidate_runtimes(dispatch: Actions) { spawn(async move { invalidate_java_queries().await; @@ -34,7 +35,7 @@ impl Component for SettingsJava { }); } - let refresh_dispatch = dispatch.clone(); + let _refresh_dispatch = dispatch.clone(); let mut shell = settings_page() .child(section_header("ADD RUNTIME")) 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 + } +} + diff --git a/packages/oneclient_auth/src/store.rs b/packages/oneclient_auth/src/store.rs index b9d45990..9ce24c6a 100644 --- a/packages/oneclient_auth/src/store.rs +++ b/packages/oneclient_auth/src/store.rs @@ -23,8 +23,13 @@ impl CredentialsStore { return Ok(Self::default()); } - match polyio::read_json(&path).await { - Ok(store) => Ok(store), + match polyio::read_json::(&path).await { + Ok(mut store) => { + if store.default_user.is_none() && !store.users.is_empty() { + store.default_user = store.users.keys().copied().next(); + } + Ok(store) + } Err(err) => { tracing::warn!("failed to read auth file: {err}"); Ok(Self::default()) @@ -91,30 +96,27 @@ impl CredentialsStore { } fn insert_offline_account(&mut self, username: String) -> AuthResult { - if !self.has_microsoft_account() { - return Err(AuthError::OfflineRequiresMicrosoft); - } - validate_offline_username(&username)?; - if self + if let Some(existing) = self .users .values() - .any(|u| u.username.eq_ignore_ascii_case(&username)) + .find(|u| u.username.eq_ignore_ascii_case(&username)) + .cloned() { - return Err(AuthError::DuplicateUsername { username }); + self.default_user = Some(existing.id); + return Ok(existing); } let account = offline_account(username); - self.users.insert(account.id, account.clone()); + let id = account.id; + self.users.insert(id, account.clone()); + self.default_user = Some(id); Ok(account) } #[tracing::instrument(level = "debug", skip_all, fields(%account.id))] - pub async fn commit_refreshed_account( - &mut self, - account: MinecraftAccount, - ) -> AuthResult<()> { + pub async fn commit_refreshed_account(&mut self, account: MinecraftAccount) -> AuthResult<()> { self.users.insert(account.id, account); self.save().await?; tracing::debug!("stored refreshed Microsoft account"); @@ -179,10 +181,6 @@ impl CredentialsStore { return Ok(None); }; - if account.is_offline() && !self.has_microsoft_account() { - return Err(AuthError::OfflineRequiresMicrosoft); - } - Ok(Some(account)) } } @@ -196,3 +194,50 @@ pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool { if source.is_connect() || source.is_timeout() ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_offline_account_creation_sets_default() { + let mut store = CredentialsStore { + users: HashMap::new(), + default_user: None, + }; + + let account = store.insert_offline_account("Player".to_string()).unwrap(); + assert_eq!(account.username, "Player"); + assert_eq!(store.default_user, Some(account.id)); + assert_eq!(store.users.len(), 1); + } + + #[test] + fn test_custom_username_switches_default() { + let mut store = CredentialsStore { + users: HashMap::new(), + default_user: None, + }; + + let p1 = store.insert_offline_account("Player".to_string()).unwrap(); + assert_eq!(store.default_user, Some(p1.id)); + + let p2 = store.insert_offline_account("Steve".to_string()).unwrap(); + assert_eq!(store.default_user, Some(p2.id)); + assert_eq!(store.users.len(), 2); + } + + #[test] + fn test_case_insensitive_username_reuse() { + let mut store = CredentialsStore { + users: HashMap::new(), + default_user: None, + }; + + let p1 = store.insert_offline_account("Steve".to_string()).unwrap(); + let p2 = store.insert_offline_account("steve".to_string()).unwrap(); + assert_eq!(p1.id, p2.id); + assert_eq!(store.users.len(), 1); + assert_eq!(store.default_user, Some(p1.id)); + } +} diff --git a/packages/oneclient_auth/tests/offline.rs b/packages/oneclient_auth/tests/offline.rs index 21325eec..ec7b074a 100644 --- a/packages/oneclient_auth/tests/offline.rs +++ b/packages/oneclient_auth/tests/offline.rs @@ -28,13 +28,14 @@ fn offline_uuid_matches_vanilla_algorithm() { } #[tokio::test] -async fn offline_account_blocked_without_microsoft() { +async fn offline_account_allowed_without_microsoft() { isolate_launcher_dir(); let mut store = CredentialsStore::default(); - let err = store.add_offline_account("Steve".into()).unwrap_err(); + let offline = store.add_offline_account("Steve".into()).unwrap(); - assert!(err.to_string().contains("Microsoft")); + assert_eq!(offline.kind, AccountKind::Offline); + assert_eq!(offline.username, "Steve"); } #[tokio::test] @@ -51,7 +52,7 @@ async fn offline_account_allowed_when_microsoft_exists() { } #[tokio::test] -async fn default_offline_fails_when_last_microsoft_removed() { +async fn default_offline_works_when_last_microsoft_removed() { isolate_launcher_dir(); let mut store = CredentialsStore::default(); @@ -60,11 +61,13 @@ async fn default_offline_fails_when_last_microsoft_removed() { store.users.insert(msa_id, msa); let offline = store.add_offline_account("Steve".into()).unwrap(); - store.default_user = Some(offline.id); + let offline_id = offline.id; + store.default_user = Some(offline_id); store.users.remove(&msa_id); - let err = store.default_account().await.unwrap_err(); - assert!(err.to_string().contains("Microsoft")); + let account = store.default_account().await.unwrap().unwrap(); + assert_eq!(account.id, offline_id); + assert_eq!(account.kind, AccountKind::Offline); } #[test] diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 10c3a754..bdf0ecf2 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -106,6 +106,18 @@ pub fn logs_dir() -> PathsResult { Ok(config_dir()?.join("logs")) } +pub fn skins_dir() -> PathsResult { + Ok(data_dir()?.join("skins")) +} + +pub fn skin_file_path(uuid: &str) -> PathsResult { + Ok(skins_dir()?.join(format!("{uuid}.png"))) +} + +pub fn skin_meta_path(uuid: &str) -> PathsResult { + Ok(skins_dir()?.join(format!("{uuid}.json"))) +} + pub fn java_dir() -> PathsResult { Ok(data_dir()?.join("metadata").join("java")) } diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs new file mode 100644 index 00000000..e30678a6 --- /dev/null +++ b/packages/oneclient_core/src/auth/mod.rs @@ -0,0 +1,13 @@ +pub mod microsoft; +pub mod offline; + +pub use offline::{OfflineAccount, generate_offline_uuid}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AccountType { + Microsoft { refresh_token_key: String }, + Offline, +} diff --git a/packages/oneclient_core/src/auth/offline.rs b/packages/oneclient_core/src/auth/offline.rs new file mode 100644 index 00000000..5c3c507f --- /dev/null +++ b/packages/oneclient_core/src/auth/offline.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use uuid::{Builder, Uuid, Variant, Version}; +use md5::{Md5, Digest}; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfflineAccount { + pub username: String, + pub uuid: Uuid, + pub access_token: String, + pub user_type: String, +} + +impl OfflineAccount { + pub fn new(username: &str) -> Result { + let trimmed = username.trim(); + if trimmed.is_empty() { return Err(OfflineAuthError::EmptyUsername); } + if trimmed.len() > 16 { return Err(OfflineAuthError::UsernameTooLong); } + if !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(OfflineAuthError::InvalidCharacters); + } + + let uuid = generate_offline_uuid(trimmed); + Ok(Self { + username: trimmed.to_string(), + uuid, + access_token: "offline".to_string(), + user_type: "offline".to_string(), + }) + } +} + +pub fn generate_offline_uuid(username: &str) -> Uuid { + let mut hasher = Md5::new(); + hasher.update(format!("OfflinePlayer:{}", username).as_bytes()); + let result = hasher.finalize(); + + let mut builder = Builder::from_slice(&result).expect("MD5 is 16 bytes"); + builder.set_version(Version::Md5); + builder.set_variant(Variant::RFC4122); + builder.into_uuid() +} + +#[derive(Debug, Error)] +pub enum OfflineAuthError { + #[error("Username cannot be empty")] + EmptyUsername, + #[error("Username cannot exceed 16 characters")] + UsernameTooLong, + #[error("Invalid characters in username")] + InvalidCharacters, +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index baa10624..71ac617b 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -296,6 +296,9 @@ async fn start( java.major, )?; + // Replace default Minecraft skin textures inside client jar, write resource packs, and sync mod folders + inject_skin(&cwd, &client_jar, &account.id.to_string(), &account.username).await; + let mut mc_args = arguments::minecraft_arguments( updated, arg_map.get(&ArgumentType::Game).map(Vec::as_slice), @@ -734,3 +737,463 @@ async fn run_hook(hook: Option<&str>, cwd: &Path) { tracing::warn!("hook '{hook}' failed: {err}"); } } + +/// Computes Java's exact 32-bit signed integer `uuid.hashCode()`. +fn java_uuid_hash(uuid: &uuid::Uuid) -> i32 { + let (most_sig, least_sig) = uuid.as_u64_pair(); + let hilo = most_sig ^ least_sig; + ((hilo >> 32) as i32) ^ (hilo as i32) +} + +/// Computes the exact Minecraft character slot(s) for the player's offline UUID. +/// Matches vanilla Minecraft's `DefaultPlayerSkin.getDefaultSkin(uuid)` in both +/// legacy (1.8-1.19.2) and modern (1.19.3+) versions, ensuring only the player's +/// exact slot is patched while leaving other default character slots untouched in multiplayer. +fn target_skin_paths_for_account(uuid_str: &str, username: &str) -> (Vec, String, String) { + let offline_uuid = if let Ok(u) = uuid::Uuid::parse_str(uuid_str) { + u + } else { + oneclient_auth::offline_uuid(username) + }; + + let hash = java_uuid_hash(&offline_uuid); + + // 1. Legacy Minecraft 1.8 - 1.19.2: ((hash & 1) != 0) ? alex : steve + let is_legacy_alex = (hash & 1) != 0; + let legacy_char = if is_legacy_alex { "alex.png" } else { "steve.png" }; + + // 2. Modern Minecraft 1.20.2+ (9 default skins) + let modern_9 = match hash.rem_euclid(9) { + 0 => ("wide", "steve.png"), + 1 => ("slim", "alex.png"), + 2 => ("slim", "ari.png"), + 3 => ("wide", "efe.png"), + 4 => ("wide", "kai.png"), + 5 => ("wide", "makena.png"), + 6 => ("slim", "noor.png"), + 7 => ("wide", "sunny.png"), + _ => ("slim", "zuri.png"), + }; + + // 3. Modern Minecraft 1.19.3 - 1.20.1 (8 default skins) + let modern_8 = match hash.rem_euclid(8) { + 0 => ("wide", "steve.png"), + 1 => ("slim", "alex.png"), + 2 => ("slim", "ari.png"), + 3 => ("wide", "efe.png"), + 4 => ("wide", "kai.png"), + 5 => ("wide", "makena.png"), + 6 => ("slim", "noor.png"), + _ => ("wide", "sunny.png"), + }; + + let mut paths = std::collections::HashSet::new(); + + // Legacy paths + paths.insert(format!("assets/minecraft/textures/entity/{legacy_char}")); + paths.insert(format!("assets/minecraft/textures/entity/player/{legacy_char}")); + + // Modern 9-character paths + paths.insert(format!("assets/minecraft/textures/entity/player/{}/{}", modern_9.0, modern_9.1)); + paths.insert(format!("assets/minecraft/textures/entity/player/{}", modern_9.1)); + + // Modern 8-character paths + paths.insert(format!("assets/minecraft/textures/entity/player/{}/{}", modern_8.0, modern_8.1)); + paths.insert(format!("assets/minecraft/textures/entity/player/{}", modern_8.1)); + + let path_list: Vec = paths.into_iter().collect(); + (path_list, modern_9.1.to_string(), legacy_char.to_string()) +} + +/// Replaces the default character textures inside the Minecraft client `.jar`, +/// creates targeted resource packs, and syncs custom skins and configs to mod directories. +async fn inject_skin(game_dir: &Path, client_jar: &Path, uuid: &str, username: &str) { + const PACK_NAME: &str = "onelauncher_skin"; + + let resourcepacks_dir = game_dir.join("resourcepacks"); + let pack_dir = resourcepacks_dir.join(PACK_NAME); + let pack_zip = resourcepacks_dir.join(format!("{PACK_NAME}.zip")); + let options_txt = game_dir.join("options.txt"); + + // --- Resolve skin PNG --- + let skin_path = match oneclient_common::paths::skin_file_path(uuid) { + Ok(p) => p, + Err(e) => { + tracing::warn!("could not resolve skin path for {uuid}: {e}"); + return; + } + }; + + let undashed = uuid.replace('-', ""); + let alt_skin_path = oneclient_common::paths::skin_file_path(&undashed).unwrap_or_default(); + + let resolved_skin_path = if skin_path.exists() { + Some(skin_path) + } else if alt_skin_path.exists() { + Some(alt_skin_path) + } else { + None + }; + + let Some(valid_skin_path) = resolved_skin_path else { + // No custom skin — restore untouched client jar, remove resource pack files, and clean options.txt + patch_client_jar_skins(client_jar, None, &[]).await; + if pack_dir.exists() { + let _ = polyio::remove_dir_all(&pack_dir).await; + } + if pack_zip.exists() { + let _ = polyio::remove_file(&pack_zip).await; + } + patch_resource_packs_option(&options_txt, PACK_NAME, false).await; + return; + }; + + let skin_bytes = match polyio::read(&valid_skin_path).await { + Ok(b) => b, + Err(e) => { + tracing::warn!("failed to read custom skin: {e}"); + return; + } + }; + + let (target_paths, modern_char, legacy_char) = target_skin_paths_for_account(uuid, username); + + // 1. Directly patch the client jar for this player's exact character slot + patch_client_jar_skins(client_jar, Some(&skin_bytes), &target_paths).await; + + // 2. Clean previous resource pack directory to avoid stale files + if pack_dir.exists() { + let _ = polyio::remove_dir_all(&pack_dir).await; + } + + // Write Resource Pack directory structure + let textures_wide = pack_dir.join("assets/minecraft/textures/entity/player/wide"); + let textures_slim = pack_dir.join("assets/minecraft/textures/entity/player/slim"); + let textures_player = pack_dir.join("assets/minecraft/textures/entity/player"); + let textures_entity = pack_dir.join("assets/minecraft/textures/entity"); + + let _ = polyio::create_dir_all(&textures_wide).await; + let _ = polyio::create_dir_all(&textures_slim).await; + let _ = polyio::create_dir_all(&textures_player).await; + let _ = polyio::create_dir_all(&textures_entity).await; + + let _ = polyio::write(textures_wide.join(&modern_char), &skin_bytes).await; + let _ = polyio::write(textures_slim.join(&modern_char), &skin_bytes).await; + let _ = polyio::write(textures_player.join(&modern_char), &skin_bytes).await; + let _ = polyio::write(textures_player.join(&legacy_char), &skin_bytes).await; + let _ = polyio::write(textures_entity.join(&legacy_char), &skin_bytes).await; + + let mcmeta = r#"{"pack":{"pack_format":34,"description":"OneLauncher Custom Skin","supported_formats":{"min_inclusive":1,"max_inclusive":9999}}}"#; + let _ = polyio::write(pack_dir.join("pack.mcmeta"), mcmeta.as_bytes()).await; + + // 3. Write zipped resource pack for loaders that only read .zip files + write_skin_zip_resource_pack(&pack_zip, &skin_bytes, &target_paths).await; + + // 4. Sync to mod directories (CustomSkinLoader, OfflineSkins, etc.) in game_dir + let mod_targets = [ + game_dir.join("CustomSkinLoader").join("skins").join(format!("{username}.png")), + game_dir.join("CustomSkinLoader").join("skins").join(format!("{uuid}.png")), + game_dir.join("CustomSkinLoader").join("skins").join(format!("{undashed}.png")), + game_dir.join("config").join("offlineskins").join(format!("{username}.png")), + game_dir.join("config").join("offlineskins").join(format!("{uuid}.png")), + game_dir.join("config").join("offlineskins").join(format!("{undashed}.png")), + game_dir.join("cachedImages").join("skins").join(format!("{uuid}.png")), + game_dir.join("cachedImages").join("skins").join(format!("{undashed}.png")), + ]; + for target in mod_targets { + if let Some(parent) = target.parent() { + let _ = polyio::create_dir_all(parent).await; + } + let _ = polyio::write(&target, &skin_bytes).await; + } + + // Configure CustomSkinLoader so it properly loads the user's skin and fetches other multiplayer skins + write_custom_skin_loader_config(&game_dir.join("CustomSkinLoader")).await; + write_custom_skin_loader_config(&game_dir.join("config").join("CustomSkinLoader")).await; + + // Also sync to shared .minecraft if distinct from game_dir + if let Some(shared) = oneclient_common::paths::shared_minecraft_dir().ok().filter(|s| s != game_dir) { + let shared_targets = [ + shared.join("CustomSkinLoader").join("skins").join(format!("{username}.png")), + shared.join("CustomSkinLoader").join("skins").join(format!("{uuid}.png")), + shared.join("config").join("offlineskins").join(format!("{username}.png")), + shared.join("config").join("offlineskins").join(format!("{uuid}.png")), + ]; + for target in shared_targets { + if let Some(parent) = target.parent() { + let _ = polyio::create_dir_all(parent).await; + } + let _ = polyio::write(&target, &skin_bytes).await; + } + write_custom_skin_loader_config(&shared.join("CustomSkinLoader")).await; + } + + tracing::info!(uuid, username, modern_char, legacy_char, "targeted skin direct injection, resource packs, and mod skins ready"); + + // 5. Patch options.txt to ensure the resource pack is enabled + patch_resource_packs_option(&options_txt, PACK_NAME, true).await; +} + +/// Automatically writes CustomSkinLoader configuration to prioritize local custom skin +/// and resolve other online/offline multiplayer skins cleanly. +async fn write_custom_skin_loader_config(csl_dir: &Path) { + let config_path = csl_dir.join("CustomSkinLoader.json"); + let content = r#"{ + "version": "14.14", + "loadlist": [ + { + "name": "LocalSkin", + "type": "CustomSkinLoader", + "root": "CustomSkinLoader/skins/{USERNAME}.png" + }, + { + "name": "Mojang", + "type": "Mojang" + }, + { + "name": "Ely.by", + "type": "ElyBy" + }, + { + "name": "SkinRestorer", + "type": "CustomSkinAPI", + "root": "https://api.skinrestorer.net/v1/profile/{USERNAME}" + } + ] +}"#; + let _ = polyio::create_dir_all(csl_dir).await; + let _ = polyio::write(&config_path, content.as_bytes()).await; +} + +/// Directly modifies only the player's model character textures inside the Minecraft client `.jar`, +/// preserving opposite-model default characters in their original pristine state. +async fn patch_client_jar_skins(client_jar: &Path, skin_bytes: Option<&[u8]>, target_paths: &[String]) { + use futures_lite::AsyncReadExt; + + if !client_jar.exists() { + return; + } + + let orig_jar = client_jar.with_extension("jar.orig"); + + // Ensure pristine backup exists + if !orig_jar.exists() + && let Err(e) = tokio::fs::copy(client_jar, &orig_jar).await + { + tracing::warn!(error = %e, "failed to backup client jar for skin replacement"); + return; + } + + let Some(skin_bytes) = skin_bytes else { + // Restore clean vanilla jar + if orig_jar.exists() { + let _ = tokio::fs::copy(&orig_jar, client_jar).await; + } + return; + }; + + // Always patch from the original pristine backup so we don't accumulate or leave wrong skins + if let Err(e) = tokio::fs::copy(&orig_jar, client_jar).await { + tracing::warn!(error = %e, "failed to restore pristine client jar before skin injection"); + } + + let jar_data = match polyio::read(&orig_jar).await { + Ok(data) => data, + Err(e) => { + tracing::warn!(error = %e, "failed to read backup client jar"); + return; + } + }; + + let reader = match async_zip::base::read::mem::ZipFileReader::new(jar_data).await { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "failed to parse client jar as zip"); + return; + } + }; + + let temp_jar = client_jar.with_extension("jar.tmp"); + let out_file = match tokio::fs::File::create(&temp_jar).await { + Ok(f) => f, + Err(e) => { + tracing::warn!(error = %e, "failed to create temporary client jar"); + return; + } + }; + + let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(out_file); + let mut written_skins = std::collections::HashSet::new(); + + let entries = reader.file().entries(); + for index in 0..entries.len() { + let entry = match entries.get(index) { + Some(e) => e, + None => continue, + }; + + let entry_filename = entry.filename().as_str().unwrap_or_default().to_string(); + + if target_paths.contains(&entry_filename) { + let builder = async_zip::ZipEntryBuilder::new( + entry_filename.clone().into(), + async_zip::Compression::Deflate, + ); + if writer.write_entry_whole(builder, skin_bytes).await.is_ok() { + written_skins.insert(entry_filename); + } + } else { + let mut entry_reader = match reader.reader_without_entry(index).await { + Ok(r) => r, + Err(_) => continue, + }; + let mut buf = Vec::new(); + if entry_reader.read_to_end(&mut buf).await.is_ok() { + let compression = if entry.compression() == async_zip::Compression::Stored { + async_zip::Compression::Stored + } else { + async_zip::Compression::Deflate + }; + let builder = async_zip::ZipEntryBuilder::new( + entry_filename.into(), + compression, + ); + let _ = writer.write_entry_whole(builder, &buf).await; + } + } + } + + // Ensure all target paths exist in the jar + for path in target_paths { + if !written_skins.contains(path) { + let builder = async_zip::ZipEntryBuilder::new( + path.clone().into(), + async_zip::Compression::Deflate, + ); + let _ = writer.write_entry_whole(builder, skin_bytes).await; + } + } + + if let Err(e) = writer.close().await { + tracing::warn!(error = %e, "failed to finalize patched client jar zip"); + let _ = tokio::fs::remove_file(&temp_jar).await; + return; + } + + if let Err(e) = tokio::fs::rename(&temp_jar, client_jar).await { + tracing::warn!(error = %e, "failed to rename patched client jar"); + let _ = tokio::fs::remove_file(&temp_jar).await; + } else { + tracing::info!(jar = %client_jar.display(), "successfully replaced model default skins inside client jar"); + } +} + +/// Creates a targeted zipped `.zip` resource pack file. +async fn write_skin_zip_resource_pack(zip_path: &Path, skin_bytes: &[u8], target_paths: &[String]) { + let out_file = match tokio::fs::File::create(zip_path).await { + Ok(f) => f, + Err(_) => return, + }; + let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(out_file); + + let mcmeta = r#"{"pack":{"pack_format":34,"description":"OneLauncher Custom Skin","supported_formats":{"min_inclusive":1,"max_inclusive":9999}}}"#; + let mcmeta_builder = async_zip::ZipEntryBuilder::new("pack.mcmeta".to_string().into(), async_zip::Compression::Stored); + let _ = writer.write_entry_whole(mcmeta_builder, mcmeta.as_bytes()).await; + + for path in target_paths { + let b = async_zip::ZipEntryBuilder::new(path.clone().into(), async_zip::Compression::Deflate); + let _ = writer.write_entry_whole(b, skin_bytes).await; + } + + let _ = writer.close().await; +} + +/// Reads `options.txt`, adds or removes `"file/"` from the `resourcePacks` and `incompatibleResourcePacks` list, +/// and writes it back. Correctly serializes clean JSON without escaped quotes. +async fn patch_resource_packs_option(options_txt: &Path, pack_name: &str, enable: bool) { + let file_dir_entry = format!("file/{pack_name}"); + let file_zip_entry = format!("file/{pack_name}.zip"); + + let content = polyio::read_to_string(options_txt) + .await + .unwrap_or_default(); + + let mut lines: Vec = content.lines().map(String::from).collect(); + + let mut found_rp = false; + let mut found_incomp = false; + + for line in &mut lines { + if line.starts_with("resourcePacks:") { + found_rp = true; + let raw = line.trim_start_matches("resourcePacks:").trim(); + let mut packs: Vec = serde_json::from_str(raw).unwrap_or_default(); + packs.retain(|p| { + p != "vanilla" + && p != &file_dir_entry + && p != &file_zip_entry + && p != &format!("\"{file_dir_entry}\"") + && p != &format!("\"{file_zip_entry}\"") + }); + if enable { + packs.insert(0, "vanilla".to_string()); + packs.push(file_dir_entry.clone()); + packs.push(file_zip_entry.clone()); + } else { + packs.insert(0, "vanilla".to_string()); + } + *line = format!( + "resourcePacks:{}", + serde_json::to_string(&packs).unwrap_or_else(|_| "[\"vanilla\"]".to_string()) + ); + } else if line.starts_with("incompatibleResourcePacks:") { + found_incomp = true; + let raw = line.trim_start_matches("incompatibleResourcePacks:").trim(); + let mut packs: Vec = serde_json::from_str(raw).unwrap_or_default(); + packs.retain(|p| { + p != &file_dir_entry + && p != &file_zip_entry + && p != &format!("\"{file_dir_entry}\"") + && p != &format!("\"{file_zip_entry}\"") + }); + if enable { + packs.push(file_dir_entry.clone()); + packs.push(file_zip_entry.clone()); + } + *line = format!( + "incompatibleResourcePacks:{}", + serde_json::to_string(&packs).unwrap_or_else(|_| "[]".to_string()) + ); + } + } + + if !found_rp && enable { + let entries = vec!["vanilla", &file_dir_entry, &file_zip_entry]; + let default_rp = serde_json::to_string(&entries).unwrap_or_default(); + lines.push(format!("resourcePacks:{default_rp}")); + } + if !found_incomp && enable { + let entries = vec![&file_dir_entry, &file_zip_entry]; + let default_incomp = serde_json::to_string(&entries).unwrap_or_default(); + lines.push(format!("incompatibleResourcePacks:{default_incomp}")); + } + + let new_content = lines.join("\n") + "\n"; + if let Err(e) = polyio::write(options_txt, new_content.as_bytes()).await { + tracing::warn!("failed to patch options.txt: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_target_skin_paths_for_account() { + let (paths, modern, legacy) = target_skin_paths_for_account("c4436573-0477-3e11-97b7-6f7ef57f354f", "oiupoyt"); + assert!(!paths.is_empty()); + assert!(!modern.is_empty()); + assert!(!legacy.is_empty()); + assert!(paths.iter().any(|p| p.contains(&legacy))); + } +}