1use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus, Stdio};
9
10use anyhow::{Context, Result};
11use serde_json::Value;
12use tracing::{debug, info, warn};
13
14use modde_core::paths;
15
16#[derive(Debug, Clone)]
18pub struct DetectedGame {
19 pub game_id: &'static str,
21 pub display_name: &'static str,
23 pub install_path: PathBuf,
25 pub source: LauncherSource,
27}
28
29#[derive(Debug, Clone)]
31pub enum LauncherSource {
32 Steam {
33 app_id: String,
34 library_path: PathBuf,
35 },
36 HeroicGog {
37 app_id: String,
38 },
39 HeroicEpic {
40 app_id: String,
41 },
42 HeroicSideload {
43 app_id: String,
44 },
45}
46
47impl LauncherSource {
48 fn label_and_id(&self) -> (&str, &str) {
49 match self {
50 LauncherSource::Steam { app_id, .. } => ("Steam", app_id),
51 LauncherSource::HeroicGog { app_id } => ("Heroic/GOG", app_id),
52 LauncherSource::HeroicEpic { app_id } => ("Heroic/Epic", app_id),
53 LauncherSource::HeroicSideload { app_id } => ("Heroic/Sideload", app_id),
54 }
55 }
56
57 pub fn launch(&self) -> Result<Option<ExitStatus>> {
62 match self {
63 LauncherSource::Steam { app_id, .. } => {
64 let url = format!("steam://rungameid/{app_id}");
65 info!(%url, "launching via Steam");
66 open::that(&url)
67 .with_context(|| format!("failed to launch Steam via URI ({url})"))?;
68 Ok(None)
69 }
70 LauncherSource::HeroicGog { app_id }
71 | LauncherSource::HeroicEpic { app_id }
72 | LauncherSource::HeroicSideload { app_id } => {
73 let (bin, base_args) = heroic_command()
74 .context("Heroic Games Launcher not found (checked flatpak and PATH)")?;
75 info!(%bin, %app_id, "launching via Heroic");
76 let mut cmd = Command::new(&bin);
77 for arg in &base_args {
78 cmd.arg(arg);
79 }
80 let status = cmd
81 .args(["--no-gui", "--launch", app_id])
82 .status()
83 .with_context(|| format!("failed to launch Heroic ({bin} --no-gui --launch {app_id})"))?;
84 Ok(Some(status))
85 }
86 }
87 }
88}
89
90impl std::fmt::Display for LauncherSource {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 let (label, id) = self.label_and_id();
93 write!(f, "{label} ({id})")
94 }
95}
96
97struct KnownGame {
101 game_id: &'static str,
102 display_name: &'static str,
103 steam_app_id: Option<&'static str>,
105 steam_dir: Option<&'static str>,
107 gog_app_id: Option<&'static str>,
109 epic_app_id: Option<&'static str>,
111}
112
113const KNOWN_GAMES: &[KnownGame] = &[
115 KnownGame {
116 game_id: "skyrim-se",
117 display_name: "The Elder Scrolls V: Skyrim Special Edition",
118 steam_app_id: Some("489830"),
119 steam_dir: Some("Skyrim Special Edition"),
120 gog_app_id: None,
121 epic_app_id: None,
122 },
123 KnownGame {
126 game_id: "fallout4",
127 display_name: "Fallout 4",
128 steam_app_id: Some("377160"),
129 steam_dir: Some("Fallout 4"),
130 gog_app_id: Some("1998527297"),
131 epic_app_id: None,
132 },
133 KnownGame {
134 game_id: "fallout76",
135 display_name: "Fallout 76",
136 steam_app_id: Some("1151340"),
137 steam_dir: Some("Fallout76"),
138 gog_app_id: None,
139 epic_app_id: None,
140 },
141 KnownGame {
142 game_id: "starfield",
143 display_name: "Starfield",
144 steam_app_id: Some("1716740"),
145 steam_dir: Some("Starfield"),
146 gog_app_id: None,
147 epic_app_id: None,
148 },
149 KnownGame {
150 game_id: "cyberpunk2077",
151 display_name: "Cyberpunk 2077",
152 steam_app_id: Some("1091500"),
153 steam_dir: Some("Cyberpunk 2077"),
154 gog_app_id: Some("1423049311"),
155 epic_app_id: Some("Ginger"),
156 },
157 KnownGame {
158 game_id: "stellar-blade",
159 display_name: "Stellar Blade",
160 steam_app_id: Some("3489700"),
161 steam_dir: Some("Stellar Blade"),
164 gog_app_id: None,
165 epic_app_id: None,
166 },
167];
168
169fn heroic_command() -> Option<(String, Vec<String>)> {
178 #[cfg(target_os = "linux")]
179 {
180 if Command::new("flatpak")
182 .args(["info", "com.heroicgameslauncher.hgl"])
183 .stdout(Stdio::null())
184 .stderr(Stdio::null())
185 .status()
186 .ok()
187 .map(|s| s.success())
188 .unwrap_or(false)
189 {
190 return Some((
191 "flatpak".to_string(),
192 vec!["run".to_string(), "com.heroicgameslauncher.hgl".to_string()],
193 ));
194 }
195
196 if let Ok(path) = which::which("heroic") {
198 return Some((path.to_string_lossy().to_string(), vec![]));
199 }
200
201 None
202 }
203
204 #[cfg(target_os = "macos")]
205 {
206 let app_path = "/Applications/Heroic.app/Contents/MacOS/Heroic";
207 if std::path::Path::new(app_path).exists() {
208 return Some((app_path.to_string(), vec![]));
209 }
210 if let Ok(path) = which::which("heroic") {
211 return Some((path.to_string_lossy().to_string(), vec![]));
212 }
213 None
214 }
215
216 #[cfg(target_os = "windows")]
217 {
218 if let Some(exe) = modde_core::paths::heroic_exe_path() {
219 return Some((exe.to_string_lossy().to_string(), vec![]));
220 }
221 if let Ok(path) = which::which("heroic") {
222 return Some((path.to_string_lossy().to_string(), vec![]));
223 }
224 None
225 }
226}
227
228pub fn find_detected_game(game_id: &str) -> Option<DetectedGame> {
233 scan_installed_games()
234 .into_iter()
235 .find(|g| g.game_id == game_id)
236}
237
238pub fn scan_installed_games() -> Vec<DetectedGame> {
243 let mut detected = Vec::new();
244
245 scan_steam_libraries(&mut detected);
246 scan_heroic_stores(&mut detected);
247
248 detected
249}
250
251fn scan_steam_libraries(detected: &mut Vec<DetectedGame>) {
253 let libraries = paths::steam_library_folders();
254
255 for lib_path in &libraries {
256 let common_dir = lib_path.join("steamapps/common");
257 if !common_dir.is_dir() {
258 continue;
259 }
260
261 for game in KNOWN_GAMES {
262 let Some(steam_dir) = game.steam_dir else {
263 continue;
264 };
265
266 let install_path = common_dir.join(steam_dir);
267 if install_path.is_dir() {
268 debug!(
269 game_id = game.game_id,
270 path = %install_path.display(),
271 "detected Steam game"
272 );
273 detected.push(DetectedGame {
274 game_id: game.game_id,
275 display_name: game.display_name,
276 install_path,
277 source: LauncherSource::Steam {
278 app_id: game.steam_app_id.unwrap_or("unknown").to_string(),
279 library_path: lib_path.clone(),
280 },
281 });
282 }
283 }
284 }
285}
286
287fn scan_heroic_stores(detected: &mut Vec<DetectedGame>) {
289 let Some(heroic_dir) = paths::heroic_config_dir() else {
290 return;
291 };
292
293 scan_heroic_store_file(
295 &heroic_dir.join("gog_store/installed.json"),
296 |app_id| {
297 KNOWN_GAMES
298 .iter()
299 .find(|g| g.gog_app_id == Some(app_id))
300 .map(|g| (g, HeroicStoreKind::Gog))
301 },
302 detected,
303 );
304
305 scan_heroic_store_file(
307 &heroic_dir.join("legendary_store/installed.json"),
308 |app_id| {
309 KNOWN_GAMES
310 .iter()
311 .find(|g| g.epic_app_id == Some(app_id))
312 .map(|g| (g, HeroicStoreKind::Epic))
313 },
314 detected,
315 );
316
317 scan_heroic_sideload(&heroic_dir.join("sideload_apps/installed.json"), detected);
319}
320
321#[derive(Clone, Copy)]
322enum HeroicStoreKind {
323 Gog,
324 Epic,
325}
326
327fn scan_heroic_store_file(
329 path: &Path,
330 matcher: impl Fn(&str) -> Option<(&KnownGame, HeroicStoreKind)>,
331 detected: &mut Vec<DetectedGame>,
332) {
333 let data = match std::fs::read_to_string(path) {
334 Ok(d) => d,
335 Err(e) => {
336 debug!(error = %e, path = %path.display(), "failed to read Heroic store file");
337 return;
338 }
339 };
340
341 let parsed: Value = match serde_json::from_str(&data) {
342 Ok(v) => v,
343 Err(e) => {
344 warn!(error = %e, path = %path.display(), "failed to parse Heroic store JSON");
345 return;
346 }
347 };
348
349 let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
350 debug!(path = %path.display(), "Heroic store file missing 'installed' array");
351 return;
352 };
353
354 for entry in installed {
355 let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
356 continue;
357 };
358 let Some(install_path) = entry.get("install_path").and_then(|v| v.as_str()) else {
359 continue;
360 };
361
362 let install_path = PathBuf::from(install_path);
363 if !install_path.is_dir() {
364 continue;
365 }
366
367 if let Some((game, kind)) = matcher(app_name) {
368 debug!(
369 game_id = game.game_id,
370 app_name,
371 path = %install_path.display(),
372 "detected Heroic game"
373 );
374 let source = match kind {
375 HeroicStoreKind::Gog => LauncherSource::HeroicGog {
376 app_id: game.gog_app_id.unwrap_or(app_name).to_string(),
377 },
378 HeroicStoreKind::Epic => LauncherSource::HeroicEpic {
379 app_id: game.epic_app_id.unwrap_or(app_name).to_string(),
380 },
381 };
382 detected.push(DetectedGame {
383 game_id: game.game_id,
384 display_name: game.display_name,
385 install_path,
386 source,
387 });
388 }
389 }
390}
391
392fn scan_heroic_sideload(path: &Path, detected: &mut Vec<DetectedGame>) {
394 let data = match std::fs::read_to_string(path) {
395 Ok(d) => d,
396 Err(e) => {
397 debug!(error = %e, path = %path.display(), "failed to read Heroic sideload file");
398 return;
399 }
400 };
401
402 let parsed: Value = match serde_json::from_str(&data) {
403 Ok(v) => v,
404 Err(e) => {
405 warn!(error = %e, path = %path.display(), "failed to parse Heroic sideload JSON");
406 return;
407 }
408 };
409
410 let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
411 debug!(path = %path.display(), "Heroic sideload file missing 'installed' array");
412 return;
413 };
414
415 for entry in installed {
416 let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
417 continue;
418 };
419 let Some(install_path_str) = entry.get("install_path").and_then(|v| v.as_str()) else {
420 continue;
421 };
422
423 let install_path = PathBuf::from(install_path_str);
424 if !install_path.is_dir() {
425 continue;
426 }
427
428 let dir_name = install_path
430 .file_name()
431 .and_then(|n| n.to_str())
432 .unwrap_or("");
433
434 for game in KNOWN_GAMES {
435 let matches = game
436 .steam_dir
437 .map(|sd| sd.eq_ignore_ascii_case(dir_name))
438 .unwrap_or(false);
439
440 if matches {
441 debug!(
442 game_id = game.game_id,
443 app_name,
444 path = %install_path.display(),
445 "detected Heroic sideloaded game"
446 );
447 detected.push(DetectedGame {
448 game_id: game.game_id,
449 display_name: game.display_name,
450 install_path,
451 source: LauncherSource::HeroicSideload {
452 app_id: app_name.to_string(),
453 },
454 });
455 break;
456 }
457 }
458 }
459}
460
461pub fn find_game_install(game_id: &str) -> Option<PathBuf> {
466 let settings = modde_core::settings::AppSettings::load();
468 if let Some(path) = settings.game_path(game_id) {
469 if path.is_dir() {
470 return Some(path.clone());
471 }
472 }
473
474 scan_installed_games()
476 .into_iter()
477 .find(|g| g.game_id == game_id)
478 .map(|g| g.install_path)
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 fn write_heroic_installed(dir: &std::path::Path, entries: &[(&str, &str)]) {
487 let items: Vec<serde_json::Value> = entries
488 .iter()
489 .map(|(app_name, install_path)| {
490 serde_json::json!({
491 "appName": app_name,
492 "install_path": install_path,
493 })
494 })
495 .collect();
496 let json = serde_json::json!({ "installed": items });
497 std::fs::write(dir, serde_json::to_string(&json).unwrap()).unwrap();
498 }
499
500 #[test]
501 fn scan_heroic_gog_detects_known_game() {
502 let tmp = tempfile::tempdir().unwrap();
503 let install_dir = tmp.path().join("cyberpunk");
504 std::fs::create_dir_all(&install_dir).unwrap();
505
506 let store_file = tmp.path().join("installed.json");
507 write_heroic_installed(&store_file, &[("1423049311", &install_dir.to_string_lossy())]);
508
509 let mut detected = Vec::new();
510 scan_heroic_store_file(
511 &store_file,
512 |app_id| {
513 KNOWN_GAMES
514 .iter()
515 .find(|g| g.gog_app_id == Some(app_id))
516 .map(|g| (g, HeroicStoreKind::Gog))
517 },
518 &mut detected,
519 );
520
521 assert_eq!(detected.len(), 1);
522 assert_eq!(detected[0].game_id, "cyberpunk2077");
523 assert_eq!(detected[0].install_path, install_dir);
524 assert!(matches!(detected[0].source, LauncherSource::HeroicGog { .. }));
525 }
526
527 #[test]
528 fn scan_heroic_gog_unknown_game_ignored() {
529 let tmp = tempfile::tempdir().unwrap();
530 let install_dir = tmp.path().join("some_game");
531 std::fs::create_dir_all(&install_dir).unwrap();
532
533 let store_file = tmp.path().join("installed.json");
534 write_heroic_installed(&store_file, &[("9999999999", &install_dir.to_string_lossy())]);
535
536 let mut detected = Vec::new();
537 scan_heroic_store_file(
538 &store_file,
539 |app_id| {
540 KNOWN_GAMES
541 .iter()
542 .find(|g| g.gog_app_id == Some(app_id))
543 .map(|g| (g, HeroicStoreKind::Gog))
544 },
545 &mut detected,
546 );
547
548 assert_eq!(detected.len(), 0, "unknown game should not be added");
549 }
550
551 #[test]
552 fn scan_heroic_nonexistent_install_path_skipped() {
553 let tmp = tempfile::tempdir().unwrap();
554 let store_file = tmp.path().join("installed.json");
555 write_heroic_installed(&store_file, &[("1423049311", "/nonexistent/cyberpunk")]);
557
558 let mut detected = Vec::new();
559 scan_heroic_store_file(
560 &store_file,
561 |app_id| {
562 KNOWN_GAMES
563 .iter()
564 .find(|g| g.gog_app_id == Some(app_id))
565 .map(|g| (g, HeroicStoreKind::Gog))
566 },
567 &mut detected,
568 );
569
570 assert_eq!(detected.len(), 0, "nonexistent install path should be skipped");
571 }
572
573 #[test]
574 fn scan_heroic_missing_file_is_no_op() {
575 let mut detected = Vec::new();
576 scan_heroic_store_file(
578 std::path::Path::new("/nonexistent/installed.json"),
579 |_| None,
580 &mut detected,
581 );
582 assert_eq!(detected.len(), 0);
583 }
584
585 #[test]
586 fn scan_heroic_malformed_json_is_no_op() {
587 let tmp = tempfile::tempdir().unwrap();
588 let store_file = tmp.path().join("installed.json");
589 std::fs::write(&store_file, "this is not json").unwrap();
590
591 let mut detected = Vec::new();
592 scan_heroic_store_file(
593 &store_file,
594 |_| None,
595 &mut detected,
596 );
597 assert_eq!(detected.len(), 0);
598 }
599
600 #[test]
601 fn scan_heroic_empty_installed_array() {
602 let tmp = tempfile::tempdir().unwrap();
603 let store_file = tmp.path().join("installed.json");
604 std::fs::write(&store_file, r#"{"installed":[]}"#).unwrap();
605
606 let mut detected = Vec::new();
607 scan_heroic_store_file(
608 &store_file,
609 |_| None,
610 &mut detected,
611 );
612 assert_eq!(detected.len(), 0);
613 }
614
615 #[test]
616 fn scan_heroic_sideload_matches_by_dirname() {
617 let tmp = tempfile::tempdir().unwrap();
618 let install_dir = tmp.path().join("Cyberpunk 2077");
620 std::fs::create_dir_all(&install_dir).unwrap();
621
622 let store_file = tmp.path().join("installed.json");
623 write_heroic_installed(&store_file, &[("some_sideload_id", &install_dir.to_string_lossy())]);
624
625 let mut detected = Vec::new();
626 scan_heroic_sideload(&store_file, &mut detected);
627
628 assert_eq!(detected.len(), 1);
629 assert_eq!(detected[0].game_id, "cyberpunk2077");
630 assert!(matches!(detected[0].source, LauncherSource::HeroicSideload { .. }));
631 }
632
633 #[test]
634 fn scan_heroic_sideload_unknown_dirname_ignored() {
635 let tmp = tempfile::tempdir().unwrap();
636 let install_dir = tmp.path().join("Some Unknown Game 2077");
637 std::fs::create_dir_all(&install_dir).unwrap();
638
639 let store_file = tmp.path().join("installed.json");
640 write_heroic_installed(&store_file, &[("some_id", &install_dir.to_string_lossy())]);
641
642 let mut detected = Vec::new();
643 scan_heroic_sideload(&store_file, &mut detected);
644
645 assert_eq!(detected.len(), 0);
646 }
647
648 #[test]
651 fn scan_steam_libraries_detects_game_in_common() {
652 let tmp = tempfile::tempdir().unwrap();
653 let common = tmp.path().join("steamapps/common/Cyberpunk 2077");
655 std::fs::create_dir_all(&common).unwrap();
656
657 let detected_game = KNOWN_GAMES.iter().find(|g| g.game_id == "cyberpunk2077").unwrap();
662 let install_path = common.clone();
663 assert_eq!(install_path.file_name().unwrap(), "Cyberpunk 2077");
664 assert!(install_path.is_dir());
665 assert_eq!(detected_game.steam_dir, Some("Cyberpunk 2077"));
668 }
669
670 #[test]
673 fn launcher_source_display_steam() {
674 let src = LauncherSource::Steam {
675 app_id: "1091500".to_string(),
676 library_path: PathBuf::from("/games"),
677 };
678 assert_eq!(src.to_string(), "Steam (1091500)");
679 }
680
681 #[test]
682 fn launcher_source_display_heroic_gog() {
683 let src = LauncherSource::HeroicGog { app_id: "1423049311".to_string() };
684 assert_eq!(src.to_string(), "Heroic/GOG (1423049311)");
685 }
686
687 #[test]
688 fn launcher_source_display_heroic_epic() {
689 let src = LauncherSource::HeroicEpic { app_id: "Ginger".to_string() };
690 assert_eq!(src.to_string(), "Heroic/Epic (Ginger)");
691 }
692
693 #[test]
694 fn launcher_source_display_sideload() {
695 let src = LauncherSource::HeroicSideload { app_id: "custom_app".to_string() };
696 assert_eq!(src.to_string(), "Heroic/Sideload (custom_app)");
697 }
698
699 #[test]
702 fn known_games_ids_are_unique() {
703 let ids: Vec<_> = KNOWN_GAMES.iter().map(|g| g.game_id).collect();
704 let deduped: std::collections::HashSet<_> = ids.iter().collect();
705 assert_eq!(ids.len(), deduped.len(), "KNOWN_GAMES has duplicate game_ids");
706 }
707
708 #[test]
709 fn known_games_includes_supported_games() {
710 use crate::SUPPORTED_GAME_IDS;
711 for &game_id in SUPPORTED_GAME_IDS.iter()
712 .filter(|g| **g != "skyrim-ae") {
714 if ["skyrim-se", "fallout4", "cyberpunk2077"].contains(&game_id) {
715 assert!(
716 KNOWN_GAMES.iter().any(|g| g.game_id == game_id),
717 "KNOWN_GAMES missing {game_id}"
718 );
719 }
720 }
721 }
722}