feat: Instance loading and rendering on the Launcher
This commit is contained in:
parent
525f38fe5c
commit
ec0590bc49
17 changed files with 199 additions and 135 deletions
|
|
@ -25,3 +25,5 @@ chrono = { workspace = true }
|
|||
reqwest = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
open = { workspace = true }
|
||||
|
||||
sha1 = "0.10.6"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ fn main() {
|
|||
"launcher-core",
|
||||
InlinedPlugin::new().commands(&[
|
||||
"login_msa",
|
||||
"refresh_client_manager",
|
||||
"create_instance",
|
||||
"get_instances",
|
||||
"get_instance",
|
||||
"get_manifest"
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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
30
src/bridge/game.ts
Normal 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
2
src/types/auth.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
declare namespace auth {
|
||||
declare namespace Core {
|
||||
export interface Account {
|
||||
username: string;
|
||||
uuid: string;
|
||||
|
|
|
|||
49
src/types/game.d.ts
vendored
49
src/types/game.d.ts
vendored
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
function useAccount(): auth.Account {
|
||||
function useAccount(): Core.Account {
|
||||
// TODO return the currently logged in account
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue