Merge branch 'oneclient/main' of https://github.com/Polyfrost/OneLauncher into oneclient/main
This commit is contained in:
commit
e59fd5369d
13 changed files with 524 additions and 63 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,4 +2,5 @@ export * from './AccountPopup';
|
|||
export * from './AddAccountModal';
|
||||
export * from './Overlay';
|
||||
export * from './Popup';
|
||||
export * from './RemoveAccountModal';
|
||||
export * from './Toasts';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue