retrom-plugin-installer 0.3.3

Retrom plugin that facilitates the installation of games locally.
use serde::de::DeserializeOwned;
use std::{
    collections::{HashMap, HashSet},
    fs::create_dir,
    path::PathBuf,
};
use tauri::{plugin::PluginApi, AppHandle, Emitter, Manager, Runtime};
use tracing::{debug, info, instrument, trace};

use retrom_codegen::retrom::{InstallationProgressUpdate, InstallationStatus};
use tokio::sync::RwLock;

pub fn init<R: Runtime, C: DeserializeOwned>(
    app: &AppHandle<R>,
    _api: PluginApi<R, C>,
) -> crate::Result<Installer<R>> {
    let app_data = app.path().app_data_dir()?;

    if !app_data.try_exists().unwrap() {
        tracing::info!("Creating app data directory.");
        create_dir(&app_data)?;
    };

    let install_dir = app_data.join("installed");

    if !install_dir.try_exists().unwrap() {
        tracing::info!("Creating install directory.");
        create_dir(&install_dir)?;
    };

    let mut installed_games = HashSet::new();
    for dir in install_dir.read_dir()? {
        let dir = dir?;
        let game_id = dir.file_name().to_string_lossy().parse::<i32>();

        if game_id.is_err() {
            continue;
        }

        if dir.path().is_dir() {
            installed_games.insert(game_id?);
        }
    }

    let installer = Installer {
        app_handle: app.clone(),
        installation_directory: RwLock::new(install_dir),
        installed_games: RwLock::new(installed_games),
        currently_installing: RwLock::new(HashMap::new()),
    };

    Ok(installer)
}

type GameId = i32;
type FileId = i32;

#[derive(Debug)]
pub struct Installer<R: Runtime> {
    app_handle: AppHandle<R>,
    pub(crate) installation_directory: RwLock<PathBuf>,
    pub(crate) installed_games: RwLock<HashSet<GameId>>,
    pub(crate) currently_installing: RwLock<HashMap<GameId, GameInstallationProgress>>,
}

impl<R: Runtime> Installer<R> {
    #[instrument(skip(self), fields(installation_progress))]
    pub async fn mark_game_installing(&self, installation_progress: GameInstallationProgress) {
        info!("Installing game: {}", installation_progress.game_id);

        let game_id = installation_progress.game_id;
        self.currently_installing
            .write()
            .await
            .insert(game_id, installation_progress);

        debug!("Game installation started: {}", game_id);
    }

    #[instrument(skip(self), fields(game_id, field_id))]
    pub async fn mark_file_installed(&self, game_id: GameId, file_id: FileId) {
        debug!("Marking file as installed: {}", file_id);

        self.currently_installing
            .write()
            .await
            .get_mut(&game_id)
            .unwrap()
            .files
            .retain(|f| f.file_id != file_id);

        let files_remaining = self
            .currently_installing
            .read()
            .await
            .get(&game_id)
            .unwrap()
            .files
            .len();

        if files_remaining == 0 {
            self.mark_game_installed(game_id).await;
        }
    }

    #[instrument(skip(self), fields(game_id))]
    pub async fn mark_game_installed(&self, game_id: GameId) {
        info!("Marking game as installed: {}", game_id);

        self.currently_installing.write().await.remove(&game_id);
        self.installed_games.write().await.insert(game_id);

        self.app_handle
            .emit("game-installed", game_id)
            .expect("Error emitting event");
    }

    #[instrument(skip(self))]
    pub async fn mark_game_uninstalled(&self, game_id: GameId) {
        info!("Marking game as uninstalled: {}", game_id);

        self.installed_games.write().await.remove(&game_id);
    }

    #[instrument(skip(self), fields(game_id))]
    pub async fn get_game_installation_status(&self, game_id: GameId) -> InstallationStatus {
        trace!("Checking game installation status: {}", game_id);

        if self
            .currently_installing
            .read()
            .await
            .contains_key(&game_id)
        {
            InstallationStatus::Installing
        } else if self.installed_games.read().await.contains(&game_id) {
            InstallationStatus::Installed
        } else {
            InstallationStatus::NotInstalled
        }
    }

    #[instrument(skip(self), fields(game_id))]
    pub async fn get_game_installation_percent(&self, game_id: GameId) -> Option<u32> {
        trace!("Checking game installation progress: {}", game_id);

        let installation = self.currently_installing.read().await;
        let installation = installation.get(&game_id)?;
        let total_size = installation
            .files
            .iter()
            .map(|f| f.total_size)
            .sum::<usize>() as f32;

        let bytes_read = installation
            .files
            .iter()
            .map(|f| f.bytes_read)
            .sum::<usize>() as f32;

        let percent = (bytes_read / total_size * 100.0) as u32;

        Some(percent)
    }

    #[instrument(skip(self), fields(game_id, file_id, bytes_read))]
    pub async fn update_installation_progress(
        &self,
        game_id: GameId,
        file_id: FileId,
        bytes_read: usize,
    ) {
        trace!(
            "Updating installation progress: game {} file {} - {} bytes read",
            game_id,
            file_id,
            bytes_read
        );

        let cur_percent = self.get_game_installation_percent(game_id).await.unwrap();

        {
            let mut installation = self.currently_installing.write().await;
            let installation = installation.get_mut(&game_id).unwrap();
            let file = installation
                .files
                .iter_mut()
                .find(|f| f.file_id == file_id)
                .unwrap();

            file.bytes_read += bytes_read;
        }

        let new_percent = self.get_game_installation_percent(game_id).await.unwrap();
        let progress = InstallationProgressUpdate {
            game_id,
            progress: new_percent,
        };

        if new_percent != cur_percent {
            self.app_handle
                .emit("install-progress", progress)
                .expect("Error emitting event");
        }
    }

    #[instrument(skip(self), fields(game_id))]
    pub async fn get_game_installation_path(&self, game_id: GameId) -> Option<PathBuf> {
        trace!("Getting game installation path: {}", game_id);

        let install_dir = self.installation_directory.read().await;
        let game_dir = install_dir.join(game_id.to_string());

        if game_dir.try_exists().unwrap() {
            Some(game_dir)
        } else {
            None
        }
    }
}

#[derive(Debug)]
pub struct FileInstallationProgress {
    pub file_id: FileId,
    pub bytes_read: usize,
    pub total_size: usize,
}

#[derive(Debug)]
pub struct GameInstallationProgress {
    pub game_id: GameId,
    pub files: Vec<FileInstallationProgress>,
}