retrom-plugin-installer 0.4.7

Retrom plugin that facilitates the installation of games locally.
use std::{collections::HashMap, path::PathBuf, sync::Mutex};

use futures::TryStreamExt;
use reqwest::header::ACCESS_CONTROL_ALLOW_ORIGIN;
use retrom_codegen::retrom::{
    GetGamesRequest, GetPlatformsRequest, InstallGamePayload, InstallationState,
    RetromClientConfig, StorageType, UninstallGamePayload,
};
use retrom_plugin_service_client::RetromPluginServiceClientExt;
use retrom_plugin_steam::SteamExt;
use tauri::{AppHandle, Manager, Runtime};
use tokio::io::AsyncWriteExt;
use tracing::{info, instrument};

use crate::{
    desktop::{FileInstallationProgress, GameInstallationProgress},
    InstallerExt,
};

#[instrument(skip_all, fields(game_id = payload.clone().game.unwrap().id))]
#[tauri::command]
pub async fn install_game<R: Runtime>(
    app_handle: AppHandle<R>,
    payload: InstallGamePayload,
) -> crate::Result<()> {
    let game = payload.game.unwrap();
    let files = payload.files;
    let installer = app_handle.installer();

    let install_dir = installer.installation_directory.read().await;
    let output_directory = install_dir.join(game.id.to_string());

    if !(output_directory.try_exists()?) {
        std::fs::create_dir_all(&output_directory)?;
    }

    if game.third_party {
        let mut platform_svc_client = app_handle.get_platform_client().await;

        let steam_id = u32::try_from(game.steam_app_id()).expect("Could not convert steam id");

        let platform_res = platform_svc_client
            .get_platforms(GetPlatformsRequest {
                ids: vec![game.platform_id()],
                ..Default::default()
            })
            .await?
            .into_inner();

        let platform = platform_res.platforms.into_iter().next();

        let platform_path = match platform {
            Some(platform) => platform.path.clone(),
            None => {
                tracing::error!("No third party platform found for game: {:?}", game);
                return Err(crate::error::Error::ThirdPartyNotFound);
            }
        };

        match platform_path.as_str() {
            "__RETROM_RESERVED__/Steam" => {
                let steam_client = app_handle.steam();

                return Ok(steam_client.install_game(steam_id).await?);
            }
            _ => {
                tracing::error!("No third party platform found for game: {:?}", game);
                return Err(crate::error::Error::ThirdPartyNotFound);
            }
        }
    }

    let installation = GameInstallationProgress {
        game_id: game.id,
        files: files
            .iter()
            .map(|f| FileInstallationProgress {
                file_id: f.id,
                bytes_read: 0,
                total_size: f.byte_size as usize,
            })
            .collect(),
    };

    installer.mark_game_installing(installation).await;
    let client = reqwest::Client::new();

    for file in files {
        let game = game.clone();
        let app_handle = app_handle.clone();
        let output_directory = output_directory.clone();
        let client = client.clone();

        tauri::async_runtime::spawn(async move {
            let config = app_handle.try_state::<Mutex<RetromClientConfig>>();

            let host: String = match config.and_then(|config| {
                config.lock().ok().and_then(|config| {
                    config.server.as_ref().map(|server| {
                        let mut host = server.hostname.to_string();

                        if let Some(port) = server.port {
                            host.push_str(&format!(":{}", port));
                        }

                        host
                    })
                })
            }) {
                Some(host) => host,
                None => {
                    tracing::warn!("No server configuration found");
                    "http://localhost:5101".to_string()
                }
            };

            let download_uri = format!("{host}/rest/file/{}", file.id);

            let res = client
                .get(download_uri)
                .header(ACCESS_CONTROL_ALLOW_ORIGIN, host)
                .send()
                .await?;

            info!("Downloading file: {:?}", res);

            let game_path = PathBuf::from(&game.path);
            let prefix = match game.storage_type() {
                StorageType::MultiFileGame => game_path.clone(),
                StorageType::SingleFileGame => game_path.clone().parent().unwrap().to_path_buf(),
            };

            let file_path = PathBuf::from(file.path);
            let relative_file = file_path
                .strip_prefix(&prefix)
                .expect("could not strip file prefix");

            let absolute_file = output_directory.join(relative_file);
            std::fs::create_dir_all(absolute_file.parent().unwrap())?;

            let installer = app_handle.installer();
            let mut outfile = tokio::fs::File::create(absolute_file).await?;
            let mut stream = res.bytes_stream();

            loop {
                let chunk = stream.try_next().await;
                let bytes = match chunk {
                    Ok(Some(bytes)) => bytes,
                    Ok(None) => break,
                    Err(e) => return Err(crate::Error::from(e)),
                };

                if bytes.is_empty() {
                    break;
                }

                outfile.write_all(&bytes).await?;
                installer
                    .update_installation_progress(game.id, file.id, bytes.len())
                    .await;
            }

            installer.mark_file_installed(game.id, file.id).await;

            Ok(())
        });
    }

    Ok(())
}

#[instrument(skip_all, fields(game_id = payload.clone().game.unwrap().id))]
#[tauri::command]
pub async fn uninstall_game<R: Runtime>(
    app_handle: AppHandle<R>,
    payload: UninstallGamePayload,
) -> crate::Result<()> {
    let game = payload.game.unwrap();

    if game.third_party {
        if let Some(Ok(steam_id)) = game.steam_app_id.map(u32::try_from) {
            let steam_client = app_handle.steam();
            return Ok(steam_client.uninstall_game(steam_id).await?);
        }
    }

    let installer = app_handle.installer();
    let install_dir = installer.installation_directory.read().await;
    let output_directory = install_dir.join(game.id.to_string());

    if output_directory.try_exists()? {
        tokio::fs::remove_dir_all(&output_directory).await?;
    }

    installer.mark_game_uninstalled(game.id).await;

    Ok(())
}

#[instrument(skip(app_handle))]
#[tauri::command]
pub async fn get_game_installation_status<R: Runtime>(
    app_handle: AppHandle<R>,
    game_id: i32,
) -> i32 {
    let installer = app_handle.installer();
    installer.get_game_installation_status(game_id).await.into()
}

#[instrument(skip(app_handle))]
#[tauri::command]
pub async fn get_installation_state<R: Runtime>(app_handle: AppHandle<R>) -> InstallationState {
    let installer = app_handle.installer();
    let mut installation_state: HashMap<i32, i32> = HashMap::new();
    let mut game_svc_client = app_handle.get_game_client().await;

    let game_res = game_svc_client
        .get_games(GetGamesRequest {
            ..Default::default()
        })
        .await
        .expect("Could not get games")
        .into_inner();

    let ids: Vec<i32> = game_res.games.iter().map(|game| game.id).collect();

    for game_id in ids.iter() {
        let status = installer.get_game_installation_status(*game_id).await;

        installation_state.insert(*game_id, status.into());
    }

    for game in game_res.games.into_iter() {
        let game_id = game.id;
        let steam_id = game.steam_app_id;

        if let Some(Ok(steam_id)) = steam_id.map(u32::try_from) {
            let steam_client = app_handle.steam();
            if let Ok(status) = steam_client.get_installation_status(steam_id).await {
                installation_state.insert(game_id, status.into());
            }
        }
    }

    InstallationState { installation_state }
}