Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/db/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ impl From<legacy::GamePrefs> 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,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/prefs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ pub struct GamePrefs {
pub custom_args_enabled: bool,
pub launch_mode: LaunchMode,
pub platform: Option<Platform>,
pub show_steam_launch_options: bool,
}

impl Default for Prefs {
Expand Down
16 changes: 14 additions & 2 deletions src-tauri/src/profile/launch/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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<String>) -> Result<()> {
if app.lock_prefs().pull_before_launch {
sync::pull_profile(false, &app).await?;
}

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(())
}
Expand Down Expand Up @@ -43,3 +43,15 @@ pub fn open_game_dir(app: AppHandle) -> Result<()> {

Ok(())
}

#[command]
pub fn get_steam_launch_options(app: AppHandle) -> Result<Vec<super::LaunchOption>> {
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)?)
}
71 changes: 69 additions & 2 deletions src-tauri/src/profile/launch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
}

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<String>,
) -> 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)?;

Expand Down Expand Up @@ -230,3 +252,48 @@ fn exe_path(game_dir: &Path) -> Result<PathBuf> {
.map(|entry| entry.path())
.ok_or_eyre("game executable not found")
}

pub fn parse_steam_launch_options(steam_id: u32) -> Result<Vec<LaunchOption>> {
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)
}
53 changes: 52 additions & 1 deletion src-tauri/src/profile/launch/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -132,6 +132,57 @@ fn read_steam_registry() -> Result<PathBuf> {
Ok(PathBuf::from(path))
}

pub fn get_steam_launch_options(app_id: u32) -> Result<serde_json::Value> {
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<serde_json::Value> {
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<String, Value> = 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<Command> {
let Some(epic) = &game.platforms.epic_games else {
bail!("{} is not available on Epic Games", game.name)
Expand Down
4 changes: 3 additions & 1 deletion src/lib/api/profile/launch.ts
Original file line number Diff line number Diff line change
@@ -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<string>('get_launch_args');
export const openGameDir = () => invoke('open_game_dir');
export const getSteamLaunchOptions = () => invoke<LaunchOption[]>('get_steam_launch_options');
84 changes: 84 additions & 0 deletions src/lib/components/dialogs/LaunchOptionsDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<script lang="ts">
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { RadioGroup } from 'bits-ui';
import type { LaunchOption } from '$lib/types';
import { formatLaunchOptionName } from '$lib/util';

interface Props {
open: boolean;
options: LaunchOption[];
gameName: string;
onselect: (args: string) => void;
}

let { open = $bindable(), options, gameName, onselect }: Props = $props();
let selectedOption = $state<string>(options[0]?.arguments ?? '');

function launch() {
open = false;
onselect(selectedOption);
}

function handleCancel() {
open = false;
}

$effect(() => {
if (options.length > 0 && !options.find((o) => o.arguments === selectedOption)) {
selectedOption = options[0].arguments;
}
});
</script>

<ConfirmDialog bind:open title="Launch {gameName}" onCancel={handleCancel}>
<p class="text-primary-400 mb-4">Select how you want to launch the game:</p>

<div class="max-h-80 overflow-y-auto">
<RadioGroup.Root bind:value={selectedOption} class="flex flex-col gap-1">
{#each options as option}
<RadioGroup.Item
value={option.arguments}
class={[
'flex cursor-pointer items-center rounded-lg border p-3',
selectedOption === option.arguments
? 'border-primary-500 bg-primary-700'
: 'hover:bg-primary-700 border-transparent'
]}
>
<div class="mr-3 flex h-5 w-5 items-center justify-center">
<div
class={[
'flex h-4 w-4 items-center justify-center rounded-full border-2',
selectedOption === option.arguments ? 'border-accent-400' : 'border-primary-400'
]}
>
{#if selectedOption === option.arguments}
<div class="bg-accent-400 h-2 w-2 rounded-full"></div>
{/if}
</div>
</div>
<div class="flex text-left">
<div class="font-medium text-white">
{formatLaunchOptionName(option.type, gameName, option.description)}
</div>
</div>
</RadioGroup.Item>
{/each}
</RadioGroup.Root>
</div>

<div class="text-primary-400 mt-4 text-xs">
You can disable this dialog by turning off "Show Steam launch options" in <a
href="/prefs"
onclick={() => (open = false)}
class="text-primary-400 hover:text-primary-300 underline"
>
Settings</a
>
</div>

{#snippet buttons()}
<Button icon="mdi:play-circle" onclick={launch}>Launch</Button>
{/snippet}
</ConfirmDialog>
Loading