diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index b61f239..7794959 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -63,10 +63,17 @@ struct UiActiveOperation { #[derive(Clone, Debug, serde::Serialize)] struct GamesListPayload { - games: Vec, + games: Vec, active_operations: Vec, } +#[derive(Clone, Debug, serde::Serialize)] +struct LauncherGame { + #[serde(flatten)] + game: Game, + can_host_server: bool, +} + #[derive(Clone, Debug, serde::Serialize)] struct UnpackLogEntry { archive: String, @@ -104,6 +111,17 @@ async fn get_unpack_logs( #[cfg(target_os = "windows")] const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const GAME_SETUP_SCRIPT: &str = "game_setup.cmd"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const GAME_START_SCRIPT: &str = "game_start.cmd"; +const SERVER_START_SCRIPT: &str = "server_start.cmd"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const DEFAULT_LANGUAGE: &str = "en"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const DEFAULT_USERNAME: &str = "Commander"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const MAX_USERNAME_CHARS: usize = 24; #[tauri::command] async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> { @@ -381,6 +399,57 @@ fn is_single_component_game_id(id: &str) -> bool { matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() } +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +struct LaunchSettings { + language: String, + username: String, +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn launch_settings(language: &str, username: &str) -> LaunchSettings { + LaunchSettings { + language: sanitize_language(language), + username: sanitize_username(username), + } +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn sanitize_language(language: &str) -> String { + match language.trim().to_ascii_lowercase().as_str() { + "de" => "de".to_string(), + "en" => "en".to_string(), + _ => DEFAULT_LANGUAGE.to_string(), + } +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn sanitize_username(username: &str) -> String { + let cleaned = username + .trim() + .chars() + .filter(|c| !c.is_control() && *c != '"' && *c != '%') + .take(MAX_USERNAME_CHARS) + .collect::(); + + if cleaned.is_empty() { + DEFAULT_USERNAME.to_string() + } else { + cleaned + } +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String { + format!( + r#"/d /s /c ""{}" "local" "{}" "{}" "{}"""#, + script_path.display(), + id, + settings.language, + settings.username, + ) +} + #[tauri::command] async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result { let peer_ctrl_arc = state.inner().peer_ctrl.clone(); @@ -445,8 +514,16 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool { #[cfg(target_os = "windows")] async fn run_game_windows( id: String, + language: String, + username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result<()> { + if !is_single_component_game_id(&id) { + log::warn!("Ignoring run request for invalid game id: {id}"); + return Ok(()); + } + + let settings = launch_settings(&language, &username); let games_folder_lock = state.inner().games_folder.clone(); let games_folder = { let guard = games_folder_lock.read().await; @@ -461,8 +538,8 @@ async fn run_game_windows( let game_path = games_folder.join(id.clone()); - let game_setup_bin = game_path.join("game_setup.cmd"); - let game_start_bin = game_path.join("game_start.cmd"); + let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT); + let game_start_bin = game_path.join(GAME_START_SCRIPT); let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE); if !first_start_done_file.exists() && game_setup_bin.exists() { @@ -476,16 +553,12 @@ async fn run_game_windows( let result = run_as_admin( "cmd.exe", - &format!( - r#"/c "{} local {} de playername""#, - game_setup_bin.display(), - &id - ), + &script_params(&game_setup_bin, &id, &settings), &game_path.display().to_string(), ); if !result { - log::error!("failed to run game_setup.cmd"); + log::error!("failed to run {GAME_SETUP_SCRIPT}"); return Ok(()); } @@ -500,16 +573,12 @@ async fn run_game_windows( if game_start_bin.exists() { let result = run_as_admin( "cmd.exe", - &format!( - r#"/c "{} local {} de playername""#, - game_start_bin.display(), - &id - ), + &script_params(&game_start_bin, &id, &settings), &game_path.display().to_string(), ); if !result { - log::error!("failed to run game_start.cmd"); + log::error!("failed to run {GAME_START_SCRIPT}"); } } @@ -517,21 +586,97 @@ async fn run_game_windows( } #[tauri::command] -async fn run_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> { +async fn run_game( + id: String, + language: String, + username: String, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result<()> { #[cfg(target_os = "windows")] { - run_game_windows(id, state).await?; + run_game_windows(id, language, username, state).await?; } #[cfg(not(target_os = "windows"))] { - let _ = state; + let _ = (state, language, username); log::error!("run_game not implemented for this platform: id={id}"); } Ok(()) } +#[cfg(target_os = "windows")] +async fn start_server_windows( + id: String, + language: String, + username: String, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { + if !is_single_component_game_id(&id) { + log::warn!("Ignoring server start request for invalid game id: {id}"); + return Ok(false); + } + + let settings = launch_settings(&language, &username); + let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone()); + if !games_folder.exists() { + log::error!("games_folder {} does not exist", games_folder.display()); + return Ok(false); + } + + let game_path = games_folder.join(id.clone()); + if !local_install_is_present(&game_path) { + log::warn!( + "local install is missing for {}; skipping {SERVER_START_SCRIPT}", + game_path.display() + ); + return Ok(false); + } + + let server_start_bin = game_path.join(SERVER_START_SCRIPT); + if !server_start_bin.is_file() { + log::warn!( + "server start script is missing for {}: {}", + id, + server_start_bin.display() + ); + return Ok(false); + } + + let result = run_as_admin( + "cmd.exe", + &script_params(&server_start_bin, &id, &settings), + &game_path.display().to_string(), + ); + + if !result { + log::error!("failed to run {SERVER_START_SCRIPT}"); + } + + Ok(result) +} + +#[tauri::command] +async fn start_server( + id: String, + language: String, + username: String, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { + #[cfg(target_os = "windows")] + { + start_server_windows(id, language, username, state).await + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (state, language, username); + log::error!("start_server not implemented for this platform: id={id}"); + Ok(false) + } +} + #[cfg(target_os = "windows")] fn local_install_is_present(game_path: &Path) -> bool { game_path.join("local").is_dir() @@ -592,6 +737,7 @@ async fn emit_games_list(app_handle: &AppHandle) { let games_db_lock = state.games.clone(); let game_db = games_db_lock.read().await; + let games_folder = state.games_folder.read().await.clone(); if game_db.games.is_empty() { log::debug!("Game database empty; skipping emit"); @@ -602,7 +748,11 @@ async fn emit_games_list(app_handle: &AppHandle) { .all_games() .into_iter() .cloned() - .collect::>(); + .map(|game| LauncherGame { + can_host_server: game_can_host_server(&games_folder, &game), + game, + }) + .collect::>(); drop(game_db); @@ -623,6 +773,17 @@ async fn emit_games_list(app_handle: &AppHandle) { } } +fn game_can_host_server(games_folder: &str, game: &Game) -> bool { + if !game.installed || games_folder.is_empty() || !is_single_component_game_id(&game.id) { + return false; + } + + PathBuf::from(games_folder) + .join(&game.id) + .join(SERVER_START_SCRIPT) + .is_file() +} + fn ui_active_operations_from_map( active_operations: &HashMap, ) -> Vec { @@ -1440,6 +1601,68 @@ mod tests { assert!(!is_single_component_game_id("/game")); } + #[test] + fn launch_settings_sanitize_script_arguments() { + assert_eq!( + launch_settings("DE", " Alice \"Ace\"%PATH%\n "), + LaunchSettings { + language: "de".to_string(), + username: "Alice AcePATH".to_string(), + } + ); + assert_eq!( + launch_settings("fr", ""), + LaunchSettings { + language: DEFAULT_LANGUAGE.to_string(), + username: DEFAULT_USERNAME.to_string(), + } + ); + } + + #[test] + fn script_params_use_common_argument_shape() { + let params = script_params( + Path::new("C:/Games/My Game") + .join(GAME_START_SCRIPT) + .as_path(), + "my-game", + &LaunchSettings { + language: "en".to_string(), + username: "Alice".to_string(), + }, + ); + + assert_eq!( + params, + r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""# + ); + } + + #[test] + fn server_host_capability_requires_installed_game_with_script() { + let root = std::env::temp_dir().join(format!( + "lanspread-server-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )); + let game_root = root.join("game"); + std::fs::create_dir_all(&game_root).expect("game root should be created"); + std::fs::write(game_root.join(SERVER_START_SCRIPT), b"").expect("script should be written"); + + let mut game = game_fixture("game", "Game"); + assert!(!game_can_host_server( + root.to_string_lossy().as_ref(), + &game + )); + + game.installed = true; + assert!(game_can_host_server(root.to_string_lossy().as_ref(), &game)); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() { let mut alpha = game_fixture("alpha", "Catalog Alpha"); @@ -1506,6 +1729,7 @@ pub fn run() { request_games, install_game, run_game, + start_server, update_game_directory, update_game, uninstall_game, diff --git a/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx b/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx index a8a6f7a..7231bea 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx @@ -21,6 +21,15 @@ export const Icon = { ), + server: (p: Props) => ( + + + + + + + + ), install: (p: Props) => ( diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx index 44e0579..52d1acb 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx @@ -16,6 +16,7 @@ interface Props { onUninstall: (game: Game) => void; onRemoveDownload: (game: Game) => void; onCancelDownload: (game: Game) => void; + onStartServer: (game: Game) => void; onViewFiles: (game: Game) => void; } @@ -45,6 +46,7 @@ export const GameDetailModal = ({ onUninstall, onRemoveDownload, onCancelDownload, + onStartServer, onViewFiles, }: Props) => { const tags = tagsFromGame(game); @@ -116,6 +118,16 @@ export const GameDetailModal = ({ onClick={() => onPrimary(game)} onCancelDownload={onCancelDownload} /> + {game.installed && game.can_host_server === true && ( + Start Server + + )} {game.installed && (