Compare commits
1 commit
main
...
update/2.3
| Author | SHA1 | Date | |
|---|---|---|---|
| c34b6a63d8 |
30 changed files with 2318 additions and 419 deletions
166
.github/workflows/release.yml
vendored
Normal file
166
.github/workflows/release.yml
vendored
Normal 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
34
Cargo.lock
generated
|
|
@ -4464,7 +4464,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_app"
|
name = "oneclient_app"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|
@ -4477,6 +4477,7 @@ dependencies = [
|
||||||
"freya-core",
|
"freya-core",
|
||||||
"freya-skia-safe",
|
"freya-skia-safe",
|
||||||
"freya-testing",
|
"freya-testing",
|
||||||
|
"futures-util",
|
||||||
"image",
|
"image",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
"notify",
|
"notify",
|
||||||
|
|
@ -4497,8 +4498,11 @@ dependencies = [
|
||||||
"polyio",
|
"polyio",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest 0.13.3",
|
||||||
"rfd",
|
"rfd",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -4510,7 +4514,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_auth"
|
name = "oneclient_auth"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -4534,7 +4538,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_cluster"
|
name = "oneclient_cluster"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes 1.11.1",
|
"bytes 1.11.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -4557,7 +4561,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_common"
|
name = "oneclient_common"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"interfrost",
|
"interfrost",
|
||||||
|
|
@ -4570,7 +4574,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_content"
|
name = "oneclient_content"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
|
|
@ -4598,7 +4602,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_core"
|
name = "oneclient_core"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-compression",
|
"async-compression",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|
@ -4662,7 +4666,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_db"
|
name = "oneclient_db"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-tempfile",
|
"async-tempfile",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -4677,7 +4681,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_discord"
|
name = "oneclient_discord"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
|
|
@ -4689,7 +4693,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_events"
|
name = "oneclient_events"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -4699,7 +4703,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_java"
|
name = "oneclient_java"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"astral-tokio-tar",
|
"astral-tokio-tar",
|
||||||
"async-tempfile",
|
"async-tempfile",
|
||||||
|
|
@ -4721,7 +4725,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_macro"
|
name = "oneclient_macro"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -4730,7 +4734,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_mc"
|
name = "oneclient_mc"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -4752,7 +4756,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_net"
|
name = "oneclient_net"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|
@ -4774,7 +4778,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oneclient_polyplus"
|
name = "oneclient_polyplus"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-tungstenite",
|
"async-tungstenite",
|
||||||
"bytes 1.11.1",
|
"bytes 1.11.1",
|
||||||
|
|
@ -5051,7 +5055,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyio"
|
name = "polyio"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"astral-tokio-tar",
|
"astral-tokio-tar",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
|
|
|
||||||
14
Cargo.toml
14
Cargo.toml
|
|
@ -9,13 +9,13 @@ authors = [
|
||||||
"Polyfrost",
|
"Polyfrost",
|
||||||
"Polyfrost Contributors",
|
"Polyfrost Contributors",
|
||||||
]
|
]
|
||||||
documentation = "https://github.com/Polyfrost/OneLauncher"
|
documentation = "https://github.com/oiupoyt/OneLauncher-Cracked"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
homepage = "https://polyfrost.org/projects/oneclient"
|
homepage = "https://polyfrost.org/projects/oneclient"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
repository = "https://github.com/Polyfrost/OneLauncher"
|
repository = "https://github.com/oiupoyt/OneLauncher-Cracked"
|
||||||
version = "2.3.1"
|
version = "2.3.1-c1"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# core
|
# core
|
||||||
|
|
@ -159,3 +159,11 @@ inherits = "release"
|
||||||
|
|
||||||
debug = true
|
debug = true
|
||||||
strip = false
|
strip = false
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = 1
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 3
|
||||||
|
debug = false
|
||||||
|
|
||||||
|
|
|
||||||
67
README.md
67
README.md
|
|
@ -1,73 +1,16 @@
|
||||||
<div align="center">
|
<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
|
forked from [OneLauncher](https://github.com/Polyfrost/OneLauncher) allowing for uh offline authentication
|
||||||
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.
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Installing
|
## download from [**Releases Page**](https://github.com/oiupoyt/OneLauncher-Cracked/releases).
|
||||||
|
yes the actual client performs normally aswell
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
## 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!
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ regex.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
dunce.workspace = true
|
dunce.workspace = true
|
||||||
percent-encoding.workspace = true
|
percent-encoding.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
futures-util = { workspace = true, features = ["std"] }
|
||||||
|
|
||||||
cargo-packager-updater = "=0.2.3"
|
cargo-packager-updater = "=0.2.3"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -6,14 +6,14 @@ use freya::{
|
||||||
use oneclient_auth::MinecraftAccount;
|
use oneclient_auth::MinecraftAccount;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ui::{divider},
|
|
||||||
Route,
|
Route,
|
||||||
components::{Avatar, Button, Icon, IconType, OverlayPopup},
|
components::{Avatar, Button, Icon, IconType, OverlayPopup},
|
||||||
hooks::{
|
hooks::{
|
||||||
SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open,
|
try_accounts, try_default_account, use_account_switcher_open, use_accounts,
|
||||||
use_accounts, use_current_account, use_dispatch, use_set_default_account,
|
use_current_account, use_dispatch,
|
||||||
},
|
},
|
||||||
theme::colors,
|
theme::colors,
|
||||||
|
ui::divider,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
|
|
@ -90,8 +90,7 @@ impl Component for AccountPanel {
|
||||||
.spacing(8.)
|
.spacing(8.)
|
||||||
.opacity(progress)
|
.opacity(progress)
|
||||||
.margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
|
.margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
|
||||||
.background(colors::page_elevated().with_a(220))
|
.background(colors::page_elevated())
|
||||||
.blur(12.)
|
|
||||||
.corner_radius(CornerRadius::new_all(12.))
|
.corner_radius(CornerRadius::new_all(12.))
|
||||||
.border(
|
.border(
|
||||||
Border::new()
|
Border::new()
|
||||||
|
|
@ -157,15 +156,19 @@ impl Component for AccountRow {
|
||||||
let active = self.active;
|
let active = self.active;
|
||||||
|
|
||||||
let dispatch = use_dispatch();
|
let dispatch = use_dispatch();
|
||||||
let set_default = use_set_default_account();
|
|
||||||
let mut hovered = use_state(|| false);
|
let mut hovered = use_state(|| false);
|
||||||
|
|
||||||
let switch = move |_| {
|
let switch = move |_| {
|
||||||
if active {
|
if active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set_default.mutate(SetDefaultAccountKeys { id: Some(id) });
|
|
||||||
dispatch.close_account_switcher();
|
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()
|
rect()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use freya::elements::image::{ImageHandle, image};
|
use freya::elements::image::{ImageHandle, image};
|
||||||
use freya::engine::prelude::{Paint, SkData, SkImage, raster_n32_premul};
|
use freya::engine::prelude::{Paint, SkData, SkImage, raster_n32_premul};
|
||||||
|
|
@ -10,6 +13,8 @@ const FACE: f32 = 8.;
|
||||||
const HEAD_FACE: (f32, f32) = (8., 8.);
|
const HEAD_FACE: (f32, f32) = (8., 8.);
|
||||||
const HEAD_OVERLAY: (f32, f32) = (40., 8.);
|
const HEAD_OVERLAY: (f32, f32) = (40., 8.);
|
||||||
|
|
||||||
|
static AVATAR_HEAD_CACHE: OnceLock<Mutex<HashMap<usize, ImageHandle>>> = OnceLock::new();
|
||||||
|
|
||||||
#[derive(PartialEq, Clone)]
|
#[derive(PartialEq, Clone)]
|
||||||
pub struct Avatar {
|
pub struct Avatar {
|
||||||
uuid: String,
|
uuid: String,
|
||||||
|
|
@ -34,22 +39,13 @@ impl LayoutExt for Avatar {
|
||||||
impl ContainerSizeExt for Avatar {}
|
impl ContainerSizeExt for Avatar {}
|
||||||
|
|
||||||
impl Component for Avatar {
|
impl Component for Avatar {
|
||||||
|
fn render_key(&self) -> DiffKey {
|
||||||
|
DiffKey::from(&self.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone());
|
let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone());
|
||||||
|
let head = get_or_compose_head(&skin_bytes);
|
||||||
let mut cache = use_state(|| None::<(usize, ImageHandle)>);
|
|
||||||
let src_ptr = skin_bytes.as_ptr() as usize;
|
|
||||||
let cached = cache.read().clone();
|
|
||||||
let head = match cached {
|
|
||||||
Some((ptr, holder)) if ptr == src_ptr => Some(holder),
|
|
||||||
_ => {
|
|
||||||
let holder = compose_head(&skin_bytes);
|
|
||||||
if let Some(holder) = &holder {
|
|
||||||
cache.set(Some((src_ptr, holder.clone())));
|
|
||||||
}
|
|
||||||
holder
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
.width(Size::px(AVATAR_SIZE))
|
.width(Size::px(AVATAR_SIZE))
|
||||||
|
|
@ -67,6 +63,23 @@ impl Component for Avatar {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_or_compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
|
||||||
|
if skin_bytes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let src_ptr = skin_bytes.as_ptr() as usize;
|
||||||
|
let cache = AVATAR_HEAD_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
if let Some(handle) = cache.lock().ok().and_then(|g| g.get(&src_ptr).cloned()) {
|
||||||
|
return Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
let composed = compose_head(skin_bytes)?;
|
||||||
|
if let Ok(mut guard) = cache.lock() {
|
||||||
|
guard.insert(src_ptr, composed.clone());
|
||||||
|
}
|
||||||
|
Some(composed)
|
||||||
|
}
|
||||||
|
|
||||||
fn compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
|
fn compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
|
||||||
let data = unsafe { SkData::new_bytes(skin_bytes) };
|
let data = unsafe { SkData::new_bytes(skin_bytes) };
|
||||||
let skin = SkImage::from_encoded(data)?;
|
let skin = SkImage::from_encoded(data)?;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
use crate::state::LoginProgress;
|
||||||
use freya::prelude::*;
|
use freya::prelude::*;
|
||||||
use freya::query::{MutationCapability, MutationStateData, UseMutation};
|
use freya::query::{MutationCapability, MutationStateData, UseMutation};
|
||||||
use freya::text_edit::Clipboard;
|
use freya::text_edit::Clipboard;
|
||||||
use oneclient_core::LauncherError;
|
|
||||||
use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession};
|
use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession};
|
||||||
use crate::state::LoginProgress;
|
use oneclient_core::LauncherError;
|
||||||
|
|
||||||
use crate::components::{Button, Icon, IconType, OverlayPopup};
|
use crate::components::{Button, Icon, IconType, OverlayPopup};
|
||||||
use crate::hooks::{
|
use crate::hooks::{
|
||||||
|
|
@ -185,7 +185,7 @@ pub(crate) fn login_dialog(
|
||||||
on_close: impl FnMut() + Clone + 'static,
|
on_close: impl FnMut() + Clone + 'static,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
let mut close_scrim = on_close.clone();
|
let mut close_scrim = on_close.clone();
|
||||||
let mut close_cancel = on_close;
|
let mut close_cancel = on_close.clone();
|
||||||
OverlayPopup::new()
|
OverlayPopup::new()
|
||||||
.on_close(move |()| close_scrim())
|
.on_close(move |()| close_scrim())
|
||||||
.child(
|
.child(
|
||||||
|
|
@ -225,10 +225,9 @@ pub(crate) fn login_dialog(
|
||||||
.spacing(18.)
|
.spacing(18.)
|
||||||
.child(browser_dialog_body(auth_url))
|
.child(browser_dialog_body(auth_url))
|
||||||
.child(dialog_divider())
|
.child(dialog_divider())
|
||||||
.child(device_code_dialog_body(
|
.child(device_code_dialog_body(user_code, verification_uri))
|
||||||
user_code,
|
.child(dialog_divider())
|
||||||
verification_uri,
|
.child(offline_mode_dialog_body(on_close.clone()))
|
||||||
))
|
|
||||||
.child(status_row(status, error))
|
.child(status_row(status, error))
|
||||||
.maybe_child(guidance.map(guidance_block)),
|
.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 {
|
fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement {
|
||||||
let mut steps = rect()
|
let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child(
|
||||||
.vertical()
|
label()
|
||||||
.width(Size::fill())
|
.text("What you can do:")
|
||||||
.spacing(6.)
|
.font_size(12.)
|
||||||
.child(
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
label()
|
.color(colors::fg_primary()),
|
||||||
.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() {
|
for (index, step) in guidance.steps_to_fix.into_iter().enumerate() {
|
||||||
steps = steps.child(
|
steps = steps.child(
|
||||||
|
|
@ -443,3 +438,39 @@ fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoE
|
||||||
)
|
)
|
||||||
.into_element()
|
.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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,9 @@ fn navbar_center() -> impl IntoElement {
|
||||||
nav_label: "Versions",
|
nav_label: "Versions",
|
||||||
})
|
})
|
||||||
.child(NavLink {
|
.child(NavLink {
|
||||||
active: false,
|
active: route == Route::AccountSkins {},
|
||||||
target: NavTarget::External("https://store.polyfrost.org"),
|
target: NavTarget::Route(Route::AccountSkins {}),
|
||||||
nav_label: "Cosmetics",
|
nav_label: "Skins",
|
||||||
})
|
})
|
||||||
.child(NavLink {
|
.child(NavLink {
|
||||||
active: matches!(
|
active: matches!(
|
||||||
|
|
@ -142,6 +142,7 @@ fn browse_target() -> Route {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Clone)]
|
#[derive(PartialEq, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
enum NavTarget {
|
enum NavTarget {
|
||||||
Route(Route),
|
Route(Route),
|
||||||
External(&'static str),
|
External(&'static str),
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ impl PlayerModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for PlayerModel {
|
impl Component for PlayerModel {
|
||||||
|
fn render_key(&self) -> DiffKey {
|
||||||
|
DiffKey::from(&self.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone());
|
let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
pub const WINDOW_APP_ID: &str = "oneclient_app";
|
pub const WINDOW_APP_ID: &str = "oneclient_app";
|
||||||
pub const WINDOW_TITLE: &str = "OneClient";
|
pub const WINDOW_TITLE: &str = "OneClient";
|
||||||
|
|
||||||
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK";
|
pub const GITHUB_REPO: &str = "oiupoyt/OneLauncher-Cracked";
|
||||||
pub const UPDATER_ENDPOINT: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json";
|
pub const GITHUB_API_LATEST_RELEASE: &str =
|
||||||
pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest";
|
"https://api.github.com/repos/oiupoyt/OneLauncher-Cracked/releases/latest";
|
||||||
|
pub const RELEASES_URL: &str = "https://github.com/oiupoyt/OneLauncher-Cracked/releases/latest";
|
||||||
|
|
@ -60,12 +60,15 @@ pub use queries::{
|
||||||
use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch,
|
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_package_project, use_package_search, use_package_versions, use_package_versions_when,
|
||||||
use_player_profile,
|
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_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account,
|
||||||
use_terms, use_upload_log,
|
use_terms, use_upload_log,
|
||||||
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
|
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub(crate) use queries::invalidate_auth_queries;
|
||||||
|
|
||||||
use crate::notifications::NotificationSnapshot;
|
use crate::notifications::NotificationSnapshot;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState,
|
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState,
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ pub fn try_account(query: &UseQuery<AccountQuery>) -> Option<MinecraftAccount> {
|
||||||
super::state::settled_or_loading(query).flatten()
|
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;
|
QueriesStorage::<ListAccountsQuery>::invalidate_matching(ListAccountsKeys).await;
|
||||||
for fallback in [false, true] {
|
for fallback in [false, true] {
|
||||||
QueriesStorage::<DefaultAccountQuery>::invalidate_matching(DefaultAccountKeys { fallback })
|
QueriesStorage::<DefaultAccountQuery>::invalidate_matching(DefaultAccountKeys { fallback })
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,13 @@ pub use settings_profiles::{
|
||||||
try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile,
|
try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile,
|
||||||
use_named_profiles,
|
use_named_profiles,
|
||||||
};
|
};
|
||||||
pub use skin::use_player_skin;
|
pub use skin::{
|
||||||
|
delete_account_skin, invalidate_skin_queries, save_account_skin, use_custom_skin,
|
||||||
|
use_player_skin,
|
||||||
|
};
|
||||||
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
|
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
|
||||||
pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms};
|
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 version_metadata::{pick_version_metadata, use_version_metadata};
|
||||||
pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata};
|
pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata};
|
||||||
|
|
||||||
|
pub(crate) use auth::invalidate_auth_queries;
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,29 @@ impl QueryCapability for FetchPlayerProfileQuery {
|
||||||
type Keys = PlayerProfileQueryKeys;
|
type Keys = PlayerProfileQueryKeys;
|
||||||
|
|
||||||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
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 state = crate::launcher::state()?;
|
||||||
let client = &state.services.requester;
|
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
|
// TODO Cache
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,110 @@
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use freya::prelude::*;
|
use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query};
|
||||||
|
use oneclient_common::paths::{shared_minecraft_dir, skin_file_path, skin_meta_path, skins_dir};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::AppAssets;
|
use crate::AppAssets;
|
||||||
|
|
||||||
|
const SKIN_STALE: Duration = Duration::from_secs(5 * 60);
|
||||||
|
const SKIN_CLEAN: Duration = Duration::from_secs(30 * 60);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct CustomSkinKeys {
|
||||||
|
pub uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct CustomSkinData {
|
||||||
|
pub uuid: String,
|
||||||
|
pub has_custom: bool,
|
||||||
|
pub bytes: Option<Bytes>,
|
||||||
|
pub is_slim: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct SkinMetadata {
|
||||||
|
pub is_slim: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct CustomSkinQuery;
|
||||||
|
|
||||||
|
impl QueryCapability for CustomSkinQuery {
|
||||||
|
type Ok = CustomSkinData;
|
||||||
|
type Err = String;
|
||||||
|
type Keys = CustomSkinKeys;
|
||||||
|
|
||||||
|
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||||
|
let uuid = &keys.uuid;
|
||||||
|
if uuid.is_empty() {
|
||||||
|
return Ok(CustomSkinData {
|
||||||
|
uuid: String::new(),
|
||||||
|
has_custom: false,
|
||||||
|
bytes: None,
|
||||||
|
is_slim: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !skin_path.exists() {
|
||||||
|
return Ok(CustomSkinData {
|
||||||
|
uuid: uuid.clone(),
|
||||||
|
has_custom: false,
|
||||||
|
bytes: None,
|
||||||
|
is_slim: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = polyio::read(&skin_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read custom skin: {e}"))?;
|
||||||
|
|
||||||
|
let is_slim = if meta_path.exists() {
|
||||||
|
polyio::read_to_string(&meta_path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<SkinMetadata>(&s).ok())
|
||||||
|
.map(|m| m.is_slim)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CustomSkinData {
|
||||||
|
uuid: uuid.clone(),
|
||||||
|
has_custom: true,
|
||||||
|
bytes: Some(Bytes::from(bytes)),
|
||||||
|
is_slim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_custom_skin(uuid: String) -> UseQuery<CustomSkinQuery> {
|
||||||
|
use_query(
|
||||||
|
Query::new(CustomSkinKeys { uuid }, CustomSkinQuery)
|
||||||
|
.stale_time(SKIN_STALE)
|
||||||
|
.clean_time(SKIN_CLEAN),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invalidate_skin_queries(uuid: Option<&str>) {
|
||||||
|
if let Some(uuid) = uuid {
|
||||||
|
QueriesStorage::<CustomSkinQuery>::invalidate_matching(CustomSkinKeys {
|
||||||
|
uuid: uuid.to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
QueriesStorage::<CustomSkinQuery>::invalidate_all().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
||||||
|
let custom_query = use_custom_skin(uuid.clone());
|
||||||
|
let custom_data = crate::hooks::settled_or_loading(&custom_query);
|
||||||
|
|
||||||
let profile = super::use_player_profile(uuid.clone(), None::<String>);
|
let profile = super::use_player_profile(uuid.clone(), None::<String>);
|
||||||
|
|
||||||
let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile)
|
let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile)
|
||||||
|
|
@ -11,22 +112,131 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
||||||
|
|
||||||
let skin_query = super::use_cached_image(skin_url.clone(), 256);
|
let skin_query = super::use_cached_image(skin_url.clone(), 256);
|
||||||
|
|
||||||
let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default());
|
let steve = AppAssets::get_bytes("steve.png").unwrap_or_default();
|
||||||
let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default());
|
let alex = AppAssets::get_bytes("alex.png").unwrap_or_default();
|
||||||
|
|
||||||
let default_slim = (java_string_hash(&uuid) & 1) == 1;
|
let default_slim = (java_string_hash(&uuid) & 1) == 1;
|
||||||
|
|
||||||
|
if let Some((bytes, is_slim)) = custom_data
|
||||||
|
.filter(|c| c.has_custom)
|
||||||
|
.and_then(|c| c.bytes.map(|b| (b, c.is_slim)))
|
||||||
|
{
|
||||||
|
return (bytes, is_slim);
|
||||||
|
}
|
||||||
|
|
||||||
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {
|
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {
|
||||||
Some((_, bytes)) => (bytes, is_slim),
|
Some((_, bytes)) => (bytes, is_slim),
|
||||||
None if default_slim => (alex.read().clone(), true),
|
None if default_slim => (alex, true),
|
||||||
None => (steve.read().clone(), false),
|
None => (steve, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn java_string_hash(s: &str) -> i32 {
|
pub fn java_string_hash(s: &str) -> i32 {
|
||||||
let mut h: i32 = 0;
|
let mut h: i32 = 0;
|
||||||
for c in s.encode_utf16() {
|
for c in s.encode_utf16() {
|
||||||
h = h.wrapping_mul(31).wrapping_add(c as i32);
|
h = h.wrapping_mul(31).wrapping_add(c as i32);
|
||||||
}
|
}
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn save_account_skin(
|
||||||
|
uuid: &str,
|
||||||
|
username: Option<&str>,
|
||||||
|
skin_bytes: &[u8],
|
||||||
|
is_slim: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let skins_folder = skins_dir().map_err(|e| e.to_string())?;
|
||||||
|
polyio::create_dir_all(&skins_folder)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let img = image::load_from_memory(skin_bytes)
|
||||||
|
.map_err(|e| format!("Invalid image format: {e}"))?
|
||||||
|
.to_rgba8();
|
||||||
|
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
|
||||||
|
let normalized_img = if w == 64 && h == 32 {
|
||||||
|
let mut new_img = image::RgbaImage::new(64, 64);
|
||||||
|
image::imageops::overlay(&mut new_img, &img, 0, 0);
|
||||||
|
new_img
|
||||||
|
} else if w == 64 && h == 64 {
|
||||||
|
img
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid Minecraft skin dimensions: {w}x{h}. Must be 64x64 or 64x32."
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut png_bytes = Vec::new();
|
||||||
|
let mut cursor = std::io::Cursor::new(&mut png_bytes);
|
||||||
|
normalized_img
|
||||||
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||||
|
.map_err(|e| format!("Failed to encode PNG: {e}"))?;
|
||||||
|
|
||||||
|
polyio::write(&skin_path, &png_bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write skin file: {e}"))?;
|
||||||
|
|
||||||
|
let meta = SkinMetadata { is_slim };
|
||||||
|
let meta_json = serde_json::to_string_pretty(&meta)
|
||||||
|
.map_err(|e| format!("Failed to serialize metadata: {e}"))?;
|
||||||
|
|
||||||
|
polyio::write(&meta_path, meta_json.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write skin metadata: {e}"))?;
|
||||||
|
|
||||||
|
if let Some(uname) = username.filter(|u| !u.is_empty()) {
|
||||||
|
let _ = sync_custom_skin_loader(uname, &png_bytes).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate_skin_queries(Some(uuid)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_account_skin(uuid: &str, username: Option<&str>) -> Result<(), String> {
|
||||||
|
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if skin_path.exists() {
|
||||||
|
let _ = polyio::remove_file(&skin_path).await;
|
||||||
|
}
|
||||||
|
if meta_path.exists() {
|
||||||
|
let _ = polyio::remove_file(&meta_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(uname) = username.filter(|u| !u.is_empty()) {
|
||||||
|
let _ = remove_custom_skin_loader(uname).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate_skin_queries(Some(uuid)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_custom_skin_loader(username: &str, skin_png: &[u8]) -> Result<(), String> {
|
||||||
|
if let Ok(mc_dir) = shared_minecraft_dir() {
|
||||||
|
let 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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ impl Component for RootLayout {
|
||||||
ctx.windows.get(&id).map(|w| w.window().is_maximized())
|
ctx.windows.get(&id).map(|w| w.window().is_maximized())
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if let Ok(Some(is_max)) = is_max {
|
if let Ok(Some(is_max)) = is_max
|
||||||
if *maximized.peek() != is_max {
|
&& *maximized.peek() != is_max
|
||||||
maximized.set(is_max);
|
{
|
||||||
}
|
maximized.set(is_max);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
use std::cell::Cell;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cargo_packager_updater::{Config, Update, check_update};
|
|
||||||
use oneclient_events::{Choice, EventBus, Prompt};
|
use oneclient_events::{Choice, EventBus, Prompt};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::constants::{RELEASES_URL, UPDATER_ENDPOINT, UPDATER_PUBKEY};
|
use crate::constants::GITHUB_API_LATEST_RELEASE;
|
||||||
|
|
||||||
pub const UPDATE_CHOICE_INSTALL: &str = "update.install";
|
pub const UPDATE_CHOICE_INSTALL: &str = "update.install";
|
||||||
|
|
||||||
|
|
@ -15,7 +14,7 @@ enum UpdateAnswer {
|
||||||
fn update_prompt(version: &str) -> Prompt<UpdateAnswer> {
|
fn update_prompt(version: &str) -> Prompt<UpdateAnswer> {
|
||||||
Prompt::new(
|
Prompt::new(
|
||||||
"Update available",
|
"Update available",
|
||||||
format!("OneClient {version} is ready to install. Download and install it now?"),
|
format!("OneLauncher-Cracked {version} is ready to install. Download and install it now?"),
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
Choice::primary(UPDATE_CHOICE_INSTALL, "Install"),
|
Choice::primary(UPDATE_CHOICE_INSTALL, "Install"),
|
||||||
|
|
@ -26,6 +25,95 @@ fn update_prompt(version: &str) -> Prompt<UpdateAnswer> {
|
||||||
|
|
||||||
const PROGRESS_STEP: u64 = 256 * 1024;
|
const PROGRESS_STEP: u64 = 256 * 1024;
|
||||||
|
|
||||||
|
/// SemVer parser supporting cracked revision suffixes like `2.2.3-c1`, `2.2.3.c1`, `v2.2.3-c2`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct CrackedVersion {
|
||||||
|
pub major: u64,
|
||||||
|
pub minor: u64,
|
||||||
|
pub patch: u64,
|
||||||
|
pub rev: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrackedVersion {
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
let trimmed = s.trim().trim_start_matches(['v', 'V']);
|
||||||
|
|
||||||
|
let (base, rev) = if let Some((base_part, rev_part)) = trimmed.split_once("-c").or_else(|| trimmed.split_once(".c")) {
|
||||||
|
let rev_num = rev_part.parse::<u64>().ok()?;
|
||||||
|
(base_part, rev_num)
|
||||||
|
} else if let Some((base_part, rev_part)) = trimmed.split_once("-r").or_else(|| trimmed.split_once(".r")) {
|
||||||
|
let rev_num = rev_part.parse::<u64>().ok()?;
|
||||||
|
(base_part, rev_num)
|
||||||
|
} else if let Some((base_part, rev_part)) = trimmed.split_once('-') {
|
||||||
|
let rev_num = rev_part.trim_start_matches(['c', 'C', 'r', 'R']).parse::<u64>().unwrap_or(0);
|
||||||
|
(base_part, rev_num)
|
||||||
|
} else {
|
||||||
|
(trimmed, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = base.split('.');
|
||||||
|
let major = parts.next()?.parse::<u64>().ok()?;
|
||||||
|
let minor = parts.next()?.parse::<u64>().ok()?;
|
||||||
|
let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
rev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct 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) {
|
pub fn spawn_update_check(auto_install: bool, events: EventBus) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(err) = run_check(auto_install, events).await {
|
if let Err(err) = run_check(auto_install, events).await {
|
||||||
|
|
@ -44,7 +132,7 @@ pub fn spawn_simulated_update() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_simulated_update() -> anyhow::Result<()> {
|
async fn run_simulated_update() -> anyhow::Result<()> {
|
||||||
const FAKE_VERSION: &str = "9999.9999.9999";
|
const FAKE_VERSION: &str = "2.2.3-c99";
|
||||||
const FAKE_TOTAL: u64 = 48 * 1024 * 1024;
|
const FAKE_TOTAL: u64 = 48 * 1024 * 1024;
|
||||||
|
|
||||||
let events = crate::launcher::state()?.services.events.clone();
|
let events = crate::launcher::state()?.services.events.clone();
|
||||||
|
|
@ -55,7 +143,7 @@ async fn run_simulated_update() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let progress_id = Uuid::new_v4();
|
let progress_id = Uuid::new_v4();
|
||||||
let label = format!("Downloading OneClient {FAKE_VERSION}");
|
let label = format!("Downloading OneLauncher-Cracked {FAKE_VERSION}");
|
||||||
|
|
||||||
let mut downloaded = 0u64;
|
let mut downloaded = 0u64;
|
||||||
events.progress(progress_id, &label, downloaded, FAKE_TOTAL);
|
events.progress(progress_id, &label, downloaded, FAKE_TOTAL);
|
||||||
|
|
@ -68,116 +156,230 @@ async fn run_simulated_update() -> anyhow::Result<()> {
|
||||||
events.finish_progress(
|
events.finish_progress(
|
||||||
progress_id,
|
progress_id,
|
||||||
"Finished Downloading",
|
"Finished Downloading",
|
||||||
format!("OneClient {FAKE_VERSION} is ready. Restart to apply."),
|
format!("OneLauncher-Cracked {FAKE_VERSION} is ready. Restart to apply."),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_check(auto_install: bool, events: EventBus) -> anyhow::Result<()> {
|
async fn run_check(auto_install: bool, events: EventBus) -> anyhow::Result<()> {
|
||||||
// `check_update` performs a blocking HTTP request so offload it to a thread pool
|
let Some(release) = check_for_github_update().await? else {
|
||||||
let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else {
|
|
||||||
tracing::info!("no update available");
|
tracing::info!("no update available");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!("update available: {}", update.version);
|
tracing::info!("update available: {}", release.tag_name);
|
||||||
|
|
||||||
// cargo-packager-updater can only replace an AppImage in place deb/rpm installs live
|
|
||||||
// under a package-managed path that an install would fail on or clobber
|
|
||||||
if !can_self_update() {
|
if !can_self_update() {
|
||||||
tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only");
|
tracing::info!("install is not self-updatable in-place; notifying with release URL");
|
||||||
events
|
events
|
||||||
.notify("Update available")
|
.notify("Update available")
|
||||||
.body(format!(
|
.body(format!(
|
||||||
"OneClient {} is available. Download the latest package from {} to update.",
|
"OneLauncher-Cracked {} is available. Download from {}",
|
||||||
update.version, RELEASES_URL
|
release.tag_name, release.html_url
|
||||||
))
|
))
|
||||||
.send();
|
.send();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auto_install && events.ask(update_prompt(&update.version)).await?.is_none() {
|
if !auto_install && events.ask(update_prompt(&release.tag_name)).await?.is_none() {
|
||||||
tracing::info!("user declined update {}", update.version);
|
tracing::info!("user declined update {}", release.tag_name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
download_and_install(update, events).await
|
download_and_install_github(release, events).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn can_self_update() -> bool {
|
fn can_self_update() -> bool {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE").is_some_and(|val| val.eq_ignore_ascii_case("1")) {
|
if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE")
|
||||||
return false;
|
.is_some_and(|val| val.eq_ignore_ascii_case("1"))
|
||||||
}
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
std::env::var_os("APPIMAGE").is_some()
|
std::env::var_os("APPIMAGE").is_some()
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
{
|
{
|
||||||
true
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_for_update() -> anyhow::Result<Option<Update>> {
|
async fn check_for_github_update() -> anyhow::Result<Option<GithubRelease>> {
|
||||||
let current = env!("CARGO_PKG_VERSION").parse()?;
|
let client = reqwest::Client::builder()
|
||||||
let config = Config {
|
.user_agent("OneLauncher-Cracked-Updater")
|
||||||
endpoints: vec![UPDATER_ENDPOINT.parse()?],
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
pubkey: UPDATER_PUBKEY.into(),
|
.build()?;
|
||||||
..Default::default()
|
|
||||||
|
let resp = client
|
||||||
|
.get(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 progress_id = Uuid::new_v4();
|
||||||
let version = update.version.clone();
|
let version = release.tag_name.clone();
|
||||||
let label = format!("Downloading OneClient {version}");
|
let label = format!("Downloading OneLauncher-Cracked {version}");
|
||||||
|
|
||||||
events.progress(progress_id, &label, 0, 0);
|
events.progress(progress_id, &label, 0, asset.size);
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
let client = reqwest::Client::builder()
|
||||||
let downloaded = Cell::new(0u64);
|
.user_agent("OneLauncher-Cracked-Updater")
|
||||||
let last_sent = Cell::new(0u64);
|
.build()?;
|
||||||
|
|
||||||
let bytes = update.download_extended(
|
let mut resp = client.get(&asset.browser_download_url).send().await?;
|
||||||
|chunk, total| {
|
if !resp.status().is_success() {
|
||||||
let now = downloaded.get() + chunk as u64;
|
anyhow::bail!("Failed to download update asset: HTTP {}", resp.status());
|
||||||
downloaded.set(now);
|
}
|
||||||
let total = total.unwrap_or(0);
|
|
||||||
|
|
||||||
if now == chunk as u64
|
let total_size = asset.size.max(resp.content_length().unwrap_or(0));
|
||||||
|| (total > 0 && now >= total)
|
|
||||||
|| now - last_sent.get() >= PROGRESS_STEP
|
|
||||||
{
|
|
||||||
last_sent.set(now);
|
|
||||||
events.progress(progress_id, &label, now, total);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let total = downloaded.get().max(1);
|
#[cfg(target_os = "windows")]
|
||||||
events.progress(progress_id, &label, total, total);
|
let target_file = std::env::temp_dir().join(&asset.name);
|
||||||
|
|
||||||
update.install(bytes)?;
|
#[cfg(target_os = "linux")]
|
||||||
|
let (target_file, is_appimage, final_appimage_dest) = if let Some(appimage) = std::env::var_os("APPIMAGE") {
|
||||||
|
let dest = PathBuf::from(appimage);
|
||||||
|
let temp_download = dest.with_extension("new_appimage");
|
||||||
|
(temp_download, true, Some(dest))
|
||||||
|
} else {
|
||||||
|
(std::env::temp_dir().join(&asset.name), false, None)
|
||||||
|
};
|
||||||
|
|
||||||
// Converts the same download card into its finished state rather than adding a second
|
#[cfg(target_os = "macos")]
|
||||||
|
let target_file = std::env::temp_dir().join(&asset.name);
|
||||||
|
|
||||||
|
let mut file = tokio::fs::File::create(&target_file).await?;
|
||||||
|
let mut downloaded = 0u64;
|
||||||
|
let mut last_sent = 0u64;
|
||||||
|
|
||||||
|
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(
|
events.finish_progress(
|
||||||
progress_id,
|
progress_id,
|
||||||
"Finished Downloading",
|
"Launching Installer",
|
||||||
format!("OneClient {version} is ready. Restart to apply."),
|
format!("OneLauncher-Cracked {version} ready. Opening installer..."),
|
||||||
);
|
);
|
||||||
Ok(())
|
let _ = std::process::Command::new(&target_file).spawn();
|
||||||
})
|
}
|
||||||
.await??;
|
|
||||||
|
|
||||||
tracing::info!("update installed; restart to apply");
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if is_appimage && let Some(dest) = final_appimage_dest {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(&target_file, std::fs::Permissions::from_mode(0o755));
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&target_file, &dest).await?;
|
||||||
|
events.finish_progress(
|
||||||
|
progress_id,
|
||||||
|
"Finished Updating",
|
||||||
|
format!("OneLauncher-Cracked {version} is ready. Restart to apply."),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
events.finish_progress(
|
||||||
|
progress_id,
|
||||||
|
"Download Complete",
|
||||||
|
format!("Downloaded {} to {}", asset.name, target_file.display()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
events.finish_progress(
|
||||||
|
progress_id,
|
||||||
|
"Download Complete",
|
||||||
|
format!("Downloaded {} to {}. Open to install.", asset.name, target_file.display()),
|
||||||
|
);
|
||||||
|
let _ = open::that(&target_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("update download and installation handling completed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cracked_version_ordering() {
|
||||||
|
let v1 = CrackedVersion::parse("2.2.3-c1").unwrap();
|
||||||
|
let v2 = CrackedVersion::parse("2.2.3-c2").unwrap();
|
||||||
|
let v3 = CrackedVersion::parse("v2.2.4-c1").unwrap();
|
||||||
|
let v_base = CrackedVersion::parse("v2.2.3").unwrap();
|
||||||
|
|
||||||
|
assert!(v2 > v1);
|
||||||
|
assert!(v3 > v2);
|
||||||
|
assert!(v1 > v_base);
|
||||||
|
assert_eq!(v1, CrackedVersion { major: 2, minor: 2, patch: 3, rev: 1 });
|
||||||
|
assert_eq!(v2, CrackedVersion { major: 2, minor: 2, patch: 3, rev: 2 });
|
||||||
|
assert_eq!(v3, CrackedVersion { major: 2, minor: 2, patch: 4, rev: 1 });
|
||||||
|
assert_eq!(v_base, CrackedVersion { major: 2, minor: 2, patch: 3, rev: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,8 +347,7 @@ impl Component for FilterPopover {
|
||||||
.opacity(progress)
|
.opacity(progress)
|
||||||
.offset_y((progress - 1.0) * 6.)
|
.offset_y((progress - 1.0) * 6.)
|
||||||
.corner_radius(CornerRadius::new_all(10.))
|
.corner_radius(CornerRadius::new_all(10.))
|
||||||
.background(colors::page_elevated().with_a(230))
|
.background(colors::page_elevated())
|
||||||
.blur(12.)
|
|
||||||
.border(crate::ui::border_all_color(1., colors::component_border()))
|
.border(crate::ui::border_all_color(1., colors::component_border()))
|
||||||
.shadow(Shadow::from((
|
.shadow(Shadow::from((
|
||||||
0.,
|
0.,
|
||||||
|
|
@ -372,11 +371,15 @@ impl Component for FilterPopover {
|
||||||
sort.set(Some(mode.key().to_string()));
|
sort.set(Some(mode.key().to_string()));
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
panel = panel.child(ChoiceRow {
|
panel = panel.child(
|
||||||
text: mode.label(),
|
ChoiceRow {
|
||||||
selected,
|
text: mode.label(),
|
||||||
on_press,
|
selected,
|
||||||
});
|
on_press,
|
||||||
|
key: DiffKey::None,
|
||||||
|
}
|
||||||
|
.key(mode.key()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
panel = panel.child(section_label("Show"));
|
panel = panel.child(section_label("Show"));
|
||||||
|
|
@ -386,11 +389,15 @@ impl Component for FilterPopover {
|
||||||
enabled_filter.set(filter);
|
enabled_filter.set(filter);
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
panel = panel.child(ChoiceRow {
|
panel = panel.child(
|
||||||
text: filter.label(),
|
ChoiceRow {
|
||||||
selected,
|
text: filter.label(),
|
||||||
on_press,
|
selected,
|
||||||
});
|
on_press,
|
||||||
|
key: DiffKey::None,
|
||||||
|
}
|
||||||
|
.key(filter.label()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
panel = panel.child(section_label("Hidden packages"));
|
panel = panel.child(section_label("Hidden packages"));
|
||||||
|
|
@ -400,11 +407,15 @@ impl Component for FilterPopover {
|
||||||
hidden_filter.set(filter);
|
hidden_filter.set(filter);
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
panel = panel.child(ChoiceRow {
|
panel = panel.child(
|
||||||
text: filter.label(),
|
ChoiceRow {
|
||||||
selected,
|
text: filter.label(),
|
||||||
on_press,
|
selected,
|
||||||
});
|
on_press,
|
||||||
|
key: DiffKey::None,
|
||||||
|
}
|
||||||
|
.key(filter.label()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
|
|
@ -442,6 +453,13 @@ struct ChoiceRow {
|
||||||
text: &'static str,
|
text: &'static str,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
on_press: EventHandler<Event<PressEventData>>,
|
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 {
|
impl Component for ChoiceRow {
|
||||||
|
|
@ -483,7 +501,7 @@ impl Component for ChoiceRow {
|
||||||
el.border(crate::ui::border_all_color(1., colors::brand()))
|
el.border(crate::ui::border_all_color(1., colors::brand()))
|
||||||
})
|
})
|
||||||
.cursor(CursorIcon::Pointer)
|
.cursor(CursorIcon::Pointer)
|
||||||
.on_press(self.on_press.clone())
|
.on_all_press(self.on_press.clone())
|
||||||
.child(
|
.child(
|
||||||
label()
|
label()
|
||||||
.text(text)
|
.text(text)
|
||||||
|
|
|
||||||
|
|
@ -38,26 +38,10 @@ impl Component for SettingsAccounts {
|
||||||
let remove = use_remove_account();
|
let remove = use_remove_account();
|
||||||
let refresh = use_refresh_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 show_offline = use_state(|| false);
|
||||||
let mut closing_offline = use_state(|| false);
|
let edit_account = use_state(|| None::<(Uuid, String)>);
|
||||||
|
let edit_name = use_state(String::new);
|
||||||
use_side_effect(move || {
|
|
||||||
if !*closing_offline.read() {
|
|
||||||
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 accounts = try_accounts(&accounts_query).unwrap_or_default();
|
let accounts = try_accounts(&accounts_query).unwrap_or_default();
|
||||||
let default_account = try_default_account(&default_query);
|
let default_account = try_default_account(&default_query);
|
||||||
|
|
@ -75,8 +59,20 @@ impl Component for SettingsAccounts {
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
add_offline.mutate(AddOfflineAccountKeys { username: name });
|
let mut show_offline = show_offline;
|
||||||
closing_offline.set(true);
|
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
|
let mut rows: Vec<Element> = accounts
|
||||||
|
|
@ -91,6 +87,8 @@ impl Component for SettingsAccounts {
|
||||||
set_default,
|
set_default,
|
||||||
remove,
|
remove,
|
||||||
refresh,
|
refresh,
|
||||||
|
edit_account,
|
||||||
|
edit_name,
|
||||||
}
|
}
|
||||||
.into_element()
|
.into_element()
|
||||||
})
|
})
|
||||||
|
|
@ -122,6 +120,78 @@ impl Component for SettingsAccounts {
|
||||||
show_offline,
|
show_offline,
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
|
.maybe_child(edit_account.read().as_ref().map(|(id, _)| {
|
||||||
|
let id = *id;
|
||||||
|
let mut edit_account = edit_account;
|
||||||
|
OverlayPopup::new()
|
||||||
|
.on_close(move |()| edit_account.set(None))
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.width(Size::window_percent(100.))
|
||||||
|
.height(Size::window_percent(100.))
|
||||||
|
.center()
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.width(Size::px(380.))
|
||||||
|
.max_width(Size::window_percent(90.))
|
||||||
|
.spacing(16.)
|
||||||
|
.padding(Gaps::new_all(20.))
|
||||||
|
.corner_radius(CornerRadius::new_all(16.))
|
||||||
|
.background(colors::page_elevated())
|
||||||
|
.border(border_all_color(1., colors::component_border()))
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Edit offline username")
|
||||||
|
.font_size(18.)
|
||||||
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.width(Size::fill())
|
||||||
|
.spacing(6.)
|
||||||
|
.child(field_label("New Username"))
|
||||||
|
.child(TextInput::new(edit_name).placeholder("Enter new username")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.width(Size::fill())
|
||||||
|
.main_align(Alignment::End)
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.ghost()
|
||||||
|
.on_press(move |_| edit_account.set(None))
|
||||||
|
.text("Cancel"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.on_press(move |_| {
|
||||||
|
let new_name = edit_name.peek().trim().to_string();
|
||||||
|
if !new_name.is_empty() {
|
||||||
|
let mut edit_account = edit_account;
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(state) = crate::launcher::state() {
|
||||||
|
let _ = state.auth.remove_account(id).await;
|
||||||
|
if let Ok(new_acc) = state.auth.add_offline_account(new_name).await {
|
||||||
|
let _ = state.auth.set_default_account(Some(new_acc.id)).await;
|
||||||
|
crate::hooks::invalidate_auth_queries(Some(new_acc.id)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit_account.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.text("Save"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}))
|
||||||
.maybe_child(msa.popup())
|
.maybe_child(msa.popup())
|
||||||
.into_element()
|
.into_element()
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +199,7 @@ impl Component for SettingsAccounts {
|
||||||
|
|
||||||
fn hero(
|
fn hero(
|
||||||
account: Option<MinecraftAccount>,
|
account: Option<MinecraftAccount>,
|
||||||
has_microsoft: bool,
|
_has_microsoft: bool,
|
||||||
microsoft_pending: bool,
|
microsoft_pending: bool,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
on_open_offline: impl FnMut(Event<PressEventData>) + 'static,
|
on_open_offline: impl FnMut(Event<PressEventData>) + 'static,
|
||||||
|
|
@ -212,16 +282,25 @@ fn hero(
|
||||||
.child(
|
.child(
|
||||||
Button::new()
|
Button::new()
|
||||||
.secondary()
|
.secondary()
|
||||||
.enabled(has_microsoft)
|
.enabled(true)
|
||||||
.on_press(on_open_offline)
|
.on_press(on_open_offline)
|
||||||
.child(Icon::new(IconType::Plus).size(16.))
|
.child(Icon::new(IconType::Plus).size(16.))
|
||||||
.text("Add offline"),
|
.text("Add offline"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.secondary()
|
||||||
|
.on_press(|_| {
|
||||||
|
let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {});
|
||||||
|
})
|
||||||
|
.child(Icon::new(IconType::Colors).size(16.))
|
||||||
|
.text("Change Skin"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map(error, |el, msg| {
|
.map(error, |el, msg| {
|
||||||
el.child(hint_line(IconType::AlertTriangle, msg, colors::danger()))
|
el.child(hint_line(IconType::AlertTriangle, msg, colors::danger()))
|
||||||
})
|
})
|
||||||
.maybe(!has_microsoft, |el| {
|
.maybe(false, |el| {
|
||||||
el.child(hint_line(
|
el.child(hint_line(
|
||||||
IconType::InfoCircle,
|
IconType::InfoCircle,
|
||||||
"Add a Microsoft account before creating offline accounts."
|
"Add a Microsoft account before creating offline accounts."
|
||||||
|
|
@ -331,7 +410,7 @@ fn offline_dialog(
|
||||||
.child(field_label("UUID"))
|
.child(field_label("UUID"))
|
||||||
.child(
|
.child(
|
||||||
label()
|
label()
|
||||||
.text(uuid_preview.unwrap_or_else(|| "-".to_string()))
|
.text(uuid_preview.unwrap_or_else(|| "—".to_string()))
|
||||||
.font_size(12.)
|
.font_size(12.)
|
||||||
.color(colors::fg_secondary()),
|
.color(colors::fg_secondary()),
|
||||||
),
|
),
|
||||||
|
|
@ -405,6 +484,8 @@ struct AccountRow {
|
||||||
set_default: crate::hooks::UseSetDefaultAccount,
|
set_default: crate::hooks::UseSetDefaultAccount,
|
||||||
remove: crate::hooks::UseRemoveAccount,
|
remove: crate::hooks::UseRemoveAccount,
|
||||||
refresh: crate::hooks::UseRefreshAccount,
|
refresh: crate::hooks::UseRefreshAccount,
|
||||||
|
edit_account: State<Option<(Uuid, String)>>,
|
||||||
|
edit_name: State<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for AccountRow {
|
impl PartialEq for AccountRow {
|
||||||
|
|
@ -414,6 +495,8 @@ impl PartialEq for AccountRow {
|
||||||
&& self.kind == other.kind
|
&& self.kind == other.kind
|
||||||
&& self.expires == other.expires
|
&& self.expires == other.expires
|
||||||
&& self.is_default == other.is_default
|
&& 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()
|
.into_element()
|
||||||
}))
|
}))
|
||||||
.child(
|
.child(
|
||||||
Button::new().ghost().icon().enabled(false).child(
|
Button::new()
|
||||||
Icon::new(IconType::Pencil01)
|
.ghost()
|
||||||
.size(18.)
|
.icon()
|
||||||
.color(colors::fg_secondary()),
|
.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(
|
.child(
|
||||||
Button::new()
|
Button::new()
|
||||||
.ghost()
|
.ghost()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ impl Component for SettingsJava {
|
||||||
let mut show_manager = use_state(|| false);
|
let mut show_manager = use_state(|| false);
|
||||||
let pending_remove = use_state(|| None::<PendingRemove>);
|
let pending_remove = use_state(|| None::<PendingRemove>);
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn invalidate_runtimes(dispatch: Actions) {
|
fn invalidate_runtimes(dispatch: Actions) {
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
invalidate_java_queries().await;
|
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()
|
let mut shell = settings_page()
|
||||||
.child(section_header("ADD RUNTIME"))
|
.child(section_header("ADD RUNTIME"))
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,37 @@
|
||||||
use freya::prelude::*;
|
use freya::prelude::*;
|
||||||
|
use oneclient_auth::MinecraftAccount;
|
||||||
|
|
||||||
use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel};
|
use crate::components::{Button, Icon, IconType, PlayerModel};
|
||||||
use crate::hooks::{try_default_account, use_current_account};
|
use crate::hooks::{
|
||||||
|
delete_account_skin, save_account_skin, try_accounts, try_default_account, use_accounts,
|
||||||
|
use_current_account, use_custom_skin,
|
||||||
|
};
|
||||||
use crate::theme::colors;
|
use crate::theme::colors;
|
||||||
use crate::ui::border_all_color;
|
use crate::ui::border_all_color;
|
||||||
|
|
||||||
const ANIMATIONS: &[&str] = &["Idle", "Walking", "Running", "Crouch", "Flying"];
|
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
pub struct AccountSkins;
|
pub struct AccountSkins;
|
||||||
|
|
||||||
impl Component for AccountSkins {
|
impl Component for AccountSkins {
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let account_uuid = try_default_account(&use_current_account()).map(|a| a.id.to_string());
|
let current_account = use_current_account();
|
||||||
|
let default_account = try_default_account(¤t_account);
|
||||||
|
let accounts = try_accounts(&use_accounts()).unwrap_or_default();
|
||||||
|
|
||||||
|
let selected_id = use_state(|| default_account.as_ref().map(|a| a.id));
|
||||||
|
|
||||||
|
// Keep selected account in sync if none is explicitly picked
|
||||||
|
let active_account = selected_id
|
||||||
|
.read()
|
||||||
|
.and_then(|id| accounts.iter().find(|a| a.id == id).cloned())
|
||||||
|
.or_else(|| default_account.clone());
|
||||||
|
|
||||||
|
let account_uuid = active_account.as_ref().map(|a| a.id.to_string());
|
||||||
|
let account_id = active_account.as_ref().map(|a| a.id);
|
||||||
|
let username = active_account.as_ref().map(|a| a.username.clone());
|
||||||
|
|
||||||
|
let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error)
|
||||||
|
let is_loading = use_state(|| false);
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
.horizontal()
|
.horizontal()
|
||||||
|
|
@ -20,150 +39,510 @@ impl Component for AccountSkins {
|
||||||
.height(Size::fill())
|
.height(Size::fill())
|
||||||
.overflow(Overflow::Clip)
|
.overflow(Overflow::Clip)
|
||||||
.padding(40.)
|
.padding(40.)
|
||||||
.spacing(24.)
|
.spacing(28.)
|
||||||
.child(preview_panel(account_uuid))
|
.child(PreviewPanel {
|
||||||
.child(side_panel())
|
account_uuid,
|
||||||
|
account_id,
|
||||||
|
username,
|
||||||
|
status_msg,
|
||||||
|
})
|
||||||
|
.child(side_panel(
|
||||||
|
active_account,
|
||||||
|
accounts,
|
||||||
|
selected_id,
|
||||||
|
status_msg,
|
||||||
|
is_loading,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preview_panel(account_uuid: Option<String>) -> impl IntoElement {
|
#[derive(PartialEq, Clone)]
|
||||||
|
struct PreviewPanel {
|
||||||
|
account_uuid: Option<String>,
|
||||||
|
account_id: Option<uuid::Uuid>,
|
||||||
|
username: Option<String>,
|
||||||
|
status_msg: State<Option<(String, bool)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for PreviewPanel {
|
||||||
|
fn render_key(&self) -> DiffKey {
|
||||||
|
DiffKey::from(&self.account_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self) -> impl IntoElement {
|
||||||
|
let account_uuid = self.account_uuid.clone();
|
||||||
|
let custom_query = use_custom_skin(account_uuid.clone().unwrap_or_default());
|
||||||
|
let custom_data = crate::hooks::settled_or_loading(&custom_query);
|
||||||
|
let is_slim = custom_data.as_ref().is_some_and(|d| d.is_slim);
|
||||||
|
let has_custom = custom_data.as_ref().is_some_and(|d| d.has_custom);
|
||||||
|
|
||||||
|
let acc_id_copy = self.account_id;
|
||||||
|
let username_copy = self.username.clone();
|
||||||
|
let status_msg = self.status_msg;
|
||||||
|
|
||||||
|
let set_classic = {
|
||||||
|
let acc_id = acc_id_copy;
|
||||||
|
let uname = username_copy.clone();
|
||||||
|
let mut status = status_msg;
|
||||||
|
move |_| {
|
||||||
|
let Some(id) = acc_id else { return };
|
||||||
|
let uname = uname.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||||
|
if let Ok(bytes) = polyio::read(&skin_path).await {
|
||||||
|
let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, false)
|
||||||
|
.await;
|
||||||
|
status.set(Some((
|
||||||
|
"Model set to Classic (4px arms)".to_string(),
|
||||||
|
false,
|
||||||
|
)));
|
||||||
|
} else if let Some(steve_bytes) = crate::AppAssets::get_bytes("steve.png") {
|
||||||
|
let _ =
|
||||||
|
save_account_skin(&uuid_str, uname.as_deref(), &steve_bytes, false)
|
||||||
|
.await;
|
||||||
|
status.set(Some(("Model set to Classic".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_slim = {
|
||||||
|
let acc_id = acc_id_copy;
|
||||||
|
let uname = username_copy.clone();
|
||||||
|
let mut status = status_msg;
|
||||||
|
move |_| {
|
||||||
|
let Some(id) = acc_id else { return };
|
||||||
|
let uname = uname.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||||
|
if let Ok(bytes) = polyio::read(&skin_path).await {
|
||||||
|
let _ =
|
||||||
|
save_account_skin(&uuid_str, uname.as_deref(), &bytes, true).await;
|
||||||
|
status.set(Some(("Model set to Slim (3px arms)".to_string(), false)));
|
||||||
|
} else if let Some(alex_bytes) = crate::AppAssets::get_bytes("alex.png") {
|
||||||
|
let _ =
|
||||||
|
save_account_skin(&uuid_str, uname.as_deref(), &alex_bytes, true)
|
||||||
|
.await;
|
||||||
|
status.set(Some(("Model set to Slim".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.width(Size::px(340.))
|
||||||
|
.height(Size::fill())
|
||||||
|
.spacing(16.)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.width(Size::fill())
|
||||||
|
.height(Size::flex(1.0))
|
||||||
|
.corner_radius(CornerRadius::new_all(16.))
|
||||||
|
.background(colors::page_elevated())
|
||||||
|
.border(border_all_color(1., colors::component_border()))
|
||||||
|
.child(match account_uuid {
|
||||||
|
Some(uuid) => PlayerModel::new(uuid)
|
||||||
|
.width(Size::fill())
|
||||||
|
.height(Size::fill())
|
||||||
|
.into_element(),
|
||||||
|
None => Icon::new(IconType::Users01)
|
||||||
|
.size(64.)
|
||||||
|
.color(colors::fg_secondary())
|
||||||
|
.into_element(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.width(Size::fill())
|
||||||
|
.main_align(Alignment::Center)
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.padding(Gaps::new_symmetric(6., 14.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(if !is_slim {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_bg()
|
||||||
|
})
|
||||||
|
.cursor(CursorIcon::Pointer)
|
||||||
|
.on_press(set_classic)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Classic (4px)")
|
||||||
|
.font_size(12.)
|
||||||
|
.font_weight(if !is_slim {
|
||||||
|
FontWeight::SEMI_BOLD
|
||||||
|
} else {
|
||||||
|
FontWeight::NORMAL
|
||||||
|
})
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.padding(Gaps::new_symmetric(6., 14.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(if is_slim {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_bg()
|
||||||
|
})
|
||||||
|
.cursor(CursorIcon::Pointer)
|
||||||
|
.on_press(set_slim)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Slim (3px)")
|
||||||
|
.font_size(12.)
|
||||||
|
.font_weight(if is_slim {
|
||||||
|
FontWeight::SEMI_BOLD
|
||||||
|
} else {
|
||||||
|
FontWeight::NORMAL
|
||||||
|
})
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.maybe_child(has_custom.then(|| {
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Custom Skin Active")
|
||||||
|
.font_size(11.)
|
||||||
|
.color(colors::success()),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
|
}))
|
||||||
|
.into_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn side_panel(
|
||||||
|
active_account: Option<MinecraftAccount>,
|
||||||
|
accounts: Vec<MinecraftAccount>,
|
||||||
|
selected_id: State<Option<uuid::Uuid>>,
|
||||||
|
status_msg: State<Option<(String, bool)>>,
|
||||||
|
is_loading: State<bool>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let username = active_account
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.username.clone())
|
||||||
|
.unwrap_or_else(|| "No Account Selected".to_string());
|
||||||
|
let account_id = active_account.as_ref().map(|a| a.id);
|
||||||
|
let username_opt = active_account.as_ref().map(|a| a.username.clone());
|
||||||
|
|
||||||
|
// Import from local file
|
||||||
|
let id_for_file = account_id;
|
||||||
|
let uname_for_file = username_opt.clone();
|
||||||
|
let mut status_file = status_msg;
|
||||||
|
let mut loading_file = is_loading;
|
||||||
|
|
||||||
|
let import_from_file = move |_| {
|
||||||
|
let Some(id) = id_for_file else { return };
|
||||||
|
let uname = uname_for_file.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
|
||||||
|
loading_file.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
if let Some(handle) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("Select Minecraft Skin PNG")
|
||||||
|
.add_filter("Minecraft Skin (*.png)", &["png"])
|
||||||
|
.pick_file()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let bytes = handle.read().await;
|
||||||
|
match save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await {
|
||||||
|
Ok(()) => {
|
||||||
|
status_file.set(Some((
|
||||||
|
"Custom skin imported successfully!".to_string(),
|
||||||
|
false,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
status_file.set(Some((err, true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading_file.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download/Export skin
|
||||||
|
let id_for_dl = account_id;
|
||||||
|
let uname_for_dl = username_opt.clone();
|
||||||
|
let mut status_dl = status_msg;
|
||||||
|
|
||||||
|
let download_skin = move |_| {
|
||||||
|
let Some(id) = id_for_dl else { return };
|
||||||
|
let uname = uname_for_dl.clone().unwrap_or_else(|| "skin".to_string());
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let skin_bytes =
|
||||||
|
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||||
|
polyio::read(&skin_path).await.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes_to_save = match skin_bytes {
|
||||||
|
Some(b) => b,
|
||||||
|
None => crate::AppAssets::get_bytes("steve.png")
|
||||||
|
.map(|b| b.to_vec())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(handle) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("Save Skin PNG")
|
||||||
|
.set_file_name(format!("{uname}_skin.png"))
|
||||||
|
.add_filter("PNG Image (*.png)", &["png"])
|
||||||
|
.save_file()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if polyio::write(handle.path(), &bytes_to_save).await.is_ok() {
|
||||||
|
status_dl.set(Some(("Skin exported successfully!".to_string(), false)));
|
||||||
|
} else {
|
||||||
|
status_dl.set(Some(("Failed to save skin file".to_string(), true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset skin to default
|
||||||
|
let id_for_reset = account_id;
|
||||||
|
let uname_for_reset = username_opt.clone();
|
||||||
|
let mut status_reset = status_msg;
|
||||||
|
|
||||||
|
let reset_skin = move |_| {
|
||||||
|
let Some(id) = id_for_reset else { return };
|
||||||
|
let uname = uname_for_reset.clone();
|
||||||
|
let uuid_str = id.to_string();
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
match delete_account_skin(&uuid_str, uname.as_deref()).await {
|
||||||
|
Ok(()) => {
|
||||||
|
status_reset
|
||||||
|
.set(Some(("Skin reset to default skin.".to_string(), false)));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
status_reset.set(Some((err, true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
.vertical()
|
.vertical()
|
||||||
.width(Size::px(320.))
|
.width(Size::flex(1.0))
|
||||||
.height(Size::fill())
|
.height(Size::fill())
|
||||||
.spacing(16.)
|
.spacing(24.)
|
||||||
.child(
|
.child(
|
||||||
rect()
|
rect()
|
||||||
.center()
|
.vertical()
|
||||||
.width(Size::fill())
|
.spacing(4.)
|
||||||
.height(Size::flex(1.0))
|
.child(
|
||||||
.corner_radius(CornerRadius::new_all(16.))
|
label()
|
||||||
.background(colors::page_elevated())
|
.text("Skin Customizer")
|
||||||
.border(border_all_color(1., colors::component_border()))
|
.font_size(24.)
|
||||||
.child(match account_uuid {
|
.font_weight(FontWeight::BOLD)
|
||||||
Some(uuid) => PlayerModel::new(uuid)
|
.color(colors::fg_primary()),
|
||||||
.width(Size::fill())
|
)
|
||||||
.height(Size::fill())
|
.child(
|
||||||
.into_element(),
|
label()
|
||||||
None => Icon::new(IconType::Users01)
|
.text(format!("Managing skin for {username}"))
|
||||||
.size(64.)
|
.font_size(13.)
|
||||||
.color(colors::fg_secondary())
|
.color(colors::fg_secondary()),
|
||||||
.into_element(),
|
),
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.child(
|
// Status message notification banner
|
||||||
|
.maybe_child(status_msg.read().as_ref().map(|(msg, is_err)| {
|
||||||
|
let is_err = *is_err;
|
||||||
rect()
|
rect()
|
||||||
.horizontal()
|
.horizontal()
|
||||||
.width(Size::fill())
|
.width(Size::fill())
|
||||||
.main_align(Alignment::Center)
|
.padding(Gaps::new_symmetric(10., 14.))
|
||||||
.spacing(8.)
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
.children(
|
.background(if is_err {
|
||||||
ANIMATIONS
|
colors::danger().with_a(30)
|
||||||
.iter()
|
} else {
|
||||||
.enumerate()
|
colors::success().with_a(30)
|
||||||
.map(|(i, name)| anim_chip(name, i == 0).into_element()),
|
})
|
||||||
|
.border(border_all_color(
|
||||||
|
1.,
|
||||||
|
if is_err {
|
||||||
|
colors::danger()
|
||||||
|
} else {
|
||||||
|
colors::success()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text(msg.clone())
|
||||||
|
.font_size(13.)
|
||||||
|
.color(if is_err {
|
||||||
|
colors::danger()
|
||||||
|
} else {
|
||||||
|
colors::success()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
|
}))
|
||||||
|
// Account selector dropdown/buttons
|
||||||
|
.child(account_selector(accounts, selected_id))
|
||||||
|
// Action buttons
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(12.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.enabled(!*is_loading.read() && account_id.is_some())
|
||||||
|
.on_press(import_from_file)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(Icon::new(IconType::FilePlus02).size(18.))
|
||||||
|
.child(label().text("Upload PNG from Computer").font_size(14.)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(12.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.ghost()
|
||||||
|
.enabled(!*is_loading.read() && account_id.is_some())
|
||||||
|
.on_press(download_skin)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(Icon::new(IconType::Download01).size(16.))
|
||||||
|
.child(label().text("Save PNG").font_size(13.)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.ghost()
|
||||||
|
.enabled(!*is_loading.read() && account_id.is_some())
|
||||||
|
.on_press(reset_skin)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(
|
||||||
|
Icon::new(IconType::Trash01)
|
||||||
|
.size(16.)
|
||||||
|
.color(colors::danger()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Reset to Default")
|
||||||
|
.font_size(13.)
|
||||||
|
.color(colors::danger()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Information note
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.padding(Gaps::new_all(14.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(colors::component_bg())
|
||||||
|
.spacing(6.)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Offline Skin System")
|
||||||
|
.font_size(13.)
|
||||||
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Custom skins are saved locally to your profile and seamlessly loaded into singleplayer and offline servers.")
|
||||||
|
.font_size(12.)
|
||||||
|
.color(colors::fg_secondary()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.into_element()
|
.into_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn anim_chip(name: &'static str, active: bool) -> impl IntoElement {
|
fn account_selector(
|
||||||
rect()
|
accounts: Vec<MinecraftAccount>,
|
||||||
.center()
|
selected_id: State<Option<uuid::Uuid>>,
|
||||||
.padding(Gaps::new_symmetric(6., 12.))
|
) -> impl IntoElement {
|
||||||
.corner_radius(CornerRadius::new_all(8.))
|
let mut selected_state = selected_id;
|
||||||
.background(if active {
|
|
||||||
colors::brand()
|
|
||||||
} else {
|
|
||||||
colors::component_bg()
|
|
||||||
})
|
|
||||||
.cursor(CursorIcon::Pointer)
|
|
||||||
.child(
|
|
||||||
label()
|
|
||||||
.text(name)
|
|
||||||
.font_size(12.)
|
|
||||||
.color(colors::fg_primary()),
|
|
||||||
)
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn side_panel() -> impl IntoElement {
|
|
||||||
rect()
|
rect()
|
||||||
.vertical()
|
.vertical()
|
||||||
.width(Size::flex(1.0))
|
.spacing(8.)
|
||||||
.height(Size::fill())
|
|
||||||
.overflow(Overflow::Clip)
|
|
||||||
.spacing(20.)
|
|
||||||
.child(
|
.child(
|
||||||
label()
|
label()
|
||||||
.text("Skins")
|
.text("SELECT ACCOUNT")
|
||||||
.font_size(32.)
|
.font_size(11.)
|
||||||
.font_weight(FontWeight::BOLD)
|
.font_weight(FontWeight::BOLD)
|
||||||
.color(colors::fg_primary()),
|
.color(colors::fg_secondary()),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
rect()
|
rect()
|
||||||
.horizontal()
|
.horizontal()
|
||||||
.spacing(12.)
|
.spacing(8.)
|
||||||
.child(action_button("Import Skin", IconType::Plus, true))
|
.content(Content::Flex)
|
||||||
.child(action_button("Download", IconType::Download01, false))
|
.children(accounts.into_iter().map(|acc| {
|
||||||
.child(action_button("Remove", IconType::Trash01, false)),
|
let id = acc.id;
|
||||||
|
let uname = acc.username.clone();
|
||||||
|
let is_active = *selected_state.read() == Some(id);
|
||||||
|
|
||||||
|
rect()
|
||||||
|
.center()
|
||||||
|
.padding(Gaps::new_symmetric(8., 16.))
|
||||||
|
.corner_radius(CornerRadius::new_all(8.))
|
||||||
|
.background(if is_active {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_bg()
|
||||||
|
})
|
||||||
|
.border(border_all_color(
|
||||||
|
1.,
|
||||||
|
if is_active {
|
||||||
|
colors::brand()
|
||||||
|
} else {
|
||||||
|
colors::component_border()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.cursor(CursorIcon::Pointer)
|
||||||
|
.on_press(move |_| selected_state.set(Some(id)))
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text(uname)
|
||||||
|
.font_size(13.)
|
||||||
|
.font_weight(if is_active {
|
||||||
|
FontWeight::SEMI_BOLD
|
||||||
|
} else {
|
||||||
|
FontWeight::NORMAL
|
||||||
|
})
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
.child(section_label("SKIN HISTORY"))
|
|
||||||
.child(skin_grid(8))
|
|
||||||
.child(section_label("CAPES"))
|
|
||||||
.child(skin_grid(4))
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement {
|
|
||||||
Button::new()
|
|
||||||
.variant(if primary {
|
|
||||||
ButtonVariant::Primary
|
|
||||||
} else {
|
|
||||||
ButtonVariant::Secondary
|
|
||||||
})
|
|
||||||
.child(Icon::new(icon).size(16.).color(colors::fg_primary()))
|
|
||||||
.text(text)
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn section_label(text: &'static str) -> impl IntoElement {
|
|
||||||
label()
|
|
||||||
.text(text)
|
|
||||||
.font_size(11.)
|
|
||||||
.font_weight(FontWeight::SEMI_BOLD)
|
|
||||||
.color(colors::fg_secondary())
|
|
||||||
.into_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn skin_grid(count: usize) -> impl IntoElement {
|
|
||||||
rect()
|
|
||||||
.horizontal()
|
|
||||||
.width(Size::fill())
|
|
||||||
.spacing(12.)
|
|
||||||
.children((0..count).map(|i| {
|
|
||||||
rect()
|
|
||||||
.center()
|
|
||||||
.width(Size::px(64.))
|
|
||||||
.height(Size::px(80.))
|
|
||||||
.corner_radius(CornerRadius::new_all(8.))
|
|
||||||
.background(colors::page_elevated())
|
|
||||||
.border(border_all_color(
|
|
||||||
if i == 0 { 2. } else { 1. },
|
|
||||||
if i == 0 {
|
|
||||||
colors::brand()
|
|
||||||
} else {
|
|
||||||
colors::component_border()
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.cursor(CursorIcon::Pointer)
|
|
||||||
.child(
|
|
||||||
Icon::new(IconType::Users01)
|
|
||||||
.size(24.)
|
|
||||||
.color(colors::fg_secondary()),
|
|
||||||
)
|
|
||||||
.into_element()
|
|
||||||
}))
|
|
||||||
.into_element()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
use freya::prelude::*;
|
use freya::prelude::*;
|
||||||
|
use freya::query::MutationStateData;
|
||||||
|
use freya::router::RouterContext;
|
||||||
use oneclient_auth::MinecraftAccount;
|
use oneclient_auth::MinecraftAccount;
|
||||||
|
|
||||||
use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login};
|
use crate::components::{
|
||||||
use crate::hooks::{try_default_account, use_current_account};
|
Avatar, Button, Icon, IconType, TextInput, use_microsoft_login,
|
||||||
|
};
|
||||||
|
use crate::hooks::{
|
||||||
|
AddOfflineAccountKeys, try_default_account, use_add_offline_account, use_current_account,
|
||||||
|
};
|
||||||
use crate::routes::Route;
|
use crate::routes::Route;
|
||||||
use crate::theme::colors;
|
use crate::theme::colors;
|
||||||
use crate::view::onboarding::{
|
use crate::view::onboarding::{
|
||||||
onboarding_illustration, onboarding_nav, onboarding_page, step_heading,
|
onboarding_illustration, onboarding_page, step_heading,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
|
|
@ -16,9 +22,30 @@ impl Component for OnboardingAccount {
|
||||||
fn render(&self) -> impl IntoElement {
|
fn render(&self) -> impl IntoElement {
|
||||||
let account_query = use_current_account();
|
let account_query = use_current_account();
|
||||||
let msa = use_microsoft_login();
|
let msa = use_microsoft_login();
|
||||||
|
let add_offline = use_add_offline_account();
|
||||||
|
|
||||||
|
let offline_name = use_state(|| "Player".to_string());
|
||||||
|
let is_editing = use_state(|| false);
|
||||||
|
let mut is_navigating = use_state(|| false);
|
||||||
|
|
||||||
|
use_side_effect(move || {
|
||||||
|
if !*is_navigating.read() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match &*add_offline.read().state() {
|
||||||
|
MutationStateData::Settled { res: Ok(_), .. } => {
|
||||||
|
is_navigating.set(false);
|
||||||
|
let _ = RouterContext::get().replace(Route::OnboardingBundles {});
|
||||||
|
}
|
||||||
|
MutationStateData::Settled { res: Err(_), .. } => {
|
||||||
|
is_navigating.set(false);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let account = try_default_account(&account_query);
|
let account = try_default_account(&account_query);
|
||||||
let has_account = account.is_some();
|
let show_entry = account.is_none() || *is_editing.read();
|
||||||
|
|
||||||
let content = rect()
|
let content = rect()
|
||||||
.vertical()
|
.vertical()
|
||||||
|
|
@ -26,26 +53,155 @@ impl Component for OnboardingAccount {
|
||||||
.spacing(24.)
|
.spacing(24.)
|
||||||
.child(step_heading(
|
.child(step_heading(
|
||||||
"Account",
|
"Account",
|
||||||
"Before you continue, we require you to own a copy of Minecraft: Java Edition.",
|
"Enter your offline username to play, or sign in with your Microsoft account.",
|
||||||
))
|
))
|
||||||
.child(match &account {
|
.child(if show_entry {
|
||||||
Some(account) => account_preview(account).into_element(),
|
let start = msa.clone();
|
||||||
None => {
|
let add_offline_click = add_offline;
|
||||||
let start = msa.clone();
|
let mut is_editing_save = is_editing;
|
||||||
sign_in_card(msa.pending, msa.error.clone(), move |_| start.start())
|
let offline_name_read = offline_name;
|
||||||
.into_element()
|
|
||||||
}
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(16.)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("Choose Username:")
|
||||||
|
.font_size(14.)
|
||||||
|
.font_weight(FontWeight::SEMI_BOLD)
|
||||||
|
.color(colors::fg_primary()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.child(
|
||||||
|
TextInput::new(offline_name)
|
||||||
|
.placeholder("Enter username (e.g. Steve)")
|
||||||
|
.width(Size::px(240.)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.large()
|
||||||
|
.on_press(move |_| {
|
||||||
|
let target = sanitize_offline_username(&offline_name_read.read());
|
||||||
|
add_offline_click.mutate(AddOfflineAccountKeys {
|
||||||
|
username: target,
|
||||||
|
});
|
||||||
|
is_editing_save.set(false);
|
||||||
|
})
|
||||||
|
.text("Set & Play"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("3–16 characters (letters, numbers, underscore)")
|
||||||
|
.font_size(11.)
|
||||||
|
.color(colors::fg_secondary()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
label()
|
||||||
|
.text("— or sign in with Microsoft —")
|
||||||
|
.font_size(12.)
|
||||||
|
.color(colors::fg_secondary()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(sign_in_card(msa.pending, msa.error.clone(), move |_| {
|
||||||
|
start.start()
|
||||||
|
}))
|
||||||
|
.into_element()
|
||||||
|
} else {
|
||||||
|
let mut is_editing_toggle = is_editing;
|
||||||
|
let account = account.as_ref().unwrap();
|
||||||
|
|
||||||
|
rect()
|
||||||
|
.vertical()
|
||||||
|
.spacing(16.)
|
||||||
|
.child(account_preview(account))
|
||||||
|
.child(
|
||||||
|
rect()
|
||||||
|
.horizontal()
|
||||||
|
.spacing(8.)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.secondary()
|
||||||
|
.on_press(move |_| {
|
||||||
|
is_editing_toggle.set(true);
|
||||||
|
})
|
||||||
|
.text("Change Username"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_element()
|
||||||
})
|
})
|
||||||
.into_element();
|
.into_element();
|
||||||
|
|
||||||
|
let add_offline_nav = add_offline;
|
||||||
|
let offline_name_nav = offline_name;
|
||||||
|
let is_editing_nav = is_editing;
|
||||||
|
let mut is_navigating_nav = is_navigating;
|
||||||
|
let account_clone = account.clone();
|
||||||
|
|
||||||
|
let on_next = move |_| {
|
||||||
|
if *is_navigating_nav.read() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if account_clone.is_none() || *is_editing_nav.read() {
|
||||||
|
let target = sanitize_offline_username(&offline_name_nav.read());
|
||||||
|
add_offline_nav.mutate(AddOfflineAccountKeys {
|
||||||
|
username: target,
|
||||||
|
});
|
||||||
|
is_navigating_nav.set(true);
|
||||||
|
} else {
|
||||||
|
let _ = RouterContext::get().replace(Route::OnboardingBundles {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nav = rect()
|
||||||
|
.horizontal()
|
||||||
|
.width(Size::fill())
|
||||||
|
.main_align(Alignment::End)
|
||||||
|
.cross_align(Alignment::Center)
|
||||||
|
.spacing(12.)
|
||||||
|
.padding(Gaps::new(0., 40., 32., 40.))
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.secondary()
|
||||||
|
.width(Size::px(128.))
|
||||||
|
.on_press(move |_| {
|
||||||
|
let _ = RouterContext::get().replace(Route::OnboardingLanguage {});
|
||||||
|
})
|
||||||
|
.text("Back"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new()
|
||||||
|
.primary()
|
||||||
|
.width(Size::px(140.))
|
||||||
|
.enabled(!*is_navigating.read())
|
||||||
|
.on_press(on_next)
|
||||||
|
.text(if *is_navigating.read() {
|
||||||
|
"Saving..."
|
||||||
|
} else {
|
||||||
|
"Next"
|
||||||
|
})
|
||||||
|
.child(Icon::new(IconType::ArrowRight).size(16.)),
|
||||||
|
);
|
||||||
|
|
||||||
let page = onboarding_page(
|
let page = onboarding_page(
|
||||||
onboarding_illustration(IconType::OnboardingAccount),
|
onboarding_illustration(IconType::OnboardingAccount),
|
||||||
content,
|
content,
|
||||||
onboarding_nav(
|
nav,
|
||||||
Some(Route::OnboardingLanguage {}),
|
|
||||||
Route::OnboardingBundles {},
|
|
||||||
has_account,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
rect()
|
rect()
|
||||||
|
|
@ -130,3 +286,19 @@ fn sign_in_card(
|
||||||
}))
|
}))
|
||||||
.into_element()
|
.into_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_offline_username(input: &str) -> String {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
let sanitized: String = trimmed
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||||
|
.collect();
|
||||||
|
if sanitized.len() < 3 {
|
||||||
|
"Player".to_string()
|
||||||
|
} else if sanitized.len() > 16 {
|
||||||
|
sanitized[..16].to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,13 @@ impl CredentialsStore {
|
||||||
return Ok(Self::default());
|
return Ok(Self::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
match polyio::read_json(&path).await {
|
match polyio::read_json::<Self>(&path).await {
|
||||||
Ok(store) => Ok(store),
|
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) => {
|
Err(err) => {
|
||||||
tracing::warn!("failed to read auth file: {err}");
|
tracing::warn!("failed to read auth file: {err}");
|
||||||
Ok(Self::default())
|
Ok(Self::default())
|
||||||
|
|
@ -91,30 +96,27 @@ impl CredentialsStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_offline_account(&mut self, username: String) -> AuthResult<MinecraftAccount> {
|
fn insert_offline_account(&mut self, username: String) -> AuthResult<MinecraftAccount> {
|
||||||
if !self.has_microsoft_account() {
|
|
||||||
return Err(AuthError::OfflineRequiresMicrosoft);
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_offline_username(&username)?;
|
validate_offline_username(&username)?;
|
||||||
|
|
||||||
if self
|
if let Some(existing) = self
|
||||||
.users
|
.users
|
||||||
.values()
|
.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);
|
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)
|
Ok(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(level = "debug", skip_all, fields(%account.id))]
|
#[tracing::instrument(level = "debug", skip_all, fields(%account.id))]
|
||||||
pub async fn commit_refreshed_account(
|
pub async fn commit_refreshed_account(&mut self, account: MinecraftAccount) -> AuthResult<()> {
|
||||||
&mut self,
|
|
||||||
account: MinecraftAccount,
|
|
||||||
) -> AuthResult<()> {
|
|
||||||
self.users.insert(account.id, account);
|
self.users.insert(account.id, account);
|
||||||
self.save().await?;
|
self.save().await?;
|
||||||
tracing::debug!("stored refreshed Microsoft account");
|
tracing::debug!("stored refreshed Microsoft account");
|
||||||
|
|
@ -179,10 +181,6 @@ impl CredentialsStore {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
if account.is_offline() && !self.has_microsoft_account() {
|
|
||||||
return Err(AuthError::OfflineRequiresMicrosoft);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(account))
|
Ok(Some(account))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,3 +194,50 @@ pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool {
|
||||||
if source.is_connect() || source.is_timeout()
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,14 @@ fn offline_uuid_matches_vanilla_algorithm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn offline_account_blocked_without_microsoft() {
|
async fn offline_account_allowed_without_microsoft() {
|
||||||
isolate_launcher_dir();
|
isolate_launcher_dir();
|
||||||
|
|
||||||
let mut store = CredentialsStore::default();
|
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]
|
#[tokio::test]
|
||||||
|
|
@ -51,7 +52,7 @@ async fn offline_account_allowed_when_microsoft_exists() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn default_offline_fails_when_last_microsoft_removed() {
|
async fn default_offline_works_when_last_microsoft_removed() {
|
||||||
isolate_launcher_dir();
|
isolate_launcher_dir();
|
||||||
|
|
||||||
let mut store = CredentialsStore::default();
|
let mut store = CredentialsStore::default();
|
||||||
|
|
@ -60,11 +61,13 @@ async fn default_offline_fails_when_last_microsoft_removed() {
|
||||||
store.users.insert(msa_id, msa);
|
store.users.insert(msa_id, msa);
|
||||||
|
|
||||||
let offline = store.add_offline_account("Steve".into()).unwrap();
|
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);
|
store.users.remove(&msa_id);
|
||||||
|
|
||||||
let err = store.default_account().await.unwrap_err();
|
let account = store.default_account().await.unwrap().unwrap();
|
||||||
assert!(err.to_string().contains("Microsoft"));
|
assert_eq!(account.id, offline_id);
|
||||||
|
assert_eq!(account.kind, AccountKind::Offline);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,18 @@ pub fn logs_dir() -> PathsResult<PathBuf> {
|
||||||
Ok(config_dir()?.join("logs"))
|
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> {
|
pub fn java_dir() -> PathsResult<PathBuf> {
|
||||||
Ok(data_dir()?.join("metadata").join("java"))
|
Ok(data_dir()?.join("metadata").join("java"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
packages/oneclient_core/src/auth/mod.rs
Normal file
13
packages/oneclient_core/src/auth/mod.rs
Normal 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,
|
||||||
|
}
|
||||||
52
packages/oneclient_core/src/auth/offline.rs
Normal file
52
packages/oneclient_core/src/auth/offline.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -296,6 +296,9 @@ async fn start(
|
||||||
java.major,
|
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(
|
let mut mc_args = arguments::minecraft_arguments(
|
||||||
updated,
|
updated,
|
||||||
arg_map.get(&ArgumentType::Game).map(Vec::as_slice),
|
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}");
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue