feat: Instance loading and rendering on the Launcher

This commit is contained in:
LynithDev 2024-03-24 15:54:42 +01:00
parent 525f38fe5c
commit ec0590bc49
17 changed files with 199 additions and 135 deletions

View file

@ -25,3 +25,5 @@ chrono = { workspace = true }
reqwest = { workspace = true }
dirs = { workspace = true }
open = { workspace = true }
sha1 = "0.10.6"

View file

@ -22,13 +22,14 @@ pub struct Version {
release_time: chrono::DateTime<chrono::Utc>
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub id: Uuid,
pub manifest: MinecraftManifest
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Instance {
pub id: Uuid,
pub created_at: DateTime<Local>,
@ -66,7 +67,7 @@ pub trait ClientTrait<'a>: Send + Sync {
async fn launch(&self) -> PolyResult<()>;
async fn setup(&self) -> PolyResult<()>;
async fn install_game(&self) -> PolyResult<()>;
async fn install_game(&self) -> PolyResult<PathBuf>;
async fn install_libraries(&self) -> PolyResult<String>;
async fn install_natives(&self) -> PolyResult<()>;
async fn install_assets(&self) -> PolyResult<()>;
@ -236,8 +237,21 @@ fn load_and_serialize<T>(dir: &PathBuf) -> PolyResult<HashMap<Uuid, T>> where T:
};
let path = file.path();
let content = fs::read_to_string(&path)?;
let parsed = serde_json::from_str::<T>(&content)?;
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
eprintln!("Couldn't read file: '{:?}'", path);
continue;
}
};
let parsed = match serde_json::from_str::<T>(&content) {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Couldn't parse file content as JSON: '{:?}'", content);
continue;
}
};
result.insert(uuid, parsed);
}

View file

@ -2,7 +2,7 @@ use std::{fs, path::PathBuf};
use anyhow::anyhow;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::{constants::MINECRAFT_VERSIONS_MANIFEST, create_game_client, impl_game_client, utils::http, PolyResult};
use crate::{constants::MINECRAFT_VERSIONS_MANIFEST, create_game_client, impl_game_client, utils::{dirs, file, http}, PolyResult};
use super::{client::{ClientTrait, Instance, Manifest, Version}, minecraft::MinecraftManifest};
create_game_client! {
@ -30,10 +30,24 @@ impl<'a> ClientTrait<'a> for VanillaClient<'a> {
Ok(())
}
async fn install_game(&self) -> PolyResult<()> {
let artifact = &self.manifest.manifest.downloads.client;
async fn install_game(&self) -> PolyResult<PathBuf> {
let manifest = &self.manifest.manifest;
let file = dirs::clients_dir()?.join(format!("{}.jar", manifest.version));
if !file.exists() {
fs::create_dir_all(&file)?;
let artifact = &manifest.downloads.client;
http::download_file(&artifact.url, &file).await?;
let file_hash = file::file_sha1(&file)?;
println!("Downloaded: '{}' | '{}'", file_hash, artifact.sha1);
if file_hash != artifact.sha1 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Hashes do not match").into());
}
}
Ok(())
Ok(file)
}
async fn install_libraries(&self) -> PolyResult<String> {

View file

@ -6,8 +6,6 @@ use crate::{constants, PolyResult};
#[derive(Debug, Error)]
pub enum DirectoryError {
#[error("failed to get the user's home directory")]
HomeDir,
#[error("failed to get the user's config directory")]
ConfigDir,
}
@ -32,4 +30,8 @@ pub fn instances_dir() -> PolyResult<PathBuf> {
pub fn manifests_dir() -> PolyResult<PathBuf> {
Ok(app_config_dir()?.join("manifests"))
}
}
pub fn clients_dir() -> PolyResult<PathBuf> {
Ok(app_config_dir()?.join("clients"))
}

View file

@ -1,8 +1,20 @@
use anyhow::anyhow;
use std::{fs::File, path::Path};
use sha1::{Digest, Sha1};
use std::{fs::File, io::BufReader, path::{Path, PathBuf}};
use crate::{PolyError, PolyResult};
pub fn file_sha1(file: &PathBuf) -> PolyResult<String> {
let file = File::open(file)?;
let reader = BufReader::new(file);
let mut hasher = Sha1::new();
hasher.update(&reader.buffer());
let hash = hasher.finalize();
Ok(format!("{:x}", hash))
}
pub fn extract_archive(archive: &Path, dest: &Path) -> PolyResult<()> {
let ext = match archive.extension() {
Some(ext) => ext.to_str().unwrap(),

View file

@ -23,4 +23,9 @@ export default petal({
'pnpm-lock.yaml',
'./src-tauri/prisma/migrations/migration_lock.toml',
],
}, { rules: { 'petal/consistent-list-newline': 'off' } });
}, {
rules: {
'petal/consistent-list-newline': 'off',
'no-console': 'off',
},
});

View file

@ -7,9 +7,11 @@ fn main() {
"launcher-core",
InlinedPlugin::new().commands(&[
"login_msa",
"refresh_client_manager",
"create_instance",
"get_instances",
"get_instance",
"get_manifest"
]),
),
);

View file

@ -1,8 +1,10 @@
[default]
description = "Default permissions for the plugin"
permissions = [
"allow-get-instance",
"allow-get-instances",
"allow-refresh-client-manager",
"allow-create-instance",
"allow-login-msa"
"allow-get-instances",
"allow-get-instance",
"allow-get-manifest",
"allow-login-msa",
]

View file

@ -1,4 +1,4 @@
use launcher_core::game::client::{ClientType, Instance};
use launcher_core::game::client::{ClientType, Instance, Manifest};
use tauri::State;
use tokio::sync::Mutex;
use uuid::Uuid;
@ -35,4 +35,14 @@ pub async fn get_instance(
let manager = &mut state.lock().await.client_manager;
let instance = manager.get_instance(uuid)?;
Ok(instance.clone())
}
#[tauri::command]
pub async fn get_manifest(
state: State<'_, Mutex<GameManagerState>>,
uuid: Uuid
) -> Result<Manifest, String> {
let manager = &mut state.lock().await.client_manager;
let manifest = manager.get_manifest(uuid)?;
Ok(manifest.clone())
}

View file

@ -1,5 +1,5 @@
use launcher_core::game::client::ClientManager;
use tauri::{plugin::TauriPlugin, Manager};
use tauri::{plugin::TauriPlugin, Manager, State};
use tokio::sync::Mutex;
mod auth;
@ -26,6 +26,17 @@ pub fn init() -> TauriPlugin<tauri::Wry> {
game::create_instance,
game::get_instances,
game::get_instance,
game::get_manifest,
refresh_client_manager,
])
.build()
}
#[tauri::command]
async fn refresh_client_manager(
state: State<'_, Mutex<GameManagerState>>
) -> Result<(), String> {
let mut state = state.lock().await;
state.client_manager = ClientManager::new()?;
Ok(())
}

View file

@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
type MicrosoftLoginCallback = (msg: string, stage: number) => never;
export async function loginMicrosoft(cb?: MicrosoftLoginCallback): Promise<auth.Account> {
export async function loginMicrosoft(cb?: MicrosoftLoginCallback): Promise<Core.Account> {
if (cb) {
const unlisten = await listen<[string, number, boolean]>('msa:status', (event) => {
cb(event.payload[0], event.payload[1]);

30
src/bridge/game.ts Normal file
View file

@ -0,0 +1,30 @@
import { invoke } from '@tauri-apps/api/core';
export async function getInstance(uuid: string): Promise<Core.Instance> {
return await invoke<Core.Instance>('plugin:launcher-core|get_instance', {
uuid,
});
}
export async function getManifest(uuid: string): Promise<Core.Manifest> {
return await invoke<Core.Manifest>('plugin:launcher-core|get_manifest', {
uuid,
});
}
export async function getInstancesWithManifests(): Promise<Core.InstanceWithManifest[]> {
const instances = await getInstances();
return Promise.all(instances.map(async instance => ({
instance,
manifest: await getManifest(instance.id),
})));
}
export async function getInstances(): Promise<Core.Instance[]> {
return await invoke<Core.Instance[]>('plugin:launcher-core|get_instances');
}
export async function refreshClientManager(): Promise<void> {
return await invoke('plugin:launcher-core|refresh_client_manager');
}

2
src/types/auth.d.ts vendored
View file

@ -1,4 +1,4 @@
declare namespace auth {
declare namespace Core {
export interface Account {
username: string;
uuid: string;

49
src/types/game.d.ts vendored
View file

@ -1,22 +1,35 @@
declare namespace game {
export interface GameClientDetails {
uuid: Uuid;
name: string;
version: string;
main_class: string;
java_version: JavaVersion;
startup_args: Vec<string>;
client_type: {
type: keyof GameClientType;
manifest: GameClientType[keyof GameClientType];
};
}
declare namespace Core {
export interface InstanceWithManifest {
instance: Instance,
manifest: Manifest,
}
export interface GameClientType {
Vanilla: VanillaManifest;
}
export interface Manifest {
id: string,
manifest: MinecraftManifest
}
export type ClientType = keyof GameClientType;
export interface MinecraftManifest {
id: string,
javaVersion: {
majorVersion: number;
},
}
export interface VanillaManifest {}
export interface Instance<T extends keyof ClientType = keyof ClientType> {
id: string,
createdAt: number,
name: string,
cover: string | null,
group: string | null,
client: ClientType[T],
}
export interface ClientType {
Vanilla: VanillaProps,
}
export interface VanillaProps {
type: "Vanilla",
}
}

View file

@ -1,4 +1,4 @@
function useAccount(): auth.Account {
function useAccount(): Core.Account {
// TODO return the currently logged in account
return {

View file

@ -1,47 +1,8 @@
import { invoke } from '@tauri-apps/api/core';
import Button from '../components/base/Button';
function BrowserPage() {
return (
<div class="flex flex-col gap-y-4">
<h1>Browser</h1>
<Button
// eslint-disable-next-line solid/reactivity
onClick={async () => {
const result = await invoke('plugin:launcher-core|get_instance', {
uuid: 'de69f608-c8de-4cb5-a295-8747dc05380a',
});
// eslint-disable-next-line no-console
console.log(result);
}}
>
Test Button
</Button>
<Button
onClick={async () => {
const result = await invoke('plugin:launcher-core|get_instances');
console.log(result);
}}
>
Test Button 2
</Button>
<Button
onClick={async () => {
const result = await invoke('plugin:launcher-core|create_instance', {
name: 'My epic instance name',
version: '1.8.9',
client: {
type: 'Vanilla',
},
});
console.log(result);
}}
>
Test Button 3
</Button>
</div>
);
}

View file

@ -5,20 +5,21 @@ import {
EyeIcon,
PlayIcon,
PlusIcon,
RefreshCw01Icon,
SearchMdIcon,
} from '@untitled-theme/icons-solid';
import type { Accessor, Setter } from 'solid-js';
import { For, createSignal } from 'solid-js';
import { For, Suspense, createSignal, lazy } from 'solid-js';
import { DragDropProvider, DragDropSensors, SortableProvider, closestCenter, createSortable } from '@thisbeyond/solid-dnd';
import type { DragEventHandler, Id } from '@thisbeyond/solid-dnd';
import { useNavigate } from '@solidjs/router';
import { v4 } from 'uuid';
import image from '../../assets/images/header.png';
import Button from '../components/base/Button';
import Tag from '../components/base/Tag';
import TextField from '../components/base/TextField';
import defaultCover from '../../assets/images/default_instance_cover.jpg';
import ContextMenu from '../components/overlay/ContextMenu';
import { getInstancesWithManifests, refreshClientManager } from '../../bridge/game';
// TODO: Replace this into it's own component
function OneConfigLogo() {
@ -63,26 +64,6 @@ function Banner() {
);
}
interface InstanceCardProps {
elementId: number;
id: string;
name: string;
version: string;
clientType: game.ClientType;
mods?: number;
cover?: string;
lastPlayed?: number;
}
declare module 'solid-js' {
// eslint-disable-next-line ts/no-namespace
namespace JSX {
interface Directives {
sortable: any;
}
}
}
interface InstanceCardContextMenuProps {
pos: Accessor<{ x: number; y: number }>;
contextMenuVisible: Accessor<boolean>;
@ -117,12 +98,27 @@ function InstanceCardContextMenu(props: InstanceCardContextMenuProps) {
);
}
interface InstanceCardProps {
id: number;
instance: Core.Instance;
manifest: Core.Manifest;
}
declare module 'solid-js' {
// eslint-disable-next-line ts/no-namespace
namespace JSX {
interface Directives {
sortable: any;
}
}
}
function InstanceCard(props: InstanceCardProps) {
const navigate = useNavigate();
const [pos, setPos] = createSignal({ x: 0, y: 0 });
const [dragged, setDragged] = createSignal(false);
const [contextMenuVisible, setContextMenuVisible] = createSignal(false);
const id = () => props.elementId as Id;
const id = () => props.id as Id;
let ref!: HTMLDivElement;
const sortable = createSortable(id());
@ -141,7 +137,7 @@ function InstanceCard(props: InstanceCardProps) {
if (didDrag())
return;
navigate(`/instances/?id=${props.id}`);
navigate(`/instances/?id=${props.instance.id}`);
}
function openContextMenu(e: MouseEvent) {
@ -182,20 +178,20 @@ function InstanceCard(props: InstanceCardProps) {
style={{ '-webkit-transform': 'translateZ(0)' }}
>
<img
class={`object-cover h-full w-full ${props.cover ? '' : ' filter grayscale'}`}
src={props.cover || defaultCover}
class={`object-cover h-full w-full ${props.instance.cover ? '' : ' filter grayscale'}`}
src={props.instance.cover || defaultCover}
/>
</div>
</div>
<div class="z-10 p-3 flex flex-row items-center justify-between">
<div class="flex flex-col gap-1.5">
<p class="h-4 font-semibold">{props.name}</p>
<p class="h-4 font-semibold">{props.instance.name}</p>
<p class="h-4 text-xs">
{props.clientType}
{props.instance.client.type}
{' '}
{props.version}
{' '}
{props.mods && `• ${props.mods} mods`}
{props.manifest.manifest.id}
{/* {' '}
{props.instance.mods && `• ${props.mods} mods`} */}
</p>
</div>
<Button onClick={e => openContextMenu(e)} styleType="icon" class="w-8 h-8">
@ -207,7 +203,7 @@ function InstanceCard(props: InstanceCardProps) {
contextMenuVisible={contextMenuVisible}
setContextMenuVisible={setContextMenuVisible}
pos={pos}
id={props.id}
id={props.instance.id}
/>
</>
);
@ -215,13 +211,13 @@ function InstanceCard(props: InstanceCardProps) {
interface InstanceGroupProps {
title: string;
instances: InstanceCardProps[];
instances: Core.InstanceWithManifest[];
}
function InstanceGroup(props: InstanceGroupProps) {
// eslint-disable-next-line solid/reactivity
const [instances, setInstances] = createSignal(props.instances);
const ids = () => instances().map(instance => instance.elementId);
const [instances, setInstances] = createSignal(props.instances.map((instance, index) => ({ ...instance, id: index })));
const ids = () => instances().map(instance => instance.id);
const onDragStart: DragEventHandler = ({ draggable }) => {
draggable.node.setAttribute('data-dragging', 'true');
@ -263,27 +259,15 @@ function InstanceGroup(props: InstanceGroupProps) {
);
}
const Instances = lazy(async () => {
const instances = await getInstancesWithManifests();
return {
default: () => <InstanceGroup title="Unnamed" instances={instances} />,
};
});
function HomePage() {
const myInstances: InstanceCardProps[] = [
{
id: v4(),
name: 'Hypixel',
clientType: 'Vanilla',
version: '1.8.9',
elementId: 1,
},
];
const myInstances2: InstanceCardProps[] = [
{
id: v4(),
name: 'Hypixel',
clientType: 'Vanilla',
version: '1.8.9',
elementId: 2,
},
];
return (
<div class="flex flex-col gap-y-4 text-fg-primary">
<Banner />
@ -295,11 +279,13 @@ function HomePage() {
<div class="flex flex-row gap-x-4">
<Button styleType="primary" iconLeft={<PlusIcon class="!w-5 stroke-[2.2]" />}>New Instance</Button>
<Button styleType="secondary" iconLeft={<Download01Icon />}>From URL</Button>
<Button styleType="secondary" iconLeft={<RefreshCw01Icon />} onClick={() => refreshClientManager()} />
</div>
</div>
<InstanceGroup title="Hypixel" instances={myInstances} />
<InstanceGroup title="Hypixel" instances={myInstances2} />
<Suspense>
<Instances />
</Suspense>
</div>
);
}