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:
parent
4b5051b9d9
commit
0e12968b9c
18 changed files with 804 additions and 34 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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> } };
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
483
apps/oneclient/frontend/src/routes/app/accountSkin.tsx
Normal file
483
apps/oneclient/frontend/src/routes/app/accountSkin.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
18
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue