Merge branch 'oneclient/main' of https://github.com/Polyfrost/OneLauncher into oneclient/main

This commit is contained in:
Szedann 2025-09-30 20:38:14 +02:00
commit e59fd5369d
13 changed files with 524 additions and 63 deletions

View file

@ -35,15 +35,15 @@ This project uses [`Cargo`] and [`pnpm`]. Make ensure you have them installed be
To make changes locally, follow these steps:
1. Clone the repository: `git clone https://github.com/polyfrost/onelauncher`
2. Navigate to the project directory: `cd onelauncher`
1. Clone the repository: `git clone https://github.com/Polyfrost/OneLauncher`
2. Navigate to the project directory: `cd OneLauncher`
3. Configure your system environment for OneLauncher development:
1. For Linux users, run: `./packages/scripts/setup.sh`
> The [Unix script] will check if Rust and pnpm are installed then proceed to install Clang, NASM, LLVM, libvips, [Tauri essentials] and any other required dependencies for OneLauncher to build.
2. For macOS users, run: `./packages/scripts/setup.sh`
> The [Unix script] will check if Rust, pnpm and Xcode are installed and proceed to use Homebrew to install NASM, [Tauri essentials] and install any other required dependencies for OneLauncher to build.
3. For Windows users, run in PowerShell: `.\packages\scripts\setup.ps1`
> The [Unix script] will install pnpm, LLVM, C++ build tools, NASM, Rust + Cargo, Rust tools, Edge Webview 2, [Tauri essentials] and any other required dependencies for OneLauncher to build.
3. For Windows users, run in PowerShell: `powershell -ExecutionPolicy Bypass -File .\packages\scripts\setup.ps1`
> The [Windows script] will install pnpm, LLVM, C++ build tools, NASM, Rust + Cargo, Rust tools, Edge Webview 2, [Tauri essentials] and any other required dependencies for OneLauncher to build.
4. Install dependencies: `pnpm i`
5. Prepare your cargo installation: `pnpm prep`
@ -98,11 +98,11 @@ Check out the [i18n README](apps/desktop/locales/README.md) for more information
This CONTRIBUTING.md file was inspired by the [`github/docs` CONTRIBUTING.md] file, and we extend our gratitude to the original authors.
[Tauri essentials]: https://v2.tauri.app/start/prerequisites/
[Unix script]: https://github.com/polyfrost/onelauncher/blob/main/packages/scripts/setup.sh
[Windows script]: https://github.com/polyfrost/onelauncher/blob/main/packages/scripts/setup.ps1
[Unix script]: https://github.com/Polyfrost/OneLauncher/blob/oneclient/main/packages/scripts/setup.sh
[Windows script]: https://github.com/Polyfrost/OneLauncher/blob/oneclient/main/packages/scripts/setup.ps1
[`cargo`]: https://doc.rust-lang.org/cargo/getting-started/installation.html
[`pnpm`]: https://pnpm.io/installation
[Labels]: https://github.com/polyfrost/onelauncher/labels
[Labels]: https://github.com/Polyfrost/OneLauncher/labels
[the `pnpm` CLI]: https://pnpm.io/pnpm-cli
[Collaborating with pull requests]: https://docs.github.com/en/github/collaborating-with-pull-requests
[GitHub flow]: https://docs.github.com/en/get-started/quickstart/github-flow
@ -111,8 +111,8 @@ This CONTRIBUTING.md file was inspired by the [`github/docs` CONTRIBUTING.md] fi
[Code of Conduct]: ./CODE_OF_CONDUCT.md
[README]: ./README.md
[search if a related issue has already been reported]: https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments
[issue form]: https://github.com/polyfrost/onelauncher/issues/new/choose
[existing issues]: https://github.com/polyfrost/onelauncher/issues
[issue form]: https://github.com/Polyfrost/OneLauncher/issues/new/choose
[existing issues]: https://github.com/Polyfrost/OneLauncher/issues
[`github/docs` CONTRIBUTING.md]: https://github.com/github/docs/blob/main/CONTRIBUTING.md
[link your PR to the issue]: https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
[allow maintainer edits]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork

View file

@ -106,8 +106,14 @@ export type ModpackFormat = "CurseForge" | "MrPack" | "PolyMrPack"
export type ModpackManifest = { name: string; version: string; loader: GameLoader; loader_version: string; mc_version: string; files: ModpackFile[] }
export type MojangCape = { id: string; state: string; url: string; alias: string }
export type MojangFullPlayerProfile = { uuid: 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 PackageAuthor = { Team: { team_id: string; org_id: string | null } } | { Users: ManagedUser[] }
export type PackageCategories = { Mod: PackageModCategory[] } | { ResourcePack: PackageResourcePackCategory[] } | { Shader: PackageShaderCategory[] } | { DataPack: PackageModCategory[] } | { ModPack: PackageModPackCategory[] }
@ -182,6 +188,11 @@ export type SettingProfileModel = { name: string; java_id: number | null; res: R
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean }
export type SettingsOsExtra = Record<string, never>
<<<<<<< HEAD
=======
export type SkinVariant = "classic" | "slim"
>>>>>>> c2ad51017be591eca0d3e63d222df0b5a6b7e0af
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
@ -244,8 +255,21 @@ export type VersionType =
*/
"old_beta"
<<<<<<< HEAD
const ARGS_MAP = { 'oneclient':'{"getBundlesFor":["cluster_id"],"getClustersGroupedByMajor":[],"openDevTools":[]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'core':'{"updateClusterProfile":["name","profile"],"getPackageBody":["provider","body"],"killProcess":["pid"],"getWorlds":["id"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getLoadersForVersion":["mc_version"],"createSettingsProfile":["name"],"getProfileOrDefault":["name"],"getGlobalProfile":[],"updateClusterById":["id","request"],"getUsers":[],"getRunningProcesses":[],"fetchMinecraftProfile":["uuid"],"getScreenshots":["id"],"open":["input"],"isClusterRunning":["cluster_id"],"installModpack":["modpack","cluster_id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"searchPackages":["provider","query"],"getUser":["uuid"],"getLogByName":["id","name"],"removeCluster":["id"],"setDefaultUser":["uuid"],"launchCluster":["id","uuid"],"getClusters":[],"getDefaultUser":["fallback"],"getRunningProcessesByClusterId":["cluster_id"],"getLogs":["id"],"getGameVersions":[],"openMsaLogin":[],"getPackage":["provider","slug"],"createCluster":["options"],"removeUser":["uuid"],"readSettings":[],"getUsersFromAuthor":["provider","author"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"getClusterById":["id"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}' }
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
=======
const ARGS_MAP = { 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[],"getBundlesFor":["cluster_id"]}', 'core':'{"getGameVersions":[],"getUsers":[],"getWorlds":["id"],"getGlobalProfile":[],"removeCluster":["id"],"updateClusterById":["id","request"],"getScreenshots":["id"],"getLoadersForVersion":["mc_version"],"getPackageBody":["provider","body"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"getProfileOrDefault":["name"],"changeSkin":["access_token","skin_url","skin_variant"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getRunningProcessesByClusterId":["cluster_id"],"searchPackages":["provider","query"],"openMsaLogin":[],"updateClusterProfile":["name","profile"],"getClusterById":["id"],"createCluster":["options"],"removeUser":["uuid"],"getRunningProcesses":[],"getClusters":[],"getLogByName":["id","name"],"getUser":["uuid"],"writeSettings":["setting"],"fetchLoggedInProfile":["access_token"],"killProcess":["pid"],"getMultiplePackages":["provider","slugs"],"getLogs":["id"],"launchCluster":["id","uuid"],"getDefaultUser":["fallback"],"readSettings":[],"getPackage":["provider","slug"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"open":["input"],"setDefaultUser":["uuid"],"isClusterRunning":["cluster_id"]}' }
export type Router = { '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> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]> },
'core': { getClusters: () => Promise<ClusterModel[]>,
>>>>>>> c2ad51017be591eca0d3e63d222df0b5a6b7e0af
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
@ -282,6 +306,7 @@ downloadPackage: (provider: Provider, packageId: string, versionId: string, clus
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]>,
installModpack: (modpack: ModpackArchive, clusterId: number) => Promise<null>,
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile>,
<<<<<<< HEAD
open: (input: string) => Promise<null> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
@ -291,6 +316,12 @@ getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
=======
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>,
open: (input: string) => Promise<null> } };
>>>>>>> c2ad51017be591eca0d3e63d222df0b5a6b7e0af
export type { InferCommandOutput }

View file

@ -3,20 +3,34 @@ 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 } from '@untitled-theme/icons-react';
import { PlusIcon, Settings01Icon, Trash01Icon } from '@untitled-theme/icons-react';
import { DialogTrigger } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { AccountAvatar } from '../AccountAvatar';
import { AddAccountModal } from './AddAccountModal';
import { Overlay } from './Overlay';
import { Popup } from './Popup';
import { RemoveAccountModal } from './RemoveAccountModal';
export function AccountPopup() {
const users = useCommand(['getUsers'], bindings.core.getUsers);
const defaultUser = useCommand(['getDefaultUser'], () => bindings.core.getDefaultUser(false));
const setDefaultUser = (user: MinecraftCredentials) => {
bindings.core.setDefaultUser(user.id);
const setDefaultUser = async (user: MinecraftCredentials) => {
await bindings.core.setDefaultUser(user.id);
users.refetch();
defaultUser.refetch();
};
const deleteUser = async (user: MinecraftCredentials) => {
await bindings.core.removeUser(user.id);
users.refetch();
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]);
}
};
return (
@ -28,6 +42,7 @@ export function AccountPopup() {
<AccountEntry
loggedIn
onClick={() => { }}
onDelete={() => deleteUser(defaultUser.data)}
user={defaultUser.data}
/>
</div>
@ -37,6 +52,7 @@ export function AccountPopup() {
<div key={user.id}>
<AccountEntry
onClick={() => setDefaultUser(user)}
onDelete={() => deleteUser(user)}
user={user}
/>
</div>
@ -73,10 +89,12 @@ export default AccountPopup;
function AccountEntry({
onClick,
onDelete,
user,
loggedIn = false,
}: {
onClick: () => void;
onDelete: () => void;
user: MinecraftCredentials;
loggedIn?: boolean;
}) {
@ -86,13 +104,25 @@ function AccountEntry({
onClick={onClick}
>
<div className="flex flex-1 flex-row justify-start gap-x-3">
<AccountAvatar className="h-8 w-8 rounded-md" uuid={user.id} />
<div className="flex flex-col items-center justify-center">
<div className="flex flex-col items-start justify-between">
<p className="h-[18px] font-semibold">{user.username}</p>
{loggedIn && <p className="text-xs">Logged in</p>}
<div className="flex flex-1 flex-row justify-start gap-x-3">
<AccountAvatar className="h-8 w-8 rounded-md" uuid={user.id} />
<div className="flex flex-col items-center justify-center">
<div className="flex flex-col items-start justify-between">
<p className="h-[18px] font-semibold">{user.username}</p>
{loggedIn && <p className="text-xs">Logged in</p>}
</div>
</div>
</div>
<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>
);

View file

@ -0,0 +1,49 @@
import type { MinecraftCredentials } from '@/bindings.gen';
import { AccountAvatar } from '@/components/AccountAvatar';
import { Button } from '@onelauncher/common/components';
import { Overlay } from './Overlay';
export function RemoveAccountModal({
profile,
onPress,
}: {
profile: MinecraftCredentials;
onPress: () => void;
}) {
return (
<Overlay.Dialog>
<Overlay.Title>Are you sure?</Overlay.Title>
<p>Do you want to remove <span className='font-bold' color='primary'>{profile.username}</span> from you're accounts?</p>
<p>This cannot be undone</p>
<AccountRow profile={profile} />
<Button
className="w-full"
color="danger"
onPress={onPress}
size="large"
slot="close"
>
Remove
</Button>
</Overlay.Dialog>
);
}
function AccountRow({
profile,
}: {
profile: MinecraftCredentials;
}) {
return (
<div className="flex flex-row items-center justify-start gap-2">
<AccountAvatar className="aspect-square h-12 rounded-sm " uuid={profile.id} />
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-semibold">{profile.username}</p>
<p className="text-fg-secondary text-sm">{profile.id}</p>
</div>
</div>
);
}

View file

@ -2,4 +2,5 @@ export * from './AccountPopup';
export * from './AddAccountModal';
export * from './Overlay';
export * from './Popup';
export * from './RemoveAccountModal';
export * from './Toasts';

View file

@ -1,7 +1,7 @@
import type { MinecraftCredentials } from '@/bindings.gen';
import type { ButtonProps } from '@onelauncher/common/components';
import { AccountAvatar, SheetPage, SkinViewer } from '@/components';
import { AddAccountModal } from '@/components/overlay';
import { AddAccountModal, RemoveAccountModal } from '@/components/overlay';
import { Overlay } from '@/components/overlay/Overlay';
import { usePlayerProfile } from '@/hooks/usePlayerProfile';
import { bindings } from '@/main';
@ -9,6 +9,7 @@ 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 { Button as AriaButton, DialogTrigger } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
@ -85,12 +86,23 @@ function AccountList() {
});
},
});
const { mutate: removeUser } = useCommandMut(bindings.core.removeUser, {
onSuccess() {
queryClient.invalidateQueries({
queryKey: ['getDefaultUser'],
});
queryClient.invalidateQueries({
queryKey: ['getUsers'],
});
},
});
return (
<div className="flex-1 flex flex-col gap-2">
{accounts.map(account => (
<AccountRow
key={account.id}
onDelete={() => removeUser(account.id)}
onPress={() => setDefaultUser(account.id)}
profile={account}
selected={currentAccount?.id === account.id}
@ -108,10 +120,12 @@ function AccountRow({
selected = false,
profile,
onPress,
onDelete,
}: {
selected?: boolean;
profile: MinecraftCredentials;
onPress: () => void;
onDelete: () => void;
}) {
const { isError } = usePlayerProfile(profile.id);
@ -125,19 +139,31 @@ function AccountRow({
)}
onPress={onPress}
>
<AccountAvatar className="aspect-square h-12 rounded-sm " uuid={profile.id} />
<div className="flex flex-row justify-between w-full">
<div className="flex flex-row gap-2">
<AccountAvatar className="aspect-square h-12 rounded-sm " uuid={profile.id} />
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-semibold">
{profile.username}
{isError && <span className="text-danger text-xs font-medium"> (Error fetching online profile)</span>}
</p>
<p className="text-fg-secondary text-sm">{profile.id}</p>
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-semibold">
{profile.username}
{isError && <span className="text-danger text-xs font-medium"> (Error fetching online profile)</span>}
</p>
<p className="text-fg-secondary text-sm">{profile.id}</p>
</div>
</div>
<div className="flex flex-row items-center gap-2">
<DialogTrigger>
<Button className="group w-8 h-8" color="ghost" size="icon">
<Trash01Icon className="group-hover:stroke-danger" />
</Button>
<Overlay>
<RemoveAccountModal onPress={onDelete} profile={profile} />
</Overlay>
</DialogTrigger>
</div>
</div>
{/* <Button className="w-8 h-8" color="ghost" size="icon">
<Trash01Icon />
</Button> */}
</AriaButton>
);
}

View file

@ -0,0 +1,30 @@
import SettingsRow from '@/components/SettingsRow';
import { Button } from '@onelauncher/common/components';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Truck01Icon } from '@untitled-theme/icons-react';
import Sidebar from './route';
export const Route = createFileRoute('/app/settings/developer')({
component: RouteComponent,
})
function RouteComponent() {
return (
<Sidebar.Page>
<div className="h-full">
<h1>General</h1>
<SettingsRow.Header>Folders and Files</SettingsRow.Header>
<SettingsRow
description="Open onboarding"
icon={<Truck01Icon />}
title="Onboarding"
>
<Link to="/onboarding">
<Button size="normal">Open</Button>
</Link>
</SettingsRow>
</div>
</Sidebar.Page>
);
}

View file

@ -1,9 +1,99 @@
import { createFileRoute } from '@tanstack/react-router'
import type { MinecraftCredentials } from '@/bindings.gen';
import { AccountAvatar, SkinViewer } from '@/components';
import { usePlayerProfile } from '@/hooks/usePlayerProfile';
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';
export const Route = createFileRoute('/onboarding/account')({
component: RouteComponent,
})
component: RouteComponent,
});
function Viewer({
credentials,
}: {
credentials: MinecraftCredentials;
}) {
const { data: profile } = usePlayerProfile(credentials.id);
return (
<SkinViewer
capeUrl={profile?.cape_url}
className="h-full w-full max-w-1/4"
height={400}
skinUrl={profile?.skin_url}
width={250}
/>
);
}
function AccountPreview({
profile,
}: {
profile: MinecraftCredentials;
}) {
return (
<div className="flex flex-row gap-2">
<div>
<Viewer credentials={profile} />
</div>
<div className="flex flex-row justify-start gap-2">
<AccountAvatar className="aspect-square h-12 rounded-sm " uuid={profile.id} />
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-semibold">{profile.username}</p>
<p className="text-fg-secondary text-sm">{profile.id}</p>
</div>
</div>
</div>
);
}
function RouteComponent() {
return <div>Hello "/onboarding/account"!</div>
const queryClient = useQueryClient();
const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const { data: profile, isPending, mutate: login } = useCommandMut(bindings.core.openMsaLogin, {
onSuccess() {
queryClient.invalidateQueries({
queryKey: ['getDefaultUser'],
});
},
});
const onClick = () => {
login();
};
return (
<div className="flex flex-col h-full px-12 gap-4">
<div>
<h1 className="text-4xl font-semibold mb-2">Account</h1>
<p className="text-slate-400 text-lg mb-2">Before you continue, we require you to own a copy of Minecraft: Java Edition.</p>
</div>
{currentAccount ?
<>
<AccountPreview profile={currentAccount} />
</>
:
<>
{profile
? (
<>
<AccountPreview profile={profile} />
</>
)
: (
<Button
color="primary"
isPending={isPending}
onClick={onClick}
size="large"
>
Add Account
</Button>
)}
</>
}
</div>
);
}

View file

@ -1,9 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router';
export const Route = createFileRoute('/onboarding/finished')({
component: RouteComponent,
})
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello "/onboarding/finished"!</div>
return (
<Link to="/app">
<div className="flex flex-col h-full px-12">
<div>
<h1 className="text-4xl font-semibold mb-2">Finished!</h1>
<p className="text-slate-400 text-lg mb-2">Thank you for using OneClient</p>
</div>
</div>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex flex-row gap-2">
<p className="text-slate-500 text-lg mb-2">Click anywhere to exit</p>
</div>
</Link>
);
}

View file

@ -4,7 +4,9 @@ import LauncherLogo from '@/assets/logos/oneclient.svg?react';
import { LoaderSuspense, NavbarButton } from '@/components';
import { GameBackground } from '@/components/GameBackground';
import { Stepper } from '@/components/Stepper';
import { bindings } from '@/main';
import useAppShellStore from '@/stores/appShellStore';
import { useCommandSuspense } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
import { Window } from '@tauri-apps/api/window';
@ -175,23 +177,23 @@ function BackgroundGradient() {
}
export function OnboardingNavigation() {
const { isFirstStep, isLastStep, previousPath, nextPath } = Route.useLoaderData();
const { isFirstStep, previousPath, nextPath, currentStepIndex } = Route.useLoaderData();
const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const forceLoginDisable = currentStepIndex === 2 && currentAccount === null;
return (
<div className="absolute bottom-2 right-2 flex flex-row gap-2">
<div>
{!isFirstStep && previousPath && (
<Link to={previousPath}>
<Button className="w-32" color="ghost">Back</Button>
<Button className="w-32" color="secondary">Back</Button>
</Link>
)}
</div>
<div>
{nextPath && (
<Link to={nextPath}>
<Button className="w-32">{isLastStep ? 'Finish' : 'Next'}</Button>
</Link>
)}
<Link disabled={forceLoginDisable} to={nextPath ?? "/app"}>
<Button className={`w-32 ${forceLoginDisable ? "line-through" : ""}`} color={forceLoginDisable ? 'secondary' : 'primary'}>Next</Button>
</Link>
</div>
</div>
);

View file

@ -197,11 +197,33 @@ pub trait TauriLauncherApi {
// endregion: modpack
// MARK: API: minecraft
// region: minecraft
#[taurpc(alias = "fetchMinecraftProfile")]
async fn fetch_player_profile(
uuid: String,
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile>;
#[taurpc(alias = "fetchLoggedInProfile")]
async fn fetch_logged_in_profile(
access_token: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile>;
#[taurpc(alias = "uploadSkinBytes")]
async fn upload_skin_bytes(
access_token: String,
skin_data: Vec<u8>,
image_format: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin>;
#[taurpc(alias = "changeSkin")]
async fn change_skin(
access_token: String,
skin_url: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin>;
// endregion: minecraft
// MARK: API: Other
async fn open(input: String) -> LauncherResult<()>;
}
@ -690,6 +712,38 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile> {
crate::utils::minecraft::fetch_player_profile(&uuid).await
}
async fn fetch_logged_in_profile(
self,
access_token: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile> {
crate::utils::minecraft::fetch_logged_in_profile(&access_token).await
}
async fn upload_skin_bytes(
self,
access_token: String,
skin_data: Vec<u8>,
image_format: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin> {
crate::utils::minecraft::upload_skin_bytes(
&access_token,
skin_data,
&image_format,
skin_variant,
)
.await
}
async fn change_skin(
self,
access_token: String,
skin_url: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin> {
crate::utils::minecraft::change_skin(&access_token, &skin_url, skin_variant).await
}
// endregion: minecraft
// MARK: Impl: Other

View file

@ -5,7 +5,7 @@ use std::path::Path;
use std::sync::{Arc, LazyLock};
use bytes::Bytes;
use reqwest::Method;
use reqwest::{Method, RequestBuilder};
use serde::de::DeserializeOwned;
use tokio::sync::Semaphore;
use tokio_stream::StreamExt;
@ -50,34 +50,32 @@ pub(crate) static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
});
pub async fn request(method: Method, url: &str) -> LauncherResult<reqwest::Response> {
request_builder(
method,
url,
None::<fn(reqwest::RequestBuilder) -> reqwest::Result<_>>,
)
.await
let req = build_request(method, url).build()?;
send_request(req).await
}
pub async fn request_builder(
method: Method,
url: &str,
builder: Option<impl Fn(reqwest::RequestBuilder) -> reqwest::Result<reqwest::Request>>,
) -> LauncherResult<reqwest::Response> {
pub fn build_request(method: Method, url: &str) -> RequestBuilder {
let client = &REQWEST_CLIENT;
client.request(method.clone(), url)
}
pub async fn send_request(request: reqwest::Request) -> LauncherResult<reqwest::Response> {
let semaphore = SemaphoreStore::fetch().await;
let permit = semaphore.acquire().await;
let client = &REQWEST_CLIENT;
let mut attempt = 0;
let res = loop {
attempt += 1;
let req = client.request(method.clone(), url);
let req = match builder {
Some(ref builder) => builder(req),
None => req.build(),
}
.map_err(LauncherError::from)?;
let req = request.try_clone().ok_or_else(|| {
LauncherError::from(anyhow::anyhow!(
"failed to clone request for retry attempt {attempt}"
))
})?;
match client.execute(req).await {
Err(err) => {

View file

@ -1,5 +1,8 @@
use std::collections::HashMap;
use base64::Engine;
use reqwest::Method;
use reqwest::{Method, multipart};
use serde_json::json;
use crate::error::LauncherResult;
use crate::utils::http;
@ -14,6 +17,50 @@ pub struct MojangPlayerProfile {
pub cape_url: Option<String>,
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum SkinVariant {
Classic,
Slim,
}
impl std::fmt::Display for SkinVariant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkinVariant::Classic => write!(f, "classic"),
SkinVariant::Slim => write!(f, "slim"),
}
}
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MojangFullPlayerProfile {
pub uuid: String,
pub username: String,
pub skins: Vec<MojangSkin>,
pub capes: Vec<MojangCape>,
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MojangSkin {
pub id: String,
pub state: String,
pub url: String,
pub variant: SkinVariant,
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MojangCape {
pub id: String,
pub state: String,
pub url: String,
pub alias: String,
}
pub async fn fetch_player_profile(uuid: &str) -> LauncherResult<MojangPlayerProfile> {
#[derive(serde::Deserialize)]
struct Properties {
@ -98,3 +145,93 @@ pub async fn fetch_player_profile(uuid: &str) -> LauncherResult<MojangPlayerProf
cape_url: decoded.cape.as_ref().map(|c| c.url.clone()),
})
}
pub async fn fetch_logged_in_profile(
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::GET,
"https://api.minecraftservices.com/minecraft/profile",
None,
Some(headers),
None,
None,
)
.await
}
pub async fn upload_skin_bytes(
access_token: &str,
skin_data: Vec<u8>,
image_format: &str,
skin_variant: SkinVariant,
) -> LauncherResult<MojangSkin> {
let bytes_part =
multipart::Part::bytes(skin_data).mime_str(&format!("image/{}", image_format))?;
let form = multipart::Form::new()
.text("variant", skin_variant.to_string())
.part("data", bytes_part);
let req = http::build_request(
Method::POST,
"https://api.minecraftservices.com/minecraft/profile/skins",
)
.header("Authorization", &format!("Bearer {access_token}"))
.header("Content-Type", "multipart/form-data")
.multipart(form)
.build()?;
let res = http::send_request(req).await?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(
anyhow::anyhow!("failed to upload skin: HTTP {}: {}", status.as_u16(), text).into(),
);
}
let full_profile = res.json::<MojangFullPlayerProfile>().await?;
dbg!(&full_profile);
full_profile
.skins
.first()
.cloned()
.ok_or_else(|| anyhow::anyhow!("no skins found in response").into())
}
pub async fn change_skin(
access_token: &str,
skin_url: &str,
skin_variant: SkinVariant,
) -> LauncherResult<MojangSkin> {
let mut headers: HashMap<&str, &str> = HashMap::with_capacity(1);
let bearer = &format!("Bearer {access_token}");
headers.insert("Authorization", bearer);
let profile = http::fetch_json_advanced::<MojangFullPlayerProfile>(
Method::POST,
"https://api.minecraftservices.com/minecraft/profile/skins",
Some(json!({
"url": skin_url,
"variant": skin_variant.to_string(),
})),
Some(headers),
None,
None,
)
.await?;
profile
.skins
.first()
.cloned()
.ok_or_else(|| anyhow::anyhow!("no skins found in response").into())
}