apple / swift (push) Successful in 5m38s
windows-host / package (push) Successful in 19m0s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m32s
ci / rust (push) Failing after 12m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
android / android (push) Successful in 17m49s
decky / build-publish (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
arch / build-publish (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m46s
flatpak / build-publish (push) Failing after 8m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m56s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
apple / screenshots (push) Successful in 25m51s
Emulation-and-beyond libraries need more than a title and a poster. Every library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput) now carries a shared, flattened GameMeta: platform, description, developer, publisher, release_year, genres, tags, region, players. All fields are optional and flat on the wire, so existing library.json files, provider plugins, and clients keep working unchanged. - Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp platform=PC; custom/provider entries carry whatever was authored. - GET /library grows a ?platform= filter (case-insensitive) beside ?provider=. - Console: the add/edit form gets a Details section (round-tripping every field, since update replaces the entry), the poster tile a platform badge (non-PC only — the store badge already implies PC) and the year. - plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema, spread flat); SDK + OpenAPI spec regenerated. - pf-client-core decodes platform for future client badges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
8.0 KiB
Rust
193 lines
8.0 KiB
Rust
//! Lutris store provider: installed games from the Lutris SQLite DB + lutris.net CDN art. Split out of the `library` facade (plan §W5).
|
|
|
|
use super::*;
|
|
|
|
/// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art
|
|
/// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only).
|
|
#[cfg(target_os = "linux")]
|
|
pub struct LutrisProvider;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
impl LibraryProvider for LutrisProvider {
|
|
fn store(&self) -> &'static str {
|
|
"lutris"
|
|
}
|
|
|
|
fn list(&self) -> Vec<GameEntry> {
|
|
let Some(db) = lutris_db() else {
|
|
return Vec::new();
|
|
};
|
|
lutris_games(&db).unwrap_or_else(|e| {
|
|
tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping");
|
|
Vec::new()
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak.
|
|
#[cfg(target_os = "linux")]
|
|
fn lutris_db() -> Option<PathBuf> {
|
|
let mut candidates = Vec::new();
|
|
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
|
|
candidates.push(PathBuf::from(d).join("lutris/pga.db"));
|
|
}
|
|
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
|
candidates.push(home.join(".local/share/lutris/pga.db"));
|
|
candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db"));
|
|
}
|
|
candidates.into_iter().find(|p| p.is_file())
|
|
}
|
|
|
|
/// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a
|
|
/// running Lutris holding the file can't make us block or fail, and we never write to it.
|
|
#[cfg(target_os = "linux")]
|
|
fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
|
use rusqlite::OpenFlags;
|
|
// `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The
|
|
// path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse,
|
|
// so fall back to a plain read-only open in that case.
|
|
let path = db.to_string_lossy();
|
|
let conn = if path.contains('?') || path.contains('#') {
|
|
rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?
|
|
} else {
|
|
rusqlite::Connection::open_with_flags(
|
|
format!("file:{path}?immutable=1"),
|
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
|
)?
|
|
};
|
|
// `directory` (the game's install dir — our detect signal) is not load-bearing for the library, so
|
|
// a pga.db schema without it must not cost the whole Lutris store: try the richer query first and
|
|
// fall back to the historical one on any prepare error.
|
|
const SELECT_WITH_DIR: &str = "SELECT id, slug, name, directory FROM games \
|
|
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
|
ORDER BY name COLLATE NOCASE";
|
|
const SELECT_PLAIN: &str = "SELECT id, slug, name, NULL FROM games \
|
|
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
|
|
ORDER BY name COLLATE NOCASE";
|
|
let mut stmt = match conn.prepare(SELECT_WITH_DIR) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "lutris pga.db has no `directory` column — listing without \
|
|
install dirs (game-exit detection unavailable for Lutris titles)");
|
|
conn.prepare(SELECT_PLAIN)?
|
|
}
|
|
};
|
|
let rows = stmt.query_map([], |row| {
|
|
Ok((
|
|
row.get::<_, i64>(0)?,
|
|
row.get::<_, Option<String>>(1)?,
|
|
row.get::<_, String>(2)?,
|
|
row.get::<_, Option<String>>(3)?,
|
|
))
|
|
})?;
|
|
let mut games = Vec::new();
|
|
for (id, slug, name, directory) in rows.flatten() {
|
|
games.push(GameEntry {
|
|
provider: None,
|
|
meta: GameMeta::pc(),
|
|
id: format!("lutris:{id}"),
|
|
store: "lutris".into(),
|
|
title: name,
|
|
art: slug.as_deref().map(lutris_art).unwrap_or_default(),
|
|
launch: Some(LaunchSpec {
|
|
kind: "lutris_id".into(),
|
|
value: id.to_string(),
|
|
}),
|
|
// Lutris stamps no per-game env marker we can rely on, so the install dir is the whole
|
|
// recipe; a game with none (an emulator entry pointing at a bare ROM) stays untracked.
|
|
detect: directory
|
|
.filter(|d| !d.trim().is_empty())
|
|
.map(DetectSpec::dir)
|
|
.unwrap_or_default(),
|
|
});
|
|
}
|
|
Ok(games)
|
|
}
|
|
|
|
/// Lutris cover art (local files keyed by slug) inlined as `data:` URLs — Lutris has no public CDN
|
|
/// keyed by a stable id (unlike Steam/Heroic), and `Artwork` fields are URLs the client fetches, so a
|
|
/// self-contained `data:` URL needs no host-served endpoint. `coverart` → portrait, `banners` → header.
|
|
#[cfg(target_os = "linux")]
|
|
fn lutris_art(slug: &str) -> Artwork {
|
|
Artwork {
|
|
portrait: lutris_image("coverart", slug),
|
|
header: lutris_image("banners", slug),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Find `<kind>/<slug>.jpg` across the current (0.5.18+), legacy (`~/.cache`), and Flatpak Lutris
|
|
/// dirs and inline it as `data:image/jpeg;base64,…`. Skips a missing or implausibly large file (a
|
|
/// 1 MiB cap bounds the catalog JSON so a few big files can't bloat it).
|
|
#[cfg(target_os = "linux")]
|
|
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
|
|
use base64::Engine as _;
|
|
// `slug` comes verbatim from Lutris's `pga.db` (untrusted at this layer). Reject any path
|
|
// separator, parent ref, or NUL so a crafted slug can't escape the art roots and read an
|
|
// arbitrary `<slug>.jpg` off disk — the bytes are base64-inlined into the `/api/v1/library`
|
|
// JSON a paired client can GET, so an escape is an arbitrary-file-read exfil primitive
|
|
// (security-review 2026-07-17). Real Lutris slugs are `[a-z0-9-]`.
|
|
if slug.is_empty()
|
|
|| slug.contains('/')
|
|
|| slug.contains('\\')
|
|
|| slug.contains("..")
|
|
|| slug.contains('\0')
|
|
{
|
|
return None;
|
|
}
|
|
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
|
let roots = [
|
|
home.join(".local/share/lutris"),
|
|
home.join(".cache/lutris"),
|
|
home.join(".var/app/net.lutris.Lutris/data/lutris"),
|
|
home.join(".var/app/net.lutris.Lutris/cache/lutris"),
|
|
];
|
|
for root in roots {
|
|
let p = root.join(kind).join(format!("{slug}.jpg"));
|
|
let Ok(meta) = std::fs::metadata(&p) else {
|
|
continue;
|
|
};
|
|
if meta.len() == 0 || meta.len() > 1024 * 1024 {
|
|
continue;
|
|
}
|
|
if let Ok(bytes) = std::fs::read(&p) {
|
|
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
|
return Some(format!("data:image/jpeg;base64,{enc}"));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn lutris_games_reads_installed_only() {
|
|
use rusqlite::Connection;
|
|
let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let db = dir.join("pga.db");
|
|
{
|
|
let c = Connection::open(&db).unwrap();
|
|
c.execute_batch(
|
|
"CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER);
|
|
INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1);
|
|
INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0);
|
|
INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);",
|
|
)
|
|
.unwrap();
|
|
}
|
|
let games = lutris_games(&db).unwrap();
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
// Only the installed, named row; the uninstalled + NULL-name rows are filtered out.
|
|
assert_eq!(games.len(), 1);
|
|
assert_eq!(games[0].id, "lutris:42");
|
|
assert_eq!(games[0].store, "lutris");
|
|
assert_eq!(games[0].title, "ELDEN RING");
|
|
let l = games[0].launch.as_ref().unwrap();
|
|
assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42"));
|
|
}
|
|
}
|