feat(install ui): ui to install games

This commit is contained in:
DecDuck
2024-12-06 22:16:50 +11:00
parent b5568429f5
commit 8670bca834
11 changed files with 436 additions and 25 deletions

View File

@ -1,29 +1,45 @@
<template>
<button
type="button"
@click="() => buttonActions[props.status]()"
:class="[
styles[props.status],
'inline-flex uppercase font-display items-center gap-x-2 rounded-md px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<component :is="buttonIcons[props.status]" class="-mr-0.5 size-5" aria-hidden="true" />
<component
:is="buttonIcons[props.status]"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
{{ buttonNames[props.status] }}
</button>
</template>
<script setup lang="ts">
import { ArrowDownTrayIcon, PlayIcon, QueueListIcon, TrashIcon } from "@heroicons/vue/20/solid";
import {
ArrowDownTrayIcon,
PlayIcon,
QueueListIcon,
TrashIcon,
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import { GameStatus } from "~/types.js";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
(e: "install"): void;
(e: "cancel"): void;
(e: "play"): void;
}>();
const styles: { [key in GameStatus]: string } = {
[GameStatus.Remote]:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
[GameStatus.Queued]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatus.Downloading]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatus.Downloading]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatus.Installed]:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600",
[GameStatus.Updating]: "",
@ -45,6 +61,15 @@ const buttonIcons: {[key in GameStatus]: Component} = {
[GameStatus.Downloading]: ArrowDownTrayIcon,
[GameStatus.Installed]: PlayIcon,
[GameStatus.Updating]: ArrowDownTrayIcon,
[GameStatus.Uninstalling]: TrashIcon
}
[GameStatus.Uninstalling]: TrashIcon,
};
const buttonActions: { [key in GameStatus]: () => void } = {
[GameStatus.Remote]: () => emit("install"),
[GameStatus.Queued]: () => emit("cancel"),
[GameStatus.Downloading]: () => emit("cancel"),
[GameStatus.Installed]: () => emit("play"),
[GameStatus.Updating]: () => emit("cancel"),
[GameStatus.Uninstalling]: () => {},
};
</script>

View File

@ -1,7 +1,7 @@
<template>
<button
type="submit"
class="inline-flex h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="inline-flex h-9 items-center justify-center rounded-md bg-blue-600 hover:bg-blue-500 disabled:text-zinc-500 disabled:bg-blue-900 disabled:hover:bg-blue-900 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<div v-if="props.loading" role="status">
<svg

View File

