Skip to main content

modde_games/
launcher.rs

1//! Launcher detection and configuration for Wine/Proton DLL overrides.
2//!
3//! After deploying mods that include proxy DLLs (e.g. `version.dll` for CET),
4//! Wine/Proton needs `WINEDLLOVERRIDES` set so it loads the native (mod) version
5//! instead of its built-in stub. This module detects the game launcher and
6//! updates its configuration automatically.
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use modde_core::resolver::GameId;
12use serde_json::Value;
13use tracing::{debug, info, warn};
14
15/// Detected game launcher type.
16#[derive(Debug)]
17pub enum Launcher {
18    /// Heroic Games Launcher — config at `~/.config/heroic/GamesConfig/<id>.json`
19    Heroic {
20        config_path: PathBuf,
21        game_id: String,
22    },
23    /// Steam — uses launch options in Steam client
24    Steam { app_id: String },
25    /// Unknown launcher — print instructions for manual setup
26    Unknown,
27}
28
29/// Structured result of launcher configuration work.
30#[derive(Debug, Clone, Default)]
31pub struct LauncherConfigurationReport {
32    pub wine_overrides: Option<WineOverrideReport>,
33    pub launch_wrapper: Option<LaunchWrapperReport>,
34    pub wrapper_registration: Option<WrapperRegistrationReport>,
35}
36
37impl LauncherConfigurationReport {
38    #[must_use]
39    pub fn is_empty(&self) -> bool {
40        self.wine_overrides.is_none()
41            && self.launch_wrapper.is_none()
42            && self.wrapper_registration.is_none()
43    }
44}
45
46/// Result of applying or instructing Wine DLL override configuration.
47#[derive(Debug, Clone)]
48pub enum WineOverrideReport {
49    HeroicUpdated { value: String },
50    SteamInstruction { override_value: String },
51    UnknownInstruction { override_value: String },
52}
53
54/// Result of generating the modde launch wrapper.
55#[derive(Debug, Clone)]
56pub struct LaunchWrapperReport {
57    pub path: PathBuf,
58    pub restore_count: usize,
59    pub tool_env_var_count: usize,
60}
61
62/// Result of registering, or instructing the user to register, a wrapper.
63#[derive(Debug, Clone)]
64pub enum WrapperRegistrationReport {
65    HeroicRegistered,
66    ManualInstruction { wrapper_path: PathBuf },
67}
68
69/// Detect which launcher manages a game at the given install path.
70#[must_use]
71pub fn detect_launcher(game_dir: &Path) -> Launcher {
72    // Check for Heroic: game paths typically contain "heroic" or match a Heroic library
73    if let Some(launcher) = detect_heroic(game_dir) {
74        return launcher;
75    }
76
77    // Check for Steam: game is under steamapps/common/
78    if let Some(app_id) = detect_steam(game_dir) {
79        return Launcher::Steam { app_id };
80    }
81
82    Launcher::Unknown
83}
84
85/// Try to detect Heroic launcher by scanning its `GamesConfig` directory.
86fn detect_heroic(game_dir: &Path) -> Option<Launcher> {
87    let config_dir = modde_core::paths::heroic_config_dir()?;
88    let games_config = config_dir.join("GamesConfig");
89
90    if !games_config.is_dir() {
91        return None;
92    }
93
94    // Read each game config and check if its install path matches
95    let entries = std::fs::read_dir(&games_config).ok()?;
96    for entry in entries.flatten() {
97        let path = entry.path();
98        if path.extension().and_then(|e| e.to_str()) != Some("json") {
99            continue;
100        }
101
102        // The filename is the game ID (e.g., "1423049311.json")
103        let game_id = path.file_stem()?.to_string_lossy().to_string();
104
105        // Also check the Heroic library for the install path
106        if heroic_game_matches(&config_dir, &game_id, game_dir) {
107            return Some(Launcher::Heroic {
108                config_path: path,
109                game_id,
110            });
111        }
112    }
113
114    None
115}
116
117/// Check if a Heroic game entry matches the given game directory.
118fn heroic_game_matches(config_dir: &Path, game_id: &str, game_dir: &Path) -> bool {
119    // Check installed.json files for each store (GOG, Epic/Legendary, etc.)
120    let installed_files = [
121        config_dir.join("gog_store/installed.json"),
122        config_dir.join("legendary_store/installed.json"),
123        config_dir.join("sideload_apps/installed.json"),
124    ];
125
126    for installed_path in &installed_files {
127        let data = match std::fs::read_to_string(installed_path) {
128            Ok(d) => d,
129            Err(e) => {
130                debug!(error = %e, path = %installed_path.display(), "failed to read Heroic installed file");
131                continue;
132            }
133        };
134        let val: Value = match serde_json::from_str(&data) {
135            Ok(v) => v,
136            Err(e) => {
137                warn!(error = %e, path = %installed_path.display(), "failed to parse Heroic installed JSON");
138                continue;
139            }
140        };
141        if let Some(games) = val.get("installed").and_then(|v| v.as_array()) {
142            for game in games {
143                if game.get("appName").and_then(|v| v.as_str()) == Some(game_id)
144                    && let Some(install_path) = game.get("install_path").and_then(|v| v.as_str())
145                {
146                    let canonical_game = game_dir
147                        .canonicalize()
148                        .unwrap_or_else(|_| game_dir.to_path_buf());
149                    let canonical_install = PathBuf::from(install_path)
150                        .canonicalize()
151                        .unwrap_or_else(|_| PathBuf::from(install_path));
152                    return canonical_game == canonical_install;
153                }
154            }
155        }
156    }
157
158    false
159}
160
161/// Try to detect Steam by checking if the game is under steamapps/common/.
162fn detect_steam(game_dir: &Path) -> Option<String> {
163    let path_str = game_dir.to_string_lossy().replace('\\', "/");
164    if path_str.contains("steamapps/common/") {
165        // Try to find the appmanifest to get the app ID
166        if let Some(steamapps) = game_dir
167            .ancestors()
168            .find(|p| p.file_name().and_then(|f| f.to_str()) == Some("common"))
169            .and_then(|p| p.parent())
170        {
171            let game_name = game_dir.file_name()?.to_string_lossy();
172            let manifests = std::fs::read_dir(steamapps).ok()?;
173            for entry in manifests.flatten() {
174                let name = entry.file_name();
175                let name_str = name.to_string_lossy();
176                if name_str.starts_with("appmanifest_")
177                    && name_str.ends_with(".acf")
178                    && let Ok(content) = std::fs::read_to_string(entry.path())
179                    && content.contains(&*game_name)
180                {
181                    let app_id = name_str
182                        .strip_prefix("appmanifest_")?
183                        .strip_suffix(".acf")?
184                        .to_string();
185                    return Some(app_id);
186                }
187            }
188        }
189    }
190    None
191}
192
193/// Generate a Unix (Linux/macOS) bash wrapper script.
194#[cfg(unix)]
195fn generate_wrapper_unix(
196    wrapper_dir: &Path,
197    restore_commands: &[(String, String)],
198    tool_env_vars: &[(String, String)],
199    game_id: &GameId,
200    modde_bin: &str,
201) -> (PathBuf, String) {
202    let wrapper_path = wrapper_dir.join("modde-launch-wrapper.sh");
203
204    let mut script = String::from("#!/usr/bin/env bash\n");
205    script.push_str("# Auto-generated by modde — tool env vars + fgmod DLL restoration\n");
206    script.push_str("# Do not edit manually; re-run `modde deploy` to regenerate.\n\n");
207
208    if !tool_env_vars.is_empty() {
209        script.push_str("# Tool environment variables\n");
210        for (key, value) in tool_env_vars {
211            script.push_str(&format!("export {key}=\"{value}\"\n"));
212        }
213        script.push('\n');
214    }
215
216    if !restore_commands.is_empty() {
217        script.push_str("# Restore mod DLLs that fgmod deletes\n");
218        for (src, dest) in restore_commands {
219            script.push_str(&format!(
220                "cp -f \"{src}\" \"{dest}\" 2>/dev/null && echo \"modde: restored $(basename \"{dest}\")\"\n"
221            ));
222        }
223        script.push('\n');
224    }
225
226    script.push_str("\"$@\"\n");
227    script.push_str("status=$?\n\n");
228    script.push_str(&format!(
229        "# Auto-capture saves after game exit\n\
230         \"{modde_bin}\" save auto-capture --game {game_id} 2>/dev/null &\n\n\
231         exit $status\n"
232    ));
233
234    (wrapper_path, script)
235}
236
237/// Generate a Windows `.cmd` wrapper script.
238#[cfg(windows)]
239fn generate_wrapper_windows(
240    wrapper_dir: &Path,
241    restore_commands: &[(String, String)],
242    tool_env_vars: &[(String, String)],
243    game_id: &GameId,
244    modde_bin: &str,
245) -> (PathBuf, String) {
246    let wrapper_path = wrapper_dir.join("modde-launch-wrapper.cmd");
247
248    let mut script = String::from("@echo off\r\n");
249    script.push_str("REM Auto-generated by modde — tool env vars + fgmod DLL restoration\r\n");
250    script.push_str("REM Do not edit manually; re-run `modde deploy` to regenerate.\r\n\r\n");
251
252    if !tool_env_vars.is_empty() {
253        script.push_str("REM Tool environment variables\r\n");
254        for (key, value) in tool_env_vars {
255            script.push_str(&format!("set \"{key}={value}\"\r\n"));
256        }
257        script.push_str("\r\n");
258    }
259
260    if !restore_commands.is_empty() {
261        script.push_str("REM Restore mod DLLs that fgmod deletes\r\n");
262        for (src, dest) in restore_commands {
263            script.push_str(&format!(
264                "copy /Y \"{src}\" \"{dest}\" >nul 2>nul && echo modde: restored {dest}\r\n"
265            ));
266        }
267        script.push_str("\r\n");
268    }
269
270    script.push_str("%*\r\n");
271    script.push_str("set status=%ERRORLEVEL%\r\n\r\n");
272    script.push_str(&format!(
273        "REM Auto-capture saves after game exit\r\n\
274         start \"\" /B \"{modde_bin}\" save auto-capture --game {game_id} 2>nul\r\n\r\n\
275         exit /b %status%\r\n"
276    ));
277
278    (wrapper_path, script)
279}
280
281/// Generate a modde launch wrapper script that restores mod DLLs deleted by fgmod
282/// and exports tool environment variables.
283///
284/// fgmod cleans up certain DLLs (winmm.dll, dxgi.dll, etc.) before installing `OptiScaler`.
285/// If mods deploy those same DLLs (e.g. `RED4ext` uses winmm.dll), we need to restore them
286/// after fgmod runs but before the game starts.
287///
288/// Additionally, the wrapper exports env vars for any enabled tools (`MangoHud`, vkBasalt, etc.)
289/// so they take effect even when launched via Steam (where we can't modify the launcher config).
290pub fn generate_launch_wrapper(
291    game_dir: &Path,
292    staging_dir: &Path,
293    game_id: &GameId,
294    tool_env_vars: &[(String, String)],
295) -> Result<Option<LaunchWrapperReport>> {
296    // Delegate fgmod restore scanning to the optiscaler module, using the
297    // selected game's metadata to derive the executable directory.
298    let executable_dir = crate::resolve_game_plugin(game_id.as_str())
299        .map(|plugin| plugin.executable_dir(game_dir))
300        .unwrap_or_else(|| game_dir.to_path_buf());
301    let restore_commands = crate::tools::optiscaler::fgmod_restore_commands_for_executable_dir(
302        game_dir,
303        staging_dir,
304        &executable_dir,
305    );
306
307    if restore_commands.is_empty() && tool_env_vars.is_empty() {
308        return Ok(None);
309    }
310
311    // Generate the wrapper script
312    let wrapper_dir = modde_core::paths::modde_data_dir().join("bin");
313    std::fs::create_dir_all(&wrapper_dir).context("failed to create modde bin directory")?;
314
315    let modde_bin = std::env::current_exe()
316        .map_or_else(|_| "modde".to_string(), |p| p.to_string_lossy().to_string());
317
318    #[cfg(unix)]
319    let (wrapper_path, script) = generate_wrapper_unix(
320        &wrapper_dir,
321        &restore_commands,
322        tool_env_vars,
323        game_id,
324        &modde_bin,
325    );
326
327    #[cfg(windows)]
328    let (wrapper_path, script) = generate_wrapper_windows(
329        &wrapper_dir,
330        &restore_commands,
331        tool_env_vars,
332        game_id,
333        &modde_bin,
334    );
335
336    std::fs::write(&wrapper_path, &script)
337        .with_context(|| format!("failed to write launch wrapper: {}", wrapper_path.display()))?;
338
339    // Make executable
340    #[cfg(unix)]
341    {
342        use std::os::unix::fs::PermissionsExt;
343        std::fs::set_permissions(&wrapper_path, std::fs::Permissions::from_mode(0o755))
344            .context("failed to set wrapper script permissions")?;
345    }
346
347    info!(
348        wrapper = %wrapper_path.display(),
349        restores = restore_commands.len(),
350        tool_vars = tool_env_vars.len(),
351        "generated modde launch wrapper"
352    );
353
354    Ok(Some(LaunchWrapperReport {
355        path: wrapper_path,
356        restore_count: restore_commands.len(),
357        tool_env_var_count: tool_env_vars.len(),
358    }))
359}
360
361/// Format a list of DLL names as a `WINEDLLOVERRIDES` value string.
362///
363/// Wine DLL overrides are only relevant on Linux (where games run via Wine/Proton).
364#[cfg(target_os = "linux")]
365fn format_wine_overrides(overrides: &[String]) -> String {
366    overrides
367        .iter()
368        .map(|dll| format!("{dll}=n,b"))
369        .collect::<Vec<_>>()
370        .join(";")
371}
372
373/// Apply Wine DLL overrides to the detected launcher's configuration.
374///
375/// Returns `true` if the config was updated, `false` if no changes were needed.
376///
377/// Only relevant on Linux where games run via Wine/Proton.
378#[cfg(target_os = "linux")]
379pub fn apply_wine_overrides(
380    launcher: &Launcher,
381    overrides: &[String],
382) -> Result<Option<WineOverrideReport>> {
383    if overrides.is_empty() {
384        return Ok(None);
385    }
386
387    match launcher {
388        Launcher::Heroic {
389            config_path,
390            game_id,
391        } => apply_heroic_overrides(config_path, game_id, overrides),
392        Launcher::Steam { app_id } => {
393            let override_str = format_wine_overrides(overrides);
394            warn!(
395                "Steam game (app {app_id}): add to launch options:\n  \
396                 WINEDLLOVERRIDES=\"{override_str}\" %command%"
397            );
398            Ok(Some(WineOverrideReport::SteamInstruction {
399                override_value: override_str,
400            }))
401        }
402        Launcher::Unknown => {
403            let override_str = format_wine_overrides(overrides);
404            warn!("Unknown launcher: set WINEDLLOVERRIDES=\"{override_str}\" before launching");
405            Ok(Some(WineOverrideReport::UnknownInstruction {
406                override_value: override_str,
407            }))
408        }
409    }
410}
411
412/// Update Heroic's `GamesConfig` JSON to include WINEDLLOVERRIDES.
413#[cfg(target_os = "linux")]
414fn apply_heroic_overrides(
415    config_path: &Path,
416    game_id: &str,
417    overrides: &[String],
418) -> Result<Option<WineOverrideReport>> {
419    let data = std::fs::read_to_string(config_path)
420        .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
421
422    let mut config: Value = serde_json::from_str(&data).with_context(|| {
423        format!(
424            "failed to parse Heroic config JSON: {}",
425            config_path.display()
426        )
427    })?;
428
429    let game_config = config
430        .get_mut(game_id)
431        .context("game entry not found in Heroic config")?;
432
433    // Build the override string: "version=n,b;winmm=n,b" etc.
434    // We don't include dxgi here since fgmod handles it at launch time.
435    let new_overrides: Vec<String> = overrides
436        .iter()
437        .filter(|dll| *dll != "dxgi") // fgmod handles dxgi
438        .map(|dll| format!("{dll}=n,b"))
439        .collect();
440
441    if new_overrides.is_empty() {
442        return Ok(None);
443    }
444
445    let override_value = new_overrides.join(";");
446
447    // Get or create enviromentOptions (Heroic uses this spelling)
448    let env_options = game_config
449        .get_mut("enviromentOptions")
450        .context("enviromentOptions not found in game config")?;
451
452    let env_array = env_options
453        .as_array_mut()
454        .context("enviromentOptions is not an array")?;
455
456    // Check if WINEDLLOVERRIDES is already set
457    let existing_idx = env_array
458        .iter()
459        .position(|entry| entry.get("key").and_then(|k| k.as_str()) == Some("WINEDLLOVERRIDES"));
460
461    if let Some(idx) = existing_idx {
462        // Update existing entry — merge with existing overrides
463        let existing_value = env_array[idx]
464            .get("value")
465            .and_then(|v| v.as_str())
466            .unwrap_or("");
467
468        // Parse existing overrides and merge (split on ';' only — commas are part of values like "n,b")
469        let mut all_overrides: Vec<String> = existing_value
470            .split(';')
471            .filter(|s| !s.is_empty())
472            .map(std::string::ToString::to_string)
473            .collect();
474
475        for new_ov in &new_overrides {
476            let dll_name = new_ov.split('=').next().unwrap_or("");
477            // Remove any existing entry for this DLL
478            all_overrides.retain(|ov| {
479                let existing_name = ov.split('=').next().unwrap_or("");
480                existing_name != dll_name
481            });
482            all_overrides.push(new_ov.clone());
483        }
484
485        let merged = all_overrides.join(";");
486        env_array[idx] = serde_json::json!({
487            "key": "WINEDLLOVERRIDES",
488            "value": merged
489        });
490
491        info!(overrides = %merged, "updated existing WINEDLLOVERRIDES in Heroic config");
492    } else {
493        // Add new entry
494        env_array.push(serde_json::json!({
495            "key": "WINEDLLOVERRIDES",
496            "value": override_value
497        }));
498
499        info!(overrides = %override_value, "added WINEDLLOVERRIDES to Heroic config");
500    }
501
502    // Write back
503    let output =
504        serde_json::to_string_pretty(&config).context("failed to serialize Heroic config")?;
505    std::fs::write(config_path, output)
506        .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
507
508    Ok(Some(WineOverrideReport::HeroicUpdated {
509        value: if existing_idx.is_some() {
510            "merged with existing".to_string()
511        } else {
512            override_value
513        },
514    }))
515}
516
517/// Register the modde launch wrapper in Heroic's wrapper chain.
518///
519/// The wrapper is inserted **after** fgmod (if present) so it can restore DLLs
520/// that fgmod deletes before the game launches.
521pub fn register_heroic_wrapper(
522    launcher: &Launcher,
523    wrapper_path: &Path,
524) -> Result<Option<WrapperRegistrationReport>> {
525    let Launcher::Heroic {
526        config_path,
527        game_id,
528    } = launcher
529    else {
530        return Ok(Some(WrapperRegistrationReport::ManualInstruction {
531            wrapper_path: wrapper_path.to_path_buf(),
532        }));
533    };
534
535    let data = std::fs::read_to_string(config_path)
536        .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
537
538    let mut config: Value = serde_json::from_str(&data).with_context(|| {
539        format!(
540            "failed to parse Heroic config JSON: {}",
541            config_path.display()
542        )
543    })?;
544
545    let game_config = config
546        .get_mut(game_id)
547        .context("game entry not found in Heroic config")?;
548
549    let wrapper_options = game_config
550        .get_mut("wrapperOptions")
551        .context("wrapperOptions not found in game config")?;
552
553    let wrappers = wrapper_options
554        .as_array_mut()
555        .context("wrapperOptions is not an array")?;
556
557    let wrapper_exe = wrapper_path.to_string_lossy().to_string();
558
559    // Check if modde wrapper is already registered
560    let already_registered = wrappers
561        .iter()
562        .any(|w| w.get("exe").and_then(|e| e.as_str()) == Some(&wrapper_exe));
563
564    if already_registered {
565        info!("modde launch wrapper already registered in Heroic config");
566        return Ok(None);
567    }
568
569    // Insert the modde wrapper. It should go after fgmod (which modifies files)
570    // but before gamemoderun/umu-run. In Heroic, wrappers are chained in order,
571    // so we insert at position 1 (after fgmod at position 0) or at the end.
572    let fgmod_idx = wrappers.iter().position(|w| {
573        w.get("exe")
574            .and_then(|e| e.as_str())
575            .is_some_and(|e| e.contains("fgmod"))
576    });
577
578    let insert_idx = match fgmod_idx {
579        Some(idx) => idx + 1,   // After fgmod
580        None => wrappers.len(), // At the end
581    };
582
583    let wrapper_entry = serde_json::json!({
584        "exe": wrapper_exe,
585        "args": "--"
586    });
587
588    wrappers.insert(insert_idx, wrapper_entry);
589
590    // Write back
591    let output =
592        serde_json::to_string_pretty(&config).context("failed to serialize Heroic config")?;
593    std::fs::write(config_path, output)
594        .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
595
596    info!(wrapper = %wrapper_exe, "registered modde wrapper in Heroic config");
597
598    Ok(Some(WrapperRegistrationReport::HeroicRegistered))
599}
600
601// ── Tool environment integration ────────────────────────────────────────
602
603/// Collect all environment variables from enabled tools for a game.
604///
605/// Reads tool configs from the database and calls each tool's `env_vars()`.
606/// Returns a flat list of `(KEY, VALUE)` pairs.
607pub fn collect_tool_env_vars(
608    game_id: &GameId,
609    db: &modde_core::db::ModdeDb,
610) -> Result<Vec<(String, String)>> {
611    let rows = db.load_tool_configs(game_id)?;
612    let mut all_vars = Vec::new();
613
614    for row in &rows {
615        if !row.enabled {
616            continue;
617        }
618
619        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
620            continue;
621        };
622
623        let mut config = crate::tools::ToolConfig {
624            tool_id: row.tool_id.clone(),
625            enabled: true,
626            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
627        };
628        // Inject game_id so tools can build per-game config paths
629        config.set("_game_id", serde_json::json!(game_id.as_str()));
630
631        all_vars.extend(tool.env_vars(&config));
632    }
633
634    Ok(all_vars)
635}
636
637/// Collect all Wine DLL overrides from enabled tools for a game.
638pub fn collect_tool_dll_overrides(
639    game_id: &GameId,
640    db: &modde_core::db::ModdeDb,
641) -> Result<Vec<String>> {
642    let rows = db.load_tool_configs(game_id)?;
643    let mut overrides = Vec::new();
644
645    for row in &rows {
646        if !row.enabled {
647            continue;
648        }
649
650        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
651            continue;
652        };
653
654        let config = crate::tools::ToolConfig {
655            tool_id: row.tool_id.clone(),
656            enabled: true,
657            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
658        };
659
660        overrides.extend(tool.wine_dll_overrides(&config));
661    }
662
663    Ok(overrides)
664}
665
666/// Collect wrapper commands from enabled tools.
667pub fn collect_tool_wrappers(
668    game_id: &GameId,
669    db: &modde_core::db::ModdeDb,
670) -> Result<Vec<crate::tools::WrapperEntry>> {
671    let rows = db.load_tool_configs(game_id)?;
672    let mut wrappers = Vec::new();
673
674    for row in &rows {
675        if !row.enabled {
676            continue;
677        }
678
679        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
680            continue;
681        };
682
683        let config = crate::tools::ToolConfig {
684            tool_id: row.tool_id.clone(),
685            enabled: true,
686            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
687        };
688
689        if let Some(wrapper) = tool.wrapper_command(&config) {
690            wrappers.push(wrapper);
691        }
692    }
693
694    Ok(wrappers)
695}
696
697/// Generate per-game config files for all enabled tools.
698///
699/// Writes configs to `~/.local/share/modde/tools/{game_id}/`.
700pub fn generate_tool_configs(game_id: &GameId, db: &modde_core::db::ModdeDb) -> Result<()> {
701    let rows = db.load_tool_configs(game_id)?;
702
703    for row in &rows {
704        if !row.enabled {
705            continue;
706        }
707
708        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
709            continue;
710        };
711
712        let mut config = crate::tools::ToolConfig {
713            tool_id: row.tool_id.clone(),
714            enabled: true,
715            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
716        };
717        config.set("_game_id", serde_json::json!(game_id.as_str()));
718
719        if let Some(generated) = tool.generate_config(&config) {
720            if let Some(parent) = generated.path.parent() {
721                std::fs::create_dir_all(parent)
722                    .with_context(|| format!("failed to create {}", parent.display()))?;
723            }
724            std::fs::write(&generated.path, &generated.content).with_context(|| {
725                format!("failed to write tool config: {}", generated.path.display())
726            })?;
727            info!(tool = tool.tool_id(), path = %generated.path.display(), "wrote tool config");
728        }
729    }
730
731    Ok(())
732}
733
734/// Apply tool environment to a Heroic launcher config.
735///
736/// Merges env vars from tools into Heroic's `enviromentOptions` and
737/// registers wrapper commands in `wrapperOptions`.
738pub fn apply_tool_environment_heroic(
739    config_path: &Path,
740    game_id_heroic: &str,
741    env_vars: &[(String, String)],
742    wrappers: &[crate::tools::WrapperEntry],
743) -> Result<ToolEnvironmentReport> {
744    if env_vars.is_empty() && wrappers.is_empty() {
745        return Ok(ToolEnvironmentReport::default());
746    }
747
748    let data = std::fs::read_to_string(config_path)
749        .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
750
751    let mut config: Value = serde_json::from_str(&data).with_context(|| {
752        format!(
753            "failed to parse Heroic config JSON: {}",
754            config_path.display()
755        )
756    })?;
757
758    let game_config = config
759        .get_mut(game_id_heroic)
760        .context("game entry not found in Heroic config")?;
761
762    // Merge env vars
763    if !env_vars.is_empty() {
764        let env_options = game_config
765            .get_mut("enviromentOptions")
766            .context("enviromentOptions not found in game config")?;
767        let env_array = env_options
768            .as_array_mut()
769            .context("enviromentOptions is not an array")?;
770
771        for (key, value) in env_vars {
772            // Remove existing entry for this key
773            env_array.retain(|entry| entry.get("key").and_then(|k| k.as_str()) != Some(key));
774            env_array.push(serde_json::json!({ "key": key, "value": value }));
775        }
776    }
777
778    // Register wrapper commands
779    if !wrappers.is_empty() {
780        let wrapper_options = game_config
781            .get_mut("wrapperOptions")
782            .context("wrapperOptions not found in game config")?;
783        let wrapper_array = wrapper_options
784            .as_array_mut()
785            .context("wrapperOptions is not an array")?;
786
787        for wrapper in wrappers {
788            let already = wrapper_array
789                .iter()
790                .any(|w| w.get("exe").and_then(|e| e.as_str()) == Some(&wrapper.exe));
791            if !already {
792                wrapper_array.push(serde_json::json!({
793                    "exe": wrapper.exe,
794                    "args": wrapper.args,
795                }));
796            }
797        }
798    }
799
800    let output =
801        serde_json::to_string_pretty(&config).context("failed to serialize Heroic config")?;
802    std::fs::write(config_path, output)
803        .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
804
805    Ok(ToolEnvironmentReport {
806        env_var_count: env_vars.len(),
807        wrapper_count: wrappers.len(),
808    })
809}
810
811/// Result of applying tool environment settings to a launcher config.
812#[derive(Debug, Clone, Copy, Default)]
813pub struct ToolEnvironmentReport {
814    pub env_var_count: usize,
815    pub wrapper_count: usize,
816}