chore: consolidate all in-progress parallel-session WIP
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
Wholesale commit of every uncommitted change across the tree, at the user's explicit request — host refactor-campaign W1 (native.rs facade + native/ dir, library/ + mgmt/ splits), Android, core. These streams were mid-flight and not individually built/tested together; this supersedes the per-session HOLD markers. Consolidating so everything lands on main in one pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
//! Heroic (Epic/GOG) store provider: installed games from Heroic's JSON stores + CDN art. Split out of the `library` facade (plan §W5).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's
|
||||
/// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a
|
||||
/// different config path and the launch path isn't wired there yet).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct HeroicProvider;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl LibraryProvider for HeroicProvider {
|
||||
fn store(&self) -> &'static str {
|
||||
"heroic"
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let Some(root) = heroic_root() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut games = Vec::new();
|
||||
// (cache file, runner id, the electron-store data key holding the games array)
|
||||
for (file, runner, key) in [
|
||||
("legendary_library.json", "legendary", "library"),
|
||||
("gog_library.json", "gog", "games"),
|
||||
("nile_library.json", "nile", "library"),
|
||||
] {
|
||||
let path = root.join("store_cache").join(file);
|
||||
match heroic_games(&path, runner, key) {
|
||||
Ok(mut g) => games.append(&mut g),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)")
|
||||
}
|
||||
}
|
||||
}
|
||||
games
|
||||
}
|
||||
}
|
||||
|
||||
/// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or
|
||||
/// the Flatpak path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_root() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
candidates.push(PathBuf::from(d).join("heroic"));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join(".config/heroic"));
|
||||
candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"));
|
||||
}
|
||||
candidates.into_iter().find(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the
|
||||
/// games array). Keeps only installed titles whose install dir still exists (the latter works around
|
||||
/// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<GameEntry>> {
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
let root: serde_json::Value = serde_json::from_str(&raw)?;
|
||||
let arr = root
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?;
|
||||
let mut games = Vec::new();
|
||||
for g in arr {
|
||||
if !g
|
||||
.get("is_installed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue; // the cache also lists owned-but-not-installed titles
|
||||
}
|
||||
let install_ok = g
|
||||
.get("install")
|
||||
.and_then(|i| i.get("install_path"))
|
||||
.and_then(|p| p.as_str())
|
||||
.is_some_and(|p| Path::new(p).is_dir());
|
||||
if !install_ok {
|
||||
continue;
|
||||
}
|
||||
let Some(app_name) = g
|
||||
.get("app_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let title = g
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(app_name)
|
||||
.to_string();
|
||||
// Only emit http(s) art (sideloaded titles can carry local file:// paths the client can't fetch).
|
||||
let http = |k: &str| {
|
||||
g.get(k)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| s.starts_with("http://") || s.starts_with("https://"))
|
||||
.map(String::from)
|
||||
};
|
||||
let art = Artwork {
|
||||
portrait: http("art_square"),
|
||||
header: http("art_cover"),
|
||||
hero: http("art_background").or_else(|| http("art_cover")),
|
||||
logo: http("art_logo"),
|
||||
};
|
||||
games.push(GameEntry {
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
title,
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "heroic".into(),
|
||||
value: format!("{runner}:{app_name}"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
|
||||
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
|
||||
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
|
||||
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
|
||||
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn heroic_command(value: &str) -> Option<String> {
|
||||
let (runner, app) = value.split_once(':')?;
|
||||
if !matches!(runner, "legendary" | "gog" | "nile") {
|
||||
return None;
|
||||
}
|
||||
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
|
||||
if app.is_empty()
|
||||
|| !app
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let prefix = heroic_launch_prefix()?;
|
||||
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
|
||||
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
|
||||
Some(format!(
|
||||
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
|
||||
))
|
||||
}
|
||||
|
||||
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
|
||||
/// root is present. `None` ⇒ Heroic not found, so no launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn heroic_launch_prefix() -> Option<String> {
|
||||
let on_path = std::env::var_os("PATH")
|
||||
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
|
||||
if on_path {
|
||||
return Some("heroic".into());
|
||||
}
|
||||
let flatpak = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
|
||||
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn heroic_games_parses_installed_with_cdn_art() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id()));
|
||||
let install = dir.join("game-install");
|
||||
std::fs::create_dir_all(&install).unwrap();
|
||||
let path = dir.join("legendary_library.json");
|
||||
let json = format!(
|
||||
r#"{{"library":[
|
||||
{{"app_name":"Quail","title":"Quail","is_installed":true,
|
||||
"install":{{"install_path":"{inst}"}},
|
||||
"art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg",
|
||||
"art_logo":"file:///local/logo.png"}},
|
||||
{{"app_name":"Owned","title":"Owned Only","is_installed":false,
|
||||
"install":{{"install_path":"{inst}"}}}}
|
||||
]}}"#,
|
||||
inst = install.display()
|
||||
);
|
||||
std::fs::write(&path, json).unwrap();
|
||||
let games = heroic_games(&path, "legendary", "library").unwrap();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(games.len(), 1); // the uninstalled title is filtered out
|
||||
assert_eq!(games[0].id, "heroic:legendary:Quail");
|
||||
assert_eq!(games[0].title, "Quail");
|
||||
assert_eq!(
|
||||
games[0].art.portrait.as_deref(),
|
||||
Some("https://cdn/quail_tall.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
games[0].art.header.as_deref(),
|
||||
Some("https://cdn/quail_wide.jpg")
|
||||
);
|
||||
assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it)
|
||||
let l = games[0].launch.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(l.kind.as_str(), l.value.as_str()),
|
||||
("heroic", "legendary:Quail")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user