@ -18,13 +18,303 @@
<div class="w-full min-h-screen mx-auto bg-zinc-900 px-5 py-6">
<!-- game toolbar -->
<div>
<GameStatusButton :status="status" />
<GameStatusButton @install="() => installFlow()" :status="status" />
</div>
</div>
</div>
<TransitionRoot as="template" :show="installFlowOpen">
<Dialog class="relative z-50" @close="installFlowOpen = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
/>
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-start justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<form
@submit.prevent="() => install()"
class="relative transform rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
>
<div class="px-4 pb-4 pt-5 space-y-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<DialogTitle
as="h3"
class="text-base font-semibold text-zinc-100"
>Install {{ game.mName }}?
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-zinc-400">
Drop will add {{ game.mName }} to the queue to be
downloaded. While downloading, Drop may use up a large
amount of resources, particularly network bandwidth and
CPU utilisation.
</p>
</div>
</div>
</div>
<div class="space-y-6">
<div v-if="versionOptions && versionOptions.length > 0">
<Listbox as="div" v-model="installVersionIndex">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Version</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate"
>{{
versionOptions[installVersionIndex].versionName
}}
on
{{
versionOptions[installVersionIndex].platform
}}</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versionOptions"
:key="version.versionName"
:value="versionIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active
? 'bg-blue-600 text-white'
: 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ version.versionName }} on
{{ version.platform }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon
class="h-5 w-5"
aria-hidden="true"
/>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
There are no versions to install. Please contact your
server admin or try again later.
</h3>
</div>
</div>
</div>
<div v-if="installDirs">
<Listbox as="div" v-model="installDir">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{
installDirs[installDir]
}}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active
? 'bg-blue-600 text-white'
: 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ dir }}}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon
class="h-5 w-5"
aria-hidden="true"
/>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
</div>
<div
v-if="installError"
class="mt-1 rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ installError }}
</h3>
</div>
</div>
</div>
</div>
<div
class="rounded-b-lg bg-zinc-800 px-4 py-3 sm:flex sm:gap-x-2 sm:flex-row-reverse sm:px-6"
>
<LoadingButton
:disabled="
!(versionOptions && versionOptions.length > 0 && !installDir)
"
:loading="installLoading"
type="submit"
class="w-full sm:w-fit"
>
Install
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="installFlowOpen = false"
ref="cancelButtonRef"
>
Cancel
</button>
</div>
</form>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import {
Dialog,
DialogTitle,
TransitionChild,
TransitionRoot,
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { XCircleIcon } from "@heroicons/vue/24/solid";
import type { Game } from "@prisma/client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
@ -45,4 +335,44 @@ listen(`update_game/${game.value.id}`, (event) => {
});
const bannerUrl = await useObject(game.value.mBannerId);
const installFlowOpen = ref(false);
const versionOptions = ref<
undefined | Array<{ versionName: string; platform: string }>
>();
const installDirs = ref<undefined | Array<string>>();
async function installFlow() {
installFlowOpen.value = true;
try {
versionOptions.value = await invoke("fetch_game_verion_options", {
gameId: game.value.id,
});
installDirs.value = await invoke("fetch_download_dir_stats");
} catch (error) {
installError.value = (error as string).toString();
}
}
const installLoading = ref(false);
const installError = ref<string | undefined>();
const installVersionIndex = ref(0);
const installDir = ref(0);
async function install() {
try {
if (!versionOptions.value)
throw new Error("Versions have not been loaded.");
installLoading.value = true;
await invoke("download_game", {
gameId: game.value.id,
gameVersion: versionOptions.value[installVersionIndex.value].versionName,
installDir: installDir.value,
});
installLoading.value = false;
installFlowOpen.value = false;
} catch (error) {
installError.value = (error as string).toString();
}
}
</script>

View File

@ -39,11 +39,6 @@ const versionName = ref("");
const progress = ref(0);
async function startGameDownload() {
await invoke("download_game", {
gameId: gameId.value,
gameVersion: versionName.value,
});
setInterval(() => {
(async () => {
const currentProgress = await invoke<number>(

View File

@ -172,7 +172,6 @@ impl GameDownloadAgent {
pub fn ensure_contexts(&self) -> Result<(), GameDownloadError> {
let context_lock = self.contexts.lock().unwrap();
info!("{:?} {}", context_lock, context_lock.is_empty());
if !context_lock.is_empty() {
return Ok(());
}
@ -209,7 +208,7 @@ impl GameDownloadAgent {
for (i, length) in chunk.lengths.iter().enumerate() {
contexts.push(DropDownloadContext {
file_name: raw_path.to_string(),
version: version.to_string(),
version: chunk.versionName.to_string(),
offset: running_offset,
index: i,
game_id: game_id.to_string(),

View File

@ -8,13 +8,14 @@ use crate::AppState;
pub fn download_game(
game_id: String,
game_version: String,
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
state
.lock()
.unwrap()
.download_manager
.queue_game(game_id, game_version, 0)
.queue_game(game_id, game_version, install_dir)
.map_err(|_| "An error occurred while communicating with the download manager.".to_string())
}

View File

@ -3,7 +3,7 @@ use crate::db::DatabaseImpls;
use crate::downloads::manifest::DropDownloadContext;
use crate::remote::RemoteAccessError;
use crate::DB;
use log::info;
use log::{info, warn};
use md5::{Context, Digest};
use reqwest::blocking::Response;
@ -150,6 +150,13 @@ pub fn download_game_chunk(
.send()
.map_err(|e| GameDownloadError::Communication(e.into()))?;
if response.status() != 200 {
warn!("{}", response.text().unwrap());
return Err(GameDownloadError::Communication(
RemoteAccessError::InvalidCodeError(400),
));
}
let mut destination = DropWriter::new(ctx.path);
if ctx.offset != 0 {
@ -160,7 +167,9 @@ pub fn download_game_chunk(
let content_length = response.content_length();
if content_length.is_none() {
return Err(GameDownloadError::Communication(RemoteAccessError::InvalidResponse));
return Err(GameDownloadError::Communication(
RemoteAccessError::InvalidResponse,
));
}
let mut pipeline = DropDownloadPipeline::new(
@ -176,7 +185,9 @@ pub fn download_game_chunk(
return Ok(false);
};
let checksum = pipeline.finish().map_err(|e| GameDownloadError::IoError(e))?;
let checksum = pipeline
.finish()
.map_err(|e| GameDownloadError::IoError(e))?;
let res = hex::encode(checksum.0);
if res != ctx.checksum {

View File

@ -8,6 +8,7 @@ use std::{
};
use log::{error, info, warn};
use rustbreak::Database;
use tauri::{AppHandle, Emitter};
use crate::{db::DatabaseGameStatus, library::GameUpdateEvent, DB};
@ -247,6 +248,11 @@ impl DownloadManagerBuilder {
let mut lock = current_status.status.lock().unwrap();
*lock = GameDownloadStatus::Error;
self.set_status(DownloadManagerStatus::Error(error));
self.set_game_status(
self.current_game_interface.as_ref().unwrap().id.clone(),
DatabaseGameStatus::Remote,
);
}
fn manage_cancel_signal(&mut self, game_id: String) {
if let Some(current_flag) = &self.active_control_flag {

View File

@ -9,6 +9,7 @@ pub struct DropChunk {
pub ids: Vec<String>,
pub checksums: Vec<String>,
pub lengths: Vec<usize>,
pub versionName: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]

View File

@ -19,7 +19,7 @@ use downloads::download_manager::DownloadManager;
use downloads::download_manager_builder::DownloadManagerBuilder;
use env_logger::Env;
use http::{header::*, response::Builder as ResponseBuilder};
use library::{fetch_game, fetch_game_status, fetch_library, Game};
use library::{fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, Game};
use log::{debug, info};
use remote::{gen_drop_url, use_remote, RemoteAccessError};
use serde::{Deserialize, Serialize};
@ -131,6 +131,7 @@ pub fn run() {
delete_download_dir,
fetch_download_dir_stats,
fetch_game_status,
fetch_game_verion_options,
// Downloads
download_game,
get_current_game_download_progress,

View File

@ -39,6 +39,19 @@ pub struct GameUpdateEvent {
pub status: DatabaseGameStatus,
}
// Game version with some fields missing and size information
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameVersionOption {
version_index: usize,
version_name: String,
platform: String,
setup_command: String,
launch_command: String,
delta: bool,
// total_size: usize,
}
fn fetch_library_logic(app: AppHandle) -> Result<String, RemoteAccessError> {
let base_url = DB.fetch_base_url();
let library_url = base_url.join("/api/v1/client/user/library")?;
@ -172,3 +185,32 @@ pub fn fetch_game_status(id: String) -> Result<DatabaseGameStatus, String> {
return Ok(status);
}
fn fetch_game_verion_options_logic(game_id: String) -> Result<Vec<GameVersionOption>, RemoteAccessError> {
let base_url = DB.fetch_base_url();
let endpoint =
base_url.join(format!("/api/v1/client/metadata/versions?id={}", game_id).as_str())?;
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(endpoint.to_string())
.header("Authorization", header)
.send()?;
if response.status() != 200 {
return Err(RemoteAccessError::InvalidCodeError(
response.status().into(),
));
}
let data = response.json::<Vec<GameVersionOption>>()?;
return Ok(data);
}
#[tauri::command]
pub fn fetch_game_verion_options(game_id: String) -> Result<Vec<GameVersionOption>, String> {
fetch_game_verion_options_logic(game_id).map_err(|e| e.to_string())
}