retrom-plugin-launcher 0.7.51

Retrom plugin to facilitate the launching of games.
Documentation
use crate::{desktop::GameProcess, LauncherExt, Result};
use prost::Message;
use retrom_codegen::retrom::{
    client::installation::InstallationStatus, emulator::OperatingSystem, GamePlayStatusUpdate,
    GetGamePlayStatusPayload, GetLocalEmulatorConfigsRequest, PlayGamePayload, PlayStatus,
    StopGamePayload,
};
use retrom_plugin_config::ConfigExt;
use retrom_plugin_installer::InstallerExt;
use retrom_plugin_service_client::RetromPluginServiceClientExt;
use retrom_plugin_steam::SteamExt;
use std::{ffi::OsStr, path::PathBuf, sync::Arc};
use tauri::{
    command, http::HeaderValue, AppHandle, Runtime, WebviewUrl, WebviewWindow, WindowEvent,
};
use tokio::sync::Mutex;
use tracing::{info, instrument};
use walkdir::WalkDir;

#[command]
#[instrument(skip_all)]
pub(crate) async fn play_game<R: Runtime>(app: AppHandle<R>, payload: Vec<u8>) -> Result<()> {
    let payload = PlayGamePayload::decode(payload.as_slice())?;
    let launcher = app.launcher();
    let installer = app.installer();
    let standalone = app
        .config_manager()
        .get_config()
        .await
        .server
        .and_then(|s| s.standalone)
        .unwrap_or(false);

    let game = match payload.game {
        Some(game) => game,
        None => return Err(crate::Error::GameNotFound(None)),
    };

    if game.third_party {
        let steam = match app.steam() {
            Some(steam) => steam,
            None => {
                return Err(crate::Error::InternalError(
                    "Steam is not initialized".into(),
                ))
            }
        };

        if let Some(Ok(app_id)) = game.steam_app_id.map(u32::try_from) {
            steam.launch_game(app_id).await?;
            return Ok(());
        }
    }

    let game_id = game.id;
    let profile = payload
        .emulator_profile
        .expect("No emulator profile provided");
    let emulator = payload.emulator.expect("No emulator provided");
    let maybe_default_game_file = payload.file;

    let maybe_default_file = maybe_default_game_file
        .clone()
        .map(|file| file.path)
        .map(PathBuf::from);

    if emulator.libretro_name.is_some()
        && emulator
            .operating_systems
            .contains(&(OperatingSystem::Wasm as i32))
    {
        let (send, mut recv) = tokio::sync::mpsc::channel(1);
        let send = Arc::new(Mutex::new(send));

        launcher
            .mark_game_as_running(
                game_id,
                GameProcess {
                    send: send.clone(),
                    start_time: std::time::SystemTime::now(),
                },
            )
            .await?;

        let app_inner = app.clone();
        app.run_on_main_thread(move || {
            tokio::spawn(async move {
                let web_view = WebviewWindow::builder(
                    &app_inner,
                    "emulator-js",
                    WebviewUrl::App(
                        format!(
                            "/play/{}/frame?coreName={}",
                            game.id,
                            emulator.libretro_name()
                        )
                        .into(),
                    ),
                )
                .title(emulator.name)
                .focused(true)
                .on_web_resource_request(|req, res| {
                    if req.uri().path().ends_with("/frame") {
                        let headers = res.headers_mut();

                        headers.insert(
                            "Cross-Origin-Opener-Policy",
                            HeaderValue::from_static("same-origin"),
                        );
                        headers.insert(
                            "Cross-Origin-Embedder-Policy",
                            HeaderValue::from_static("credentialless"),
                        );
                    }
                })
                .build()
                .expect("Failed to build webview window");

                web_view.on_window_event(move |event| {
                    let send = send.clone();
                    if let WindowEvent::CloseRequested { .. } = event {
                        tokio::spawn(async move {
                            send.lock()
                                .await
                                .send(())
                                .await
                                .expect("Failed to send stop signal");
                        });
                    }
                });
            });
        })?;

        tokio::spawn(async move {
            recv.recv().await;
            app.launcher()
                .mark_game_as_stopped(game_id)
                .await
                .expect("Error stopping game");
        });

        return Ok(());
    }

    if !standalone
        && installer.get_game_installation_status(game_id).await != InstallationStatus::Installed
    {
        return Err(crate::Error::NotInstalled(game_id));
    }

    let install_dir = match standalone {
        true => PathBuf::from(&game.path),
        false => match installer.get_game_installation_path(game_id).await {
            Some(path) => path,
            None => return Err(crate::Error::NotInstalled(game_id)),
        },
    };

    let mut files: Vec<PathBuf> = WalkDir::new(&install_dir)
        .into_iter()
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.into_path())
        .filter(|p| p.is_file())
        .collect();

    files.sort();

    tracing::debug!("Files: {:?}", files);
    tracing::debug!("Default file: {:?}", &maybe_default_file);

    let fallback_file = match profile.supported_extensions.is_empty() {
        true => files
            .iter()
            .find(|file| Some(*file) != maybe_default_file.as_ref()),
        false => files.iter().find(|file| {
            profile
                .supported_extensions
                .iter()
                .any(|ext| file.extension().and_then(OsStr::to_str) == Some(ext.as_str()))
        }),
    };

    tracing::debug!("Fallback file: {:?}", fallback_file);

    let relative_to_install_dir = maybe_default_file
        .as_deref()
        .and_then(|f| f.strip_prefix(&game.path).ok());

    let file_path = match files
        .iter()
        .find(|f| f.strip_prefix(&install_dir).ok() == relative_to_install_dir)
    {
        Some(file) => file,
        None => match fallback_file {
            Some(file) => file,
            None => return Err(crate::Error::FileNotFound(game_id)),
        },
    };

    tracing::debug!("File path: {:?}", file_path);

    let file_path = match file_path.canonicalize()?.to_str() {
        Some(path) => path.to_string(),
        None => return Err(crate::Error::FileNotFound(game_id)),
    };

    let install_dir = match install_dir.canonicalize()?.to_str() {
        Some(path) => path.to_string(),
        None => return Err(crate::Error::FileNotFound(game_id)),
    };

    let client_config = app.config_manager().get_config().await;

    let client_id = client_config
        .config
        .and_then(|c| c.client_info.map(|info| info.id))
        .expect("Client ID not found");

    let mut emulator_client = app.get_emulator_client().await;
    let res = emulator_client
        .get_local_emulator_configs(GetLocalEmulatorConfigsRequest {
            client_id,
            emulator_ids: vec![emulator.id],
        })
        .await
        .expect("Failed to get local emulator configs")
        .into_inner();

    let local_config = res.configs.first().expect("No emulator config found");

    let mut cmd = launcher.get_open_cmd(&local_config.executable_path);

    tracing::Span::current().record("command", format!("{cmd:?}"));
    info!("Command: {cmd:?}");

    let args = if !profile.custom_args.is_empty() {
        #[allow(clippy::literal_string_with_formatting_args)]
        profile
            .custom_args
            .into_iter()
            .map(|arg| match arg.starts_with("\"") && arg.ends_with("\"") {
                false => arg,
                true => arg[1..arg.len() - 1].to_string(),
            })
            .map(|arg| match arg.starts_with("'") && arg.ends_with("'") {
                false => arg,
                true => arg[1..arg.len() - 1].to_string(),
            })
            .map(|arg| arg.replace("{file}", &file_path))
            .map(|arg| arg.replace("{install_dir}", &install_dir))
            .collect()
    } else {
        vec![file_path]
    };

    tracing::Span::current().record("args", format!("{args:?}"));
    info!("Args Constructed: {:?}", args);

    cmd.args(args);

    let (send, mut recv) = tokio::sync::mpsc::channel(1);
    let mut process = cmd.spawn()?;
    let send = Arc::new(Mutex::new(send));

    launcher
        .mark_game_as_running(
            game_id,
            GameProcess {
                send: send.clone(),
                start_time: std::time::SystemTime::now(),
            },
        )
        .await?;

    let app = app.clone();
    tokio::select! {
        _ = recv.recv() => {
            info!("Recieved stop signal for game {game_id}");

            process.kill().await?;
            info!("Killed game process for game {game_id}");

            app.launcher().mark_game_as_stopped(game_id).await?;
        }
        _ = process.wait() => {
            info!("Game process for game {game_id} was exited");

            app.launcher()
                .mark_game_as_stopped(game_id)
                .await
                .expect("Error stopping game");
        }
    };

    Ok(())
}

#[command]
#[instrument(skip_all)]
pub(crate) async fn stop_game<R: Runtime>(app: AppHandle<R>, payload: Vec<u8>) -> Result<()> {
    let payload = StopGamePayload::decode(payload.as_slice())?;
    let launcher = app.launcher();
    let game = payload.game;

    let game_id = match game {
        Some(game) => game.id,
        None => return Err(crate::Error::GameNotFound(None)),
    };

    launcher.stop_game(game_id).await?;

    Ok(())
}

#[command]
#[instrument(skip_all)]
pub(crate) async fn get_game_play_status<R: Runtime>(
    app: AppHandle<R>,
    payload: Vec<u8>,
) -> Result<Vec<u8>> {
    let launcher = app.launcher();
    let payload = GetGamePlayStatusPayload::decode(payload.as_slice())?;
    let game = payload.game;

    let game_id = match &game {
        Some(game) => game.id,
        None => return Err(crate::Error::GameNotFound(None)),
    };

    let res = if launcher.is_game_running(game_id).await {
        GamePlayStatusUpdate {
            game_id,
            play_status: PlayStatus::Playing.into(),
        }
    } else {
        GamePlayStatusUpdate {
            game_id,
            play_status: PlayStatus::NotPlaying.into(),
        }
    };

    Ok(res.encode_to_vec())
}