diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2d4c777..e7b1e723 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 6c83ffbd..e1bfee9a 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -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 +<<<<<<< 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, +======= +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, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, +'oneclient': { openDevTools: () => Promise, +getClustersGroupedByMajor: () => Promise>, +getBundlesFor: (clusterId: number) => Promise }, +'core': { getClusters: () => Promise, +>>>>>>> c2ad51017be591eca0d3e63d222df0b5a6b7e0af getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -282,6 +306,7 @@ downloadPackage: (provider: Provider, packageId: string, versionId: string, clus getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise, installModpack: (modpack: ModpackArchive, clusterId: number) => Promise, fetchMinecraftProfile: (uuid: string) => Promise, +<<<<<<< HEAD open: (input: string) => Promise }, 'folders': { fromCluster: (folderName: string) => Promise, openCluster: (folderName: string) => Promise }, @@ -291,6 +316,12 @@ getBundlesFor: (clusterId: number) => Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, process: (event: ProcessPayload) => Promise } }; +======= +fetchLoggedInProfile: (accessToken: string) => Promise, +uploadSkinBytes: (accessToken: string, skinData: number[], imageFormat: string, skinVariant: SkinVariant) => Promise, +changeSkin: (accessToken: string, skinUrl: string, skinVariant: SkinVariant) => Promise, +open: (input: string) => Promise } }; +>>>>>>> c2ad51017be591eca0d3e63d222df0b5a6b7e0af export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx b/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx index d3cf6579..c7a9cd9e 100644 --- a/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx +++ b/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx @@ -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() { { }} + onDelete={() => deleteUser(defaultUser.data)} user={defaultUser.data} /> @@ -37,6 +52,7 @@ export function AccountPopup() {
setDefaultUser(user)} + onDelete={() => deleteUser(user)} user={user} />
@@ -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} >
- -
-
-

{user.username}

- {loggedIn &&

Logged in

} +
+ +
+
+

{user.username}

