Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
c34b6a63d8 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
2026-09-07 17:37:54 +05:30
30 changed files with 2318 additions and 419 deletions

166
.github/workflows/release.yml vendored Normal file
View file

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

34
Cargo.lock generated
View file

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

View file

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

View file

@ -1,73 +1,16 @@
<div align="center">
<img src=".github/media/RepoBanner.png" alt="Repository Banner" />
<img src="https://github.com/user-attachments/assets/e8359baa-23fb-457a-8910-169974487a98" alt="OneLauncher Logo" width="220" style="border-radius: 24px;" />
# 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
</div>
## 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 <targets>
# 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!

View file

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

View file

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

View file

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

View file

@ -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<Mutex<HashMap<usize, ImageHandle>>> = 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<ImageHandle> {
if skin_bytes.is_empty() {
return None;
}
let src_ptr = skin_bytes.as_ptr() as usize;
let cache = AVATAR_HEAD_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(handle) = cache.lock().ok().and_then(|g| g.get(&src_ptr).cloned()) {
return Some(handle);
}
let composed = compose_head(skin_bytes)?;
if let Ok(mut guard) = cache.lock() {
guard.insert(src_ptr, composed.clone());
}
Some(composed)
}
fn compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
let data = unsafe { SkData::new_bytes(skin_bytes) };
let skin = SkImage::from_encoded(data)?;

View file

@ -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<LoginProgress>, error: Option<String>) -> 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()
}

View file

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

View file

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

View file

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

View file

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

View file

@ -112,7 +112,7 @@ pub fn try_account(query: &UseQuery<AccountQuery>) -> Option<MinecraftAccount> {
super::state::settled_or_loading(query).flatten()
}
async fn invalidate_auth_queries(account_id: Option<Uuid>) {
pub(crate) async fn invalidate_auth_queries(account_id: Option<Uuid>) {
QueriesStorage::<ListAccountsQuery>::invalidate_matching(ListAccountsKeys).await;
for fallback in [false, true] {
QueriesStorage::<DefaultAccountQuery>::invalidate_matching(DefaultAccountKeys { fallback })

View file

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

View file

@ -22,12 +22,29 @@ impl QueryCapability for FetchPlayerProfileQuery {
type Keys = PlayerProfileQueryKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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

View file

@ -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<Bytes>,
pub is_slim: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct SkinMetadata {
pub is_slim: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CustomSkinQuery;
impl QueryCapability for CustomSkinQuery {
type Ok = CustomSkinData;
type Err = String;
type Keys = CustomSkinKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let uuid = &keys.uuid;
if uuid.is_empty() {
return Ok(CustomSkinData {
uuid: String::new(),
has_custom: false,
bytes: None,
is_slim: false,
});
}
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
if !skin_path.exists() {
return Ok(CustomSkinData {
uuid: uuid.clone(),
has_custom: false,
bytes: None,
is_slim: false,
});
}
let bytes = polyio::read(&skin_path)
.await
.map_err(|e| format!("Failed to read custom skin: {e}"))?;
let is_slim = if meta_path.exists() {
polyio::read_to_string(&meta_path)
.await
.ok()
.and_then(|s| serde_json::from_str::<SkinMetadata>(&s).ok())
.map(|m| m.is_slim)
.unwrap_or(false)
} else {
false
};
Ok(CustomSkinData {
uuid: uuid.clone(),
has_custom: true,
bytes: Some(Bytes::from(bytes)),
is_slim,
})
}
}
pub fn use_custom_skin(uuid: String) -> UseQuery<CustomSkinQuery> {
use_query(
Query::new(CustomSkinKeys { uuid }, CustomSkinQuery)
.stale_time(SKIN_STALE)
.clean_time(SKIN_CLEAN),
)
}
pub async fn invalidate_skin_queries(uuid: Option<&str>) {
if let Some(uuid) = uuid {
QueriesStorage::<CustomSkinQuery>::invalidate_matching(CustomSkinKeys {
uuid: uuid.to_string(),
})
.await;
} else {
QueriesStorage::<CustomSkinQuery>::invalidate_all().await;
}
}
pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
let custom_query = use_custom_skin(uuid.clone());
let custom_data = crate::hooks::settled_or_loading(&custom_query);
let profile = super::use_player_profile(uuid.clone(), None::<String>);
let (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(())
}

View file

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

View file

@ -1,10 +1,9 @@
use std::cell::Cell;
use std::path::PathBuf;
use cargo_packager_updater::{Config, Update, check_update};
use oneclient_events::{Choice, EventBus, Prompt};
use 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<UpdateAnswer> {
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<UpdateAnswer> {
const PROGRESS_STEP: u64 = 256 * 1024;
/// SemVer parser supporting cracked revision suffixes like `2.2.3-c1`, `2.2.3.c1`, `v2.2.3-c2`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct CrackedVersion {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub rev: u64,
}
impl CrackedVersion {
pub fn parse(s: &str) -> Option<Self> {
let trimmed = s.trim().trim_start_matches(['v', 'V']);
let (base, rev) = if let Some((base_part, rev_part)) = trimmed.split_once("-c").or_else(|| trimmed.split_once(".c")) {
let rev_num = rev_part.parse::<u64>().ok()?;
(base_part, rev_num)
} else if let Some((base_part, rev_part)) = trimmed.split_once("-r").or_else(|| trimmed.split_once(".r")) {
let rev_num = rev_part.parse::<u64>().ok()?;
(base_part, rev_num)
} else if let Some((base_part, rev_part)) = trimmed.split_once('-') {
let rev_num = rev_part.trim_start_matches(['c', 'C', 'r', 'R']).parse::<u64>().unwrap_or(0);
(base_part, rev_num)
} else {
(trimmed, 0)
};
let mut parts = base.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
Some(Self {
major,
minor,
patch,
rev,
})
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct GithubRelease {
pub tag_name: String,
pub name: Option<String>,
pub body: Option<String>,
pub html_url: String,
#[serde(default)]
pub assets: Vec<GithubReleaseAsset>,
}
#[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<Option<Update>> {
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<Option<GithubRelease>> {
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 });
}
}

View file

@ -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<Event<PressEventData>>,
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)

View file

@ -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<Element> = 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<MinecraftAccount>,
has_microsoft: bool,
_has_microsoft: bool,
microsoft_pending: bool,
error: Option<String>,
on_open_offline: impl FnMut(Event<PressEventData>) + '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<Option<(Uuid, String)>>,
edit_name: State<String>,
}
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<PressEventData>| {
e.stop_propagation();
let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {});
})
.child(
Icon::new(IconType::Colors)
.size(18.)
.color(colors::fg_primary()),
),
)
.child({
let is_offline = !is_microsoft;
let mut edit_acc_state = self.edit_account;
let mut edit_name_state = self.edit_name;
let username_val = self.username.clone();
let id_val = self.id;
Button::new()
.ghost()
.icon()
.enabled(is_offline)
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
edit_name_state.set(username_val.clone());
edit_acc_state.set(Some((id_val, username_val.clone())));
})
.child(
Icon::new(IconType::Pencil01)
.size(18.)
.color(if is_offline {
colors::fg_primary()
} else {
colors::fg_secondary()
}),
)
})
.child(
Button::new()
.ghost()

View file

@ -23,6 +23,7 @@ impl Component for SettingsJava {
let mut show_manager = use_state(|| false);
let pending_remove = use_state(|| None::<PendingRemove>);
#[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"))

View file

@ -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(&current_account);
let accounts = try_accounts(&use_accounts()).unwrap_or_default();
let selected_id = use_state(|| default_account.as_ref().map(|a| a.id));
// Keep selected account in sync if none is explicitly picked
let active_account = selected_id
.read()
.and_then(|id| accounts.iter().find(|a| a.id == id).cloned())
.or_else(|| default_account.clone());
let account_uuid = active_account.as_ref().map(|a| a.id.to_string());
let account_id = active_account.as_ref().map(|a| a.id);
let username = active_account.as_ref().map(|a| a.username.clone());
let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error)
let is_loading = use_state(|| false);
rect()
.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<String>) -> impl IntoElement {
#[derive(PartialEq, Clone)]
struct PreviewPanel {
account_uuid: Option<String>,
account_id: Option<uuid::Uuid>,
username: Option<String>,
status_msg: State<Option<(String, bool)>>,
}
impl Component for PreviewPanel {
fn render_key(&self) -> DiffKey {
DiffKey::from(&self.account_id)
}
fn render(&self) -> impl IntoElement {
let account_uuid = self.account_uuid.clone();
let custom_query = use_custom_skin(account_uuid.clone().unwrap_or_default());
let custom_data = crate::hooks::settled_or_loading(&custom_query);
let is_slim = custom_data.as_ref().is_some_and(|d| d.is_slim);
let has_custom = custom_data.as_ref().is_some_and(|d| d.has_custom);
let acc_id_copy = self.account_id;
let username_copy = self.username.clone();
let status_msg = self.status_msg;
let set_classic = {
let acc_id = acc_id_copy;
let uname = username_copy.clone();
let mut status = status_msg;
move |_| {
let Some(id) = acc_id else { return };
let uname = uname.clone();
let uuid_str = id.to_string();
spawn(async move {
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
if let Ok(bytes) = polyio::read(&skin_path).await {
let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, false)
.await;
status.set(Some((
"Model set to Classic (4px arms)".to_string(),
false,
)));
} else if let Some(steve_bytes) = crate::AppAssets::get_bytes("steve.png") {
let _ =
save_account_skin(&uuid_str, uname.as_deref(), &steve_bytes, false)
.await;
status.set(Some(("Model set to Classic".to_string(), false)));
}
}
});
}
};
let set_slim = {
let acc_id = acc_id_copy;
let uname = username_copy.clone();
let mut status = status_msg;
move |_| {
let Some(id) = acc_id else { return };
let uname = uname.clone();
let uuid_str = id.to_string();
spawn(async move {
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
if let Ok(bytes) = polyio::read(&skin_path).await {
let _ =
save_account_skin(&uuid_str, uname.as_deref(), &bytes, true).await;
status.set(Some(("Model set to Slim (3px arms)".to_string(), false)));
} else if let Some(alex_bytes) = crate::AppAssets::get_bytes("alex.png") {
let _ =
save_account_skin(&uuid_str, uname.as_deref(), &alex_bytes, true)
.await;
status.set(Some(("Model set to Slim".to_string(), false)));
}
}
});
}
};
rect()
.vertical()
.width(Size::px(340.))
.height(Size::fill())
.spacing(16.)
.child(
rect()
.center()
.width(Size::fill())
.height(Size::flex(1.0))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(match account_uuid {
Some(uuid) => PlayerModel::new(uuid)
.width(Size::fill())
.height(Size::fill())
.into_element(),
None => Icon::new(IconType::Users01)
.size(64.)
.color(colors::fg_secondary())
.into_element(),
}),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::Center)
.spacing(8.)
.child(
rect()
.center()
.padding(Gaps::new_symmetric(6., 14.))
.corner_radius(CornerRadius::new_all(8.))
.background(if !is_slim {
colors::brand()
} else {
colors::component_bg()
})
.cursor(CursorIcon::Pointer)
.on_press(set_classic)
.child(
label()
.text("Classic (4px)")
.font_size(12.)
.font_weight(if !is_slim {
FontWeight::SEMI_BOLD
} else {
FontWeight::NORMAL
})
.color(colors::fg_primary()),
),
)
.child(
rect()
.center()
.padding(Gaps::new_symmetric(6., 14.))
.corner_radius(CornerRadius::new_all(8.))
.background(if is_slim {
colors::brand()
} else {
colors::component_bg()
})
.cursor(CursorIcon::Pointer)
.on_press(set_slim)
.child(
label()
.text("Slim (3px)")
.font_size(12.)
.font_weight(if is_slim {
FontWeight::SEMI_BOLD
} else {
FontWeight::NORMAL
})
.color(colors::fg_primary()),
),
),
)
.maybe_child(has_custom.then(|| {
rect()
.center()
.child(
label()
.text("Custom Skin Active")
.font_size(11.)
.color(colors::success()),
)
.into_element()
}))
.into_element()
}
}
fn side_panel(
active_account: Option<MinecraftAccount>,
accounts: Vec<MinecraftAccount>,
selected_id: State<Option<uuid::Uuid>>,
status_msg: State<Option<(String, bool)>>,
is_loading: State<bool>,
) -> impl IntoElement {
let username = active_account
.as_ref()
.map(|a| a.username.clone())
.unwrap_or_else(|| "No Account Selected".to_string());
let account_id = active_account.as_ref().map(|a| a.id);
let username_opt = active_account.as_ref().map(|a| a.username.clone());
// Import from local file
let id_for_file = account_id;
let uname_for_file = username_opt.clone();
let mut status_file = status_msg;
let mut loading_file = is_loading;
let import_from_file = move |_| {
let Some(id) = id_for_file else { return };
let uname = uname_for_file.clone();
let uuid_str = id.to_string();
loading_file.set(true);
spawn(async move {
if let Some(handle) = rfd::AsyncFileDialog::new()
.set_title("Select Minecraft Skin PNG")
.add_filter("Minecraft Skin (*.png)", &["png"])
.pick_file()
.await
{
let bytes = handle.read().await;
match save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await {
Ok(()) => {
status_file.set(Some((
"Custom skin imported successfully!".to_string(),
false,
)));
}
Err(err) => {
status_file.set(Some((err, true)));
}
}
}
loading_file.set(false);
});
};
// Download/Export skin
let id_for_dl = account_id;
let uname_for_dl = username_opt.clone();
let mut status_dl = status_msg;
let download_skin = move |_| {
let Some(id) = id_for_dl else { return };
let uname = uname_for_dl.clone().unwrap_or_else(|| "skin".to_string());
let uuid_str = id.to_string();
spawn(async move {
let skin_bytes =
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
polyio::read(&skin_path).await.ok()
} else {
None
};
let bytes_to_save = match skin_bytes {
Some(b) => b,
None => crate::AppAssets::get_bytes("steve.png")
.map(|b| b.to_vec())
.unwrap_or_default(),
};
if let Some(handle) = rfd::AsyncFileDialog::new()
.set_title("Save Skin PNG")
.set_file_name(format!("{uname}_skin.png"))
.add_filter("PNG Image (*.png)", &["png"])
.save_file()
.await
{
if polyio::write(handle.path(), &bytes_to_save).await.is_ok() {
status_dl.set(Some(("Skin exported successfully!".to_string(), false)));
} else {
status_dl.set(Some(("Failed to save skin file".to_string(), true)));
}
}
});
};
// Reset skin to default
let id_for_reset = account_id;
let uname_for_reset = username_opt.clone();
let mut status_reset = status_msg;
let reset_skin = move |_| {
let Some(id) = id_for_reset else { return };
let uname = uname_for_reset.clone();
let uuid_str = id.to_string();
spawn(async move {
match delete_account_skin(&uuid_str, uname.as_deref()).await {
Ok(()) => {
status_reset
.set(Some(("Skin reset to default skin.".to_string(), false)));
}
Err(err) => {
status_reset.set(Some((err, true)));
}
}
});
};
rect()
.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<MinecraftAccount>,
selected_id: State<Option<uuid::Uuid>>,
) -> 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()
}

View file

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

View file

@ -23,8 +23,13 @@ impl CredentialsStore {
return Ok(Self::default());
}
match polyio::read_json(&path).await {
Ok(store) => Ok(store),
match polyio::read_json::<Self>(&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<MinecraftAccount> {
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));
}
}

View file

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

View file

@ -106,6 +106,18 @@ pub fn logs_dir() -> PathsResult<PathBuf> {
Ok(config_dir()?.join("logs"))
}
pub fn skins_dir() -> PathsResult<PathBuf> {
Ok(data_dir()?.join("skins"))
}
pub fn skin_file_path(uuid: &str) -> PathsResult<PathBuf> {
Ok(skins_dir()?.join(format!("{uuid}.png")))
}
pub fn skin_meta_path(uuid: &str) -> PathsResult<PathBuf> {
Ok(skins_dir()?.join(format!("{uuid}.json")))
}
pub fn java_dir() -> PathsResult<PathBuf> {
Ok(data_dir()?.join("metadata").join("java"))
}

View file

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

View file

@ -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<Self, OfflineAuthError> {
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,
}

View file

@ -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, 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<String> = 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/<name>"` 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<String> = 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<String> = 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<String> = 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)));
}
}