1use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use serde_json::Value;
12use tracing::{debug, info, warn};
13
14#[derive(Debug)]
16pub enum Launcher {
17 Heroic { config_path: PathBuf, game_id: String },
19 Steam { app_id: String },
21 Unknown,
23}
24
25pub fn detect_launcher(game_dir: &Path) -> Launcher {
27 if let Some(launcher) = detect_heroic(game_dir) {
29 return launcher;
30 }
31
32 if let Some(app_id) = detect_steam(game_dir) {
34 return Launcher::Steam { app_id };
35 }
36
37 Launcher::Unknown
38}
39
40fn detect_heroic(game_dir: &Path) -> Option<Launcher> {
42 let config_dir = modde_core::paths::heroic_config_dir()?;
43 let games_config = config_dir.join("GamesConfig");
44
45 if !games_config.is_dir() {
46 return None;
47 }
48
49 let entries = std::fs::read_dir(&games_config).ok()?;
51 for entry in entries.flatten() {
52 let path = entry.path();
53 if path.extension().and_then(|e| e.to_str()) != Some("json") {
54 continue;
55 }
56
57 let game_id = path.file_stem()?.to_string_lossy().to_string();
59
60 if heroic_game_matches(&config_dir, &game_id, game_dir) {
62 return Some(Launcher::Heroic {
63 config_path: path,
64 game_id,
65 });
66 }
67 }
68
69 None
70}
71
72fn heroic_game_matches(config_dir: &Path, game_id: &str, game_dir: &Path) -> bool {
74 let installed_files = [
76 config_dir.join("gog_store/installed.json"),
77 config_dir.join("legendary_store/installed.json"),
78 config_dir.join("sideload_apps/installed.json"),
79 ];
80
81 for installed_path in &installed_files {
82 let data = match std::fs::read_to_string(installed_path) {
83 Ok(d) => d,
84 Err(e) => {
85 debug!(error = %e, path = %installed_path.display(), "failed to read Heroic installed file");
86 continue;
87 }
88 };
89 let val: Value = match serde_json::from_str(&data) {
90 Ok(v) => v,
91 Err(e) => {
92 warn!(error = %e, path = %installed_path.display(), "failed to parse Heroic installed JSON");
93 continue;
94 }
95 };
96 if let Some(games) = val.get("installed").and_then(|v| v.as_array()) {
97 for game in games {
98 if game.get("appName").and_then(|v| v.as_str()) == Some(game_id) {
99 if let Some(install_path) = game.get("install_path").and_then(|v| v.as_str()) {
100 let canonical_game = game_dir.canonicalize().unwrap_or_else(|_| game_dir.to_path_buf());
101 let canonical_install = PathBuf::from(install_path).canonicalize().unwrap_or_else(|_| PathBuf::from(install_path));
102 return canonical_game == canonical_install;
103 }
104 }
105 }
106 }
107 }
108
109 false
110}
111
112
113fn detect_steam(game_dir: &Path) -> Option<String> {
115 let path_str = game_dir.to_string_lossy().replace('\\', "/");
116 if path_str.contains("steamapps/common/") {
117 if let Some(steamapps) = game_dir.ancestors().find(|p| p.file_name().and_then(|f| f.to_str()) == Some("common")).and_then(|p| p.parent()) {
119 let game_name = game_dir.file_name()?.to_string_lossy();
120 let manifests = std::fs::read_dir(steamapps).ok()?;
121 for entry in manifests.flatten() {
122 let name = entry.file_name();
123 let name_str = name.to_string_lossy();
124 if name_str.starts_with("appmanifest_") && name_str.ends_with(".acf") {
125 if let Ok(content) = std::fs::read_to_string(entry.path()) {
126 if content.contains(&*game_name) {
127 let app_id = name_str
128 .strip_prefix("appmanifest_")?
129 .strip_suffix(".acf")?
130 .to_string();
131 return Some(app_id);
132 }
133 }
134 }
135 }
136 }
137 }
138 None
139}
140
141#[cfg(unix)]
143fn generate_wrapper_unix(
144 wrapper_dir: &Path,
145 restore_commands: &[(String, String)],
146 tool_env_vars: &[(String, String)],
147 game_id: &str,
148 modde_bin: &str,
149) -> (PathBuf, String) {
150 let wrapper_path = wrapper_dir.join("modde-launch-wrapper.sh");
151
152 let mut script = String::from("#!/usr/bin/env bash\n");
153 script.push_str("# Auto-generated by modde — tool env vars + fgmod DLL restoration\n");
154 script.push_str("# Do not edit manually; re-run `modde deploy` to regenerate.\n\n");
155
156 if !tool_env_vars.is_empty() {
157 script.push_str("# Tool environment variables\n");
158 for (key, value) in tool_env_vars {
159 script.push_str(&format!("export {key}=\"{value}\"\n"));
160 }
161 script.push('\n');
162 }
163
164 if !restore_commands.is_empty() {
165 script.push_str("# Restore mod DLLs that fgmod deletes\n");
166 for (src, dest) in restore_commands {
167 script.push_str(&format!(
168 "cp -f \"{src}\" \"{dest}\" 2>/dev/null && echo \"modde: restored $(basename \"{dest}\")\"\n"
169 ));
170 }
171 script.push('\n');
172 }
173
174 script.push_str("\"$@\"\n");
175 script.push_str("status=$?\n\n");
176 script.push_str(&format!(
177 "# Auto-capture saves after game exit\n\
178 \"{modde_bin}\" save auto-capture --game {game_id} 2>/dev/null &\n\n\
179 exit $status\n"
180 ));
181
182 (wrapper_path, script)
183}
184
185#[cfg(windows)]
187fn generate_wrapper_windows(
188 wrapper_dir: &Path,
189 restore_commands: &[(String, String)],
190 tool_env_vars: &[(String, String)],
191 game_id: &str,
192 modde_bin: &str,
193) -> (PathBuf, String) {
194 let wrapper_path = wrapper_dir.join("modde-launch-wrapper.cmd");
195
196 let mut script = String::from("@echo off\r\n");
197 script.push_str("REM Auto-generated by modde — tool env vars + fgmod DLL restoration\r\n");
198 script.push_str("REM Do not edit manually; re-run `modde deploy` to regenerate.\r\n\r\n");
199
200 if !tool_env_vars.is_empty() {
201 script.push_str("REM Tool environment variables\r\n");
202 for (key, value) in tool_env_vars {
203 script.push_str(&format!("set \"{key}={value}\"\r\n"));
204 }
205 script.push_str("\r\n");
206 }
207
208 if !restore_commands.is_empty() {
209 script.push_str("REM Restore mod DLLs that fgmod deletes\r\n");
210 for (src, dest) in restore_commands {
211 script.push_str(&format!(
212 "copy /Y \"{src}\" \"{dest}\" >nul 2>nul && echo modde: restored {dest}\r\n"
213 ));
214 }
215 script.push_str("\r\n");
216 }
217
218 script.push_str("%*\r\n");
219 script.push_str("set status=%ERRORLEVEL%\r\n\r\n");
220 script.push_str(&format!(
221 "REM Auto-capture saves after game exit\r\n\
222 start \"\" /B \"{modde_bin}\" save auto-capture --game {game_id} 2>nul\r\n\r\n\
223 exit /b %status%\r\n"
224 ));
225
226 (wrapper_path, script)
227}
228
229pub fn generate_launch_wrapper(
239 game_dir: &Path,
240 staging_dir: &Path,
241 game_id: &str,
242 tool_env_vars: &[(String, String)],
243) -> Result<Option<PathBuf>> {
244 let restore_commands = crate::tools::optiscaler::fgmod_restore_commands(game_dir, staging_dir);
246
247 if restore_commands.is_empty() && tool_env_vars.is_empty() {
248 return Ok(None);
249 }
250
251 let wrapper_dir = modde_core::paths::modde_data_dir().join("bin");
253 std::fs::create_dir_all(&wrapper_dir)
254 .context("failed to create modde bin directory")?;
255
256 let modde_bin = std::env::current_exe()
257 .map(|p| p.to_string_lossy().to_string())
258 .unwrap_or_else(|_| "modde".to_string());
259
260 #[cfg(unix)]
261 let (wrapper_path, script) = generate_wrapper_unix(
262 &wrapper_dir,
263 &restore_commands,
264 tool_env_vars,
265 game_id,
266 &modde_bin,
267 );
268
269 #[cfg(windows)]
270 let (wrapper_path, script) = generate_wrapper_windows(
271 &wrapper_dir,
272 &restore_commands,
273 tool_env_vars,
274 game_id,
275 &modde_bin,
276 );
277
278 std::fs::write(&wrapper_path, &script)
279 .with_context(|| format!("failed to write launch wrapper: {}", wrapper_path.display()))?;
280
281 #[cfg(unix)]
283 {
284 use std::os::unix::fs::PermissionsExt;
285 std::fs::set_permissions(&wrapper_path, std::fs::Permissions::from_mode(0o755))
286 .context("failed to set wrapper script permissions")?;
287 }
288
289 info!(
290 wrapper = %wrapper_path.display(),
291 restores = restore_commands.len(),
292 tool_vars = tool_env_vars.len(),
293 "generated modde launch wrapper"
294 );
295
296 if !restore_commands.is_empty() {
297 println!(
298 " Launch wrapper: restores {} DLL(s)",
299 restore_commands.len(),
300 );
301 }
302 if !tool_env_vars.is_empty() {
303 println!(
304 " Launch wrapper: exports {} tool env var(s)",
305 tool_env_vars.len(),
306 );
307 }
308
309 Ok(Some(wrapper_path))
310}
311
312#[cfg(target_os = "linux")]
316fn format_wine_overrides(overrides: &[String]) -> String {
317 overrides.iter()
318 .map(|dll| format!("{dll}=n,b"))
319 .collect::<Vec<_>>()
320 .join(";")
321}
322
323#[cfg(target_os = "linux")]
329pub fn apply_wine_overrides(launcher: &Launcher, overrides: &[String]) -> Result<bool> {
330 if overrides.is_empty() {
331 return Ok(false);
332 }
333
334 match launcher {
335 Launcher::Heroic { config_path, game_id } => {
336 apply_heroic_overrides(config_path, game_id, overrides)
337 }
338 Launcher::Steam { app_id } => {
339 let override_str = format_wine_overrides(overrides);
340 warn!(
341 "Steam game (app {app_id}): add to launch options:\n \
342 WINEDLLOVERRIDES=\"{override_str}\" %command%"
343 );
344 println!(
345 "\nSteam: Add this to your launch options:\n \
346 WINEDLLOVERRIDES=\"{override_str}\" %command%"
347 );
348 Ok(false)
349 }
350 Launcher::Unknown => {
351 let override_str = format_wine_overrides(overrides);
352 warn!(
353 "Unknown launcher: set WINEDLLOVERRIDES=\"{override_str}\" before launching"
354 );
355 println!(
356 "\nSet this environment variable before launching:\n \
357 WINEDLLOVERRIDES=\"{override_str}\""
358 );
359 Ok(false)
360 }
361 }
362}
363
364#[cfg(target_os = "linux")]
366fn apply_heroic_overrides(
367 config_path: &Path,
368 game_id: &str,
369 overrides: &[String],
370) -> Result<bool> {
371 let data = std::fs::read_to_string(config_path)
372 .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
373
374 let mut config: Value = serde_json::from_str(&data)
375 .with_context(|| format!("failed to parse Heroic config JSON: {}", config_path.display()))?;
376
377 let game_config = config
378 .get_mut(game_id)
379 .context("game entry not found in Heroic config")?;
380
381 let new_overrides: Vec<String> = overrides
384 .iter()
385 .filter(|dll| *dll != "dxgi") .map(|dll| format!("{dll}=n,b"))
387 .collect();
388
389 if new_overrides.is_empty() {
390 return Ok(false);
391 }
392
393 let override_value = new_overrides.join(";");
394
395 let env_options = game_config
397 .get_mut("enviromentOptions")
398 .context("enviromentOptions not found in game config")?;
399
400 let env_array = env_options
401 .as_array_mut()
402 .context("enviromentOptions is not an array")?;
403
404 let existing_idx = env_array.iter().position(|entry| {
406 entry.get("key").and_then(|k| k.as_str()) == Some("WINEDLLOVERRIDES")
407 });
408
409 if let Some(idx) = existing_idx {
410 let existing_value = env_array[idx]
412 .get("value")
413 .and_then(|v| v.as_str())
414 .unwrap_or("");
415
416 let mut all_overrides: Vec<String> = existing_value
418 .split(';')
419 .filter(|s| !s.is_empty())
420 .map(|s| s.to_string())
421 .collect();
422
423 for new_ov in &new_overrides {
424 let dll_name = new_ov.split('=').next().unwrap_or("");
425 all_overrides.retain(|ov| {
427 let existing_name = ov.split('=').next().unwrap_or("");
428 existing_name != dll_name
429 });
430 all_overrides.push(new_ov.clone());
431 }
432
433 let merged = all_overrides.join(";");
434 env_array[idx] = serde_json::json!({
435 "key": "WINEDLLOVERRIDES",
436 "value": merged
437 });
438
439 info!(overrides = %merged, "updated existing WINEDLLOVERRIDES in Heroic config");
440 } else {
441 env_array.push(serde_json::json!({
443 "key": "WINEDLLOVERRIDES",
444 "value": override_value
445 }));
446
447 info!(overrides = %override_value, "added WINEDLLOVERRIDES to Heroic config");
448 }
449
450 let output = serde_json::to_string_pretty(&config)
452 .context("failed to serialize Heroic config")?;
453 std::fs::write(config_path, output)
454 .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
455
456 println!(" Updated Heroic config with WINEDLLOVERRIDES: {}",
457 if existing_idx.is_some() { "merged with existing" } else { &override_value });
458
459 Ok(true)
460}
461
462pub fn register_heroic_wrapper(launcher: &Launcher, wrapper_path: &Path) -> Result<bool> {
467 let Launcher::Heroic { config_path, game_id } = launcher else {
468 let wrapper_str = wrapper_path.display();
469 println!(
470 "\nAdd this wrapper before your game launcher:\n {wrapper_str} --"
471 );
472 return Ok(false);
473 };
474
475 let data = std::fs::read_to_string(config_path)
476 .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
477
478 let mut config: Value = serde_json::from_str(&data)
479 .with_context(|| format!("failed to parse Heroic config JSON: {}", config_path.display()))?;
480
481 let game_config = config
482 .get_mut(game_id)
483 .context("game entry not found in Heroic config")?;
484
485 let wrapper_options = game_config
486 .get_mut("wrapperOptions")
487 .context("wrapperOptions not found in game config")?;
488
489 let wrappers = wrapper_options
490 .as_array_mut()
491 .context("wrapperOptions is not an array")?;
492
493 let wrapper_exe = wrapper_path.to_string_lossy().to_string();
494
495 let already_registered = wrappers.iter().any(|w| {
497 w.get("exe").and_then(|e| e.as_str()) == Some(&wrapper_exe)
498 });
499
500 if already_registered {
501 info!("modde launch wrapper already registered in Heroic config");
502 return Ok(false);
503 }
504
505 let fgmod_idx = wrappers.iter().position(|w| {
509 w.get("exe")
510 .and_then(|e| e.as_str())
511 .map(|e| e.contains("fgmod"))
512 .unwrap_or(false)
513 });
514
515 let insert_idx = match fgmod_idx {
516 Some(idx) => idx + 1, None => wrappers.len(), };
519
520 let wrapper_entry = serde_json::json!({
521 "exe": wrapper_exe,
522 "args": "--"
523 });
524
525 wrappers.insert(insert_idx, wrapper_entry);
526
527 let output = serde_json::to_string_pretty(&config)
529 .context("failed to serialize Heroic config")?;
530 std::fs::write(config_path, output)
531 .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
532
533 println!(" Registered modde launch wrapper in Heroic (position: after fgmod)");
534 info!(wrapper = %wrapper_exe, "registered modde wrapper in Heroic config");
535
536 Ok(true)
537}
538
539pub fn collect_tool_env_vars(
546 game_id: &str,
547 db: &modde_core::db::ModdeDb,
548) -> Result<Vec<(String, String)>> {
549 let rows = db.load_tool_configs(game_id)?;
550 let mut all_vars = Vec::new();
551
552 for row in &rows {
553 if !row.enabled {
554 continue;
555 }
556
557 let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
558 continue;
559 };
560
561 let mut config = crate::tools::ToolConfig {
562 tool_id: row.tool_id.clone(),
563 enabled: true,
564 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
565 };
566 config.set("_game_id", serde_json::json!(game_id));
568
569 all_vars.extend(tool.env_vars(&config));
570 }
571
572 Ok(all_vars)
573}
574
575pub fn collect_tool_dll_overrides(
577 game_id: &str,
578 db: &modde_core::db::ModdeDb,
579) -> Result<Vec<String>> {
580 let rows = db.load_tool_configs(game_id)?;
581 let mut overrides = Vec::new();
582
583 for row in &rows {
584 if !row.enabled {
585 continue;
586 }
587
588 let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
589 continue;
590 };
591
592 let config = crate::tools::ToolConfig {
593 tool_id: row.tool_id.clone(),
594 enabled: true,
595 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
596 };
597
598 overrides.extend(tool.wine_dll_overrides(&config).into_iter());
599 }
600
601 Ok(overrides)
602}
603
604pub fn collect_tool_wrappers(
606 game_id: &str,
607 db: &modde_core::db::ModdeDb,
608) -> Result<Vec<crate::tools::WrapperEntry>> {
609 let rows = db.load_tool_configs(game_id)?;
610 let mut wrappers = Vec::new();
611
612 for row in &rows {
613 if !row.enabled {
614 continue;
615 }
616
617 let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
618 continue;
619 };
620
621 let config = crate::tools::ToolConfig {
622 tool_id: row.tool_id.clone(),
623 enabled: true,
624 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
625 };
626
627 if let Some(wrapper) = tool.wrapper_command(&config) {
628 wrappers.push(wrapper);
629 }
630 }
631
632 Ok(wrappers)
633}
634
635pub fn generate_tool_configs(
639 game_id: &str,
640 db: &modde_core::db::ModdeDb,
641) -> Result<()> {
642 let rows = db.load_tool_configs(game_id)?;
643
644 for row in &rows {
645 if !row.enabled {
646 continue;
647 }
648
649 let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
650 continue;
651 };
652
653 let mut config = crate::tools::ToolConfig {
654 tool_id: row.tool_id.clone(),
655 enabled: true,
656 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
657 };
658 config.set("_game_id", serde_json::json!(game_id));
659
660 if let Some(generated) = tool.generate_config(&config) {
661 if let Some(parent) = generated.path.parent() {
662 std::fs::create_dir_all(parent)?;
663 }
664 std::fs::write(&generated.path, &generated.content)
665 .with_context(|| format!("failed to write tool config: {}", generated.path.display()))?;
666 info!(tool = tool.tool_id(), path = %generated.path.display(), "wrote tool config");
667 }
668 }
669
670 Ok(())
671}
672
673pub fn apply_tool_environment_heroic(
678 config_path: &Path,
679 game_id_heroic: &str,
680 env_vars: &[(String, String)],
681 wrappers: &[crate::tools::WrapperEntry],
682) -> Result<()> {
683 if env_vars.is_empty() && wrappers.is_empty() {
684 return Ok(());
685 }
686
687 let data = std::fs::read_to_string(config_path)
688 .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
689
690 let mut config: Value = serde_json::from_str(&data)
691 .with_context(|| format!("failed to parse Heroic config JSON: {}", config_path.display()))?;
692
693 let game_config = config
694 .get_mut(game_id_heroic)
695 .context("game entry not found in Heroic config")?;
696
697 if !env_vars.is_empty() {
699 let env_options = game_config
700 .get_mut("enviromentOptions")
701 .context("enviromentOptions not found in game config")?;
702 let env_array = env_options
703 .as_array_mut()
704 .context("enviromentOptions is not an array")?;
705
706 for (key, value) in env_vars {
707 env_array.retain(|entry| {
709 entry.get("key").and_then(|k| k.as_str()) != Some(key)
710 });
711 env_array.push(serde_json::json!({ "key": key, "value": value }));
712 }
713 }
714
715 if !wrappers.is_empty() {
717 let wrapper_options = game_config
718 .get_mut("wrapperOptions")
719 .context("wrapperOptions not found in game config")?;
720 let wrapper_array = wrapper_options
721 .as_array_mut()
722 .context("wrapperOptions is not an array")?;
723
724 for wrapper in wrappers {
725 let already = wrapper_array.iter().any(|w| {
726 w.get("exe").and_then(|e| e.as_str()) == Some(&wrapper.exe)
727 });
728 if !already {
729 wrapper_array.push(serde_json::json!({
730 "exe": wrapper.exe,
731 "args": wrapper.args,
732 }));
733 }
734 }
735 }
736
737 let output = serde_json::to_string_pretty(&config)
738 .context("failed to serialize Heroic config")?;
739 std::fs::write(config_path, output)
740 .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
741
742 if !env_vars.is_empty() {
743 println!(" Applied {} tool env var(s) to Heroic config", env_vars.len());
744 }
745 if !wrappers.is_empty() {
746 println!(" Registered {} tool wrapper(s) in Heroic config", wrappers.len());
747 }
748
749 Ok(())
750}