use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use anyhow::{Context, Result};
use serde_json::Value;
use tracing::{debug, info, warn};
use modde_core::paths;
#[derive(Debug, Clone)]
pub struct DetectedGame {
pub game_id: &'static str,
pub display_name: &'static str,
pub install_path: PathBuf,
pub source: LauncherSource,
}
#[derive(Debug, Clone)]
pub enum LauncherSource {
Steam {
app_id: String,
library_path: PathBuf,
},
HeroicGog {
app_id: String,
},
HeroicEpic {
app_id: String,
},
HeroicSideload {
app_id: String,
},
}
impl LauncherSource {
fn label_and_id(&self) -> (&str, &str) {
match self {
LauncherSource::Steam { app_id, .. } => ("Steam", app_id),
LauncherSource::HeroicGog { app_id } => ("Heroic/GOG", app_id),
LauncherSource::HeroicEpic { app_id } => ("Heroic/Epic", app_id),
LauncherSource::HeroicSideload { app_id } => ("Heroic/Sideload", app_id),
}
}
pub fn launch(&self) -> Result<Option<ExitStatus>> {
match self {
LauncherSource::Steam { app_id, .. } => {
let url = format!("steam://rungameid/{app_id}");
info!(%url, "launching via Steam");
open::that(&url)
.with_context(|| format!("failed to launch Steam via URI ({url})"))?;
Ok(None)
}
LauncherSource::HeroicGog { app_id }
| LauncherSource::HeroicEpic { app_id }
| LauncherSource::HeroicSideload { app_id } => {
let (bin, base_args) = heroic_command()
.context("Heroic Games Launcher not found (checked flatpak and PATH)")?;
info!(%bin, %app_id, "launching via Heroic");
let mut cmd = Command::new(&bin);
for arg in &base_args {
cmd.arg(arg);
}
let status = cmd
.args(["--no-gui", "--launch", app_id])
.status()
.with_context(|| format!("failed to launch Heroic ({bin} --no-gui --launch {app_id})"))?;
Ok(Some(status))
}
}
}
}
impl std::fmt::Display for LauncherSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (label, id) = self.label_and_id();
write!(f, "{label} ({id})")
}
}
struct KnownGame {
game_id: &'static str,
display_name: &'static str,
steam_app_id: Option<&'static str>,
steam_dir: Option<&'static str>,
gog_app_id: Option<&'static str>,
epic_app_id: Option<&'static str>,
}
const KNOWN_GAMES: &[KnownGame] = &[
KnownGame {
game_id: "skyrim-se",
display_name: "The Elder Scrolls V: Skyrim Special Edition",
steam_app_id: Some("489830"),
steam_dir: Some("Skyrim Special Edition"),
gog_app_id: None,
epic_app_id: None,
},
KnownGame {
game_id: "fallout4",
display_name: "Fallout 4",
steam_app_id: Some("377160"),
steam_dir: Some("Fallout 4"),
gog_app_id: Some("1998527297"),
epic_app_id: None,
},
KnownGame {
game_id: "fallout76",
display_name: "Fallout 76",
steam_app_id: Some("1151340"),
steam_dir: Some("Fallout76"),
gog_app_id: None,
epic_app_id: None,
},
KnownGame {
game_id: "starfield",
display_name: "Starfield",
steam_app_id: Some("1716740"),
steam_dir: Some("Starfield"),
gog_app_id: None,
epic_app_id: None,
},
KnownGame {
game_id: "cyberpunk2077",
display_name: "Cyberpunk 2077",
steam_app_id: Some("1091500"),
steam_dir: Some("Cyberpunk 2077"),
gog_app_id: Some("1423049311"),
epic_app_id: Some("Ginger"),
},
KnownGame {
game_id: "stellar-blade",
display_name: "Stellar Blade",
steam_app_id: Some("3489700"),
steam_dir: Some("Stellar Blade"),
gog_app_id: None,
epic_app_id: None,
},
];
fn heroic_command() -> Option<(String, Vec<String>)> {
#[cfg(target_os = "linux")]
{
if Command::new("flatpak")
.args(["info", "com.heroicgameslauncher.hgl"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok()
.map(|s| s.success())
.unwrap_or(false)
{
return Some((
"flatpak".to_string(),
vec!["run".to_string(), "com.heroicgameslauncher.hgl".to_string()],
));
}
if let Ok(path) = which::which("heroic") {
return Some((path.to_string_lossy().to_string(), vec![]));
}
None
}
#[cfg(target_os = "macos")]
{
let app_path = "/Applications/Heroic.app/Contents/MacOS/Heroic";
if std::path::Path::new(app_path).exists() {
return Some((app_path.to_string(), vec![]));
}
if let Ok(path) = which::which("heroic") {
return Some((path.to_string_lossy().to_string(), vec![]));
}
None
}
#[cfg(target_os = "windows")]
{
if let Some(exe) = modde_core::paths::heroic_exe_path() {
return Some((exe.to_string_lossy().to_string(), vec![]));
}
if let Ok(path) = which::which("heroic") {
return Some((path.to_string_lossy().to_string(), vec![]));
}
None
}
}
pub fn find_detected_game(game_id: &str) -> Option<DetectedGame> {
scan_installed_games()
.into_iter()
.find(|g| g.game_id == game_id)
}
pub fn scan_installed_games() -> Vec<DetectedGame> {
let mut detected = Vec::new();
scan_steam_libraries(&mut detected);
scan_heroic_stores(&mut detected);
detected
}
fn scan_steam_libraries(detected: &mut Vec<DetectedGame>) {
let libraries = paths::steam_library_folders();
for lib_path in &libraries {
let common_dir = lib_path.join("steamapps/common");
if !common_dir.is_dir() {
continue;
}
for game in KNOWN_GAMES {
let Some(steam_dir) = game.steam_dir else {
continue;
};
let install_path = common_dir.join(steam_dir);
if install_path.is_dir() {
debug!(
game_id = game.game_id,
path = %install_path.display(),
"detected Steam game"
);
detected.push(DetectedGame {
game_id: game.game_id,
display_name: game.display_name,
install_path,
source: LauncherSource::Steam {
app_id: game.steam_app_id.unwrap_or("unknown").to_string(),
library_path: lib_path.clone(),
},
});
}
}
}
}
fn scan_heroic_stores(detected: &mut Vec<DetectedGame>) {
let Some(heroic_dir) = paths::heroic_config_dir() else {
return;
};
scan_heroic_store_file(
&heroic_dir.join("gog_store/installed.json"),
|app_id| {
KNOWN_GAMES
.iter()
.find(|g| g.gog_app_id == Some(app_id))
.map(|g| (g, HeroicStoreKind::Gog))
},
detected,
);
scan_heroic_store_file(
&heroic_dir.join("legendary_store/installed.json"),
|app_id| {
KNOWN_GAMES
.iter()
.find(|g| g.epic_app_id == Some(app_id))
.map(|g| (g, HeroicStoreKind::Epic))
},
detected,
);
scan_heroic_sideload(&heroic_dir.join("sideload_apps/installed.json"), detected);
}
#[derive(Clone, Copy)]
enum HeroicStoreKind {
Gog,
Epic,
}
fn scan_heroic_store_file(
path: &Path,
matcher: impl Fn(&str) -> Option<(&KnownGame, HeroicStoreKind)>,
detected: &mut Vec<DetectedGame>,
) {
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(e) => {
debug!(error = %e, path = %path.display(), "failed to read Heroic store file");
return;
}
};
let parsed: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, path = %path.display(), "failed to parse Heroic store JSON");
return;
}
};
let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
debug!(path = %path.display(), "Heroic store file missing 'installed' array");
return;
};
for entry in installed {
let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
continue;
};
let Some(install_path) = entry.get("install_path").and_then(|v| v.as_str()) else {
continue;
};
let install_path = PathBuf::from(install_path);
if !install_path.is_dir() {
continue;
}
if let Some((game, kind)) = matcher(app_name) {
debug!(
game_id = game.game_id,
app_name,
path = %install_path.display(),
"detected Heroic game"
);
let source = match kind {
HeroicStoreKind::Gog => LauncherSource::HeroicGog {
app_id: game.gog_app_id.unwrap_or(app_name).to_string(),
},
HeroicStoreKind::Epic => LauncherSource::HeroicEpic {
app_id: game.epic_app_id.unwrap_or(app_name).to_string(),
},
};
detected.push(DetectedGame {
game_id: game.game_id,
display_name: game.display_name,
install_path,
source,
});
}
}
}
fn scan_heroic_sideload(path: &Path, detected: &mut Vec<DetectedGame>) {
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(e) => {
debug!(error = %e, path = %path.display(), "failed to read Heroic sideload file");
return;
}
};
let parsed: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, path = %path.display(), "failed to parse Heroic sideload JSON");
return;
}
};
let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
debug!(path = %path.display(), "Heroic sideload file missing 'installed' array");
return;
};
for entry in installed {
let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
continue;
};
let Some(install_path_str) = entry.get("install_path").and_then(|v| v.as_str()) else {
continue;
};
let install_path = PathBuf::from(install_path_str);
if !install_path.is_dir() {
continue;
}
let dir_name = install_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
for game in KNOWN_GAMES {
let matches = game
.steam_dir
.map(|sd| sd.eq_ignore_ascii_case(dir_name))
.unwrap_or(false);
if matches {
debug!(
game_id = game.game_id,
app_name,
path = %install_path.display(),
"detected Heroic sideloaded game"
);
detected.push(DetectedGame {
game_id: game.game_id,
display_name: game.display_name,
install_path,
source: LauncherSource::HeroicSideload {
app_id: app_name.to_string(),
},
});
break;
}
}
}
}
pub fn find_game_install(game_id: &str) -> Option<PathBuf> {
let settings = modde_core::settings::AppSettings::load();
if let Some(path) = settings.game_path(game_id) {
if path.is_dir() {
return Some(path.clone());
}
}
scan_installed_games()
.into_iter()
.find(|g| g.game_id == game_id)
.map(|g| g.install_path)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_heroic_installed(dir: &std::path::Path, entries: &[(&str, &str)]) {
let items: Vec<serde_json::Value> = entries
.iter()
.map(|(app_name, install_path)| {
serde_json::json!({
"appName": app_name,
"install_path": install_path,
})
})
.collect();
let json = serde_json::json!({ "installed": items });
std::fs::write(dir, serde_json::to_string(&json).unwrap()).unwrap();
}
#[test]
fn scan_heroic_gog_detects_known_game() {
let tmp = tempfile::tempdir().unwrap();
let install_dir = tmp.path().join("cyberpunk");
std::fs::create_dir_all(&install_dir).unwrap();
let store_file = tmp.path().join("installed.json");
write_heroic_installed(&store_file, &[("1423049311", &install_dir.to_string_lossy())]);
let mut detected = Vec::new();
scan_heroic_store_file(
&store_file,
|app_id| {
KNOWN_GAMES
.iter()
.find(|g| g.gog_app_id == Some(app_id))
.map(|g| (g, HeroicStoreKind::Gog))
},
&mut detected,
);
assert_eq!(detected.len(), 1);
assert_eq!(detected[0].game_id, "cyberpunk2077");
assert_eq!(detected[0].install_path, install_dir);
assert!(matches!(detected[0].source, LauncherSource::HeroicGog { .. }));
}
#[test]
fn scan_heroic_gog_unknown_game_ignored() {
let tmp = tempfile::tempdir().unwrap();
let install_dir = tmp.path().join("some_game");
std::fs::create_dir_all(&install_dir).unwrap();
let store_file = tmp.path().join("installed.json");
write_heroic_installed(&store_file, &[("9999999999", &install_dir.to_string_lossy())]);
let mut detected = Vec::new();
scan_heroic_store_file(
&store_file,
|app_id| {
KNOWN_GAMES
.iter()
.find(|g| g.gog_app_id == Some(app_id))
.map(|g| (g, HeroicStoreKind::Gog))
},
&mut detected,
);
assert_eq!(detected.len(), 0, "unknown game should not be added");
}
#[test]
fn scan_heroic_nonexistent_install_path_skipped() {
let tmp = tempfile::tempdir().unwrap();
let store_file = tmp.path().join("installed.json");
write_heroic_installed(&store_file, &[("1423049311", "/nonexistent/cyberpunk")]);
let mut detected = Vec::new();
scan_heroic_store_file(
&store_file,
|app_id| {
KNOWN_GAMES
.iter()
.find(|g| g.gog_app_id == Some(app_id))
.map(|g| (g, HeroicStoreKind::Gog))
},
&mut detected,
);
assert_eq!(detected.len(), 0, "nonexistent install path should be skipped");
}
#[test]
fn scan_heroic_missing_file_is_no_op() {
let mut detected = Vec::new();
scan_heroic_store_file(
std::path::Path::new("/nonexistent/installed.json"),
|_| None,
&mut detected,
);
assert_eq!(detected.len(), 0);
}
#[test]
fn scan_heroic_malformed_json_is_no_op() {
let tmp = tempfile::tempdir().unwrap();
let store_file = tmp.path().join("installed.json");
std::fs::write(&store_file, "this is not json").unwrap();
let mut detected = Vec::new();
scan_heroic_store_file(
&store_file,
|_| None,
&mut detected,
);
assert_eq!(detected.len(), 0);
}
#[test]
fn scan_heroic_empty_installed_array() {
let tmp = tempfile::tempdir().unwrap();
let store_file = tmp.path().join("installed.json");
std::fs::write(&store_file, r#"{"installed":[]}"#).unwrap();
let mut detected = Vec::new();
scan_heroic_store_file(
&store_file,
|_| None,
&mut detected,
);
assert_eq!(detected.len(), 0);
}
#[test]
fn scan_heroic_sideload_matches_by_dirname() {
let tmp = tempfile::tempdir().unwrap();
let install_dir = tmp.path().join("Cyberpunk 2077");
std::fs::create_dir_all(&install_dir).unwrap();
let store_file = tmp.path().join("installed.json");
write_heroic_installed(&store_file, &[("some_sideload_id", &install_dir.to_string_lossy())]);
let mut detected = Vec::new();
scan_heroic_sideload(&store_file, &mut detected);
assert_eq!(detected.len(), 1);
assert_eq!(detected[0].game_id, "cyberpunk2077");
assert!(matches!(detected[0].source, LauncherSource::HeroicSideload { .. }));
}
#[test]
fn scan_heroic_sideload_unknown_dirname_ignored() {
let tmp = tempfile::tempdir().unwrap();
let install_dir = tmp.path().join("Some Unknown Game 2077");
std::fs::create_dir_all(&install_dir).unwrap();
let store_file = tmp.path().join("installed.json");
write_heroic_installed(&store_file, &[("some_id", &install_dir.to_string_lossy())]);
let mut detected = Vec::new();
scan_heroic_sideload(&store_file, &mut detected);
assert_eq!(detected.len(), 0);
}
#[test]
fn scan_steam_libraries_detects_game_in_common() {
let tmp = tempfile::tempdir().unwrap();
let common = tmp.path().join("steamapps/common/Cyberpunk 2077");
std::fs::create_dir_all(&common).unwrap();
let detected_game = KNOWN_GAMES.iter().find(|g| g.game_id == "cyberpunk2077").unwrap();
let install_path = common.clone();
assert_eq!(install_path.file_name().unwrap(), "Cyberpunk 2077");
assert!(install_path.is_dir());
assert_eq!(detected_game.steam_dir, Some("Cyberpunk 2077"));
}
#[test]
fn launcher_source_display_steam() {
let src = LauncherSource::Steam {
app_id: "1091500".to_string(),
library_path: PathBuf::from("/games"),
};
assert_eq!(src.to_string(), "Steam (1091500)");
}
#[test]
fn launcher_source_display_heroic_gog() {
let src = LauncherSource::HeroicGog { app_id: "1423049311".to_string() };
assert_eq!(src.to_string(), "Heroic/GOG (1423049311)");
}
#[test]
fn launcher_source_display_heroic_epic() {
let src = LauncherSource::HeroicEpic { app_id: "Ginger".to_string() };
assert_eq!(src.to_string(), "Heroic/Epic (Ginger)");
}
#[test]
fn launcher_source_display_sideload() {
let src = LauncherSource::HeroicSideload { app_id: "custom_app".to_string() };
assert_eq!(src.to_string(), "Heroic/Sideload (custom_app)");
}
#[test]
fn known_games_ids_are_unique() {
let ids: Vec<_> = KNOWN_GAMES.iter().map(|g| g.game_id).collect();
let deduped: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(ids.len(), deduped.len(), "KNOWN_GAMES has duplicate game_ids");
}
#[test]
fn known_games_includes_supported_games() {
use crate::SUPPORTED_GAME_IDS;
for &game_id in SUPPORTED_GAME_IDS.iter()
.filter(|g| **g != "skyrim-ae") {
if ["skyrim-se", "fallout4", "cyberpunk2077"].contains(&game_id) {
assert!(
KNOWN_GAMES.iter().any(|g| g.game_id == game_id),
"KNOWN_GAMES missing {game_id}"
);
}
}
}
}