Merge branch 'oneclient/main' of https://github.com/Polyfrost/OneLauncher into refactor/frontend
This commit is contained in:
commit
b7cac50020
46 changed files with 494 additions and 347 deletions
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -4,8 +4,7 @@
|
||||||
},
|
},
|
||||||
"files.readonlyInclude": {
|
"files.readonlyInclude": {
|
||||||
"**/node_modules/**": true,
|
"**/node_modules/**": true,
|
||||||
"**/routeTree.gen.ts": true,
|
"**/*.gen.ts": true
|
||||||
"**/bindings.gen.ts": true
|
|
||||||
},
|
},
|
||||||
"files.watcherExclude": {
|
"files.watcherExclude": {
|
||||||
"**/_build": true,
|
"**/_build": true,
|
||||||
|
|
@ -21,7 +20,7 @@
|
||||||
"**/.rustup": true,
|
"**/.rustup": true,
|
||||||
"**/.cargo": true,
|
"**/.cargo": true,
|
||||||
"**/.crates": true,
|
"**/.crates": true,
|
||||||
"**/routeTree.gen.ts": true
|
"**/*.gen.ts": true
|
||||||
},
|
},
|
||||||
"rust-analyzer.procMacro.enable": true,
|
"rust-analyzer.procMacro.enable": true,
|
||||||
"rust-analyzer.procMacro.attributes.enable": true,
|
"rust-analyzer.procMacro.attributes.enable": true,
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3388,6 +3388,7 @@ version = "0.2.0-alpha.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ sea-orm-migration = { version = "1.1", features = [
|
||||||
# utils
|
# utils
|
||||||
anyhow = { version = "1" }
|
anyhow = { version = "1" }
|
||||||
async-trait = { version = "=0.1.88" }
|
async-trait = { version = "=0.1.88" }
|
||||||
|
async-stream = { version = "=0.3.6" }
|
||||||
cc = { version = "1" }
|
cc = { version = "1" }
|
||||||
discord-rich-presence = { version = "=0.2.5" }
|
discord-rich-presence = { version = "=0.2.5" }
|
||||||
futures = { version = "0.3" }
|
futures = { version = "0.3" }
|
||||||
|
|
|
||||||
2
apps/oneclient/frontend/.gitignore
vendored
2
apps/oneclient/frontend/.gitignore
vendored
|
|
@ -3,3 +3,5 @@ node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
*.gen.ts
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,6 @@ export type JavaError = { ParseVersion: [string] } | 'Execute' | 'MissingJava';
|
||||||
|
|
||||||
export type LauncherError = { type: 'DirError'; data: DirectoryError } | { type: 'IOError'; data: IOError } | { type: 'IngressError'; data: IngressError } | { type: 'JavaError'; data: JavaError } | { type: 'CryptoError'; data: CryptoError } | { type: 'DiscordError'; data: DiscordError } | { type: 'MetadataError'; data: MetadataError } | { type: 'ClusterError'; data: ClusterError } | { type: 'MinecraftAuthError'; data: MinecraftAuthError } | { type: 'ProcessError'; data: ProcessError } | { type: 'PackageError'; data: PackageError } | { type: 'DaoError'; data: DaoError } | { type: 'SerdeError' } | { type: 'AnyhowError' } | { type: 'DbError' } | { type: 'ReqwestError' } | { type: 'InterpulseError' } | { type: 'RegexError' } | { type: 'SemaphoreError' } | { type: 'UrlError' } | { type: 'TauriError' };
|
export type LauncherError = { type: 'DirError'; data: DirectoryError } | { type: 'IOError'; data: IOError } | { type: 'IngressError'; data: IngressError } | { type: 'JavaError'; data: JavaError } | { type: 'CryptoError'; data: CryptoError } | { type: 'DiscordError'; data: DiscordError } | { type: 'MetadataError'; data: MetadataError } | { type: 'ClusterError'; data: ClusterError } | { type: 'MinecraftAuthError'; data: MinecraftAuthError } | { type: 'ProcessError'; data: ProcessError } | { type: 'PackageError'; data: PackageError } | { type: 'DaoError'; data: DaoError } | { type: 'SerdeError' } | { type: 'AnyhowError' } | { type: 'DbError' } | { type: 'ReqwestError' } | { type: 'InterpulseError' } | { type: 'RegexError' } | { type: 'SemaphoreError' } | { type: 'UrlError' } | { type: 'TauriError' };
|
||||||
|
|
||||||
export type LauncherEvent = { Ingress: IngressPayload } | { Message: MessagePayload } | { Process: ProcessPayload };
|
|
||||||
|
|
||||||
export type MessageLevel = 'Info' | 'Warn' | 'Error';
|
export type MessageLevel = 'Info' | 'Warn' | 'Error';
|
||||||
|
|
||||||
export interface MessagePayload { level: MessageLevel; message: string }
|
export interface MessagePayload { level: MessageLevel; message: string }
|
||||||
|
|
@ -95,7 +93,7 @@ export interface Process { pid: number; started_at: string; cluster_id: bigint;
|
||||||
|
|
||||||
export type ProcessError = { HookUnsuccessful: number } | 'NoPid';
|
export type ProcessError = { HookUnsuccessful: number } | 'NoPid';
|
||||||
|
|
||||||
export type ProcessPayload = { Starting: { command: string } } | { Started: { process: Process } } | { Stopped: { pid: number; exit_code: number } } | { Output: { pid: number; output: string } };
|
export type ProcessPayload = { type: 'Starting'; command: string } | { type: 'Started'; process: Process } | { type: 'Stopped'; pid: number; exit_code: number } | { type: 'Output'; pid: number; output: string };
|
||||||
|
|
||||||
export interface Resolution { width: number; height: number }
|
export interface Resolution { width: number; height: number }
|
||||||
|
|
||||||
|
|
@ -103,11 +101,11 @@ export interface SettingProfileModel { name: string; java_id: bigint | null; res
|
||||||
|
|
||||||
export interface SettingsOsExtra { enable_gamemode: boolean | null }
|
export interface SettingsOsExtra { enable_gamemode: boolean | null }
|
||||||
|
|
||||||
const ARGS_MAP = { 'core': '{"get_cluster":["id"],"remove_cluster":["id"],"get_profile_or_default":["name"],"get_clusters":[],"remove_user":["uuid"],"get_global_profile":[],"get_users":[],"get_default_user":["fallback"],"create_cluster":["options"],"open_msa_login":[],"get_user":["uuid"],"set_default_user":["uuid"]}', 'oneclient': '{"open_dev_tools":[],"return_error":[]}', '': '{"send_event":["event"]}' };
|
const ARGS_MAP = { oneclient: '{"open_dev_tools":[],"return_error":[]}', core: '{"getClusters":[],"createCluster":["options"],"getGlobalProfile":[],"getClusterById":["id"],"openMsaLogin":[],"getUsers":[],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"getUser":["uuid"],"getProfileOrDefault":["name"],"removeUser":["uuid"],"removeCluster":["id"],"launchCluster":["id","uuid"]}', events: '{"message":["event"],"process":["event"],"ingress":["event"]}' };
|
||||||
export interface Router {
|
export interface Router {
|
||||||
'core': { get_clusters: () => Promise<Array<Model>>; get_cluster: (id: bigint) => Promise<Model | null>; remove_cluster: (id: bigint) => Promise<null>; create_cluster: (options: CreateCluster) => Promise<Model>; get_profile_or_default: (name: string | null) => Promise<SettingProfileModel>; get_global_profile: () => Promise<SettingProfileModel>; get_users: () => Promise<Array<MinecraftCredentials>>; get_user: (uuid: string) => Promise<MinecraftCredentials | null>; remove_user: (uuid: string) => Promise<null>; get_default_user: (fallback: boolean | null) => Promise<MinecraftCredentials | null>; set_default_user: (uuid: string | null) => Promise<null>; open_msa_login: () => Promise<MinecraftCredentials | null> };
|
events: { ingress: (event: IngressPayload) => Promise<void>; message: (event: MessagePayload) => Promise<void>; process: (event: ProcessPayload) => Promise<void> };
|
||||||
'oneclient': { return_error: () => Promise<null>; open_dev_tools: () => Promise<void> };
|
core: { getClusters: () => Promise<Array<Model>>; getClusterById: (id: bigint) => Promise<Model | null>; removeCluster: (id: bigint) => Promise<null>; createCluster: (options: CreateCluster) => Promise<Model>; launchCluster: (id: bigint, uuid: string | null) => Promise<null>; getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>; getGlobalProfile: () => Promise<SettingProfileModel>; getUsers: () => Promise<Array<MinecraftCredentials>>; getUser: (uuid: string) => Promise<MinecraftCredentials | null>; removeUser: (uuid: string) => Promise<null>; getDefaultUser: (fallback: boolean | null) => Promise<MinecraftCredentials | null>; setDefaultUser: (uuid: string | null) => Promise<null>; openMsaLogin: () => Promise<MinecraftCredentials | null> };
|
||||||
'': { send_event: (event: LauncherEvent) => Promise<void> };
|
oneclient: { return_error: () => Promise<null>; open_dev_tools: () => Promise<void> };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { InferCommandOutput };
|
export type { InferCommandOutput };
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,36 @@
|
||||||
|
import { bindings } from '@/main';
|
||||||
|
import { Button } from '@onelauncher/common/components';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Button } from "@onelauncher/common/components";
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: App,
|
component: App,
|
||||||
});
|
});
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const onClick = async () => {
|
||||||
|
// const result = await bindings.core.createCluster({
|
||||||
|
// name: 'Test 1.8.9 Forge',
|
||||||
|
// mc_loader: 'forge',
|
||||||
|
// mc_version: '1.8.9',
|
||||||
|
// icon: null,
|
||||||
|
// mc_loader_version: '11.15.1.2318',
|
||||||
|
// });
|
||||||
|
|
||||||
|
await bindings.core.launchCluster(2 as unknown as bigint, null);
|
||||||
|
|
||||||
|
const unlisten = await bindings.events.process.on((e) => {
|
||||||
|
if (e.type === 'Output') { console.log(e.output); }
|
||||||
|
else if (e.type === 'Stopped') {
|
||||||
|
console.log(e.exit_code);
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1>test</h1>
|
<h1>test</h1>
|
||||||
<Button>Click Me!</Button>
|
<Button onClick={onClick}>Click Me!</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
apps/onelauncher/frontend/.gitignore
vendored
2
apps/onelauncher/frontend/.gitignore
vendored
|
|
@ -3,3 +3,5 @@ node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
*.gen.ts
|
||||||
|
|
|
||||||
|
|
@ -117,12 +117,16 @@ set_default_user: (uuid: string | null) => Promise<null>,
|
||||||
open_msa_login: () => Promise<MinecraftCredentials | null> },
|
open_msa_login: () => Promise<MinecraftCredentials | null> },
|
||||||
'': { send_event: (event: LauncherEvent) => Promise<void> } };
|
'': { send_event: (event: LauncherEvent) => Promise<void> } };
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
<<<<<<< Updated upstream
|
<<<<<<< Updated upstream
|
||||||
const ARGS_MAP = { '': '{"send_event":["event"]}', 'onelauncher': '{"return_error":[],"open_dev_tools":[]}', 'core': '{"get_user":["uuid"],"get_default_user":["fallback"],"set_default_user":["uuid"],"get_global_profile":[],"get_clusters":[],"remove_cluster":["id"],"get_profile_or_default":["name"],"get_users":[],"remove_user":["uuid"],"open_msa_login":[],"create_cluster":["options"],"get_cluster":["id"]}' };
|
const ARGS_MAP = { '': '{"send_event":["event"]}', 'onelauncher': '{"return_error":[],"open_dev_tools":[]}', 'core': '{"get_user":["uuid"],"get_default_user":["fallback"],"set_default_user":["uuid"],"get_global_profile":[],"get_clusters":[],"remove_cluster":["id"],"get_profile_or_default":["name"],"get_users":[],"remove_user":["uuid"],"open_msa_login":[],"create_cluster":["options"],"get_cluster":["id"]}' };
|
||||||
|
=======
|
||||||
|
const ARGS_MAP = { 'core': '{"getGlobalProfile":[],"getUsers":[],"removeUser":["uuid"],"getDefaultUser":["fallback"],"getUser":["uuid"],"getClusterById":["id"],"setDefaultUser":["uuid"],"openMsaLogin":[],"removeCluster":["id"],"getProfileOrDefault":["name"],"getClusters":[],"createCluster":["options"]}', 'onelauncher': '{"open_dev_tools":[],"return_error":[]}', '': '{"send_event":["event"]}' };
|
||||||
|
>>>>>>> 17734ca1b9033a35ee6a581cc55e25fa7fc932b3
|
||||||
export interface Router {
|
export interface Router {
|
||||||
'onelauncher': { return_error: () => Promise<null>; open_dev_tools: () => Promise<void> };
|
'onelauncher': { return_error: () => Promise<null>; open_dev_tools: () => Promise<void> };
|
||||||
'core': { get_clusters: () => Promise<Array<Model>>; get_cluster: (id: bigint) => Promise<Model | null>; remove_cluster: (id: bigint) => Promise<null>; create_cluster: (options: CreateCluster) => Promise<Model>; get_profile_or_default: (name: string | null) => Promise<SettingProfileModel>; get_global_profile: () => Promise<SettingProfileModel>; get_users: () => Promise<Array<MinecraftCredentials>>; get_user: (uuid: string) => Promise<MinecraftCredentials | null>; remove_user: (uuid: string) => Promise<null>; get_default_user: (fallback: boolean | null) => Promise<MinecraftCredentials | null>; set_default_user: (uuid: string | null) => Promise<null>; open_msa_login: () => Promise<MinecraftCredentials | null> };
|
|
||||||
'': { send_event: (event: LauncherEvent) => Promise<void> };
|
'': { send_event: (event: LauncherEvent) => Promise<void> };
|
||||||
|
'core': { getClusters: () => Promise<Array<Model>>; getClusterById: (id: bigint) => Promise<Model | null>; removeCluster: (id: bigint) => Promise<null>; createCluster: (options: CreateCluster) => Promise<Model>; getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>; getGlobalProfile: () => Promise<SettingProfileModel>; getUsers: () => Promise<Array<MinecraftCredentials>>; getUser: (uuid: string) => Promise<MinecraftCredentials | null>; removeUser: (uuid: string) => Promise<null>; getDefaultUser: (fallback: boolean | null) => Promise<MinecraftCredentials | null>; setDefaultUser: (uuid: string | null) => Promise<null>; openMsaLogin: () => Promise<MinecraftCredentials | null> };
|
||||||
}
|
}
|
||||||
=======
|
=======
|
||||||
>>>>>>> Stashed changes
|
>>>>>>> Stashed changes
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import type { ToOptions } from '@tanstack/react-router';
|
import type { ToOptions } from '@tanstack/react-router';
|
||||||
import {useCommand} from '@onelauncher/common';
|
|
||||||
import { bindings } from '@/main';
|
import { bindings } from '@/main';
|
||||||
|
import { useCommand } from '@onelauncher/common';
|
||||||
|
import { Button, Menu } from '@onelauncher/common/components';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { Bell01Icon, Cloud01Icon, TerminalBrowserIcon } from '@untitled-theme/icons-react';
|
import { Bell01Icon, Cloud01Icon, TerminalBrowserIcon } from '@untitled-theme/icons-react';
|
||||||
import { MenuTrigger } from 'react-aria-components';
|
import { MenuTrigger } from 'react-aria-components';
|
||||||
import { Button, Menu } from '@onelauncher/common/components';
|
|
||||||
import PlayerHead from './content/PlayerHead';
|
import PlayerHead from './content/PlayerHead';
|
||||||
import AccountPopup from './overlay/popups/AccountPopup';
|
import AccountPopup from './overlay/popups/AccountPopup';
|
||||||
|
|
||||||
function Navbar() {
|
function Navbar() {
|
||||||
const defaultUser = useCommand('getDefaultUser', () => bindings.core.get_default_user(false));
|
const defaultUser = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-15 min-h-15 flex flex-row items-center *:flex-1">
|
<div className="h-15 min-h-15 flex flex-row items-center *:flex-1">
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,45 @@
|
||||||
import type { JSX } from "react";
|
import type { JSX } from 'react';
|
||||||
|
|
||||||
export type SettingsRowProps = {
|
export interface SettingsRowProps {
|
||||||
title: JSX.Element | string;
|
title: JSX.Element | string;
|
||||||
description: JSX.Element | string;
|
description: JSX.Element | string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: JSX.Element;
|
children?: JSX.Element;
|
||||||
};
|
}
|
||||||
|
|
||||||
function SettingsRow(props: SettingsRowProps) {
|
function SettingsRow({ disabled, description, icon, title, children }: SettingsRowProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center gap-3.5 rounded-xl p-4 my-2
|
flex flex-row items-center gap-3.5 rounded-xl p-4 my-2
|
||||||
${props.disabled ? 'bg-component-bg-disabled' : 'bg-page-elevated hover:bg-component-bg-hover'}
|
${disabled ? 'bg-component-bg-disabled' : 'bg-page-elevated hover:bg-component-bg-hover'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 items-center justify-center">
|
<div className="flex h-8 w-8 items-center justify-center">
|
||||||
{props.icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<h3 className="text-lg capitalize leading-tight">{props.title}</h3>
|
<h3 className="text-lg capitalize leading-tight">{title}</h3>
|
||||||
<p className="text-sm text-fg-secondary">{props.description}</p>
|
<p className="text-sm text-fg-secondary">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-9 flex-row items-center gap-2">
|
<div className="flex h-9 flex-row items-center gap-2">
|
||||||
{props.children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsRowHeaderProps {
|
interface SettingsRowHeaderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsRow.Header = ({ className, children }: SettingsRowHeaderProps) => {
|
SettingsRow.Header = ({ className, children }: SettingsRowHeaderProps) => {
|
||||||
const headerClasses = `mt-4 mb-1 ml-2 text-md text-fg-secondary uppercase ${className || ''}`;
|
const headerClasses = `mt-4 mb-1 ml-2 text-md text-fg-secondary uppercase ${className || ''}`;
|
||||||
return <h3 className={headerClasses}>{children}</h3>;
|
return <h3 className={headerClasses}>{children}</h3>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsRow;
|
export default SettingsRow;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ImgHTMLAttributes, JSX } from 'react';
|
import type { ImgHTMLAttributes, JSX } from 'react';
|
||||||
|
import { Show } from '@onelauncher/common/components';
|
||||||
import CurseforgeIcon from '../../assets/logos/curseforge.svg';
|
import CurseforgeIcon from '../../assets/logos/curseforge.svg';
|
||||||
import AtLauncherIcon from '../../assets/logos/launchers/atlauncher.svg';
|
import AtLauncherIcon from '../../assets/logos/launchers/atlauncher.svg';
|
||||||
import FTBIcon from '../../assets/logos/launchers/ftb.svg';
|
import FTBIcon from '../../assets/logos/launchers/ftb.svg';
|
||||||
|
|
@ -9,7 +10,6 @@ import TechnicImage from '../../assets/logos/launchers/technic.png';
|
||||||
import TLauncherImage from '../../assets/logos/launchers/tlauncher.png';
|
import TLauncherImage from '../../assets/logos/launchers/tlauncher.png';
|
||||||
import UnknownImage from '../../assets/logos/minecraft.png';
|
import UnknownImage from '../../assets/logos/minecraft.png';
|
||||||
import ModrinthIcon from '../../assets/logos/modrinth.svg';
|
import ModrinthIcon from '../../assets/logos/modrinth.svg';
|
||||||
import { Show } from '../base/Show';
|
|
||||||
|
|
||||||
type ElementType = (props: any) => JSX.Element;
|
type ElementType = (props: any) => JSX.Element;
|
||||||
|
|
||||||
|
|
@ -45,8 +45,8 @@ type LauncherIconProps = ImgHTMLAttributes<HTMLImageElement> & {
|
||||||
};
|
};
|
||||||
|
|
||||||
function LauncherIcon({
|
function LauncherIcon({
|
||||||
launcher,
|
launcher,
|
||||||
...props
|
...props
|
||||||
}: LauncherIconProps) {
|
}: LauncherIconProps) {
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import type { Model, GameLoader } from '@/bindings.gen';
|
import type { GameLoader, Model } from '@/bindings.gen';
|
||||||
|
import type { ImgHTMLAttributes, RefAttributes } from 'react';
|
||||||
import FabricImage from '@/assets/logos/loaders/fabric.png';
|
import FabricImage from '@/assets/logos/loaders/fabric.png';
|
||||||
import ForgeImage from '@/assets/logos/loaders/forge.png';
|
import ForgeImage from '@/assets/logos/loaders/forge.png';
|
||||||
import NeoForgeImage from '@/assets/logos/loaders/neoforge.png';
|
import NeoForgeImage from '@/assets/logos/loaders/neoforge.png';
|
||||||
import QuiltImage from '@/assets/logos/loaders/quilt.png';
|
import QuiltImage from '@/assets/logos/loaders/quilt.png';
|
||||||
import VanillaImage from '@/assets/logos/minecraft.png';
|
import VanillaImage from '@/assets/logos/minecraft.png';
|
||||||
import type { RefAttributes } from 'react';
|
|
||||||
|
|
||||||
export function getLoaderLogoSrc(loader: Model | GameLoader): string {
|
export function getLoaderLogoSrc(loader: Model | GameLoader): string {
|
||||||
const loaderName = (typeof loader === 'string' ? loader : loader.mc_loader)?.toLowerCase() as GameLoader;
|
const loaderName = (typeof loader === 'string' ? loader : loader.mc_loader).toLowerCase() as GameLoader;
|
||||||
|
|
||||||
const mapping: Record<GameLoader, string> = {
|
const mapping: Record<GameLoader, string> = {
|
||||||
vanilla: VanillaImage,
|
vanilla: VanillaImage,
|
||||||
|
|
@ -21,17 +21,21 @@ export function getLoaderLogoSrc(loader: Model | GameLoader): string {
|
||||||
return mapping[loaderName];
|
return mapping[loaderName];
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoaderIconProp = RefAttributes<HTMLImageElement> & {
|
type LoaderIconProp = ImgHTMLAttributes<HTMLImageElement> & RefAttributes<HTMLImageElement> & {
|
||||||
loader: GameLoader | undefined;
|
loader: GameLoader | undefined;
|
||||||
className?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function LoaderIcon(props: LoaderIconProp) {
|
function LoaderIcon({
|
||||||
|
loader,
|
||||||
|
src: _src,
|
||||||
|
alt: _alt,
|
||||||
|
...rest
|
||||||
|
}: LoaderIconProp) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...rest}
|
||||||
alt={`${props.loader}'s logo`}
|
alt={`${loader}'s logo`}
|
||||||
src={props.loader ? getLoaderLogoSrc(props.loader) : VanillaImage}
|
src={loader ? getLoaderLogoSrc(loader) : VanillaImage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import type { ClassNameString } from '@/types/global';
|
import type { ClassNameString } from '@onelauncher/common';
|
||||||
import {
|
|
||||||
Dialog as AriaDialog,
|
|
||||||
DialogTrigger,
|
|
||||||
Modal as ModalOverlay
|
|
||||||
} from 'react-aria-components';
|
|
||||||
import type { DialogProps } from 'react-aria-components';
|
import type { DialogProps } from 'react-aria-components';
|
||||||
|
import {
|
||||||
|
Dialog as AriaDialog,
|
||||||
|
DialogTrigger,
|
||||||
|
Modal as ModalOverlay,
|
||||||
|
} from 'react-aria-components';
|
||||||
|
|
||||||
interface ModalProps extends DialogProps, ClassNameString { }
|
interface ModalProps extends DialogProps, ClassNameString { }
|
||||||
|
|
||||||
function Modal({ className, ...props }: ModalProps) {
|
function Modal({ className, ...props }: ModalProps) {
|
||||||
return (
|
return (
|
||||||
<ModalOverlay isDismissable className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<ModalOverlay className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" isDismissable>
|
||||||
<AriaDialog className="" {...props} />
|
<AriaDialog className="" {...props} />
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Modal.Trigger = DialogTrigger;
|
Modal.Trigger = DialogTrigger;
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import { Separator } from 'react-aria-components';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
function AccountPopup() {
|
function AccountPopup() {
|
||||||
const users = useCommand('getUsers', bindings.core.get_users);
|
const users = useCommand('getUsers', bindings.core.getUsers);
|
||||||
const defaultUser = useCommand('getDefaultUser', () => bindings.core.get_default_user(false));
|
const defaultUser = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
|
||||||
|
|
||||||
const setDefaultUser = (user: MinecraftCredentials) => {
|
const setDefaultUser = (user: MinecraftCredentials) => {
|
||||||
bindings.core.set_default_user(user.id);
|
bindings.core.setDefaultUser(user.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useCommand } from '@onelauncher/common';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
function useRecentCluster() {
|
function useRecentCluster() {
|
||||||
const result = useCommand('getClusters', bindings.core.get_clusters);
|
const result = useCommand('getClusters', bindings.core.getClusters);
|
||||||
const [cluster, setCluster] = useState<Model | undefined>();
|
const [cluster, setCluster] = useState<Model | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Button, Show, TextField } from '@onelauncher/common/components';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Edit02Icon, FolderIcon, ImagePlusIcon, Share07Icon, Tool02Icon, Trash01Icon } from '@untitled-theme/icons-react';
|
import { Edit02Icon, FolderIcon, ImagePlusIcon, Share07Icon, Tool02Icon, Trash01Icon } from '@untitled-theme/icons-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
import Sidebar from '../settings/route';
|
import Sidebar from '../settings/route';
|
||||||
|
|
||||||
export const Route = createFileRoute('/app/cluster/')({
|
export const Route = createFileRoute('/app/cluster/')({
|
||||||
|
|
@ -21,7 +22,7 @@ function RouteComponent() {
|
||||||
const [newName, setNewName] = useState<string>('');
|
const [newName, setNewName] = useState<string>('');
|
||||||
|
|
||||||
// dumbass fix ik
|
// dumbass fix ik
|
||||||
const cluster = useCommand('getClusterById', () => bindings.core.get_cluster(Number(id.toString()) as unknown as bigint));
|
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar.Page>
|
<Sidebar.Page>
|
||||||
|
|
@ -102,7 +103,11 @@ interface BannerProps {
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Banner(props: BannerProps) {
|
function Banner({
|
||||||
|
cluster,
|
||||||
|
editMode,
|
||||||
|
setNewName,
|
||||||
|
}: BannerProps) {
|
||||||
// async function launchFilePicker() {
|
// async function launchFilePicker() {
|
||||||
// const selected = await dialog.open({
|
// const selected = await dialog.open({
|
||||||
// multiple: false,
|
// multiple: false,
|
||||||
|
|
@ -162,7 +167,7 @@ function Banner(props: BannerProps) {
|
||||||
|
|
||||||
<div className="flex flex-1 flex-row">
|
<div className="flex flex-1 flex-row">
|
||||||
<div
|
<div
|
||||||
className={`flex flex-1 flex-col items-start justify-between ${editMode ? 'text-fg-primary-disabled' : ''}`}
|
className={twMerge(`flex flex-1 flex-col items-start justify-between`, editMode && 'text-fg-primary-disabled')}
|
||||||
>
|
>
|
||||||
<span className="flex flex-row items-center gap-x-1">
|
<span className="flex flex-row items-center gap-x-1">
|
||||||
<LoaderIcon
|
<LoaderIcon
|
||||||
|
|
@ -174,7 +179,7 @@ function Banner(props: BannerProps) {
|
||||||
{cluster?.mc_loader_version && <span>{cluster.mc_loader_version}</span>}
|
{cluster?.mc_loader_version && <span>{cluster.mc_loader_version}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-xs text-fg-secondary ${editMode ? 'text-fg-secondary-disabled' : ''}`}
|
className={twMerge(`text-xs text-fg-secondary`, editMode && 'text-fg-secondary-disabled')}
|
||||||
>
|
>
|
||||||
Played for
|
Played for
|
||||||
{' '}
|
{' '}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ export const Route = createFileRoute('/app/cluster')({
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useSearch();
|
const { id } = Route.useSearch();
|
||||||
|
|
||||||
const cluster = useCommand('getClusterById', () => bindings.core.get_cluster(Number(id.toString()) as unknown as bigint));
|
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||||
|
|
||||||
const isModded = cluster.data?.mc_loader !== 'vanilla';
|
const isModded = cluster.data?.mc_loader != 'vanilla';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-row overflow-hidden">
|
<div className="h-full flex flex-row overflow-hidden">
|
||||||
|
|
@ -32,12 +32,12 @@ function RouteComponent() {
|
||||||
links={{
|
links={{
|
||||||
'Cluster Settings': [
|
'Cluster Settings': [
|
||||||
// if someone has a better way to do this, please let me know
|
// if someone has a better way to do this, please let me know
|
||||||
[<EyeIcon key="overview" />, 'Overview', `/?id=${id}`],
|
[<EyeIcon />, 'Overview', `/?id=${id}`],
|
||||||
(isModded ? [<PackagePlusIcon key="mods" />, 'Mods', `/mods?id=${id}`] : undefined),
|
(isModded ? [<PackagePlusIcon />, 'Mods', `/mods?id=${id}`] : undefined),
|
||||||
[<Image03Icon key="screenshots" />, 'Screenshots', `/screenshots?id=${id}`],
|
[<Image03Icon />, 'Screenshots', `/screenshots?id=${id}`],
|
||||||
[<Globe04Icon key="worlds" />, 'Worlds', `/worlds?id=${id}`],
|
[<Globe04Icon />, 'Worlds', `/worlds?id=${id}`],
|
||||||
[<File06Icon key="logs" />, 'Logs', `/logs?id=${id}`],
|
[<File06Icon />, 'Logs', `/logs?id=${id}`],
|
||||||
[<Settings04Icon key="gamesettings" />, 'Game Settings', `/settings?id=${id}`],
|
[<Settings04Icon />, 'Game Settings', `/settings?id=${id}`],
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,16 @@ function RouteComponent() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScreenshotEntry(props: { path: string; cluster_path: string }) {
|
function ScreenshotEntry({
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
cluster_path: string;
|
||||||
|
}) {
|
||||||
function onClick() {
|
function onClick() {
|
||||||
// screen shot viewer overlay
|
// screen shot viewer overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path } = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80"
|
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80"
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export const Route = createFileRoute('/app/cluster/settings')({
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useSearch();
|
const { id } = Route.useSearch();
|
||||||
|
|
||||||
const cluster = useCommand('getClusterById', () => bindings.core.get_cluster(Number(id.toString()) as unknown as bigint));
|
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||||
const _result = useCommand('getProfileOrDefault', () => bindings.core.get_profile_or_default(cluster.data?.name as string), {
|
const result = useCommand('getProfileOrDefault', () => bindings.core.getProfileOrDefault(cluster.data?.name as string), {
|
||||||
enabled: !!cluster.data?.name,
|
enabled: !!cluster.data?.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import Modal from '@/components/overlay/Modal';
|
||||||
import { bindings } from '@/main';
|
import { bindings } from '@/main';
|
||||||
import { useCommand } from '@onelauncher/common';
|
import { useCommand } from '@onelauncher/common';
|
||||||
import { Button, TextField } from '@onelauncher/common/components';
|
import { Button, TextField } from '@onelauncher/common/components';
|
||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
import { Server01Icon } from '@untitled-theme/icons-react';
|
import { Server01Icon } from '@untitled-theme/icons-react';
|
||||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ Please note this route has a very big issue related to scrolling
|
||||||
and i am very angry rn so i will not be fixing it rn
|
and i am very angry rn so i will not be fixing it rn
|
||||||
*/
|
*/
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const result = useCommand('getClusters', bindings.core.get_clusters);
|
const result = useCommand('getClusters', bindings.core.getClusters);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-y-4 text-fg-primary">
|
<div className="h-full flex flex-col gap-y-4 text-fg-primary">
|
||||||
|
|
@ -49,7 +49,7 @@ function Banner() {
|
||||||
*/
|
*/
|
||||||
// const cluster = useRecentCluster();
|
// const cluster = useRecentCluster();
|
||||||
// const launch = useLaunchCluster(() => cluster()?.uuid);
|
// const launch = useLaunchCluster(() => cluster()?.uuid);
|
||||||
const navigate = useNavigate();
|
// const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-52 min-h-52 w-full overflow-hidden rounded-xl border border-component-border">
|
<div className="relative h-52 min-h-52 w-full overflow-hidden rounded-xl border border-component-border">
|
||||||
|
|
@ -110,8 +110,8 @@ function Banner() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClusterCreate() {
|
function ClusterCreate() {
|
||||||
const result = useCommand('createCluster', () => bindings.commands.createCluster({
|
const result = useCommand('createCluster', () => bindings.core.createCluster({
|
||||||
icon_url: 'asd',
|
icon: 'asd',
|
||||||
mc_loader: 'vanilla',
|
mc_loader: 'vanilla',
|
||||||
mc_version: '1.20.1',
|
mc_version: '1.20.1',
|
||||||
name: 'Test Cluster',
|
name: 'Test Cluster',
|
||||||
|
|
@ -177,8 +177,11 @@ interface ClusterGroupProps {
|
||||||
isFetching?: boolean;
|
isFetching?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClusterGroup(props: ClusterGroupProps) {
|
function ClusterGroup({
|
||||||
if (props.isFetching)
|
isFetching,
|
||||||
|
clusters,
|
||||||
|
}: ClusterGroupProps) {
|
||||||
|
if (isFetching)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="w-8 h-8 border-4 border-brand rounded-full border-t-transparent animate-spin" />
|
<div className="w-8 h-8 border-4 border-brand rounded-full border-t-transparent animate-spin" />
|
||||||
|
|
@ -191,7 +194,7 @@ function ClusterGroup(props: ClusterGroupProps) {
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-4 gap-4 max-h-96 2xl:grid-cols-6 pb-4">
|
<div className="grid grid-cols-4 gap-4 max-h-96 2xl:grid-cols-6 pb-4">
|
||||||
{props.clusters?.map(data => (
|
{clusters?.map(data => (
|
||||||
<ClusterCard key={data.id} {...data} />
|
<ClusterCard key={data.id} {...data} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -200,11 +203,16 @@ function ClusterGroup(props: ClusterGroupProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClusterCard(props: Model) {
|
function ClusterCard({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
mc_loader,
|
||||||
|
mc_version,
|
||||||
|
}: Model) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
search={{
|
search={{
|
||||||
id: props.id,
|
id,
|
||||||
}} to="/app/cluster"
|
}} to="/app/cluster"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -222,11 +230,11 @@ function ClusterCard(props: Model) {
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 flex flex-row items-center justify-between gap-x-3 p-3">
|
<div className="z-10 flex flex-row items-center justify-between gap-x-3 p-3">
|
||||||
<div className="h-full flex flex-col gap-1.5 overflow-hidden">
|
<div className="h-full flex flex-col gap-1.5 overflow-hidden">
|
||||||
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">{props.name}</p>
|
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">{name}</p>
|
||||||
<p className="h-4 text-xs">
|
<p className="h-4 text-xs">
|
||||||
{props.mc_loader}
|
{mc_loader}
|
||||||
{' '}
|
{' '}
|
||||||
{props.mc_version}
|
{mc_version}
|
||||||
{/* {' '}
|
{/* {' '}
|
||||||
{props.packages.mods && `• ${props.mods} mods`} */}
|
{props.packages.mods && `• ${props.mods} mods`} */}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ interface Account {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { data: usersData, isLoading: usersLoading } = useCommand('getUsers', bindings.core.get_users);
|
const { data: usersData, isLoading: usersLoading } = useCommand('getUsers', bindings.core.getUsers);
|
||||||
const { data: defaultUserData, isLoading: defaultUserLoading } = useCommand('getDefaultUser', () => bindings.core.get_default_user(false));
|
const { data: defaultUserData, isLoading: defaultUserLoading } = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
|
||||||
|
|
||||||
if (usersLoading || defaultUserLoading)
|
if (usersLoading || defaultUserLoading)
|
||||||
return (
|
return (
|
||||||
|
|
@ -67,7 +67,7 @@ function AccountRow({ account, isCurrent }: AccountRowProps) {
|
||||||
|
|
||||||
const setDefaultUserCommand = useCommand(
|
const setDefaultUserCommand = useCommand(
|
||||||
'setDefaultUser',
|
'setDefaultUser',
|
||||||
() => bindings.core.set_default_user(account.id),
|
() => bindings.core.setDefaultUser(account.id),
|
||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
subscribed: false,
|
subscribed: false,
|
||||||
|
|
@ -76,7 +76,7 @@ function AccountRow({ account, isCurrent }: AccountRowProps) {
|
||||||
|
|
||||||
const removeUserCommand = useCommand(
|
const removeUserCommand = useCommand(
|
||||||
'removeUser',
|
'removeUser',
|
||||||
() => bindings.core.remove_user(account.id),
|
() => bindings.core.removeUser(account.id),
|
||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
subscribed: false,
|
subscribed: false,
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,12 @@ interface ThemeCardProps {
|
||||||
setTheme: (merged: string) => void;
|
setTheme: (merged: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ThemeCard(props: ThemeCardProps) {
|
function ThemeCard({
|
||||||
|
themeSelector,
|
||||||
|
setTheme,
|
||||||
|
}: ThemeCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`h-min theme-${props.themeSelector}`} onClick={() => props.setTheme(props.themeSelector)}>
|
<div className={`h-min theme-${themeSelector}`} onClick={() => setTheme(themeSelector)}>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
height="78"
|
height="78"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export const Route = createFileRoute('/app/settings/')({
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const _result = useCommand('getGlobalProfile', bindings.core.get_global_profile);
|
const result = useCommand('getGlobalProfile', bindings.core.getGlobalProfile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar.Page>
|
<Sidebar.Page>
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@ function Sidebar(props: SidebarProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
export const Route = createFileRoute('/onboarding/complete')({
|
export const Route = createFileRoute('/onboarding/complete')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <div>Hello "/onboarding/complete"!</div>
|
return <div>Hello "/onboarding/complete"!</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
export const Route = createFileRoute('/onboarding/')({
|
export const Route = createFileRoute('/onboarding/')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 h-full w-full items-center gap-x-16">
|
<div className="grid grid-cols-2 h-full w-full items-center gap-x-16">
|
||||||
<h1 className="text-6xl">
|
<h1 className="text-6xl">
|
||||||
Welcome to
|
Welcome to
|
||||||
{' '}
|
{' '}
|
||||||
<span className="underline underline-8 underline-brand">OneLauncher</span>
|
<span className="underline underline-8 underline-brand">OneLauncher</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<h3>A powerful yet easy to use launcher for Minecraft.</h3>
|
<h3>A powerful yet easy to use launcher for Minecraft.</h3>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const Route = createFileRoute('/onboarding/login')({
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const [profile, setProfile] = useState<MinecraftCredentials>();
|
const [profile, setProfile] = useState<MinecraftCredentials>();
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
const result = useCommand('beginMsFlow', bindings.core.open_msa_login, {
|
const result = useCommand('openMsaLogin', bindings.core.openMsaLogin, {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
subscribed: false,
|
subscribed: false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,12 @@ export interface OnboardingStepProps {
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingStep(props: OnboardingStepProps) {
|
export function OnboardingStep({
|
||||||
const { illustration, title, paragraph, children } = props;
|
title,
|
||||||
|
paragraph,
|
||||||
|
illustration,
|
||||||
|
children,
|
||||||
|
}: OnboardingStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 h-full w-full gap-x-16">
|
<div className="grid grid-cols-2 h-full w-full gap-x-16">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
|
|
||||||
10
apps/onelauncher/frontend/src/types/global.d.ts
vendored
10
apps/onelauncher/frontend/src/types/global.d.ts
vendored
|
|
@ -1,3 +1,9 @@
|
||||||
export interface ClassNameString {
|
import type { bindings } from '@/main';
|
||||||
className?: string;
|
|
||||||
|
export type BindingCommands = (keyof typeof bindings.core) | (keyof typeof bindings.onelauncher);
|
||||||
|
|
||||||
|
declare module '@onelauncher/common' {
|
||||||
|
interface Register {
|
||||||
|
commands: Array<BindingCommands>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,5 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx", "prettier.config.js", "vite.config.js"]
|
"include": ["**/*.ts", "**/*.tsx", "vite.config.js"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@ export default defineConfig(
|
||||||
react: true,
|
react: true,
|
||||||
query: true,
|
query: true,
|
||||||
ignores: ['*.rs', '**/migrations/**'],
|
ignores: ['*.rs', '**/migrations/**'],
|
||||||
rules: {
|
|
||||||
'ts/no-use-before-define': 'off',
|
|
||||||
},
|
|
||||||
toml: {
|
toml: {
|
||||||
overrides: {
|
overrides: {
|
||||||
'toml/array-element-newline': ['error', 'always'],
|
'toml/array-element-newline': ['error', 'always'],
|
||||||
|
|
@ -30,7 +27,9 @@ export default defineConfig(
|
||||||
rules: {
|
rules: {
|
||||||
'sort-imports': 'off', // Replaced by perfectionist/sort-named-imports',
|
'sort-imports': 'off', // Replaced by perfectionist/sort-named-imports',
|
||||||
'import/order': 'off', // Replaced by perfectionist/sort-named-imports',
|
'import/order': 'off', // Replaced by perfectionist/sort-named-imports',
|
||||||
'style/jsx-max-props-per-line': ['error', { maximum: 3}]
|
'style/jsx-max-props-per-line': ['error', { maximum: 3}],
|
||||||
|
'ts/no-use-before-define': 'off',
|
||||||
|
'style/function-paren-newline': ['error', 'consistent']
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ whoami = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
async-stream = { workspace = true }
|
||||||
paste = { workspace = true }
|
paste = { workspace = true }
|
||||||
byteorder = { workspace = true }
|
byteorder = { workspace = true }
|
||||||
merge = { workspace = true }
|
merge = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -84,3 +84,14 @@ pub async fn get_user(user: uuid::Uuid) -> LauncherResult<Option<MinecraftCreden
|
||||||
|
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a fake user for testing or offline mode.
|
||||||
|
pub fn get_fake_user() -> MinecraftCredentials {
|
||||||
|
MinecraftCredentials {
|
||||||
|
id: uuid::Uuid::new_v4(),
|
||||||
|
username: "Player".to_string(),
|
||||||
|
access_token: "0".to_string(),
|
||||||
|
refresh_token: "0".to_string(),
|
||||||
|
expires: chrono::Utc::now() + chrono::Duration::days(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use futures::AsyncReadExt;
|
use futures::{pin_mut, AsyncReadExt, TryStreamExt};
|
||||||
use interpulse::api::minecraft::{Argument, ArgumentValue, Library, VersionType};
|
use interpulse::api::minecraft::{Argument, ArgumentValue, Library, VersionType};
|
||||||
use interpulse::api::modded::SidedDataEntry;
|
use interpulse::api::modded::SidedDataEntry;
|
||||||
use interpulse::utils::get_path_from_artifact;
|
use interpulse::utils::get_path_from_artifact;
|
||||||
|
|
@ -156,38 +156,48 @@ pub fn processor_arguments<T: AsRef<str>, S: ::std::hash::BuildHasher>(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn main_class(path: String) -> LauncherResult<Option<String>> {
|
pub async fn main_class(path: String) -> LauncherResult<Option<String>> {
|
||||||
// let zipfs = std::fs::File::open(&path).map_err(|e| IOError::with_path(e, &path))?;
|
|
||||||
// let mut archive = zip::ZipArchive::new(zipfs)
|
|
||||||
// .map_err(|_| anyhow::anyhow!("failed to read main class {}", path))?;
|
|
||||||
// let file = archive
|
|
||||||
// .by_name("META-INF/MANIFEST.MF")
|
|
||||||
// .map_err(|_| anyhow::anyhow!("failed to read manifest {}", path))?;
|
|
||||||
let data = io::read(path).await?;
|
let data = io::read(path).await?;
|
||||||
let mut class_name: Option<String> = None;
|
let mut class_name: Option<String> = None;
|
||||||
|
let reader = io::stream_zip_entries_bytes(data);
|
||||||
|
|
||||||
io::read_zip_entries_bytes(data, async |_, entry, entry_reader| {
|
pin_mut!(reader);
|
||||||
if entry.dir()? {
|
while let Ok(item) = reader.try_next().await {
|
||||||
return Ok(());
|
let Some((index, entry, reader)) = item else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if entry.dir().unwrap_or(true) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.filename().as_str()? != "META-INF/MANIFEST.MF" {
|
if entry.filename().as_str().unwrap_or_default() != "META-INF/MANIFEST.MF" {
|
||||||
return Ok(());
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
entry_reader.read_to_string(&mut buf).await?;
|
let mut entry_reader = match reader.reader_without_entry(index).await {
|
||||||
|
Ok(reader) => reader,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("failed to get entry reader for {}: {}", entry.filename().as_str().unwrap_or_default(), err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = entry_reader.read_to_string(&mut buf).await {
|
||||||
|
tracing::error!("failed to read entry {}: {}", entry.filename().as_str().unwrap_or_default(), err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for line in buf.lines() {
|
for line in buf.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("Main-Class:")
|
if line.starts_with("Main-Class:") {
|
||||||
&& let Some(class) = line.split(':').nth(1) {
|
if let Some(class) = line.split(':').nth(1) {
|
||||||
class_name = Some(class.to_string());
|
class_name = Some(class.trim().to_string());
|
||||||
return Ok(());
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
|
||||||
}).await?;
|
|
||||||
|
|
||||||
Ok(class_name)
|
Ok(class_name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ pub async fn launch_minecraft(
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare_cluster(cluster, force).await?;
|
prepare_cluster(cluster, force).await?;
|
||||||
|
|
||||||
let mut settings = get_global_profile().await;
|
let mut settings = get_global_profile().await;
|
||||||
if let Some(name) = &cluster.setting_profile_name
|
if let Some(name) = &cluster.setting_profile_name
|
||||||
&& let Some(profile) = get_profile_by_name(name).await? {
|
&& let Some(profile) = get_profile_by_name(name).await? {
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ pub async fn download_minecraft(
|
||||||
|
|
||||||
let asset_index = download_assets_index(version, sub_ingress, force).await?;
|
let asset_index = download_assets_index(version, sub_ingress, force).await?;
|
||||||
tokio::try_join! {
|
tokio::try_join! {
|
||||||
download_assets(version.assets == "legacy", &asset_index, sub_ingress, force),
|
download_assets(version.assets == "legacy", asset_index, sub_ingress, force),
|
||||||
download_client(version, sub_ingress, force),
|
download_client(version, sub_ingress, force),
|
||||||
download_libraries(&version.id, &version.libraries, java_arch, sub_ingress, force),
|
download_libraries(version.id.clone(), version.libraries.clone(), java_arch.to_string(), sub_ingress, force),
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -122,9 +122,6 @@ pub async fn download_assets_index(
|
||||||
.await?
|
.await?
|
||||||
.join(format!("{}.json", version.asset_index.id));
|
.join(format!("{}.json", version.asset_index.id));
|
||||||
|
|
||||||
// let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / 2.0));
|
|
||||||
// let ingress_ref = ingress_ref.as_ref();
|
|
||||||
// ingress_ref.map(async |i| send_ingress_ref_message(i, Some("fetching assets index")).await);
|
|
||||||
let ingress_step = ingress.ingress_total().map(|i| i / 2.0).unwrap_or_default();
|
let ingress_step = ingress.ingress_total().map(|i| i / 2.0).unwrap_or_default();
|
||||||
ingress.set_ingress_message("fetching assets index").await?;
|
ingress.set_ingress_message("fetching assets index").await?;
|
||||||
|
|
||||||
|
|
@ -159,9 +156,9 @@ pub async fn download_assets_index(
|
||||||
// MARK: Assets
|
// MARK: Assets
|
||||||
/// Downloads the assets for a given asset index, returns the number of failed downloads
|
/// Downloads the assets for a given asset index, returns the number of failed downloads
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub async fn download_assets(
|
pub async fn download_assets<'a>(
|
||||||
legacy: bool,
|
legacy: bool,
|
||||||
assets_index: &AssetsIndex,
|
assets_index: AssetsIndex,
|
||||||
ingress: Option<&SubIngress<'_>>,
|
ingress: Option<&SubIngress<'_>>,
|
||||||
force: Option<bool>,
|
force: Option<bool>,
|
||||||
) -> LauncherResult<usize> {
|
) -> LauncherResult<usize> {
|
||||||
|
|
@ -178,30 +175,33 @@ pub async fn download_assets(
|
||||||
|
|
||||||
ingress.set_ingress_message("fetching assets").await?;
|
ingress.set_ingress_message("fetching assets").await?;
|
||||||
|
|
||||||
let requests = stream::iter(assets_index.objects.iter().map(|(name, asset)| async {
|
let requests = stream::iter(assets_index.objects.into_iter().map(|(name, asset)| {
|
||||||
let hash = &asset.hash;
|
let dir = dir.clone();
|
||||||
let subhash = &hash[0..2];
|
async move {
|
||||||
let path = if legacy {
|
let hash = &asset.hash;
|
||||||
dir.join(name.replace('/', std::path::MAIN_SEPARATOR_STR))
|
let subhash = &hash[0..2];
|
||||||
} else {
|
let path = if legacy {
|
||||||
dir.join(subhash).join(hash)
|
dir.join(name.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||||
};
|
} else {
|
||||||
|
dir.join(subhash).join(hash)
|
||||||
|
};
|
||||||
|
|
||||||
if !path.exists() || force.unwrap_or(false) {
|
if !path.exists() || force.unwrap_or(false) {
|
||||||
http::download_advanced(
|
http::download_advanced(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&format!("https://resources.download.minecraft.net/{subhash}/{hash}"),
|
&format!("https://resources.download.minecraft.net/{subhash}/{hash}"),
|
||||||
path,
|
path,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some((HashAlgorithm::Sha1, hash)),
|
Some((HashAlgorithm::Sha1, hash)),
|
||||||
ingress.ingress_sub(|total| total / len as f64).as_ref(),
|
ingress.ingress_sub(|total| total / len as f64).as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
} else {
|
} else {
|
||||||
// TODO: Possibly check hash? (not sure if this is a good idea here)
|
// TODO: Possibly check hash? (not sure if this is a good idea here)
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.buffer_unordered(Core::get().fetch_attempts.min(7))
|
.buffer_unordered(Core::get().fetch_attempts.min(7))
|
||||||
|
|
@ -265,9 +265,9 @@ pub async fn download_client(
|
||||||
// MARK: Libraries
|
// MARK: Libraries
|
||||||
/// Downloads the libraries and returns the number of failed downloads
|
/// Downloads the libraries and returns the number of failed downloads
|
||||||
pub async fn download_libraries(
|
pub async fn download_libraries(
|
||||||
version: &str,
|
version: String,
|
||||||
libraries: &[Library],
|
libraries: Vec<Library>,
|
||||||
java_arch: &str,
|
java_arch: String,
|
||||||
ingress: Option<&SubIngress<'_>>,
|
ingress: Option<&SubIngress<'_>>,
|
||||||
force: Option<bool>,
|
force: Option<bool>,
|
||||||
) -> LauncherResult<usize> {
|
) -> LauncherResult<usize> {
|
||||||
|
|
@ -284,88 +284,95 @@ pub async fn download_libraries(
|
||||||
let ingress = ingress.as_ref();
|
let ingress = ingress.as_ref();
|
||||||
ingress.set_ingress_message("fetching libraries").await?;
|
ingress.set_ingress_message("fetching libraries").await?;
|
||||||
|
|
||||||
let requests = stream::iter(libraries.iter().map(|lib| async {
|
let requests = stream::iter(libraries.into_iter().map(|lib| {
|
||||||
if let Some(rules) = &lib.rules
|
let lib_dir = lib_dir.clone();
|
||||||
&& !super::rules::validate_rules(rules, java_arch, lib.natives.is_some()) {
|
let natives_dest = natives_dest.clone();
|
||||||
tracing::debug!("skipping library {} due to rules", lib.name);
|
let java_arch = java_arch.clone();
|
||||||
return Ok::<(), LauncherError>(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if !lib.downloadable {
|
async move {
|
||||||
tracing::debug!("skipping library {} due to downloadability", lib.name);
|
if let Some(rules) = &lib.rules
|
||||||
return Ok(());
|
&& !super::rules::validate_rules(rules, &java_arch, lib.natives.is_some()) {
|
||||||
}
|
tracing::debug!("skipping library {} due to rules", lib.name);
|
||||||
|
return Ok::<(), LauncherError>(());
|
||||||
let artifact_path = interpulse::utils::get_path_from_artifact(&lib.name)?;
|
|
||||||
let path = lib_dir.join(&artifact_path);
|
|
||||||
|
|
||||||
if path.exists() && !force.unwrap_or(false) {
|
|
||||||
tracing::debug!("library {} is installed, skipping", &lib.name);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::try_join! {
|
|
||||||
async {
|
|
||||||
if let Some(interpulse::api::minecraft::LibraryDownloads {
|
|
||||||
artifact: Some(ref artifact), ..
|
|
||||||
}) = lib.downloads
|
|
||||||
&& !artifact.url.is_empty() {
|
|
||||||
http::download(
|
|
||||||
Method::GET,
|
|
||||||
&artifact.url,
|
|
||||||
&path,
|
|
||||||
Some((HashAlgorithm::Sha1, &artifact.sha1)),
|
|
||||||
ingress,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
tracing::trace!("fetched library {} to path {:?}", &lib.name, &path);
|
|
||||||
return Ok::<_, LauncherError>(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = [lib.url.as_deref().unwrap_or("https://libraries.minecraft.net/"), &artifact_path].concat();
|
|
||||||
http::download(
|
|
||||||
Method::GET,
|
|
||||||
&url,
|
|
||||||
&path,
|
|
||||||
None,
|
|
||||||
ingress,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
tracing::trace!("fetched library {} to path {:?}", &lib.name, &path);
|
|
||||||
Ok::<_, LauncherError>(())
|
|
||||||
},
|
|
||||||
async {
|
|
||||||
if let Some((os_key, classifiers)) = None.or_else(|| Some((
|
|
||||||
lib.natives.as_ref()?.get(&Os::native_arch(java_arch))?,
|
|
||||||
lib.downloads.as_ref()?.classifiers.as_ref()?
|
|
||||||
))) {
|
|
||||||
let parsed = os_key.replace("${arch}", crate::constants::ARCH_WIDTH);
|
|
||||||
if let Some(native) = classifiers.get(&parsed) {
|
|
||||||
tracing::trace!("found native library {}", &lib.name);
|
|
||||||
|
|
||||||
let data = http::fetch_advanced(
|
|
||||||
Method::GET,
|
|
||||||
&native.url,
|
|
||||||
None,
|
|
||||||
Some((HashAlgorithm::Sha1, &native.sha1)),
|
|
||||||
None,
|
|
||||||
ingress,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
io::unzip_bytes_filtered(
|
|
||||||
data.to_vec(),
|
|
||||||
Some(|name: &str| !name.starts_with("META-INF")),
|
|
||||||
&natives_dest
|
|
||||||
).await?;
|
|
||||||
tracing::trace!("extracted native {} to path {:?}", &lib.name, &natives_dest);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
if !lib.downloadable {
|
||||||
|
tracing::debug!("skipping library {} due to downloadability", lib.name);
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
}?;
|
|
||||||
|
|
||||||
Ok(())
|
let artifact_path = interpulse::utils::get_path_from_artifact(&lib.name)?;
|
||||||
|
let path = lib_dir.join(&artifact_path);
|
||||||
|
|
||||||
|
if path.exists() && !force.unwrap_or(false) {
|
||||||
|
tracing::debug!("library {} is installed, skipping", &lib.name);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::try_join! {
|
||||||
|
async {
|
||||||
|
if let Some(interpulse::api::minecraft::LibraryDownloads {
|
||||||
|
artifact: Some(ref artifact), ..
|
||||||
|
}) = lib.downloads
|
||||||
|
&& !artifact.url.is_empty() {
|
||||||
|
http::download(
|
||||||
|
Method::GET,
|
||||||
|
&artifact.url,
|
||||||
|
&path,
|
||||||
|
Some((HashAlgorithm::Sha1, &artifact.sha1)),
|
||||||
|
ingress,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
tracing::trace!("fetched library {} to path {:?}", &lib.name, &path);
|
||||||
|
return Ok::<_, LauncherError>(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = [lib.url.as_deref().unwrap_or("https://libraries.minecraft.net/"), &artifact_path].concat();
|
||||||
|
http::download(
|
||||||
|
Method::GET,
|
||||||
|
&url,
|
||||||
|
&path,
|
||||||
|
None,
|
||||||
|
ingress,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
tracing::trace!("fetched library {} to path {:?}", &lib.name, &path);
|
||||||
|
Ok::<_, LauncherError>(())
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
if let Some((os_key, classifiers)) = None.or_else(|| Some((
|
||||||
|
lib.natives.as_ref()?.get(&Os::native_arch(&java_arch))?,
|
||||||
|
lib.downloads.as_ref()?.classifiers.as_ref()?
|
||||||
|
))) {
|
||||||
|
let parsed = os_key.replace("${arch}", crate::constants::ARCH_WIDTH);
|
||||||
|
if let Some(native) = classifiers.get(&parsed) {
|
||||||
|
tracing::trace!("found native library {}", &lib.name);
|
||||||
|
|
||||||
|
let data = http::fetch_advanced(
|
||||||
|
Method::GET,
|
||||||
|
&native.url,
|
||||||
|
None,
|
||||||
|
Some((HashAlgorithm::Sha1, &native.sha1)),
|
||||||
|
None,
|
||||||
|
ingress,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
io::unzip_bytes_filtered(
|
||||||
|
data.to_vec(),
|
||||||
|
Some(|name: &str| !name.starts_with("META-INF")),
|
||||||
|
&natives_dest
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
tracing::trace!("extracted native {} to path {:?}", &lib.name, &natives_dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
})).buffer_unordered(7).collect::<Vec<_>>();
|
})).buffer_unordered(7).collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut failed = 0;
|
let mut failed = 0;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use crate::store::processes::Process;
|
||||||
|
|
||||||
#[onelauncher_macro::specta]
|
#[onelauncher_macro::specta]
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
pub enum ProcessPayload {
|
pub enum ProcessPayload {
|
||||||
Starting {
|
Starting {
|
||||||
command: String,
|
command: String,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@ impl LauncherProxy for ProxyTauri {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(self.emitter.send_event(event)?)
|
Ok(match event {
|
||||||
|
LauncherEvent::Ingress(ingress) => self.emitter.ingress(ingress),
|
||||||
|
LauncherEvent::Message(message) => self.emitter.message(message),
|
||||||
|
LauncherEvent::Process(process) => self.emitter.process(process),
|
||||||
|
}?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,47 @@ use onelauncher_entity::prelude::model::*;
|
||||||
pub trait TauriLauncherApi {
|
pub trait TauriLauncherApi {
|
||||||
|
|
||||||
// Clusters
|
// Clusters
|
||||||
|
#[taurpc(alias = "getClusters")]
|
||||||
async fn get_clusters() -> LauncherResult<Vec<Cluster>>;
|
async fn get_clusters() -> LauncherResult<Vec<Cluster>>;
|
||||||
async fn get_cluster(id: ClusterId) -> LauncherResult<Option<Cluster>>;
|
|
||||||
|
#[taurpc(alias = "getClusterById")]
|
||||||
|
async fn get_cluster_by_id(id: ClusterId) -> LauncherResult<Option<Cluster>>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "removeCluster")]
|
||||||
async fn remove_cluster(id: ClusterId) -> LauncherResult<()>;
|
async fn remove_cluster(id: ClusterId) -> LauncherResult<()>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "createCluster")]
|
||||||
async fn create_cluster(options: CreateCluster) -> LauncherResult<Cluster>;
|
async fn create_cluster(options: CreateCluster) -> LauncherResult<Cluster>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "launchCluster")]
|
||||||
|
async fn launch_cluster(id: ClusterId, uuid: Option<uuid::Uuid>) -> LauncherResult<()>;
|
||||||
|
|
||||||
|
|
||||||
// Setting Profiles
|
// Setting Profiles
|
||||||
|
#[taurpc(alias = "getProfileOrDefault")]
|
||||||
async fn get_profile_or_default(name: Option<String>) -> LauncherResult<SettingsProfile>;
|
async fn get_profile_or_default(name: Option<String>) -> LauncherResult<SettingsProfile>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "getGlobalProfile")]
|
||||||
async fn get_global_profile() -> SettingsProfile;
|
async fn get_global_profile() -> SettingsProfile;
|
||||||
|
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
#[taurpc(alias = "getUsers")]
|
||||||
async fn get_users() -> LauncherResult<Vec<MinecraftCredentials>>;
|
async fn get_users() -> LauncherResult<Vec<MinecraftCredentials>>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "getUser")]
|
||||||
async fn get_user(uuid: uuid::Uuid) -> LauncherResult<Option<MinecraftCredentials>>;
|
async fn get_user(uuid: uuid::Uuid) -> LauncherResult<Option<MinecraftCredentials>>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "removeUser")]
|
||||||
async fn remove_user(uuid: uuid::Uuid) -> LauncherResult<()>;
|
async fn remove_user(uuid: uuid::Uuid) -> LauncherResult<()>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "getDefaultUser")]
|
||||||
async fn get_default_user(fallback: Option<bool>) -> LauncherResult<Option<MinecraftCredentials>>;
|
async fn get_default_user(fallback: Option<bool>) -> LauncherResult<Option<MinecraftCredentials>>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "setDefaultUser")]
|
||||||
async fn set_default_user(uuid: Option<uuid::Uuid>) -> LauncherResult<()>;
|
async fn set_default_user(uuid: Option<uuid::Uuid>) -> LauncherResult<()>;
|
||||||
|
|
||||||
|
#[taurpc(alias = "openMsaLogin")]
|
||||||
async fn open_msa_login<R: Runtime>(app_handle: AppHandle<R>) -> LauncherResult<Option<MinecraftCredentials>>;
|
async fn open_msa_login<R: Runtime>(app_handle: AppHandle<R>) -> LauncherResult<Option<MinecraftCredentials>>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +74,7 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
||||||
api::cluster::dao::get_all_clusters().await
|
api::cluster::dao::get_all_clusters().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_cluster(self, id: ClusterId) -> LauncherResult<Option<Cluster>> {
|
async fn get_cluster_by_id(self, id: ClusterId) -> LauncherResult<Option<Cluster>> {
|
||||||
api::cluster::dao::get_cluster_by_id(id).await
|
api::cluster::dao::get_cluster_by_id(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +95,25 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
||||||
Ok(cluster)
|
Ok(cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn launch_cluster(self, id: ClusterId, uuid: Option<uuid::Uuid>) -> LauncherResult<()> {
|
||||||
|
let mut cluster = api::cluster::dao::get_cluster_by_id(id).await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?;
|
||||||
|
|
||||||
|
let uuid = match uuid {
|
||||||
|
Some(uuid) => uuid,
|
||||||
|
None => api::credentials::get_default_user().await?.ok_or_else(|| anyhow::anyhow!("no default user set"))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = api::credentials::get_user(uuid).await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("user with uuid {} not found", uuid))?;
|
||||||
|
|
||||||
|
// let user = api::credentials::get_fake_user();
|
||||||
|
|
||||||
|
let _ = api::game::launch::launch_minecraft(&mut cluster, user, None).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_profile_or_default(self, name: Option<String>) -> LauncherResult<SettingsProfile> {
|
async fn get_profile_or_default(self, name: Option<String>) -> LauncherResult<SettingsProfile> {
|
||||||
api::setting_profiles::dao::get_profile_or_default(name.as_ref()).await
|
api::setting_profiles::dao::get_profile_or_default(name.as_ref()).await
|
||||||
}
|
}
|
||||||
|
|
@ -164,4 +209,3 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
use crate::api::proxy::event::LauncherEvent;
|
#[taurpc::procedures(path = "events", event_trigger = LauncherEventEmitter)]
|
||||||
|
|
||||||
#[taurpc::procedures(event_trigger = LauncherEventEmitter)]
|
|
||||||
pub trait TauriLauncherEventApi {
|
pub trait TauriLauncherEventApi {
|
||||||
#[taurpc(event)]
|
#[taurpc(event)]
|
||||||
async fn send_event(event: LauncherEvent);
|
#[taurpc(alias = "ingress")]
|
||||||
|
async fn ingress(event: crate::api::ingress::IngressPayload);
|
||||||
|
|
||||||
|
#[taurpc(event)]
|
||||||
|
#[taurpc(alias = "message")]
|
||||||
|
async fn message(event: crate::api::proxy::message::MessagePayload);
|
||||||
|
|
||||||
|
#[taurpc(event)]
|
||||||
|
#[taurpc(alias = "process")]
|
||||||
|
async fn process(event: crate::api::processes::ProcessPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[taurpc::ipc_type]
|
#[taurpc::ipc_type]
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,4 @@ pub async fn initialize_core(options: CoreOptions, proxy_backend: impl LauncherP
|
||||||
tracing::info!("Core initialized successfully");
|
tracing::info!("Core initialized successfully");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -32,76 +32,54 @@ pub enum HashAlgorithm {
|
||||||
const BUFFER_SIZE: usize = 1024 * 256; // 16 KiB
|
const BUFFER_SIZE: usize = 1024 * 256; // 16 KiB
|
||||||
|
|
||||||
impl HashAlgorithm {
|
impl HashAlgorithm {
|
||||||
pub fn hasher_sync<F>(&self, closure: F) -> Result<String, IOError>
|
|
||||||
where
|
|
||||||
F: Fn(&mut (dyn DynDigest)) -> Result<(), IOError>,
|
|
||||||
{
|
|
||||||
let mut hasher = get_inner(self);
|
|
||||||
closure(&mut *hasher)?;
|
|
||||||
Ok(to_hex(&hasher.finalize()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn hasher<F>(&self, closure: F) -> Result<String, IOError>
|
|
||||||
where
|
|
||||||
F: AsyncFn(&mut (dyn DynDigest + Send + Sync)) -> Result<(), IOError> + Send + Sync,
|
|
||||||
{
|
|
||||||
let mut hasher = get_inner(self);
|
|
||||||
closure(&mut *hasher).await?;
|
|
||||||
Ok(to_hex(&hasher.finalize()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn hash(&self, data: &[u8]) -> Result<String, IOError> {
|
pub async fn hash(&self, data: &[u8]) -> Result<String, IOError> {
|
||||||
self.hasher(async move |hasher| {
|
let mut hasher = get_inner(self);
|
||||||
hasher.update(data);
|
hasher.update(data);
|
||||||
Ok(())
|
Ok(to_hex(&hasher.finalize()))
|
||||||
}).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hash_sync(&self, data: &[u8]) -> Result<String, IOError> {
|
pub fn hash_sync(&self, data: &[u8]) -> Result<String, IOError> {
|
||||||
self.hasher_sync(move |hasher| {
|
let mut hasher = get_inner(self);
|
||||||
hasher.update(data);
|
hasher.update(data);
|
||||||
Ok(())
|
Ok(to_hex(&hasher.finalize()))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn hash_file(&self, path: impl AsRef<std::path::Path>) -> Result<String, IOError> {
|
pub async fn hash_file(&self, path: impl AsRef<std::path::Path>) -> Result<String, IOError> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
self.hasher(async |hasher: &mut (dyn DynDigest + Send + Sync)| {
|
|
||||||
let file = tokio::fs::File::open(path).await?;
|
|
||||||
let mut reader = tokio::io::BufReader::new(file);
|
|
||||||
|
|
||||||
let mut buffer = vec![0; BUFFER_SIZE].into_boxed_slice();
|
let mut hasher = get_inner(self);
|
||||||
loop {
|
|
||||||
let bytes_read = reader.read(&mut buffer).await?;
|
|
||||||
if bytes_read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasher.update(&buffer[..bytes_read]);
|
let file = tokio::fs::File::open(path).await?;
|
||||||
|
let mut reader = tokio::io::BufReader::new(file);
|
||||||
|
|
||||||
|
let mut buffer = vec![0; BUFFER_SIZE].into_boxed_slice();
|
||||||
|
loop {
|
||||||
|
let bytes_read = reader.read(&mut buffer).await?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
hasher.update(&buffer[..bytes_read]);
|
||||||
}).await
|
}
|
||||||
|
|
||||||
|
Ok(to_hex(&hasher.finalize()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hash_file_sync(&self, path: impl AsRef<std::path::Path>) -> Result<String, IOError> {
|
pub fn hash_file_sync(&self, path: impl AsRef<std::path::Path>) -> Result<String, IOError> {
|
||||||
self.hasher_sync(|hasher: &mut dyn DynDigest| {
|
let path = path.as_ref();
|
||||||
let path = path.as_ref();
|
let mut hasher = get_inner(self);
|
||||||
let file = std::fs::File::open(path)?;
|
let mut file = std::fs::File::open(path)?;
|
||||||
let mut reader = std::io::BufReader::new(file);
|
|
||||||
|
|
||||||
let mut buffer = vec![0; BUFFER_SIZE].into_boxed_slice();
|
let mut buffer = vec![0; BUFFER_SIZE].into_boxed_slice();
|
||||||
loop {
|
loop {
|
||||||
let bytes_read = reader.read(&mut buffer)?;
|
let bytes_read = file.read(&mut buffer)?;
|
||||||
if bytes_read == 0 {
|
if bytes_read == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
hasher.update(&buffer[..bytes_read]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
hasher.update(&buffer[..bytes_read]);
|
||||||
})
|
}
|
||||||
|
Ok(to_hex(&hasher.finalize()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use std::path::PathBuf;
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use async_compression::tokio::bufread::GzipDecoder;
|
use async_compression::tokio::bufread::GzipDecoder;
|
||||||
|
use async_stream::try_stream;
|
||||||
use async_zip::StoredZipEntry;
|
use async_zip::StoredZipEntry;
|
||||||
use async_zip::base::read::WithoutEntry;
|
use async_zip::base::read::WithoutEntry;
|
||||||
|
use futures::{pin_mut, Stream, TryStreamExt};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
@ -278,6 +280,24 @@ where
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a zip archive from a byte array and returns a stream of entries.
|
||||||
|
pub fn stream_zip_entries_bytes(
|
||||||
|
data: Vec<u8>,
|
||||||
|
) -> impl Stream<Item = Result<(
|
||||||
|
usize, StoredZipEntry, Arc<async_zip::base::read::mem::ZipFileReader>
|
||||||
|
), IOError>> {
|
||||||
|
try_stream! {
|
||||||
|
let reader = Arc::new(async_zip::base::read::mem::ZipFileReader::new(data).await?);
|
||||||
|
let entries = reader.file().entries();
|
||||||
|
|
||||||
|
for index in 0..entries.len() {
|
||||||
|
let entry = entries.get(index).expect("expected more zip entries");
|
||||||
|
|
||||||
|
yield (index, entry.clone(), reader.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Unzips a zip archive from a byte array
|
/// Unzips a zip archive from a byte array
|
||||||
pub async fn unzip_bytes(
|
pub async fn unzip_bytes(
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
|
|
@ -289,15 +309,18 @@ pub async fn unzip_bytes(
|
||||||
/// Unzips a zip archive from a byte array
|
/// Unzips a zip archive from a byte array
|
||||||
pub async fn unzip_bytes_filtered(
|
pub async fn unzip_bytes_filtered(
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
filter_entries: Option<impl Fn(&str) -> bool>,
|
filter_entries: Option<impl Fn(&str) -> bool + Send + Sync>,
|
||||||
dest_path: impl AsRef<std::path::Path>,
|
dest_path: impl AsRef<std::path::Path>,
|
||||||
) -> Result<(), IOError> {
|
) -> Result<(), IOError> {
|
||||||
read_zip_entries_bytes(data, async |_, entry, entry_reader| {
|
let stream = stream_zip_entries_bytes(data);
|
||||||
|
pin_mut!(stream);
|
||||||
|
|
||||||
|
while let Some((index, entry, reader)) = stream.try_next().await? {
|
||||||
let file_name = entry.filename().as_str()?;
|
let file_name = entry.filename().as_str()?;
|
||||||
|
|
||||||
if let Some(filter) = &filter_entries
|
if let Some(filter) = &filter_entries
|
||||||
&& !filter(file_name) {
|
&& !filter(file_name) {
|
||||||
return Ok(());
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = dest_path.as_ref().join(sanitize_path(file_name));
|
let path = dest_path.as_ref().join(sanitize_path(file_name));
|
||||||
|
|
@ -311,15 +334,14 @@ pub async fn unzip_bytes_filtered(
|
||||||
create_dir_all(parent).await?;
|
create_dir_all(parent).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let entry_reader = reader.reader_without_entry(index).await?;
|
||||||
|
|
||||||
let file = tokio::fs::File::create(&path).await?;
|
let file = tokio::fs::File::create(&path).await?;
|
||||||
let writer = tokio::io::BufWriter::new(file);
|
let writer = tokio::io::BufWriter::new(file);
|
||||||
|
|
||||||
futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?;
|
futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,7 @@ export function AnimatedOutlet({
|
||||||
|
|
||||||
const handleAnimationComplete = (id: number) => {
|
const handleAnimationComplete = (id: number) => {
|
||||||
setSnapshots(prevSnapshots =>
|
setSnapshots(prevSnapshots =>
|
||||||
prevSnapshots.filter(snapshot => snapshot.id !== id),
|
prevSnapshots.filter(snapshot => snapshot.id !== id));
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import type { UndefinedInitialDataOptions, UseQueryResult } from '@tanstack/react-query';
|
import type { UndefinedInitialDataOptions, UseQueryResult } from '@tanstack/react-query';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
type CacheKey = string;
|
// this gets overwritten by the consumer project
|
||||||
|
export interface Register {
|
||||||
|
// commands
|
||||||
|
}
|
||||||
|
|
||||||
export function useCommand<T>(cacheKey: CacheKey | (string & {}), command: () => Promise<T>, options?: Omit<UndefinedInitialDataOptions<T>, 'queryKey' | 'queryFn'>): UseQueryResult<T, Error> {
|
type RegisterConfig = Register extends {
|
||||||
|
commands: infer Keys;
|
||||||
|
} ? Keys : never;
|
||||||
|
|
||||||
|
export function useCommand<T>(cacheKey: RegisterConfig[number] | (string & {}) | false, command: () => Promise<T>, options?: Omit<UndefinedInitialDataOptions<T>, 'queryKey' | 'queryFn'>): UseQueryResult<T, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [cacheKey],
|
queryKey: cacheKey === false ? [] : [cacheKey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await command();
|
const result = await command();
|
||||||
|
|
@ -24,5 +31,3 @@ export function useCommand<T>(cacheKey: CacheKey | (string & {}), command: () =>
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// export useCommand;
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
|
export type * from './types/global';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue