1use 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#[derive(Debug)]
17pub enum Launcher {
18 Heroic {
20 config_path: PathBuf,
21 game_id: String,
22 },
23 Steam { app_id: String },
25 Unknown,
27}
28
29#[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#[derive(Debug, Clone)]
48pub enum WineOverrideReport {
49 HeroicUpdated { value: String },
50 SteamInstruction { override_value: String },
51 UnknownInstruction { override_value: String },
52}
53
54#[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#[derive(Debug, Clone)]
64pub enum WrapperRegistrationReport {
65 HeroicRegistered,
66 ManualInstruction { wrapper_path: PathBuf },
67}
68
69#[must_use]
71pub fn detect_launcher(game_dir: &Path) -> Launcher {
72 if let Some(launcher) = detect_heroic(game_dir) {
74 return launcher;
75 }
76
77 if let Some(app_id) = detect_steam(game_dir) {
79 return Launcher::Steam { app_id };
80 }
81
82 Launcher::Unknown
83}
84
85fn 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 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 let game_id = path.file_stem()?.to_string_lossy().to_string();
104
105 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
117fn heroic_game_matches(config_dir: &Path, game_id: &str, game_dir: &Path) -> bool {
119 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
161fn 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 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#[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#[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
281pub 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 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 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 #[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#[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#[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#[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 let new_overrides: Vec<String> = overrides
436 .iter()
437 .filter(|dll| *dll != "dxgi") .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 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 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 let existing_value = env_array[idx]
464 .get("value")
465 .and_then(|v| v.as_str())
466 .unwrap_or("");
467
468 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 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 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 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
517pub 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 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 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, None => wrappers.len(), };
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 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
601pub 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 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
637pub 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
666pub 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
697pub 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
734pub 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 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 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 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#[derive(Debug, Clone, Copy, Default)]
813pub struct ToolEnvironmentReport {
814 pub env_var_count: usize,
815 pub wrapper_count: usize,
816}