+ {loggedIn &&

Logged in

} +
+ + + + + + + +
); diff --git a/apps/oneclient/frontend/src/components/overlay/RemoveAccountModal.tsx b/apps/oneclient/frontend/src/components/overlay/RemoveAccountModal.tsx new file mode 100644 index 00000000..a9439b1b --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/RemoveAccountModal.tsx @@ -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 ( + + Are you sure? + +

Do you want to remove {profile.username} from you're accounts?

+

This cannot be undone

+ + + +
+ ); +} + +function AccountRow({ + profile, +}: { + profile: MinecraftCredentials; +}) { + return ( +
+ + +
+

{profile.username}

+

{profile.id}

+
+
+ ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/index.ts b/apps/oneclient/frontend/src/components/overlay/index.ts index c0ccba68..913737fa 100644 --- a/apps/oneclient/frontend/src/components/overlay/index.ts +++ b/apps/oneclient/frontend/src/components/overlay/index.ts @@ -2,4 +2,5 @@ export * from './AccountPopup'; export * from './AddAccountModal'; export * from './Overlay'; export * from './Popup'; +export * from './RemoveAccountModal'; export * from './Toasts'; diff --git a/apps/oneclient/frontend/src/routes/app/accounts.tsx b/apps/oneclient/frontend/src/routes/app/accounts.tsx index 9bcad5de..b61503ab 100644 --- a/apps/oneclient/frontend/src/routes/app/accounts.tsx +++ b/apps/oneclient/frontend/src/routes/app/accounts.tsx @@ -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 (
{accounts.map(account => ( 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} > - +
+
+ -
-

- {profile.username} - {isError && (Error fetching online profile)} -

-

{profile.id}

+
+

+ {profile.username} + {isError && (Error fetching online profile)} +

+

{profile.id}

+
+
+ +
+ + + + + + + +
- - {/* */} ); } diff --git a/apps/oneclient/frontend/src/routes/app/settings/developer.tsx b/apps/oneclient/frontend/src/routes/app/settings/developer.tsx new file mode 100644 index 00000000..66cb6ef1 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/settings/developer.tsx @@ -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 ( + +
+

General

+ + Folders and Files + } + title="Onboarding" + > + + + + +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/onboarding/account.tsx b/apps/oneclient/frontend/src/routes/onboarding/account.tsx index 60a283e7..e6f8d433 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/account.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/account.tsx @@ -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 ( + + ); +} + +function AccountPreview({ + profile, +}: { + profile: MinecraftCredentials; +}) { + return ( +
+
+ +
+
+ +
+

{profile.username}

+

{profile.id}

+
+
+
+ ); +} function RouteComponent() { - return
Hello "/onboarding/account"!
+ 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 ( +
+
+

Account

+

Before you continue, we require you to own a copy of Minecraft: Java Edition.

+
+ {currentAccount ? + <> + + + : + <> + {profile + ? ( + <> + + + ) + : ( + + )} + + } +
+ ); } diff --git a/apps/oneclient/frontend/src/routes/onboarding/finished.tsx b/apps/oneclient/frontend/src/routes/onboarding/finished.tsx index 63221c15..d9a7b77b 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/finished.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/finished.tsx @@ -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
Hello "/onboarding/finished"!
+ return ( + +
+
+

Finished!

+

Thank you for using OneClient

+
+
+ +
+

Click anywhere to exit

+
+ + ); } diff --git a/apps/oneclient/frontend/src/routes/onboarding/route.tsx b/apps/oneclient/frontend/src/routes/onboarding/route.tsx index 8a362461..5155cbb4 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/route.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/route.tsx @@ -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 (
{!isFirstStep && previousPath && ( - + )}
- {nextPath && ( - - - - )} + + +
); diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 84cb5dd1..d7806bda 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -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; + #[taurpc(alias = "fetchLoggedInProfile")] + async fn fetch_logged_in_profile( + access_token: String, + ) -> LauncherResult; + + #[taurpc(alias = "uploadSkinBytes")] + async fn upload_skin_bytes( + access_token: String, + skin_data: Vec, + image_format: String, + skin_variant: crate::utils::minecraft::SkinVariant, + ) -> LauncherResult; + + #[taurpc(alias = "changeSkin")] + async fn change_skin( + access_token: String, + skin_url: String, + skin_variant: crate::utils::minecraft::SkinVariant, + ) -> LauncherResult; + // endregion: minecraft + // MARK: API: Other async fn open(input: String) -> LauncherResult<()>; } @@ -690,6 +712,38 @@ impl TauriLauncherApi for TauriLauncherApiImpl { ) -> LauncherResult { crate::utils::minecraft::fetch_player_profile(&uuid).await } + + async fn fetch_logged_in_profile( + self, + access_token: String, + ) -> LauncherResult { + crate::utils::minecraft::fetch_logged_in_profile(&access_token).await + } + + async fn upload_skin_bytes( + self, + access_token: String, + skin_data: Vec, + image_format: String, + skin_variant: crate::utils::minecraft::SkinVariant, + ) -> LauncherResult { + 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::change_skin(&access_token, &skin_url, skin_variant).await + } // endregion: minecraft // MARK: Impl: Other diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index aaf7d58d..d37612a6 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -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 = LazyLock::new(|| { }); pub async fn request(method: Method, url: &str) -> LauncherResult { - request_builder( - method, - url, - None:: 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 reqwest::Result>, -) -> LauncherResult { +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 { 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) => { diff --git a/packages/core/src/utils/minecraft.rs b/packages/core/src/utils/minecraft.rs index a275141a..d106fc60 100644 --- a/packages/core/src/utils/minecraft.rs +++ b/packages/core/src/utils/minecraft.rs @@ -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, } +#[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, + pub capes: Vec, +} + +#[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 { #[derive(serde::Deserialize)] struct Properties { @@ -98,3 +145,93 @@ pub async fn fetch_player_profile(uuid: &str) -> LauncherResult LauncherResult { + let mut headers: HashMap<&str, &str> = HashMap::with_capacity(1); + + let bearer = &format!("Bearer {access_token}"); + headers.insert("Authorization", bearer); + + http::fetch_json_advanced::( + 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, + image_format: &str, + skin_variant: SkinVariant, +) -> LauncherResult { + 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::().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 { + 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::( + 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()) +}