diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a1027fe6..44ee7c12 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1861,6 +1861,7 @@ dependencies = [ "itertools 0.13.0", "justerror", "keyring", + "new-vdf-parser", "open", "rayon", "reqwest", @@ -3332,6 +3333,17 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "new-vdf-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfae50b7ad7d93142e170e4f86d90599282ef7f5108fad4155610cf0aacf1f5" +dependencies = [ + "rayon", + "serde", + "serde_json", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a20696d..2511e360 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -77,6 +77,7 @@ flate2 = "1" font-kit = "0.14" internment = { version = "0.8.6", features = ["serde"] } reqwest-websocket = { version = "0.5.0", features = ["json"] } +new-vdf-parser = "0.1.0" [target.'cfg(target_os="windows")'.dependencies] winreg = "0.52" diff --git a/src-tauri/src/db/migrate.rs b/src-tauri/src/db/migrate.rs index 94b414f5..b4c52ab0 100644 --- a/src-tauri/src/db/migrate.rs +++ b/src-tauri/src/db/migrate.rs @@ -152,6 +152,7 @@ impl From for GamePrefs { custom_args_enabled: legacy.custom_args.is_some(), launch_mode: legacy.launch_mode.into(), platform: legacy.platform.map(Into::into), + show_steam_launch_options: false, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f12eddc1..e405cdd6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,7 @@ pub fn run() { profile::launch::commands::launch_game, profile::launch::commands::get_launch_args, profile::launch::commands::open_game_dir, + profile::launch::commands::get_steam_launch_options, profile::install::commands::install_all_mods, profile::install::commands::install_mod, profile::install::commands::cancel_all_installs, diff --git a/src-tauri/src/prefs/mod.rs b/src-tauri/src/prefs/mod.rs index 97e33ab2..233ee1af 100644 --- a/src-tauri/src/prefs/mod.rs +++ b/src-tauri/src/prefs/mod.rs @@ -198,6 +198,7 @@ pub struct GamePrefs { pub custom_args_enabled: bool, pub launch_mode: LaunchMode, pub platform: Option, + pub show_steam_launch_options: bool, } impl Default for Prefs { diff --git a/src-tauri/src/profile/launch/commands.rs b/src-tauri/src/profile/launch/commands.rs index c66bc494..1c94947d 100644 --- a/src-tauri/src/profile/launch/commands.rs +++ b/src-tauri/src/profile/launch/commands.rs @@ -5,7 +5,7 @@ use tauri::{command, AppHandle}; use crate::{profile::sync, state::ManagerExt, util::cmd::Result}; #[command] -pub async fn launch_game(app: AppHandle) -> Result<()> { +pub async fn launch_game(app: AppHandle, args: Option) -> Result<()> { if app.lock_prefs().pull_before_launch { sync::pull_profile(false, &app).await?; } @@ -13,7 +13,7 @@ pub async fn launch_game(app: AppHandle) -> Result<()> { let prefs = app.lock_prefs(); let manager = app.lock_manager(); - manager.active_game().launch(&prefs, &app)?; + manager.active_game().launch_with_args(&prefs, &app, args)?; Ok(()) } @@ -43,3 +43,15 @@ pub fn open_game_dir(app: AppHandle) -> Result<()> { Ok(()) } + +#[command] +pub fn get_steam_launch_options(app: AppHandle) -> Result> { + let manager = app.lock_manager(); + let managed_game = manager.active_game(); + let game_name = &managed_game.game.name; + let Some(steam) = &managed_game.game.platforms.steam else { + return Err(eyre::eyre!("{} is not available on Steam", game_name).into()); + }; + + Ok(super::parse_steam_launch_options(steam.id)?) +} diff --git a/src-tauri/src/profile/launch/mod.rs b/src-tauri/src/profile/launch/mod.rs index e8dc5e77..67629c45 100644 --- a/src-tauri/src/profile/launch/mod.rs +++ b/src-tauri/src/profile/launch/mod.rs @@ -5,7 +5,7 @@ use std::{ process::Command, }; -use eyre::{bail, ensure, eyre, OptionExt, Result}; +use eyre::{bail, ensure, eyre, Context, OptionExt, Result}; use serde::{Deserialize, Serialize}; use tauri::AppHandle; use tokio::time::Duration; @@ -39,14 +39,36 @@ pub enum LaunchMode { Direct { instances: u32, interval_secs: f32 }, } +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +pub struct LaunchOption { + pub arguments: String, + #[serde(rename = "type")] + pub launch_type: String, + pub description: Option, +} + impl ManagedGame { pub fn launch(&self, prefs: &Prefs, app: &AppHandle) -> Result<()> { + self.launch_with_args(prefs, app, None) + } + + pub fn launch_with_args( + &self, + prefs: &Prefs, + app: &AppHandle, + args: Option, + ) -> Result<()> { let game_dir = locate_game_dir(self.game, prefs)?; if let Err(err) = self.copy_required_files(&game_dir) { warn!("failed to copy required files to game directory: {:#}", err); } - let (launch_mode, command) = self.launch_command(&game_dir, prefs)?; + let (launch_mode, mut command) = self.launch_command(&game_dir, prefs)?; + + if let Some(args) = args { + command.args(args.split_whitespace()); + } + info!("launching {} with command {:?}", self.game.slug, command); do_launch(command, app, launch_mode)?; @@ -230,3 +252,48 @@ fn exe_path(game_dir: &Path) -> Result { .map(|entry| entry.path()) .ok_or_eyre("game executable not found") } + +pub fn parse_steam_launch_options(steam_id: u32) -> Result> { + let raw_options = platform::get_steam_launch_options(steam_id) + .context("failed to get Steam launch options")?; + + let mut launch_options = Vec::new(); + + if let Some(options_obj) = raw_options.as_object() { + for (_, option_value) in options_obj.iter() { + if let Some(option) = option_value.as_object() { + // TODO: Figure out how to properly filter by active beta branch. + // Need to find where Steam stores info about which beta branch is active for an app. + if let Some(config) = option.get("config") { + if config.get("BetaKey").is_some() { + continue; + } + } + + let option_type = option + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("undefined"); + + let arguments = option + .get("arguments") + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); + + let description = option + .get("description") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + + launch_options.push(LaunchOption { + arguments, + launch_type: option_type.to_string(), + description, + }); + } + } + } + + Ok(launch_options) +} diff --git a/src-tauri/src/profile/launch/platform.rs b/src-tauri/src/profile/launch/platform.rs index c0e01ee9..e9bdd6ea 100644 --- a/src-tauri/src/profile/launch/platform.rs +++ b/src-tauri/src/profile/launch/platform.rs @@ -3,7 +3,7 @@ use std::{ process::Command, }; -use eyre::{bail, ensure, Context, OptionExt, Result}; +use eyre::{bail, ensure, eyre, Context, OptionExt, Result}; use tracing::{info, warn}; use crate::{ @@ -132,6 +132,57 @@ fn read_steam_registry() -> Result { Ok(PathBuf::from(path)) } +pub fn get_steam_launch_options(app_id: u32) -> Result { + let app_info = get_steam_app_info(app_id)?; + + app_info + .get("config") + .and_then(|config| config.get("launch")) + .cloned() + .ok_or_else(|| eyre!("no launch options found for app ID {}", app_id)) +} + +pub fn get_steam_app_info(app_id: u32) -> Result { + use new_vdf_parser::appinfo_vdf_parser::open_appinfo_vdf; + use serde_json::{Map, Value}; + + let steam_command = create_base_steam_command()?; + let steam_binary = steam_command.get_program(); + let steam_path = Path::new(steam_binary) + .parent() + .ok_or_eyre("steam binary has no parent directory")? + .to_path_buf(); + drop(steam_command); + + let appinfo_path = steam_path.join("appcache").join("appinfo.vdf"); + + ensure!( + appinfo_path.exists(), + "steam appinfo.vdf not found at {}", + appinfo_path.display() + ); + + info!("reading Steam app info from {}", appinfo_path.display()); + + let appinfo_vdf: Map = open_appinfo_vdf(&appinfo_path); + + let entries = appinfo_vdf + .get("entries") + .and_then(|e| e.as_array()) + .ok_or_eyre("no entries found in appinfo.vdf")?; + + entries + .iter() + .find(|entry| { + entry + .get("appid") + .and_then(|id| id.as_u64()) + .map_or(false, |id| id == app_id as u64) + }) + .cloned() + .ok_or_else(|| eyre!("app ID {} not found in Steam appinfo.vdf", app_id)) +} + fn create_epic_command(game: Game) -> Result { let Some(epic) = &game.platforms.epic_games else { bail!("{} is not available on Epic Games", game.name) diff --git a/src/lib/api/profile/launch.ts b/src/lib/api/profile/launch.ts index ba42ebff..ddad11a3 100644 --- a/src/lib/api/profile/launch.ts +++ b/src/lib/api/profile/launch.ts @@ -1,5 +1,7 @@ import { invoke } from '$lib/invoke'; +import type { LaunchOption } from '$lib/types'; -export const launchGame = () => invoke('launch_game'); +export const launchGame = (args?: string) => invoke('launch_game', { args }); export const getArgs = () => invoke('get_launch_args'); export const openGameDir = () => invoke('open_game_dir'); +export const getSteamLaunchOptions = () => invoke('get_steam_launch_options'); diff --git a/src/lib/components/dialogs/LaunchOptionsDialog.svelte b/src/lib/components/dialogs/LaunchOptionsDialog.svelte new file mode 100644 index 00000000..3a0abb3e --- /dev/null +++ b/src/lib/components/dialogs/LaunchOptionsDialog.svelte @@ -0,0 +1,84 @@ + + + +

Select how you want to launch the game:

+ +
+ + {#each options as option} + +
+
+ {#if selectedOption === option.arguments} +
+ {/if} +
+
+
+
+ {formatLaunchOptionName(option.type, gameName, option.description)} +
+
+
+ {/each} +
+
+ +
+ You can disable this dialog by turning off "Show Steam launch options" in (open = false)} + class="text-primary-400 hover:text-primary-300 underline" + > + Settings +
+ + {#snippet buttons()} + + {/snippet} +
diff --git a/src/lib/components/toolbar/Toolbar.svelte b/src/lib/components/toolbar/Toolbar.svelte index 5b42d4d4..856a8450 100644 --- a/src/lib/components/toolbar/Toolbar.svelte +++ b/src/lib/components/toolbar/Toolbar.svelte @@ -1,5 +1,6 @@
@@ -80,3 +119,10 @@ Last updated {timeSinceGamesUpdate} ago
+ + diff --git a/src/lib/types.ts b/src/lib/types.ts index 0fd3cded..7221c441 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -306,6 +306,7 @@ export type GamePrefs = { customArgsEnabled: boolean; launchMode: LaunchMode; platform: Platform | null; + showSteamLaunchOptions: boolean; }; export type Platform = 'steam' | 'epicGames' | 'oculus' | 'origin' | 'xboxStore'; @@ -328,3 +329,30 @@ export type ModContextItem = { export type Zoom = { factor: number } | { delta: number }; export type MarkdownType = 'readme' | 'changelog'; + +export type LaunchOptionType = + | 'none' + | 'default' + | 'application' + | 'safemode' + | 'multiplayer' + | 'config' + | 'vr' + | 'server' + | 'editor' + | 'manual' + | 'benchmark' + | 'option1' + | 'option2' + | 'option3' + | 'othervr' + | 'openvroverlay' + | 'osvr' + | 'openxr' + | { unknown: string }; + +export interface LaunchOption { + arguments: string; + type: LaunchOptionType; + description?: string; +} diff --git a/src/lib/util.ts b/src/lib/util.ts index 34af746f..543df040 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -4,6 +4,7 @@ import { type SyncUser, type Game, type MarkdownType, + type LaunchOptionType, ModType } from './types'; import { convertFileSrc } from '@tauri-apps/api/core'; @@ -33,6 +34,53 @@ export function formatTime(seconds: number): string { return `${hours} hour${hours > 1 ? 's' : ''}`; } +export function formatLaunchOptionName( + type: LaunchOptionType, + gameName: string, + description?: string +): string { + switch (type) { + case 'none': + case 'default': + return `Play ${gameName}`; + case 'application': + return `Launch ${gameName}`; + case 'safemode': + return `Launch ${gameName} in Safe Mode`; + case 'multiplayer': + return `Launch ${gameName} in Multiplayer Mode`; + case 'config': + return 'Launch Controller Layout Tool'; + case 'vr': + return `Launch ${gameName} in Steam VR Mode`; + case 'server': + return 'Launch Dedicated Server'; + case 'editor': + return 'Launch Game Editor'; + case 'manual': + return 'Show Manual'; + case 'benchmark': + return 'Launch Benchmark Tool'; + case 'option1': + case 'option2': + case 'option3': + return description ? `Play ${description}` : `Play ${gameName} (${type})`; + case 'othervr': + return `Launch ${gameName} in Oculus VR Mode`; + case 'openvroverlay': + return `Launch ${gameName} as Steam VR Overlay`; + case 'osvr': + return `Launch ${gameName} in OSVR Mode`; + case 'openxr': + return `Launch ${gameName} in OpenXR Mode`; + default: + if (typeof type === 'object' && 'unknown' in type) { + return `Launch ${gameName} (${type.unknown})`; + } + return `Launch ${gameName}`; + } +} + export function shortenNum(value: number): string { var i = value == 0 ? 0 : Math.floor(Math.log(value) / Math.log(1000)); if (i === 0) { diff --git a/src/routes/prefs/+page.svelte b/src/routes/prefs/+page.svelte index 05b08ca0..8e267c3f 100644 --- a/src/routes/prefs/+page.svelte +++ b/src/routes/prefs/+page.svelte @@ -29,13 +29,18 @@ let gameSlug = $derived(games.active?.slug ?? ''); + let shownPlatform = $derived.by( + () => gamePrefs?.platform ?? games.active?.platforms[0] ?? 'Unknown' + ); + $effect(() => { gamePrefs = prefs?.gamePrefs.get(gameSlug) ?? { launchMode: { type: 'launcher' }, dirOverride: null, customArgs: [], customArgsEnabled: false, - platform: null + platform: null, + showSteamLaunchOptions: false }; }); @@ -168,11 +173,23 @@ Launch (gamePrefs!.launchMode = value))} /> + {#if gamePrefs.launchMode.type === 'launcher' && shownPlatform === 'steam'} + (gamePrefs!.showSteamLaunchOptions = value))} + > + When enabled, displays Steam launch options defined by the game developer (if any) before + starting the game. These options may include different game modes like VR, Safe Mode, + Dedicated Server, or other launch configurations specific to the game. + + {/if} +