refactor(game status): transient vs synced state now defined

This commit is contained in:
DecDuck
2024-12-23 20:44:02 +11:00
parent 64ebc191bf
commit 42c0198f1d
14 changed files with 220 additions and 145 deletions

View File

@ -30,8 +30,8 @@ import { GameStatusEnum, type GameStatus } from "~/types.js";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
(e: "install"): void;
(e: "cancel"): void;
(e: "play"): void;
(e: "queue"): void;
}>();
const styles: { [key in GameStatusEnum]: string } = {
@ -71,11 +71,11 @@ const buttonIcons: { [key in GameStatusEnum]: Component } = {
const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.Remote]: () => emit("install"),
[GameStatusEnum.Queued]: () => emit("cancel"),
[GameStatusEnum.Downloading]: () => emit("cancel"),
[GameStatusEnum.Queued]: () => emit("queue"),
[GameStatusEnum.Downloading]: () => emit("queue"),
[GameStatusEnum.SetupRequired]: () => {},
[GameStatusEnum.Installed]: () => emit("play"),
[GameStatusEnum.Updating]: () => emit("cancel"),
[GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => {},
};
</script>

View File

@ -1,14 +1,36 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus } from "~/types";
import type { Game, GameStatus, GameStatusEnum } from "~/types";
const gameRegistry: { [key: string]: Game } = {};
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
type OptionGameStatus = { [key in GameStatusEnum]: { version_name?: string } };
export type SerializedGameStatus = [
{ type: GameStatusEnum },
OptionGameStatus | null
];
const parseStatus = (status: SerializedGameStatus): GameStatus => {
if (status[0]) {
return {
type: status[0].type,
};
} else if (status[1]) {
const [[gameStatus, options]] = Object.entries(status[1]);
return {
type: gameStatus as GameStatusEnum,
...options,
};
} else {
throw new Error("No game status");
}
};
export const useGame = async (id: string) => {
if (!gameRegistry[id]) {
const data: { game: Game; status: GameStatus } = await invoke(
const data: { game: Game; status: SerializedGameStatus } = await invoke(
"fetch_game",
{
id,
@ -16,11 +38,13 @@ export const useGame = async (id: string) => {
);
gameRegistry[id] = data.game;
if (!gameStatusRegistry[id]) {
gameStatusRegistry[id] = ref(data.status);
gameStatusRegistry[id] = ref(parseStatus(data.status));
listen(`update_game/${id}`, (event) => {
const payload: { status: GameStatus } = event.payload as any;
gameStatusRegistry[id].value = payload.status;
const payload: {
status: SerializedGameStatus;
} = event.payload as any;
gameStatusRegistry[id].value = parseStatus(payload.status);
});
}
}

View File

@ -18,10 +18,14 @@ export function setupHooks() {
router.push("/store");
});
/*
document.addEventListener("contextmenu", (event) => {
event.target?.dispatchEvent(new Event("contextmenu"));
event.preventDefault();
});
*/
}
export function initialNavigation(state: Ref<AppState>) {

View File

@ -6,7 +6,7 @@
<div class="absolute flex top-0 h-fit inset-x-0 z-[-20]">
<img :src="bannerUrl" class="w-full h-auto object-cover" />
<h1
class="absolute inset-x-0 w-fit mx-auto text-center top-32 -translate-y-[50%] text-4xl text-zinc-100 font-bold font-display z-50 p-4 shadow-xl bg-zinc-900/80 rounded"
class="absolute inset-x-0 w-fit mx-auto text-center top-32 -translate-y-[50%] text-4xl text-zinc-100 font-bold font-display z-50 p-4 shadow-xl bg-zinc-900/80 rounded-xl"
>
{{ game.mName }}
</h1>
@ -17,40 +17,23 @@
<!-- main page -->
<div class="w-full min-h-screen mx-auto bg-zinc-900 px-5 py-6">
<!-- game toolbar -->
<div>
<div class="h-full flex flex-row gap-x-4 items-stretch">
<GameStatusButton
@install="() => installFlow()"
@play="() => play()"
@queue="() => queue()"
:status="status"
/>
</div>
<a
:href="remoteUrl"
target="_blank"
type="button"
class="inline-flex items-center rounded-md bg-zinc-800/50 px-4 font-semibold text-white shadow-sm hover:bg-zinc-800/80 uppercase font-display"
>
<BuildingStorefrontIcon class="mr-2 size-5" aria-hidden="true" />
<div class="flex flex-row">
<div>
<div
v-if="showPreview"
v-html="previewHTML"
class="mt-12 prose prose-invert prose-blue max-w-none"
/>
<div
v-else
v-html="descriptionHTML"
class="mt-12 prose prose-invert prose-blue max-w-none"
/>
<button
v-if="showReadMore"
class="mt-8 w-full inline-flex items-center gap-x-6"
@click="() => (showPreview = !showPreview)"
>
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
<span
class="uppercase text-sm font-semibold font-display text-zinc-600"
>Click to read {{ showPreview ? "more" : "less" }}</span
>
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
</button>
</div>
Store
</a>
</div>
</div>
</div>
@ -349,45 +332,23 @@ import {
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core";
import MarkdownIt from "markdown-it";
import moment from "moment";
const route = useRoute();
const router = useRouter();
const id = route.params.id.toString();
const { game: rawGame, status } = await useGame(id);
const game = ref(rawGame);
const remoteUrl: string = await invoke("gen_drop_url", {
path: `/store/${game.value.id}`,
});
const bannerUrl = await useObject(game.value.mBannerId);
const md = MarkdownIt();
const showPreview = ref(true);
const gameDescriptionCharacters = game.value.mDescription.split("");
// First new line after x characters
const descriptionSplitIndex = gameDescriptionCharacters.findIndex(
(v, i, arr) => {
// If we're at the last element, we return true.
// So we don't have to handle a -1 from this findIndex
if (i + 1 == arr.length) return true;
if (i < 500) return false;
if (v != "\n") return false;
return true;
}
);
const previewDescription = gameDescriptionCharacters
.slice(0, descriptionSplitIndex + 1) // Slice a character after
.join("");
const previewHTML = md.render(previewDescription);
const descriptionHTML = md.render(game.value.mDescription);
const showReadMore = previewHTML != descriptionHTML;
const installFlowOpen = ref(false);
const versionOptions = ref<
undefined | Array<{ versionName: string; platform: string }>
@ -432,8 +393,11 @@ async function play() {
try {
await invoke("launch_game", { gameId: game.value.id });
} catch (e) {
game.value.mName = e as string;
console.error(e);
}
}
async function queue() {
router.push("/queue");
}
</script>

View File

@ -5,9 +5,9 @@
<li
v-if="games[element.id]"
:key="element.id"
class="mb-4 bg-zinc-900 rounded-lg relative flex justify-between gap-x-6 py-5 px-4"
class="mb-4 bg-zinc-900 rounded-lg flex flex-row justify-between gap-x-6 py-5 px-4"
>
<div class="flex items-center max-w-md gap-x-4">
<div class="w-full flex items-center max-w-md gap-x-4 relative">
<img
class="size-24 flex-none bg-zinc-800 object-cover rounded"
:src="games[element.id].cover"
@ -15,7 +15,7 @@
/>
<div class="min-w-0 flex-auto">
<p class="text-xl font-semibold text-zinc-100">
<NuxtLink :href="`/library/${element.id}`">
<NuxtLink :href="`/library/${element.id}`" class="">
<span class="absolute inset-x-0 -top-px bottom-0" />
{{ games[element.id].game.mName }}
</NuxtLink>
@ -40,10 +40,12 @@
/>
</div>
</div>
<ChevronRightIcon
class="size-5 flex-none text-gray-400"
aria-hidden="true"
/>
<button @click="() => cancelGame(element.id)" class="group">
<XMarkIcon
class="transition size-8 flex-none text-zinc-600 group-hover:text-zinc-300"
aria-hidden="true"
/>
</button>
</div>
</li>
<p v-else>Loading...</p>
@ -59,6 +61,7 @@
</template>
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import type { Game, GameStatus } from "~/types";
@ -94,4 +97,8 @@ async function onEnd(event: { oldIndex: number; newIndex: number }) {
newIndex: event.newIndex,
});
}
async function cancelGame(id: string) {
await invoke("cancel_game", { gameId: id });
}
</script>

View File

@ -24,11 +24,8 @@ pub struct DatabaseAuth {
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum DatabaseGameStatus {
pub enum GameStatus {
Remote {},
Downloading {
version_name: String,
},
SetupRequired {
version_name: String,
install_dir: String,
@ -37,10 +34,14 @@ pub enum DatabaseGameStatus {
version_name: String,
install_dir: String,
},
Updating {
version_name: String,
},
}
// Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize)]
pub enum GameTransientStatus {
Downloading { version_name: String },
Uninstalling {},
Updating { version_name: String },
}
#[derive(Serialize, Deserialize, Clone)]
@ -58,8 +59,11 @@ pub struct GameVersion {
pub struct DatabaseGames {
pub install_dirs: Vec<String>,
// Guaranteed to exist if the game also exists in the app state map
pub games_statuses: HashMap<String, DatabaseGameStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub statuses: HashMap<String, GameStatus>,
pub versions: HashMap<String, HashMap<String, GameVersion>>,
#[serde(skip)]
pub transient_statuses: HashMap<String, GameTransientStatus>,
}
#[derive(Serialize, Clone, Deserialize)]
@ -119,8 +123,9 @@ impl DatabaseImpls for DatabaseInterface {
base_url: "".to_string(),
games: DatabaseGames {
install_dirs: vec![games_base_dir.to_str().unwrap().to_string()],
games_statuses: HashMap::new(),
game_versions: HashMap::new(),
statuses: HashMap::new(),
transient_statuses: HashMap::new(),
versions: HashMap::new(),
},
};
debug!(

View File

@ -40,6 +40,11 @@ pub fn move_game_in_queue(
.rearrange(old_index, new_index)
}
#[tauri::command]
pub fn cancel_game(state: tauri::State<'_, Mutex<AppState>>, game_id: String) {
state.lock().unwrap().download_manager.cancel(game_id)
}
/*
#[tauri::command]
pub fn get_current_write_speed(state: tauri::State<'_, Mutex<AppState>>) {}

View File

@ -33,7 +33,10 @@ pub enum DownloadManagerSignal {
/// download, sync everything to disk, and
/// then exit
Finish,
/// Stops (but doesn't remove) current download
Cancel,
/// Removes a given game
Remove(String),
/// Any error which occurs in the agent
Error(GameDownloadError),
/// Pushes UI update
@ -142,6 +145,11 @@ impl DownloadManager {
.send(DownloadManagerSignal::Update)
.unwrap();
}
pub fn cancel(&self, game_id: String) {
self.command_sender
.send(DownloadManagerSignal::Remove(game_id))
.unwrap();
}
pub fn rearrange(&self, current_index: usize, new_index: usize) {
if current_index == new_index {
return;
@ -159,8 +167,8 @@ impl DownloadManager {
let mut queue = self.edit();
let to_move = queue.remove(current_index).unwrap();
queue.insert(new_index, to_move);
info!("new queue: {:?}", queue);
drop(queue);
if needs_pause {
self.command_sender.send(DownloadManagerSignal::Go).unwrap();

View File

@ -2,7 +2,7 @@ use std::{
collections::HashMap,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex,
Arc, Mutex, RwLockWriteGuard,
},
thread::{spawn, JoinHandle},
};
@ -11,8 +11,9 @@ use log::{error, info};
use tauri::{AppHandle, Emitter};
use crate::{
db::DatabaseGameStatus,
db::{Database, GameStatus, GameTransientStatus},
library::{on_game_complete, GameUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData},
state::GameStatusManager,
DB,
};
@ -107,14 +108,18 @@ impl DownloadManagerBuilder {
DownloadManager::new(terminator, queue, active_progress, command_sender)
}
fn set_game_status(&self, id: String, status: DatabaseGameStatus) {
fn set_game_status<F: FnOnce(&mut RwLockWriteGuard<'_, Database>, &String) -> ()>(
&self,
id: String,
setter: F,
) {
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.games_statuses
.insert(id.clone(), status.clone());
setter(&mut db_handle, &id);
drop(db_handle);
DB.save().unwrap();
let status = GameStatusManager::fetch_state(&id);
self.app_handle
.emit(
&format!("update_game/{}", id),
@ -208,10 +213,35 @@ impl DownloadManagerBuilder {
self.stop_and_wait_current_download();
return Ok(());
}
DownloadManagerSignal::Remove(game_id) => {
self.manage_remove_game(game_id);
}
};
}
}
fn manage_remove_game(&mut self, game_id: String) {
if let Some(current_download) = &self.current_download_agent {
if current_download.id == game_id {
self.manage_cancel_signal();
}
}
let index = self.download_queue.get_by_id(game_id.clone()).unwrap();
let mut queue_handle = self.download_queue.edit();
queue_handle.remove(index);
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
drop(queue_handle);
if self.current_download_agent.is_none() {
self.manage_go_signal();
}
self.push_manager_update();
}
fn manage_stop_signal(&mut self) {
info!("Got signal 'Stop'");
self.set_status(DownloadManagerStatus::Paused);
@ -273,7 +303,12 @@ impl DownloadManagerBuilder {
.insert(interface_data.id.clone(), download_agent);
self.download_queue.append(interface_data);
self.set_game_status(id, DatabaseGameStatus::Downloading { version_name });
self.set_game_status(id, |db, id| {
db.games.transient_statuses.insert(
id.to_string(),
GameTransientStatus::Downloading { version_name },
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
@ -344,10 +379,12 @@ impl DownloadManagerBuilder {
// Set flags for download manager
active_control_flag.set(DownloadThreadControlFlag::Go);
self.set_status(DownloadManagerStatus::Downloading);
self.set_game_status(
self.current_download_agent.as_ref().unwrap().id.clone(),
DatabaseGameStatus::Downloading { version_name },
);
self.set_game_status(agent_data.id.clone(), |db, id| {
db.games.transient_statuses.insert(
id.to_string(),
GameTransientStatus::Downloading { version_name },
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
@ -361,7 +398,9 @@ impl DownloadManagerBuilder {
self.set_status(DownloadManagerStatus::Error(error));
let game_id = current_status.id.clone();
self.set_game_status(game_id, DatabaseGameStatus::Remote {});
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}

View File

@ -5,7 +5,7 @@ mod library;
mod process;
mod remote;
mod settings;
mod state;
#[cfg(test)]
mod tests;
@ -178,6 +178,7 @@ pub fn run() {
move_game_in_queue,
pause_game_downloads,
resume_game_downloads,
cancel_game,
// Processes
launch_game,
])

View File

@ -5,18 +5,19 @@ use tauri::Emitter;
use tauri::{AppHandle, Manager};
use urlencoding::encode;
use crate::db::DatabaseGameStatus;
use crate::db::DatabaseImpls;
use crate::db::GameVersion;
use crate::db::{GameStatus, GameTransientStatus};
use crate::downloads::download_manager::GameDownloadStatus;
use crate::process::process_manager::Platform;
use crate::remote::RemoteAccessError;
use crate::state::{GameStatusManager, GameStatusWithTransient};
use crate::{auth::generate_authorization_header, AppState, DB};
#[derive(serde::Serialize)]
pub struct FetchGameStruct {
game: Game,
status: DatabaseGameStatus,
status: GameStatusWithTransient,
}
#[derive(Serialize, Deserialize, Clone)]
@ -36,7 +37,7 @@ pub struct Game {
#[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent {
pub game_id: String,
pub status: DatabaseGameStatus,
pub status: (Option<GameStatus>, Option<GameTransientStatus>),
}
#[derive(Serialize, Clone)]
@ -61,6 +62,7 @@ pub struct GameVersionOption {
setup_command: String,
launch_command: String,
delta: bool,
umu_id_override: Option<String>,
// total_size: usize,
}
@ -89,11 +91,11 @@ fn fetch_library_logic(app: AppHandle) -> Result<Vec<Game>, RemoteAccessError> {
for game in games.iter() {
handle.games.insert(game.id.clone(), game.clone());
if !db_handle.games.games_statuses.contains_key(&game.id) {
if !db_handle.games.statuses.contains_key(&game.id) {
db_handle
.games
.games_statuses
.insert(game.id.clone(), DatabaseGameStatus::Remote {});
.statuses
.insert(game.id.clone(), GameStatus::Remote {});
}
}
@ -116,16 +118,11 @@ fn fetch_game_logic(
let game = state_handle.games.get(&id);
if let Some(game) = game {
let db_handle = DB.borrow_data().unwrap();
let status = GameStatusManager::fetch_state(&id);
let data = FetchGameStruct {
game: game.clone(),
status: db_handle
.games
.games_statuses
.get(&game.id)
.unwrap()
.clone(),
status,
};
return Ok(data);
@ -158,28 +155,23 @@ fn fetch_game_logic(
db_handle
.games
.games_statuses
.entry(id)
.or_insert(DatabaseGameStatus::Remote {});
.statuses
.entry(id.clone())
.or_insert(GameStatus::Remote {});
drop(db_handle);
let status = GameStatusManager::fetch_state(&id);
let data = FetchGameStruct {
game: game.clone(),
status: db_handle
.games
.games_statuses
.get(&game.id)
.unwrap()
.clone(),
status,
};
Ok(data)
}
#[tauri::command]
pub fn fetch_game(
id: String,
app: tauri::AppHandle,
) -> Result<FetchGameStruct, String> {
pub fn fetch_game(id: String, app: tauri::AppHandle) -> Result<FetchGameStruct, String> {
let result = fetch_game_logic(id, app);
if result.is_err() {
@ -190,15 +182,8 @@ pub fn fetch_game(
}
#[tauri::command]
pub fn fetch_game_status(id: String) -> Result<DatabaseGameStatus, String> {
let db_handle = DB.borrow_data().unwrap();
let status = db_handle
.games
.games_statuses
.get(&id)
.unwrap_or(&DatabaseGameStatus::Remote {})
.clone();
drop(db_handle);
pub fn fetch_game_status(id: String) -> Result<GameStatusWithTransient, String> {
let status = GameStatusManager::fetch_state(&id);
Ok(status)
}
@ -277,7 +262,7 @@ pub fn on_game_complete(
let mut handle = DB.borrow_data_mut().unwrap();
handle
.games
.game_versions
.versions
.entry(game_id.clone())
.or_default()
.insert(version_name.clone(), data.clone());
@ -285,12 +270,12 @@ pub fn on_game_complete(
DB.save().unwrap();
let status = if data.setup_command.is_empty() {
DatabaseGameStatus::Installed {
GameStatus::Installed {
version_name,
install_dir,
}
} else {
DatabaseGameStatus::SetupRequired {
GameStatus::SetupRequired {
version_name,
install_dir,
}
@ -299,14 +284,17 @@ pub fn on_game_complete(
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.games_statuses
.statuses
.insert(game_id.clone(), status.clone());
drop(db_handle);
DB.save().unwrap();
app_handle
.emit(
&format!("update_game/{}", game_id),
GameUpdateEvent { game_id, status },
GameUpdateEvent {
game_id,
status: (Some(status), None),
},
)
.unwrap();

View File

@ -10,7 +10,7 @@ use log::info;
use serde::{Deserialize, Serialize};
use crate::{
db::{DatabaseGameStatus, DATA_ROOT_DIR},
db::{GameStatus, DATA_ROOT_DIR},
DB,
};
@ -74,11 +74,11 @@ impl ProcessManager {
let db_lock = DB.borrow_data().unwrap();
let game_status = db_lock
.games
.games_statuses
.statuses
.get(&game_id)
.ok_or("Game not installed")?;
let DatabaseGameStatus::Installed {
let GameStatus::Installed {
version_name,
install_dir,
} = game_status
@ -88,7 +88,7 @@ impl ProcessManager {
let game_version = db_lock
.games
.game_versions
.versions
.get(&game_id)
.ok_or("Invalid game ID".to_owned())?
.get(version_name)

View File

@ -1 +0,0 @@

31
src-tauri/src/state.rs Normal file
View File

@ -0,0 +1,31 @@
use std::collections::HashMap;
use crate::{
db::{GameStatus, GameTransientStatus},
DB,
};
pub type GameStatusWithTransient = (
Option<GameStatus>,
Option<GameTransientStatus>,
);
pub struct GameStatusManager {}
impl GameStatusManager {
pub fn fetch_state(game_id: &String) -> GameStatusWithTransient {
let db_lock = DB.borrow_data().unwrap();
let offline_state = db_lock.games.statuses.get(game_id).cloned();
let online_state = db_lock.games.transient_statuses.get(game_id).cloned();
drop(db_lock);
if online_state.is_some() {
return (None, online_state);
}
if offline_state.is_some() {
return (offline_state, None);
}
return (None, None);
}
}