feat: oneclient account skin manager (#373)

* feat: oneclient account skin manager

* feat: oneclient account skin manager import

* feat: oneclient account skin manager improt from url

* feat: oneclient accoutn skin manager animations

* feat: oneclient account skin manager export skin

* feat: oneclient account skin manager skin history

* feat: oneclient mini accounts edit button

* feat: oneclient account skin manager saving skins

* change order of mini account buttons

* feat: oneclient account skin manager elytra support

* fix: oneclient account skin manager default rotate

See https://github.com/bs-community/skinview3d/issues/198 for where fix
came from

Co-authored-by: undefined303 <undefined303@qq.com>
Co-authored-by: Zickles <zicklesistaken@gmail.com>

* feat: oneclient account skin manager change cape

* feat: oneclient account skin manager import from username

---------

Co-authored-by: undefined303 <undefined303@qq.com>
Co-authored-by: Zickles <zicklesistaken@gmail.com>
This commit is contained in:
Jacob 2025-10-01 17:31:42 +00:00 • committed by GitHub
parent 4b5051b9d9
commit 0e12968b9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 804 additions and 34 deletions

6
Cargo.lock generated
View file

@ -3411,6 +3411,7 @@ dependencies = [
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
@ -6061,9 +6062,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.3.0"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ead0daec5d305adcefe05af9d970fc437bcc7996052d564e7393eb291252da"
checksum = "88371e340ad2f07409a3b68294abe73f20bc9c1bc1b631a31dc37a3d0161f682"
dependencies = [
"anyhow",
"dunce",
@ -6079,6 +6080,7 @@ dependencies = [
"thiserror 2.0.12",
"toml",
"url",
"uuid 1.16.0",
]
[[package]]

View file

@ -48,6 +48,7 @@ tauri-build = { version = "=2.2.0" }
tauri-plugin-clipboard-manager = { version = "=2.2.2" }
tauri-plugin-deep-link = { version = "=2.2.1" }
tauri-plugin-dialog = { version = "=2.2.1" }
tauri-plugin-fs = { version = "=2.2.1" }
tauri-plugin-single-instance = { version = "=2.2.3" }
tauri-plugin-updater = { version = "=2.7.1" }
tauri-plugin-window-state = { version = "=2.2.2" }

View file

@ -36,6 +36,7 @@ tauri-plugin-single-instance = { workspace = true }
tauri-plugin-updater = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = { workspace = true }
tauri-plugin-deep-link = { workspace = true }
# code gen

View file

@ -31,6 +31,11 @@
"dialog:allow-save",
"dialog:allow-confirm",
"deep-link:default",
"updater:default"
"updater:default",
"fs:allow-download-read",
"fs:allow-download-write",
"fs:allow-data-read-recursive",
"fs:allow-data-write-recursive"
]
}

View file

@ -71,6 +71,7 @@ async fn initialize_tauri(builder: tauri::Builder<tauri::Wry>) -> LauncherResult
// .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_deep_link::init())
.menu(tauri::menu::Menu::new)
.invoke_handler(router.into_handler())

View file

@ -28,6 +28,7 @@
"@tauri-apps/api": "catalog:",
"@tauri-apps/plugin-clipboard-manager": "catalog:",
"@tauri-apps/plugin-dialog": "catalog:",
"@tauri-apps/plugin-fs": "catalog:",
"@untitled-theme/icons-react": "catalog:",
"motion": "catalog:",
"overlayscrollbars": "catalog:",

View file

@ -108,12 +108,14 @@ export type ModpackManifest = { name: string; version: string; loader: GameLoade
export type MojangCape = { id: string; state: string; url: string; alias: string }
export type MojangFullPlayerProfile = { uuid: string; username: string; skins: MojangSkin[]; capes: MojangCape[] }
export type MojangFullPlayerProfile = { id: string; username: string; skins: MojangSkin[]; capes: MojangCape[] }
export type MojangPlayerProfile = { uuid: string; username: string; is_slim: boolean; skin_url: string | null; cape_url: string | null }
export type MojangSkin = { id: string; state: string; url: string; variant: SkinVariant }
export type MowojangProfile = { id: string; username: string }
export type PackageAuthor = { Team: { team_id: string; org_id: string | null } } | { Users: ManagedUser[] }
export type PackageCategories = { Mod: PackageModCategory[] } | { ResourcePack: PackageResourcePackCategory[] } | { Shader: PackageShaderCategory[] } | { DataPack: PackageModCategory[] } | { ModPack: PackageModPackCategory[] }
@ -252,15 +254,15 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'core':'{"getScreenshots":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"fetchLoggedInProfile":["access_token"],"getPackageBody":["provider","body"],"getLogs":["id"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"updateClusterById":["id","request"],"getMultiplePackages":["provider","slugs"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getUsersFromAuthor":["provider","author"],"updateClusterProfile":["name","profile"],"getLogByName":["id","name"],"removeUser":["uuid"],"getUser":["uuid"],"launchCluster":["id","uuid"],"readSettings":[],"getProfileOrDefault":["name"],"getGameVersions":[],"removeCluster":["id"],"getRunningProcessesByClusterId":["cluster_id"],"open":["input"],"createSettingsProfile":["name"],"getClusterById":["id"],"openMsaLogin":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getRunningProcesses":[],"getClusters":[],"getWorlds":["id"],"changeSkin":["access_token","skin_url","skin_variant"],"killProcess":["pid"],"getLoadersForVersion":["mc_version"],"createCluster":["options"],"getUsers":[],"getGlobalProfile":[],"searchPackages":["provider","query"],"writeSettings":["setting"],"isClusterRunning":["cluster_id"],"getPackage":["provider","slug"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}' }
export type Router = { 'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'oneclient': { openDevTools: () => Promise<void>,
const ARGS_MAP = { 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[],"getBundlesFor":["cluster_id"]}', 'core':'{"updateClusterProfile":["name","profile"],"updateClusterById":["id","request"],"getLogByName":["id","name"],"killProcess":["pid"],"getUsers":[],"getPackage":["provider","slug"],"removeCluster":["id"],"launchCluster":["id","uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getProfileOrDefault":["name"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"changeCape":["access_token","cape_uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"getPackageBody":["provider","body"],"installModpack":["modpack","cluster_id"],"getWorlds":["id"],"removeUser":["uuid"],"convertUsernameUUID":["username_uuid"],"writeSettings":["setting"],"getGameVersions":[],"getClusterById":["id"],"getLoadersForVersion":["mc_version"],"getScreenshots":["id"],"getMultiplePackages":["provider","slugs"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"openMsaLogin":[],"getDefaultUser":["fallback"],"getUser":["uuid"],"getRunningProcessesByClusterId":["cluster_id"],"getGlobalProfile":[],"createSettingsProfile":["name"],"setDefaultUser":["uuid"],"getClusters":[],"removeCape":["access_token"],"searchPackages":["provider","query"],"isClusterRunning":["cluster_id"],"getLogs":["id"],"fetchLoggedInProfile":["access_token"],"open":["input"],"fetchMinecraftProfile":["uuid"],"readSettings":[],"getRunningProcesses":[],"createCluster":["options"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}' }
export type Router = { 'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -301,6 +303,9 @@ fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile>,
fetchLoggedInProfile: (accessToken: string) => Promise<MojangFullPlayerProfile>,
uploadSkinBytes: (accessToken: string, skinData: number[], imageFormat: string, skinVariant: SkinVariant) => Promise<MojangSkin>,
changeSkin: (accessToken: string, skinUrl: string, skinVariant: SkinVariant) => Promise<MojangSkin>,
changeCape: (accessToken: string, capeUuid: string) => Promise<MojangFullPlayerProfile>,
removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
open: (input: string) => Promise<null> } };

View file

@ -9,14 +9,48 @@ export interface SkinViewerProps {
width?: number;
height?: number;
className?: string | undefined;
autoRotate?: boolean;
autoRotateSpeed?: number;
showText?: boolean;
playerRotatePhi?: number;
playerRotateTheta?: number;
translateRotateX?: number;
translateRotateY?: number;
translateRotateZ?: number;
zoom?: number;
animate?: boolean;
animation?: skinviewer.PlayerAnimation;
enableDamping?: boolean;
enableZoom?: boolean;
enableRotate?: boolean;
enablePan?: boolean;
elytra?: boolean;
}
const defaultIdleAnimation = new skinviewer.IdleAnimation();
export function SkinViewer({
skinUrl,
capeUrl,
width = 260,
height = 300,
className,
autoRotate = true,
autoRotateSpeed = 0.25,
showText = true,
playerRotatePhi = Math.PI / 3,
playerRotateTheta = -Math.PI / 6,
translateRotateX = 0,
translateRotateY = 0,
translateRotateZ = 0,
zoom = 0.9,
animate = false,
animation = defaultIdleAnimation,
enableDamping = true,
enableZoom = true,
enableRotate = true,
enablePan = true,
elytra = false
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<skinviewer.SkinViewer | null>(null);
@ -29,8 +63,32 @@ export function SkinViewer({
canvas: canvasRef.current,
});
viewer.autoRotate = true;
viewer.autoRotateSpeed = 0.25;
viewer.controls.enableDamping = enableDamping;
viewer.controls.enableZoom = enableZoom;
viewer.controls.enableRotate = enableRotate;
viewer.controls.enablePan = enablePan;
viewer.zoom = zoom;
const setAngle = (phi: number, theta: number) => {
const r = viewer.controls.object.position.distanceTo(viewer.controls.target);
const x = r * Math.cos(phi - Math.PI / 2) * Math.sin(theta) + viewer.controls.target.x;
const y = r * Math.sin(phi + Math.PI / 2) + viewer.controls.target.y;
const z = r * Math.cos(phi - Math.PI / 2) * Math.cos(theta) + viewer.controls.target.z;
viewer.controls.object.position.set(x, y, z);
viewer.controls.object.lookAt(viewer.controls.target);
};
setAngle(playerRotatePhi, playerRotateTheta)
viewer.playerWrapper.translateX(translateRotateX);
viewer.playerWrapper.translateY(translateRotateY);
viewer.playerWrapper.translateZ(translateRotateZ);
viewer.animation = animation;
viewer.autoRotate = autoRotate;
viewer.autoRotateSpeed = autoRotateSpeed;
viewerRef.current = viewer;
@ -46,19 +104,15 @@ export function SkinViewer({
viewerRef.current.loadSkin(getSkinUrl(skinUrl));
}, [skinUrl]);
useEffect(() => {
}, [capeUrl]);
useEffect(() => {
if (!viewerRef.current)
return;
if (capeUrl)
viewerRef.current.loadCape(capeUrl);
viewerRef.current.loadCape(capeUrl, { backEquipment: elytra ? 'elytra' : 'cape' });
else
viewerRef.current.resetCape();
}, [capeUrl]);
}, [capeUrl, elytra]);
useEffect(() => {
if (!viewerRef.current)
@ -67,6 +121,20 @@ export function SkinViewer({
viewerRef.current.setSize(width, height);
}, [width, height]);
useEffect(() => {
if (!viewerRef.current)
return;
viewerRef.current.animation = animation;
}, [animation]);
useEffect(() => {
if (!viewerRef.current || !viewerRef.current.animation)
return;
viewerRef.current.animation.paused = !animate;
}, [animate]);
return (
<div className={twMerge('flex flex-col justify-center items-center', className)} style={{ minWidth: `${width}px`, minHeight: `${height}px` }}>
<canvas
@ -75,7 +143,7 @@ export function SkinViewer({
width={width}
/>
<span className="text-fg-secondary text-xs">Hold to drag. Scroll to zoom in/out.</span>
{showText ? <span className="text-fg-secondary text-xs">Hold to drag. Scroll to zoom in/out.</span> : <></>}
</div>
);
}

View file

@ -3,7 +3,7 @@ import { bindings } from '@/main';
import { useCommand, useCommandMut } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router';
import { PlusIcon, Settings01Icon, Trash01Icon } from '@untitled-theme/icons-react';
import { Pencil01Icon, PlusIcon, Settings01Icon, Trash01Icon } from '@untitled-theme/icons-react';
import { DialogTrigger } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { AccountAvatar } from '../AccountAvatar';
@ -28,8 +28,9 @@ export function AccountPopup() {
defaultUser.refetch();
if (defaultUser.data && defaultUser.data.id === user.id && users.data && users.data.length > 1) {
const filtered = users.data.filter((userData) => userData.id !== user.id)
if (filtered.length > 0) setDefaultUser(filtered[0]);
const filtered = users.data.filter(userData => userData.id !== user.id);
if (filtered.length > 0)
setDefaultUser(filtered[0]);
}
};
@ -99,8 +100,9 @@ function AccountEntry({
loggedIn?: boolean;
}) {
return (
<div
className={twMerge('flex flex-row justify-between p-2 rounded-lg', !loggedIn && 'hover:bg-component-bg-hover active:bg-component-bg-pressed hover:text-fg-primary-hover')}
<Button
className={twMerge('w-full flex flex-row justify-between p-2 rounded-lg', !loggedIn && 'hover:bg-component-bg-hover active:bg-component-bg-pressed hover:text-fg-primary-hover')}
color="ghost"
onClick={onClick}
>
<div className="flex flex-1 flex-row justify-start gap-x-3">
@ -114,16 +116,29 @@ function AccountEntry({
</div>
</div>
<DialogTrigger>
<Button className="group w-8 h-8" color="ghost" size="icon">
<Trash01Icon className="group-hover:stroke-danger" />
</Button>
<div className="flex flex-row items-center">
<Link to="/app/accountSkin">
<Button
className="group w-8 h-8"
color="ghost"
onClick={onClick}
size="icon"
>
<Pencil01Icon className="group-hover:stroke-brand-hover" />
</Button>
</Link>
<Overlay>
<RemoveAccountModal onPress={onDelete} profile={user} />
</Overlay>
</DialogTrigger>
<DialogTrigger>
<Button className="group w-8 h-8" color="ghost" size="icon">
<Trash01Icon className="group-hover:stroke-danger" />
</Button>
<Overlay>
<RemoveAccountModal onPress={onDelete} profile={user} />
</Overlay>
</DialogTrigger>
</div>
</div>
</div>
</Button>
);
}

View file

@ -0,0 +1,34 @@
import { Button, TextField } from '@onelauncher/common/components';
import { useState } from 'react';
import { Overlay } from './Overlay';
export function ImportSkinModal({ importFromURL, importFromUsername }: { importFromURL: (url: string) => void; importFromUsername: (username: string) => void }) {
const [input, setInput] = useState<string>('');
return (
<Overlay.Dialog>
<Overlay.Title>Import</Overlay.Title>
<TextField className="w-full" onChange={e => setInput(e.target.value)} />
<div className="flex flex-row gap-4 h-1/2 w-full">
<Button
className="w-1/2"
color="primary"
onClick={() => importFromUsername(input)}
size="normal"
slot="close"
>
From Username
</Button>
<Button
className="w-1/2"
color="primary"
onClick={() => importFromURL(input)}
size="normal"
slot="close"
>
From URL
</Button>
</div>
</Overlay.Dialog>
);
}

View file

@ -0,0 +1,22 @@
import { Button } from '@onelauncher/common/components';
import { Overlay } from './Overlay';
export function RemoveSkinCapeModal({ onPress }: { onPress: () => void; }) {
return (
<Overlay.Dialog>
<Overlay.Title>Are you sure?</Overlay.Title>
<p>This cannot be undone</p>
<Button
className="w-full"
color="danger"
onPress={onPress}
size="large"
slot="close"
>
Remove
</Button>
</Overlay.Dialog>
);
}

View file

@ -1,6 +1,8 @@
export * from './AccountPopup';
export * from './AddAccountModal';
export * from './ImportSkinModal';
export * from './Overlay';
export * from './Popup';
export * from './RemoveAccountModal';
export * from './RemoveSkinCapeModal';
export * from './Toasts';

View file

@ -0,0 +1,483 @@
import type { MinecraftCredentials } from '@/bindings.gen';
import type { PlayerAnimation } from 'skinview3d';
import { SheetPage, SkinViewer } from '@/components';
import { ImportSkinModal, RemoveSkinCapeModal } from '@/components/overlay';
import { Overlay } from '@/components/overlay/Overlay';
import { usePlayerProfile } from '@/hooks/usePlayerProfile';
import { bindings } from '@/main';
import { getSkinUrl } from '@/utils/minecraft';
import { useCommandSuspense } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { dataDir, downloadDir, join } from '@tauri-apps/api/path';
import { save } from '@tauri-apps/plugin-dialog';
import { exists, mkdir, readTextFile, writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { Download01Icon, PlusIcon, Trash01Icon } from '@untitled-theme/icons-react';
import { useEffect, useState } from 'react';
import { DialogTrigger } from 'react-aria-components';
import { CrouchAnimation, FlyingAnimation, HitAnimation, IdleAnimation, WalkingAnimation } from 'skinview3d';
import { toast } from '@/utils/toast';
interface Skin {
is_slim: boolean;
skin_url: string;
}
interface Cape {
url: string;
id: string;
}
export const Route = createFileRoute('/app/accountSkin')({
component: RouteComponent,
});
const animations = [
{ name: 'Idle', animation: new IdleAnimation(), speed: 0.1 },
{ name: 'Walking', animation: new WalkingAnimation(), speed: 0.1 },
{ name: 'Flying', animation: new FlyingAnimation(), speed: 0.2 },
{ name: 'Crouch', animation: new CrouchAnimation(), speed: 0.025 },
{ name: 'Hit', animation: new HitAnimation(), speed: 0.1 },
];
async function getSkinHistory(): Promise<Array<Skin>> {
const parentDir = await join(await dataDir(), 'OneClient', 'metadata', 'history');
const skinsPath = await join(parentDir, 'skins.json');
try {
const dirExists = await exists(parentDir);
if (!dirExists)
await mkdir(parentDir, { recursive: true });
const fileExists = await exists(skinsPath);
if (!fileExists) {
await writeTextFile(skinsPath, JSON.stringify([]));
return [];
}
const contents = await readTextFile(skinsPath);
return JSON.parse(contents) as Array<Skin>;
}
catch (error) {
console.error(error);
await writeTextFile(skinsPath, JSON.stringify([]));
return [];
}
}
async function saveSkinHistory(skins: Array<Skin>): Promise<void> {
const parentDir = await join(await dataDir(), 'OneClient', 'metadata', 'history');
const skinsPath = await join(parentDir, 'skins.json');
try {
const dirExists = await exists(parentDir);
if (!dirExists)
await mkdir(parentDir, { recursive: true });
await writeTextFile(skinsPath, JSON.stringify(skins));
}
catch (error) {
console.error(error);
}
}
function RouteComponent() {
const queryClient = useQueryClient();
const [skins, setSkinsSTATE] = useState<Array<Skin>>([]);
const setSkins = (updater: Array<Skin> | ((prev: Array<Skin>) => Array<Skin>)) => {
const newSkins = typeof updater === 'function' ? updater(skins) : updater;
const seen: Set<string> = new Set();
const filteredDupes = newSkins.filter((skin) => {
const key = skin.skin_url || '';
if (seen.has(key))
return false;
seen.add(key);
return true;
});
saveSkinHistory(filteredDupes);
setSkinsSTATE(filteredDupes);
};
const [capes, setCapes] = useState<Array<Cape>>([]);
const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const { data: loggedInUser } = useCommandSuspense(['fetchLoggedInProfile'], () => bindings.core.fetchLoggedInProfile((currentAccount as MinecraftCredentials).access_token));
useEffect(() => {
async function fetchSkins() {
const skins: Array<Skin> = loggedInUser.skins.map(skin => ({
is_slim: skin.variant === 'slim',
skin_url: skin.url,
}));
(await getSkinHistory()).forEach(skin => skins.push(skin));
setSkins(skins);
}
fetchSkins();
setCapes([{ url: "", id: "" }, ...loggedInUser.capes.map(cape => ({ url: cape.url, id: cape.id }))]);
}, [loggedInUser]);
const { data: profile } = usePlayerProfile(currentAccount?.id);
const [animation, setAnimation] = useState<PlayerAnimation>(animations[0].animation);
const [animationName, setAnimationName] = useState<string>(animations[0].name);
const skinData: Skin = {
is_slim: profile?.is_slim ?? false,
skin_url: getSkinUrl(profile?.skin_url),
};
const [selectedSkin, setSelectedSkin] = useState<Skin>(skinData);
useEffect(() => {
if (!skinData.skin_url)
return;
setSkins((prev) => {
const filtered = prev.filter(skin => skin.skin_url !== skinData.skin_url);
return [skinData, ...filtered];
});
setSelectedSkin(skinData);
}, [skinData.skin_url, profile]);
const [selectedCape, setSelectedCape] = useState<string>('');
const importFromURL = (url: string) => {
setSkins([...skins, { is_slim: false, skin_url: url }]);
};
const importFromUsername = async (username: string) => {
toast({
type: 'info',
title: 'Import Skin',
message: `Importing skin from ${username}`,
});
const { id } = await bindings.core.convertUsernameUUID(username) ?? { id: '', username: '' }
if (id === '')
return toast({
type: 'error',
title: 'Import Skin',
message: `${username} doesn't exist`,
});
const playerProfile = await bindings.core.fetchMinecraftProfile(id)
if (playerProfile.skin_url)
setSkins([...skins, { is_slim: playerProfile.is_slim, skin_url: playerProfile.skin_url }]);
toast({
type: 'success',
title: 'Import Skin',
message: `Imported skin from ${username}`,
});
}
const [shouldShowElytra, setShouldShowElytra] = useState<boolean>(false);
const saveSkinToAccount = async () => {
try {
if (!currentAccount)
return;
await bindings.core.changeSkin(currentAccount.access_token, selectedSkin.skin_url, selectedSkin.is_slim ? 'slim' : 'classic');
if (selectedCape === '') {
await bindings.core.removeCape(currentAccount.access_token)
} else {
const capeData = capes.find((cape) => cape.url === selectedCape)
if (!capeData) return
await bindings.core.changeCape(currentAccount.access_token, capeData.id)
}
queryClient.invalidateQueries({
queryKey: ['getDefaultUser'],
});
queryClient.invalidateQueries({
queryKey: ['fetchLoggedInProfile'],
});
queryClient.invalidateQueries({
queryKey: ['fetchMinecraftProfile'],
});
}
catch (error) {
console.error(error);
}
};
if (currentAccount === null)
return (
<SheetPage headerLarge={<></>} headerSmall={<></>}>
<SheetPage.Content>
<p>No accounts added</p>
</SheetPage.Content>
</SheetPage>
);
const getNextAnimationData = () => {
const animationIndex = animations.findIndex(animationData => animationData.name === animationName);
if (animationIndex === -1 || animationIndex === animations.length - 1)
return animations[0];
else
return animations[animationIndex + 1];
};
const changeSelectedAnimation = () => {
const data = getNextAnimationData();
data.animation.speed = data.speed;
if (data.name === 'Walking')
(data.animation as WalkingAnimation).headBobbing = false;
setAnimation(data.animation);
setAnimationName(data.name);
};
if (!selectedSkin.skin_url)
return <></>;
return (
<SheetPage
headerLarge={(
<HeaderLarge save={saveSkinToAccount} username={profile?.username || 'UNKNOWN'} />
)}
headerSmall={<HeaderSmall />}
>
<SheetPage.Content>
<div className="flex-1 flex flex-row gap-8">
<div className="flex flex-col justify-center items-center">
<p>Current Skin</p>
<Button className="mt-2" color="ghost" onClick={changeSelectedAnimation}>
<p>Change to {`${getNextAnimationData().name} Animation`}</p>
</Button>
<Viewer
animation={animation}
capeURL={selectedCape}
enableControls
shouldShowElytra={shouldShowElytra}
skinURL={selectedSkin.skin_url}
/>
</div>
<div className="min-h-full w-px bg-component-border"></div>
<div className="w-full flex flex-col min-h-full justify-between">
<SkinHistoryRow
animation={animation}
capeURL={selectedCape}
importFromURL={importFromURL}
importFromUsername={importFromUsername}
selected={selectedSkin}
setSelectedSkin={setSelectedSkin}
setSkins={setSkins}
shouldShowElytra={shouldShowElytra}
skins={skins}
/>
<div className="min-w-full h-px bg-component-border"></div>
<CapeRow
animation={animation}
capes={capes}
selected={selectedCape}
setSelectedCape={setSelectedCape}
setShouldShowElytra={() => setShouldShowElytra(!shouldShowElytra)}
shouldShowElytra={shouldShowElytra}
skinURL={selectedSkin.skin_url}
/>
</div>
</div>
</SheetPage.Content>
</SheetPage>
);
}
function SkinHistoryRow({ selected, animation, setSelectedSkin, skins, setSkins, importFromURL, importFromUsername, capeURL, shouldShowElytra }: { selected: Skin; animation: PlayerAnimation; setSelectedSkin: (skin: Skin) => void; skins: Array<Skin>; setSkins: React.Dispatch<React.SetStateAction<Array<Skin>>>; importFromURL: (url: string) => void; importFromUsername: (username: string) => void; capeURL: string; shouldShowElytra: boolean }) {
return (
<div className="flex flex-col h-full justify-around">
<div className="flex flex-col justify-center items-center">
<p>Skin History</p>
</div>
<div className="flex flex-row h-fit gap-2">
<DialogTrigger>
<Button className="border border-component-border rounded-xl bg-component-border w-[75px] h-[120px]" color="ghost">
<div className="flex flex-col justify-center items-center content-center h-full">
<PlusIcon className="scale-200" />
</div>
</Button>
<Overlay>
<ImportSkinModal importFromURL={importFromURL} importFromUsername={importFromUsername} />
</Overlay>
</DialogTrigger>
{skins.map(skinData => (
<RenderSkin
animation={animation}
capeURL={capeURL}
key={skinData.skin_url}
selected={selected}
setSelectedSkin={setSelectedSkin}
setSkins={setSkins}
shouldShowElytra={shouldShowElytra}
skin={skinData}
/>
))}
</div>
</div>
);
}
function RenderSkin({ skin, selected, animation, setSelectedSkin, setSkins, capeURL, shouldShowElytra }: { skin: Skin; selected: Skin; animation: PlayerAnimation; setSelectedSkin: (skin: Skin) => void; setSkins: React.Dispatch<React.SetStateAction<Array<Skin>>>; capeURL: string; shouldShowElytra: boolean }) {
const exportSkin = async () => {
try {
if (!skin.skin_url)
return;
const filePath = await save({
title: 'Skin Export Location',
filters: [
{
name: 'Images',
extensions: ['png'],
},
],
defaultPath: await join(await downloadDir(), `${skin.skin_url.split('/').reverse()[0]}.png`),
});
if (!filePath)
return;
const response = await fetch(skin.skin_url);
const buffer = await response.arrayBuffer();
await writeFile(filePath, new Uint8Array(buffer));
}
catch (error) {
console.error(error);
}
};
if (!skin.skin_url)
return <></>;
return (
<Button
className={`w-[75px] h-[120px] relative border rounded-xl bg-component-border ${selected.skin_url === skin.skin_url ? 'border-brand' : 'hover:border-brand border-component-border'}`}
color="ghost"
onClick={() => setSelectedSkin(skin)}
>
<Viewer
animation={animation}
capeURL={capeURL}
height={120}
shouldShowElytra={shouldShowElytra}
showText={false}
skinURL={skin.skin_url}
width={75}
/>
{selected.skin_url === skin.skin_url
? <></>
: (
<DialogTrigger>
<Button className="group w-8 h-8 absolute top-0 right-0" color="ghost" size="icon">
<Trash01Icon className="group-hover:stroke-danger" />
</Button>
<Overlay>
<RemoveSkinCapeModal onPress={() => setSkins(prev => prev.filter(skinData => skinData.skin_url !== skin.skin_url))} />
</Overlay>
</DialogTrigger>
)}
<Button
className="group w-8 h-8 absolute bottom-0 right-0"
color="ghost"
onPress={exportSkin}
size="icon"
>
<Download01Icon className="group-hover:stroke-brand" />
</Button>
</Button>
);
}
function CapeRow({ selected, animation, setSelectedCape, capes, shouldShowElytra, setShouldShowElytra, skinURL }: { selected: string | null; animation: PlayerAnimation; setSelectedCape: (cape: string) => void; capes: Array<Cape>; shouldShowElytra: boolean; setShouldShowElytra: () => void; skinURL: string }) {
return (
<div className="flex flex-col h-full justify-around">
<div className="flex flex-row h-fit gap-2">
{capes.map(cape => (
<RenderCape
animation={animation}
cape={cape.url}
key={cape.id}
selected={selected}
setSelectedCape={setSelectedCape}
shouldShowElytra={shouldShowElytra}
skinURL={skinURL}
/>
))}
</div>
<div className="flex flex-col justify-center items-center">
<p>Cape History</p>
<Button color="ghost" onClick={setShouldShowElytra}>
<p>{`${shouldShowElytra ? 'Disable' : 'Enable'} Elytra`}</p>
</Button>
</div>
</div>
);
}
function RenderCape({ selected, animation, setSelectedCape, cape, shouldShowElytra, skinURL }: { selected: string | null; animation: PlayerAnimation; setSelectedCape: (cape: string) => void; cape: string; shouldShowElytra: boolean; skinURL: string }) {
return (
<Button
className={`w-[75px] h-[120px] relative border rounded-xl bg-component-border ${selected === cape ? 'border-brand' : 'hover:border-brand border-component-border'}`}
color="ghost"
onClick={() => setSelectedCape(cape)}
>
<Viewer
animation={animation}
capeURL={cape}
flip
height={120}
shouldShowElytra={shouldShowElytra}
showText={false}
skinURL={skinURL}
width={75}
/>
</Button>
);
}
function HeaderLarge({ username, save }: { username: string; save: () => void }) {
return (
<div className="flex flex-row justify-between items-end gap-16">
<div className="flex-1 flex flex-row justify-between">
<h1 className="text-3xl font-semibold">{`${username}'s Skins`}</h1>
<div className="flex flex-row gap-2">
<Button color="primary" onClick={save} size="large">
<p>Save</p>
</Button>
</div>
</div>
</div>
);
}
function HeaderSmall() {
return (
<div className="flex flex-row justify-between items-center h-full">
<h1 className="text-2lg h-full font-medium">Accounts</h1>
</div>
);
}
function Viewer({ skinURL, capeURL, height = 400, width = 250, showText = true, animation, enableControls = false, flip = false, shouldShowElytra }: { skinURL: string; capeURL: string; height?: number; width?: number; showText?: boolean; animation?: PlayerAnimation; enableControls?: boolean; flip?: boolean; shouldShowElytra: boolean }) {
return (
<SkinViewer
animate
animation={animation}
autoRotate={false}
capeUrl={capeURL === '' ? null : capeURL}
className="h-full w-full max-w-1/4"
elytra={shouldShowElytra}
enableDamping={enableControls}
enablePan={enableControls}
enableRotate={enableControls}
enableZoom={enableControls}
height={height}
playerRotateTheta={(-Math.PI / 6) - (flip ? Math.PI : 0)}
showText={showText}
skinUrl={skinURL}
translateRotateY={-2}
width={width}
zoom={0.8}
/>
);
}

View file

@ -8,8 +8,8 @@ import { bindings } from '@/main';
import { useCommandMut, useCommandSuspense } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { Trash01Icon } from '@untitled-theme/icons-react';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Pencil01Icon, Trash01Icon } from '@untitled-theme/icons-react';
import { Button as AriaButton, DialogTrigger } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
@ -153,6 +153,17 @@ function AccountRow({
</div>
<div className="flex flex-row items-center gap-2">
<Link to="/app/accountSkin">
{/* TODO: Find a better way to handle handle a user that isn't just changing the default user */}
<Button
className="group w-8 h-8"
color="ghost"
onPress={() => onPress()}
size="icon"
>
<Pencil01Icon className="group-hover:stroke-brand-hover" />
</Button>
</Link>
<DialogTrigger>
<Button className="group w-8 h-8" color="ghost" size="icon">
<Trash01Icon className="group-hover:stroke-danger" />

View file

@ -222,6 +222,22 @@ pub trait TauriLauncherApi {
skin_url: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin>;
#[taurpc(alias = "changeCape")]
async fn change_cape(
access_token: String,
cape_uuid: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile>;
#[taurpc(alias = "removeCape")]
async fn remove_cape(
access_token: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile>;
#[taurpc(alias = "convertUsernameUUID")]
async fn convert_username_uuid(
username_uuid: String,
) -> LauncherResult<crate::utils::minecraft::MowojangProfile>;
// endregion: minecraft
// MARK: API: Other
@ -744,6 +760,28 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
) -> LauncherResult<crate::utils::minecraft::MojangSkin> {
crate::utils::minecraft::change_skin(&access_token, &skin_url, skin_variant).await
}
async fn change_cape(
self,
access_token: String,
cape_uuid: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile> {
crate::utils::minecraft::change_cape(&access_token, &cape_uuid).await
}
async fn remove_cape(
self,
access_token: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile> {
crate::utils::minecraft::remove_cape(&access_token).await
}
async fn convert_username_uuid(
self,
username_uuid: String,
) -> LauncherResult<crate::utils::minecraft::MowojangProfile> {
crate::utils::minecraft::convert_username_uuid(&username_uuid).await
}
// endregion: minecraft
// MARK: Impl: Other

View file

@ -21,7 +21,9 @@ pub struct MojangPlayerProfile {
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum SkinVariant {
#[serde(alias = "CLASSIC")]
Classic,
#[serde(alias = "SLIM")]
Slim,
}
@ -37,7 +39,8 @@ impl std::fmt::Display for SkinVariant {
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MojangFullPlayerProfile {
pub uuid: String,
pub id: String,
#[serde(alias = "name")]
pub username: String,
pub skins: Vec<MojangSkin>,
pub capes: Vec<MojangCape>,
@ -235,3 +238,62 @@ pub async fn change_skin(
.cloned()
.ok_or_else(|| anyhow::anyhow!("no skins found in response").into())
}
pub async fn change_cape(
access_token: &str,
cape_uuid: &str,
) -> LauncherResult<MojangFullPlayerProfile> {
let mut headers: HashMap<&str, &str> = HashMap::with_capacity(1);
let bearer = &format!("Bearer {access_token}");
headers.insert("Authorization", bearer);
http::fetch_json_advanced::<MojangFullPlayerProfile>(
Method::PUT,
"https://api.minecraftservices.com/minecraft/profile/capes/active",
Some(json!({
"capeId": cape_uuid,
})),
Some(headers),
None,
None,
)
.await
}
pub async fn remove_cape(access_token: &str) -> LauncherResult<MojangFullPlayerProfile> {
let mut headers: HashMap<&str, &str> = HashMap::with_capacity(1);
let bearer = &format!("Bearer {access_token}");
headers.insert("Authorization", bearer);
http::fetch_json_advanced::<MojangFullPlayerProfile>(
Method::DELETE,
"https://api.minecraftservices.com/minecraft/profile/capes/active",
None,
Some(headers),
None,
None,
)
.await
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MowojangProfile {
pub id: String,
#[serde(alias = "name")]
pub username: String,
}
pub async fn convert_username_uuid(username_uuid: &str) -> LauncherResult<MowojangProfile> {
http::fetch_json_advanced::<MowojangProfile>(
Method::GET,
&format!("https://mowojang.matdoes.dev/{username_uuid}"),
None,
None,
None,
None,
)
.await
}

18
pnpm-lock.yaml generated
View file

@ -60,6 +60,9 @@ catalogs:
'@tauri-apps/plugin-dialog':
specifier: ^2.2.1
version: 2.2.1
'@tauri-apps/plugin-fs':
specifier: ^2.2.1
version: 2.4.2
'@tauri-apps/plugin-opener':
specifier: 2.3.0
version: 2.3.0
@ -277,6 +280,9 @@ importers:
'@tauri-apps/plugin-dialog':
specifier: 'catalog:'
version: 2.2.1
'@tauri-apps/plugin-fs':
specifier: 'catalog:'
version: 2.4.2
'@untitled-theme/icons-react':
specifier: 'catalog:'
version: 0.14.0(@types/react@19.1.2)(react@19.1.0)
@ -2868,6 +2874,9 @@ packages:
'@tauri-apps/api@2.5.0':
resolution: {integrity: sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA==}
'@tauri-apps/api@2.8.0':
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
'@tauri-apps/cli-darwin-arm64@2.5.0':
resolution: {integrity: sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==}
engines: {node: '>= 10'}
@ -2945,6 +2954,9 @@ packages:
'@tauri-apps/plugin-dialog@2.2.1':
resolution: {integrity: sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==}
'@tauri-apps/plugin-fs@2.4.2':
resolution: {integrity: sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig==}
'@tauri-apps/plugin-opener@2.3.0':
resolution: {integrity: sha512-yAbauwp8BCHIhhA48NN8rEf6OtfZBPCgTOCa10gmtoVCpmic5Bq+1Ba7C+NZOjogedkSiV7hAotjYnnbUVmYrw==}
@ -8828,6 +8840,8 @@ snapshots:
'@tauri-apps/api@2.5.0': {}
'@tauri-apps/api@2.8.0': {}
'@tauri-apps/cli-darwin-arm64@2.5.0':
optional: true
@ -8883,6 +8897,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-fs@2.4.2':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-opener@2.3.0':
dependencies:
'@tauri-apps/api': 2.5.0

View file

@ -46,6 +46,7 @@ catalog:
'@tauri-apps/api': ^2.5.0
'@tauri-apps/plugin-clipboard-manager': ^2.2.2
'@tauri-apps/plugin-dialog': ^2.2.1
'@tauri-apps/plugin-fs': ^2.2.1
'@tauri-apps/plugin-shell': ^2.2.1
'@tauri-apps/plugin-opener': 2.3.0
overlayscrollbars: ^2.11.2