Added game scanning and Steam Metadata grabbing

This commit is contained in:
2026-05-16 20:20:56 +12:00
parent 38be2f43f1
commit 9de7a338a4
21 changed files with 2968 additions and 101 deletions
+128
View File
@@ -1495,6 +1495,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2106,6 +2112,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-opener",
"ureq",
]
[[package]]
@@ -2994,6 +3001,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
@@ -3030,6 +3051,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3440,6 +3496,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3580,6 +3642,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",
@@ -4231,6 +4294,41 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4262,6 +4360,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4527,6 +4631,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4757,6 +4870,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5301,6 +5423,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
+2 -1
View File
@@ -18,9 +18,10 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
ureq = "3.3.0"
+22 -26
View File
@@ -1,14 +1,13 @@
mod library;
mod storage;
use library::commands::{list_library_games, scan_library_command, update_library_game};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use storage::AppStorage;
use tauri::Manager;
struct DbState {
db_path: PathBuf,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UserRecord {
@@ -19,12 +18,6 @@ struct UserRecord {
created_at_unix_ms: i64,
}
impl DbState {
fn connect(&self) -> Result<Connection, String> {
Connection::open(&self.db_path).map_err(|err| format!("Failed to open database: {err}"))
}
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -120,8 +113,8 @@ fn ensure_users_schema(conn: &Connection) -> Result<(), String> {
}
#[tauri::command]
fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>, String> {
let conn = state.connect()?;
fn get_first_user(storage: tauri::State<'_, AppStorage>) -> Result<Option<UserRecord>, String> {
let conn = storage.connect_users()?;
ensure_users_schema(&conn)?;
conn.query_row(
"SELECT
@@ -155,13 +148,13 @@ fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>
fn create_user(
first_name: String,
last_name: Option<String>,
state: tauri::State<'_, DbState>,
storage: tauri::State<'_, AppStorage>,
) -> Result<UserRecord, String> {
let safe_first_name = sanitize_name(&first_name)?;
let safe_last_name = sanitize_optional_name(last_name)?;
let display_name = build_display_name(&safe_first_name, safe_last_name.as_deref());
let created_at_unix_ms = now_unix_ms();
let conn = state.connect()?;
let conn = storage.connect_users()?;
ensure_users_schema(&conn)?;
conn.execute(
"INSERT INTO users (name, first_name, last_name, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4)",
@@ -193,19 +186,22 @@ pub fn run() {
.path()
.app_data_dir()
.map_err(|err| format!("Failed to resolve app data dir: {err}"))?;
fs::create_dir_all(&app_data_dir)
.map_err(|err| format!("Failed to create app data dir: {err}"))?;
let storage = AppStorage::new(app_data_dir)?;
let users_conn = storage.connect_users()?;
ensure_users_schema(&users_conn)?;
drop(users_conn);
library::initialize(&storage)?;
let db_path = app_data_dir.join("nebula.db");
let conn = Connection::open(&db_path)
.map_err(|err| format!("Failed to initialize database: {err}"))?;
ensure_users_schema(&conn)?;
drop(conn);
app.manage(DbState { db_path });
app.manage(storage);
Ok(())
})
.invoke_handler(tauri::generate_handler![get_first_user, create_user])
.invoke_handler(tauri::generate_handler![
get_first_user,
create_user,
list_library_games,
scan_library_command,
update_library_game
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+47
View File
@@ -0,0 +1,47 @@
use super::db::{ensure_library_schema, update_customization};
use super::models::{GameCustomizationRequest, LibraryGame, LibraryScanRequest, ScanSummary};
use super::{list_visible_games, scan_library};
use crate::storage::AppStorage;
use std::path::PathBuf;
#[tauri::command]
pub async fn scan_library_command(
request: LibraryScanRequest,
storage: tauri::State<'_, AppStorage>,
) -> Result<ScanSummary, String> {
let storage = storage.inner().clone();
let local_folders = request
.local_folders
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
tauri::async_runtime::spawn_blocking(move || scan_library(storage, local_folders))
.await
.map_err(|err| format!("Library scanner task failed: {err}"))?
}
#[tauri::command]
pub async fn list_library_games(
storage: tauri::State<'_, AppStorage>,
) -> Result<Vec<LibraryGame>, String> {
let storage = storage.inner().clone();
tauri::async_runtime::spawn_blocking(move || list_visible_games(&storage))
.await
.map_err(|err| format!("Library list task failed: {err}"))?
}
#[tauri::command]
pub async fn update_library_game(
request: GameCustomizationRequest,
storage: tauri::State<'_, AppStorage>,
) -> Result<Option<LibraryGame>, String> {
let storage = storage.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
let conn = storage.connect_library()?;
ensure_library_schema(&conn)?;
update_customization(&conn, request)
})
.await
.map_err(|err| format!("Library update task failed: {err}"))?
}
+480
View File
@@ -0,0 +1,480 @@
use super::models::{
infer_library_item_kind, GameCandidate, GameCustomizationRequest, GameMetadata, GameSource,
LibraryGame, LibraryItemKind, MetadataStatus,
};
use rusqlite::{params, Connection, OptionalExtension};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
pub fn ensure_library_schema(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
platform_source TEXT NOT NULL,
source_store TEXT NOT NULL,
source_app_id TEXT,
install_path TEXT NOT NULL,
executable_path TEXT,
launch_command TEXT,
description TEXT,
app_kind TEXT NOT NULL DEFAULT 'game',
steam_app_type TEXT,
genres_json TEXT NOT NULL DEFAULT '[]',
steam_categories_json TEXT NOT NULL DEFAULT '[]',
release_date TEXT,
developer TEXT,
publisher TEXT,
cover_image TEXT,
hero_image TEXT,
icon_image TEXT,
metadata_status TEXT NOT NULL DEFAULT 'pending',
last_scanned INTEGER NOT NULL,
user_hidden INTEGER NOT NULL DEFAULT 0,
user_favourite INTEGER NOT NULL DEFAULT 0,
user_title TEXT,
manual_metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_games_source_app
ON games (platform_source, source_app_id)
WHERE source_app_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_games_install_path
ON games (install_path);
CREATE INDEX IF NOT EXISTS idx_games_hidden ON games (user_hidden);
CREATE INDEX IF NOT EXISTS idx_games_metadata_status ON games (metadata_status);
CREATE TABLE IF NOT EXISTS scan_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at INTEGER NOT NULL,
completed_at INTEGER,
status TEXT NOT NULL,
discovered_count INTEGER NOT NULL DEFAULT 0,
metadata_matched_count INTEGER NOT NULL DEFAULT 0,
unmatched_count INTEGER NOT NULL DEFAULT 0,
error TEXT
);
CREATE TABLE IF NOT EXISTS metadata_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
lookup_key TEXT NOT NULL,
metadata_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(provider, lookup_key)
);",
)
.map_err(|err| format!("Failed to prepare library schema: {err}"))?;
ensure_column(
conn,
"games",
"app_kind",
"app_kind TEXT NOT NULL DEFAULT 'game'",
)?;
ensure_column(conn, "games", "steam_app_type", "steam_app_type TEXT")?;
ensure_column(
conn,
"games",
"steam_categories_json",
"steam_categories_json TEXT NOT NULL DEFAULT '[]'",
)?;
Ok(())
}
pub fn upsert_candidate(
conn: &Connection,
candidate: &GameCandidate,
metadata: Option<&GameMetadata>,
) -> Result<(), String> {
let now = now_unix_ms();
let metadata_status = metadata
.map(|value| value.status.as_str())
.unwrap_or(MetadataStatus::NeedsReview.as_str());
let title = metadata
.and_then(|value| value.title.as_deref())
.unwrap_or(&candidate.title);
let source = candidate.source.as_str();
let app_kind = metadata
.map(|value| value.app_kind.clone())
.unwrap_or_else(|| infer_library_item_kind(None, Some(&candidate.title), &[], &[]));
let genres_json = metadata
.map(|value| serde_json::to_string(&value.genres))
.transpose()
.map_err(|err| format!("Failed to encode genres: {err}"))?
.unwrap_or_else(|| "[]".to_string());
let steam_categories_json = metadata
.map(|value| serde_json::to_string(&value.steam_categories))
.transpose()
.map_err(|err| format!("Failed to encode Steam categories: {err}"))?
.unwrap_or_else(|| "[]".to_string());
conn.execute(
"INSERT INTO games (
title,
platform_source,
source_store,
source_app_id,
install_path,
executable_path,
launch_command,
description,
app_kind.as_str(),
steam_app_type,
genres_json,
steam_categories_json,
release_date,
developer,
publisher,
cover_image,
hero_image,
icon_image,
metadata_status,
last_scanned,
created_at,
updated_at
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)
ON CONFLICT(platform_source, source_app_id) WHERE source_app_id IS NOT NULL DO UPDATE SET
title = excluded.title,
source_store = excluded.source_store,
install_path = excluded.install_path,
executable_path = excluded.executable_path,
launch_command = excluded.launch_command,
description = COALESCE(excluded.description, games.description),
app_kind = excluded.app_kind,
steam_app_type = COALESCE(excluded.steam_app_type, games.steam_app_type),
genres_json = excluded.genres_json,
steam_categories_json = excluded.steam_categories_json,
release_date = COALESCE(excluded.release_date, games.release_date),
developer = COALESCE(excluded.developer, games.developer),
publisher = COALESCE(excluded.publisher, games.publisher),
cover_image = excluded.cover_image,
hero_image = excluded.hero_image,
icon_image = excluded.icon_image,
metadata_status = excluded.metadata_status,
last_scanned = excluded.last_scanned,
updated_at = excluded.updated_at
ON CONFLICT(install_path) DO UPDATE SET
title = excluded.title,
platform_source = excluded.platform_source,
source_store = excluded.source_store,
source_app_id = COALESCE(excluded.source_app_id, games.source_app_id),
executable_path = excluded.executable_path,
launch_command = excluded.launch_command,
description = COALESCE(excluded.description, games.description),
app_kind = excluded.app_kind,
steam_app_type = COALESCE(excluded.steam_app_type, games.steam_app_type),
genres_json = excluded.genres_json,
steam_categories_json = excluded.steam_categories_json,
release_date = COALESCE(excluded.release_date, games.release_date),
developer = COALESCE(excluded.developer, games.developer),
publisher = COALESCE(excluded.publisher, games.publisher),
cover_image = excluded.cover_image,
hero_image = excluded.hero_image,
icon_image = excluded.icon_image,
metadata_status = excluded.metadata_status,
last_scanned = excluded.last_scanned,
updated_at = excluded.updated_at",
params![
title,
source,
source,
candidate.app_id,
candidate.install_path.to_string_lossy(),
candidate
.executable_path
.as_ref()
.map(|path| path.to_string_lossy().to_string()),
candidate.launch_command,
metadata.and_then(|value| value.description.as_deref()),
app_kind.as_str(),
metadata.and_then(|value| value.steam_app_type.as_deref()),
genres_json,
steam_categories_json,
metadata.and_then(|value| value.release_date.as_deref()),
metadata.and_then(|value| value.developer.as_deref()),
metadata.and_then(|value| value.publisher.as_deref()),
metadata
.and_then(|value| value.cover_image.as_ref())
.map(|path| path.to_string_lossy().to_string()),
metadata
.and_then(|value| value.hero_image.as_ref())
.map(|path| path.to_string_lossy().to_string()),
metadata
.and_then(|value| value.icon_image.as_ref())
.map(|path| path.to_string_lossy().to_string()),
metadata_status,
now,
now,
now,
],
)
.map_err(|err| format!("Failed to upsert library game: {err}"))?;
Ok(())
}
pub fn list_games(conn: &Connection, include_hidden: bool) -> Result<Vec<LibraryGame>, String> {
let where_clause = if include_hidden {
""
} else {
"WHERE user_hidden = 0"
};
let sql = format!(
"SELECT
id,
title,
platform_source,
source_store,
source_app_id,
install_path,
executable_path,
launch_command,
description,
app_kind,
steam_app_type,
genres_json,
steam_categories_json,
release_date,
developer,
publisher,
cover_image,
hero_image,
icon_image,
metadata_status,
last_scanned,
user_hidden,
user_favourite,
user_title
FROM games
{where_clause}
ORDER BY COALESCE(user_title, title) COLLATE NOCASE ASC"
);
let mut stmt = conn
.prepare(&sql)
.map_err(|err| format!("Failed to load library games: {err}"))?;
let rows = stmt
.query_map([], |row| {
let genres_json: String = row.get(11)?;
let genres = parse_string_list(&genres_json);
let steam_categories_json: String = row.get(12)?;
let steam_categories = parse_string_list(&steam_categories_json);
let platform_source: String = row.get(2)?;
let stored_app_kind: String = row.get(9)?;
let metadata_status: String = row.get(19)?;
let steam_app_type: Option<String> = row.get(10)?;
let title: String = row.get(1)?;
let inferred_app_kind = infer_library_item_kind(
steam_app_type.as_deref(),
Some(&title),
&genres,
&steam_categories,
);
let app_kind = if inferred_app_kind == LibraryItemKind::Unknown {
LibraryItemKind::from_str(&stored_app_kind)
} else {
inferred_app_kind
};
Ok(LibraryGame {
id: row.get(0)?,
title,
platform_source: GameSource::from_str(&platform_source),
source_store: row.get(3)?,
source_app_id: row.get(4)?,
install_path: row.get(5)?,
executable_path: row.get(6)?,
launch_command: row.get(7)?,
description: row.get(8)?,
app_kind,
steam_app_type,
genres,
steam_categories,
release_date: row.get(13)?,
developer: row.get(14)?,
publisher: row.get(15)?,
cover_image: row.get(16)?,
hero_image: row.get(17)?,
icon_image: row.get(18)?,
metadata_status: MetadataStatus::from_str(&metadata_status),
last_scanned: row.get(20)?,
user_hidden: row.get::<_, i64>(21)? != 0,
user_favourite: row.get::<_, i64>(22)? != 0,
user_title: row.get(23)?,
})
})
.map_err(|err| format!("Failed to read library games: {err}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("Failed to parse library games: {err}"))
}
pub fn update_customization(
conn: &Connection,
request: GameCustomizationRequest,
) -> Result<Option<LibraryGame>, String> {
let now = now_unix_ms();
let current = conn
.query_row(
"SELECT id FROM games WHERE id = ?1",
params![request.game_id],
|row| row.get::<_, i64>(0),
)
.optional()
.map_err(|err| format!("Failed to find library game: {err}"))?;
if current.is_none() {
return Ok(None);
}
conn.execute(
"UPDATE games SET
user_title = COALESCE(?1, user_title),
executable_path = COALESCE(?2, executable_path),
user_hidden = COALESCE(?3, user_hidden),
user_favourite = COALESCE(?4, user_favourite),
metadata_status = CASE WHEN ?1 IS NULL THEN metadata_status ELSE 'manual' END,
updated_at = ?5
WHERE id = ?6",
params![
request.title.and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}),
request.executable_path,
request.hidden.map(|value| if value { 1 } else { 0 }),
request.favourite.map(|value| if value { 1 } else { 0 }),
now,
request.game_id,
],
)
.map_err(|err| format!("Failed to update library game: {err}"))?;
conn.query_row(
"SELECT
id,
title,
platform_source,
source_store,
source_app_id,
install_path,
executable_path,
launch_command,
description,
app_kind,
steam_app_type,
genres_json,
steam_categories_json,
release_date,
developer,
publisher,
cover_image,
hero_image,
icon_image,
metadata_status,
last_scanned,
user_hidden,
user_favourite,
user_title
FROM games
WHERE id = ?1",
params![request.game_id],
|row| {
let genres_json: String = row.get(11)?;
let steam_categories_json: String = row.get(12)?;
let platform_source: String = row.get(2)?;
let stored_app_kind: String = row.get(9)?;
let metadata_status: String = row.get(19)?;
let steam_app_type: Option<String> = row.get(10)?;
let title: String = row.get(1)?;
let genres = parse_string_list(&genres_json);
let steam_categories = parse_string_list(&steam_categories_json);
let inferred_app_kind = infer_library_item_kind(
steam_app_type.as_deref(),
Some(&title),
&genres,
&steam_categories,
);
let app_kind = if inferred_app_kind == LibraryItemKind::Unknown {
LibraryItemKind::from_str(&stored_app_kind)
} else {
inferred_app_kind
};
Ok(LibraryGame {
id: row.get(0)?,
title,
platform_source: GameSource::from_str(&platform_source),
source_store: row.get(3)?,
source_app_id: row.get(4)?,
install_path: row.get(5)?,
executable_path: row.get(6)?,
launch_command: row.get(7)?,
description: row.get(8)?,
app_kind,
steam_app_type,
genres,
steam_categories,
release_date: row.get(13)?,
developer: row.get(14)?,
publisher: row.get(15)?,
cover_image: row.get(16)?,
hero_image: row.get(17)?,
icon_image: row.get(18)?,
metadata_status: MetadataStatus::from_str(&metadata_status),
last_scanned: row.get(20)?,
user_hidden: row.get::<_, i64>(21)? != 0,
user_favourite: row.get::<_, i64>(22)? != 0,
user_title: row.get(23)?,
})
},
)
.optional()
.map_err(|err| format!("Failed to reload library game: {err}"))
}
fn ensure_column(
conn: &Connection,
table: &str,
column: &str,
definition: &str,
) -> Result<(), String> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({table})"))
.map_err(|err| format!("Failed to inspect {table} schema: {err}"))?;
let columns = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|err| format!("Failed to read {table} schema: {err}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("Failed to parse {table} schema: {err}"))?;
if !columns.iter().any(|name| name == column) {
conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {definition}"), [])
.map_err(|err| format!("Failed to add {table}.{column}: {err}"))?;
}
Ok(())
}
fn parse_string_list(value: &str) -> Vec<String> {
serde_json::from_str(value).unwrap_or_default()
}
+114
View File
@@ -0,0 +1,114 @@
use std::fs;
use std::path::PathBuf;
#[derive(Clone)]
pub struct ImageCache {
root: PathBuf,
}
impl ImageCache {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
#[allow(dead_code)]
pub fn game_dir(&self, source: &str, app_id_or_slug: &str) -> Result<PathBuf, String> {
let safe_slug = app_id_or_slug
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect::<String>();
let dir = self.root.join(source).join(safe_slug);
fs::create_dir_all(&dir).map_err(|err| format!("Failed to prepare image cache: {err}"))?;
Ok(dir)
}
#[allow(dead_code)]
pub fn local_image_path(
&self,
source: &str,
app_id_or_slug: &str,
image_kind: &str,
extension: &str,
) -> Result<PathBuf, String> {
Ok(self
.game_dir(source, app_id_or_slug)?
.join(format!("{image_kind}.{extension}")))
}
pub fn cache_remote_image(
&self,
source: &str,
app_id_or_slug: &str,
image_kind: &str,
extension: &str,
url: &str,
) -> Result<Option<PathBuf>, String> {
let target = self.local_image_path(source, app_id_or_slug, image_kind, extension)?;
if target.is_file() {
if is_valid_image_file(&target) {
return Ok(Some(target));
}
let _ = fs::remove_file(&target);
}
let response = match ureq::get(url)
.header("User-Agent", "NebulaOS/0.1 library-scanner")
.call()
{
Ok(response) => response,
Err(_) => return Ok(None),
};
if !response.status().is_success() {
return Ok(None);
}
let mut response = response;
let bytes = response
.body_mut()
.read_to_vec()
.map_err(|err| format!("Failed to download image {url}: {err}"))?;
if !looks_like_image(&bytes) {
return Ok(None);
}
fs::write(&target, bytes)
.map_err(|err| format!("Failed to write image cache {}: {err}", target.display()))?;
Ok(Some(target))
}
pub fn cache_first_remote_image(
&self,
source: &str,
app_id_or_slug: &str,
image_kind: &str,
extension: &str,
urls: &[String],
) -> Result<Option<PathBuf>, String> {
for url in urls {
if let Some(path) =
self.cache_remote_image(source, app_id_or_slug, image_kind, extension, url)?
{
return Ok(Some(path));
}
}
Ok(None)
}
}
fn is_valid_image_file(path: &PathBuf) -> bool {
fs::read(path)
.map(|bytes| looks_like_image(&bytes))
.unwrap_or(false)
}
fn looks_like_image(bytes: &[u8]) -> bool {
if bytes.len() < 1024 {
return false;
}
bytes.starts_with(&[0xff, 0xd8, 0xff])
|| bytes.starts_with(&[0x89, b'P', b'N', b'G'])
|| bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP")
}
+250
View File
@@ -0,0 +1,250 @@
use super::images::ImageCache;
use super::models::{
infer_library_item_kind, GameCandidate, GameMetadata, GameSource, MetadataStatus,
};
use serde_json::Value;
pub mod providers {
pub mod igdb {
use crate::library::metadata::MetadataProvider;
#[allow(dead_code)]
pub trait IgdbMetadataProvider: MetadataProvider {}
}
pub mod rawg {
use crate::library::metadata::MetadataProvider;
#[allow(dead_code)]
pub trait RawgMetadataProvider: MetadataProvider {}
}
pub mod steam {
use crate::library::metadata::MetadataProvider;
#[allow(dead_code)]
pub trait SteamMetadataProvider: MetadataProvider {}
}
}
pub trait MetadataProvider: Send + Sync {
#[allow(dead_code)]
fn provider_id(&self) -> &'static str;
fn resolve(&self, candidate: &GameCandidate) -> Result<Option<GameMetadata>, String>;
}
pub struct LocalCacheMetadataProvider {
image_cache: ImageCache,
}
impl LocalCacheMetadataProvider {
pub fn new(image_cache: ImageCache) -> Self {
Self { image_cache }
}
}
impl MetadataProvider for LocalCacheMetadataProvider {
fn provider_id(&self) -> &'static str {
"local_cache"
}
fn resolve(&self, candidate: &GameCandidate) -> Result<Option<GameMetadata>, String> {
if candidate.source == GameSource::Steam {
return self.resolve_steam(candidate);
}
Ok(None)
}
}
impl LocalCacheMetadataProvider {
fn resolve_steam(&self, candidate: &GameCandidate) -> Result<Option<GameMetadata>, String> {
let Some(app_id) = candidate.app_id.as_deref() else {
return Ok(None);
};
let store_data = fetch_steam_store_data(app_id)?;
let title = store_data
.as_ref()
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
.map(ToString::to_string)
.or_else(|| Some(candidate.title.clone()));
let description = store_data
.as_ref()
.and_then(|value| value.get("short_description"))
.and_then(Value::as_str)
.map(ToString::to_string);
let genres = store_data
.as_ref()
.and_then(|value| value.get("genres"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("description").and_then(Value::as_str))
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let steam_categories = store_data
.as_ref()
.and_then(|value| value.get("categories"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("description").and_then(Value::as_str))
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let steam_app_type = store_data
.as_ref()
.and_then(|value| value.get("type"))
.and_then(Value::as_str)
.map(ToString::to_string);
let app_kind = infer_library_item_kind(
steam_app_type.as_deref(),
title.as_deref(),
&genres,
&steam_categories,
);
let release_date = store_data
.as_ref()
.and_then(|value| value.get("release_date"))
.and_then(|value| value.get("date"))
.and_then(Value::as_str)
.map(ToString::to_string);
let developer = joined_string_array(store_data.as_ref(), "developers");
let publisher = joined_string_array(store_data.as_ref(), "publishers");
let header_image = store_data
.as_ref()
.and_then(|value| value.get("header_image"))
.and_then(Value::as_str)
.map(ToString::to_string);
let source = candidate.source.as_str();
let cover_image = self.image_cache.cache_first_remote_image(
source,
app_id,
"cover",
"jpg",
&[
format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/library_600x900.jpg"),
format!("https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/{app_id}/library_600x900.jpg"),
format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_616x353.jpg"),
],
)?;
let hero_urls = [
Some(format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/library_hero.jpg"
)),
header_image.clone(),
Some(format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/header.jpg"
)),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let hero_image = self
.image_cache
.cache_first_remote_image(source, app_id, "hero", "jpg", &hero_urls)?;
let icon_image = self.image_cache.cache_first_remote_image(
source,
app_id,
"icon",
"jpg",
&[
format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_184x69.jpg"
),
format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/header.jpg"),
],
)?;
Ok(Some(GameMetadata {
title,
description,
app_kind,
steam_app_type,
genres,
steam_categories,
release_date,
developer,
publisher,
cover_image,
hero_image,
icon_image,
status: MetadataStatus::Matched,
}))
}
}
fn fetch_steam_store_data(app_id: &str) -> Result<Option<Value>, String> {
let url = format!("https://store.steampowered.com/api/appdetails?appids={app_id}");
let response = match ureq::get(&url)
.header("User-Agent", "NebulaOS/0.1 library-scanner")
.call()
{
Ok(response) => response,
Err(_) => return Ok(None),
};
if !response.status().is_success() {
return Ok(None);
}
let mut response = response;
let body = response
.body_mut()
.read_to_string()
.map_err(|err| format!("Failed to read Steam metadata response: {err}"))?;
let value: Value = serde_json::from_str(&body)
.map_err(|err| format!("Failed to parse Steam metadata: {err}"))?;
Ok(value
.get(app_id)
.filter(|entry| {
entry
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.and_then(|entry| entry.get("data"))
.cloned())
}
fn joined_string_array(value: Option<&Value>, key: &str) -> Option<String> {
value
.and_then(|value| value.get(key))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", ")
})
.filter(|value| !value.is_empty())
}
#[allow(dead_code)]
pub struct MetadataMatchPlan {
pub exact_app_id: bool,
pub store_specific_lookup: bool,
pub fuzzy_title_matching: bool,
pub manual_user_correction: bool,
}
impl Default for MetadataMatchPlan {
fn default() -> Self {
Self {
exact_app_id: true,
store_specific_lookup: true,
fuzzy_title_matching: true,
manual_user_correction: true,
}
}
}
+142
View File
@@ -0,0 +1,142 @@
pub mod commands;
pub mod db;
pub mod images;
pub mod metadata;
pub mod models;
pub mod scanners;
use crate::storage::AppStorage;
use db::{ensure_library_schema, list_games, upsert_candidate};
use images::ImageCache;
use metadata::{LocalCacheMetadataProvider, MetadataProvider};
use models::{GameCandidate, GameSource, LibraryGame, ScanProviderReport, ScanSummary};
use scanners::{
epic::EpicScanner, gog::GogScanner, local::LocalAppScanner, steam::SteamScanner, ScanContext,
ScannerProvider,
};
use std::collections::HashMap;
use std::path::PathBuf;
pub fn initialize(storage: &AppStorage) -> Result<(), String> {
let conn = storage.connect_library()?;
ensure_library_schema(&conn)
}
pub fn list_visible_games(storage: &AppStorage) -> Result<Vec<LibraryGame>, String> {
let conn = storage.connect_library()?;
ensure_library_schema(&conn)?;
list_games(&conn, false)
}
pub fn scan_library(
storage: AppStorage,
local_folders: Vec<PathBuf>,
) -> Result<ScanSummary, String> {
let conn = storage.connect_library()?;
ensure_library_schema(&conn)?;
let context = ScanContext { local_folders };
let scanners: Vec<Box<dyn ScannerProvider>> = vec![
Box::new(SteamScanner::new()),
Box::new(EpicScanner::new()),
Box::new(GogScanner::new()),
Box::new(LocalAppScanner::new()),
];
let metadata_provider =
LocalCacheMetadataProvider::new(ImageCache::new(storage.image_cache_dir));
let mut provider_reports = Vec::new();
let mut all_candidates = Vec::new();
for scanner in scanners {
let source = scanner.source();
match scanner.scan(&context) {
Ok(mut candidates) => {
let discovered = candidates.len();
all_candidates.append(&mut candidates);
provider_reports.push(ScanProviderReport {
source,
discovered,
error: None,
});
}
Err(error) => {
provider_reports.push(ScanProviderReport {
source,
discovered: 0,
error: Some(error),
});
}
}
}
let candidates = dedupe_candidates(all_candidates);
let mut inserted_or_updated = 0;
let mut metadata_matched = 0;
let mut unmatched = 0;
for candidate in &candidates {
let metadata = metadata_provider.resolve(candidate)?;
if metadata.is_some() {
metadata_matched += 1;
} else {
unmatched += 1;
}
upsert_candidate(&conn, candidate, metadata.as_ref())?;
inserted_or_updated += 1;
}
let games = list_games(&conn, false)?;
Ok(ScanSummary {
discovered: candidates.len(),
inserted_or_updated,
metadata_matched,
unmatched,
providers: provider_reports,
games,
})
}
fn dedupe_candidates(candidates: Vec<GameCandidate>) -> Vec<GameCandidate> {
let mut by_identity: HashMap<String, GameCandidate> = HashMap::new();
for candidate in candidates {
let key = match (&candidate.source, &candidate.app_id) {
(source, Some(app_id)) => format!("source:{}:{app_id}", source.as_str()),
(_, None) => format!(
"path:{}",
candidate.install_path.to_string_lossy().to_lowercase()
),
};
by_identity.entry(key).or_insert(candidate);
}
let mut unique: Vec<GameCandidate> = by_identity.into_values().collect();
unique.sort_by(|left, right| left.title.to_lowercase().cmp(&right.title.to_lowercase()));
unique
}
#[allow(dead_code)]
pub struct LibraryExpansionHooks;
#[allow(dead_code)]
impl LibraryExpansionHooks {
pub const ACHIEVEMENTS_DB: &'static str = "databases/achievements.db";
pub const SAVES_DB: &'static str = "databases/saves.db";
pub const MODS_DB: &'static str = "databases/mods.db";
pub const ACCOUNTS_DB: &'static str = "databases/accounts.db";
}
#[allow(dead_code)]
pub trait AccountLinkedLibraryProvider: ScannerProvider {
fn account_provider_id(&self) -> &'static str;
}
#[allow(dead_code)]
pub trait LaunchProvider {
fn source(&self) -> GameSource;
fn launch(&self, game: &LibraryGame) -> Result<(), String>;
}
+249
View File
@@ -0,0 +1,249 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GameSource {
Steam,
Epic,
Gog,
Local,
Unknown,
}
impl GameSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Steam => "steam",
Self::Epic => "epic",
Self::Gog => "gog",
Self::Local => "local",
Self::Unknown => "unknown",
}
}
pub fn from_str(value: &str) -> Self {
match value {
"steam" => Self::Steam,
"epic" => Self::Epic,
"gog" => Self::Gog,
"local" => Self::Local,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetadataStatus {
Pending,
Matched,
NeedsReview,
Manual,
}
impl MetadataStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Matched => "matched",
Self::NeedsReview => "needs_review",
Self::Manual => "manual",
}
}
pub fn from_str(value: &str) -> Self {
match value {
"matched" => Self::Matched,
"needs_review" => Self::NeedsReview,
"manual" => Self::Manual,
_ => Self::Pending,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LibraryItemKind {
Game,
Tool,
Software,
Unknown,
}
impl LibraryItemKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Game => "game",
Self::Tool => "tool",
Self::Software => "software",
Self::Unknown => "unknown",
}
}
pub fn from_str(value: &str) -> Self {
match value {
"tool" => Self::Tool,
"software" => Self::Software,
"unknown" => Self::Unknown,
_ => Self::Game,
}
}
}
pub fn infer_library_item_kind(
steam_app_type: Option<&str>,
title: Option<&str>,
genres: &[String],
categories: &[String],
) -> LibraryItemKind {
if steam_app_type
.map(|value| value.eq_ignore_ascii_case("software"))
.unwrap_or(false)
{
return LibraryItemKind::Software;
}
if genres.iter().any(|genre| is_tool_label(genre))
|| categories.iter().any(|category| is_tool_label(category))
|| title.map(is_tool_title).unwrap_or(false)
{
return LibraryItemKind::Tool;
}
if steam_app_type
.map(|value| value.eq_ignore_ascii_case("game"))
.unwrap_or(false)
{
LibraryItemKind::Game
} else {
LibraryItemKind::Unknown
}
}
fn is_tool_label(value: &str) -> bool {
matches!(
normalize_label(value).as_str(),
"animation & modeling"
| "audio production"
| "design & illustration"
| "education"
| "game development"
| "photo editing"
| "software training"
| "utilities"
| "video production"
| "web publishing"
| "includes level editor"
)
}
fn is_tool_title(value: &str) -> bool {
let value = normalize_label(value);
value == "blender"
|| value.contains("creation kit")
|| value.contains("mod tools")
|| value.contains("modding tools")
|| value.contains("developer tools")
|| value.contains("dedicated server")
|| value.ends_with(" sdk")
|| value.contains(" editor")
}
fn normalize_label(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCandidate {
pub title: String,
pub source: GameSource,
pub app_id: Option<String>,
pub install_path: PathBuf,
pub executable_path: Option<PathBuf>,
pub launch_command: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameMetadata {
pub title: Option<String>,
pub description: Option<String>,
pub app_kind: LibraryItemKind,
pub steam_app_type: Option<String>,
pub genres: Vec<String>,
pub steam_categories: Vec<String>,
pub release_date: Option<String>,
pub developer: Option<String>,
pub publisher: Option<String>,
pub cover_image: Option<PathBuf>,
pub hero_image: Option<PathBuf>,
pub icon_image: Option<PathBuf>,
pub status: MetadataStatus,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryGame {
pub id: i64,
pub title: String,
pub platform_source: GameSource,
pub source_store: String,
pub source_app_id: Option<String>,
pub install_path: String,
pub executable_path: Option<String>,
pub launch_command: Option<String>,
pub description: Option<String>,
pub app_kind: LibraryItemKind,
pub steam_app_type: Option<String>,
pub genres: Vec<String>,
pub steam_categories: Vec<String>,
pub release_date: Option<String>,
pub developer: Option<String>,
pub publisher: Option<String>,
pub cover_image: Option<String>,
pub hero_image: Option<String>,
pub icon_image: Option<String>,
pub metadata_status: MetadataStatus,
pub last_scanned: i64,
pub user_hidden: bool,
pub user_favourite: bool,
pub user_title: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanProviderReport {
pub source: GameSource,
pub discovered: usize,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanSummary {
pub discovered: usize,
pub inserted_or_updated: usize,
pub metadata_matched: usize,
pub unmatched: usize,
pub providers: Vec<ScanProviderReport>,
pub games: Vec<LibraryGame>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScanRequest {
#[serde(default)]
pub local_folders: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCustomizationRequest {
pub game_id: i64,
pub title: Option<String>,
pub executable_path: Option<String>,
pub hidden: Option<bool>,
pub favourite: Option<bool>,
}
+118
View File
@@ -0,0 +1,118 @@
use super::ScannerProvider;
use crate::library::models::{GameCandidate, GameSource};
use crate::library::scanners::ScanContext;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
pub struct EpicScanner;
impl EpicScanner {
pub fn new() -> Self {
Self
}
}
impl ScannerProvider for EpicScanner {
fn source(&self) -> GameSource {
GameSource::Epic
}
fn scan(&self, _context: &ScanContext) -> Result<Vec<GameCandidate>, String> {
let mut candidates = Vec::new();
for manifest_dir in discover_manifest_dirs() {
let entries = match fs::read_dir(manifest_dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("item") {
continue;
}
if let Some(candidate) = parse_manifest(&path)? {
candidates.push(candidate);
}
}
}
Ok(candidates)
}
}
fn discover_manifest_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
if let Some(program_data) = std::env::var_os("PROGRAMDATA") {
dirs.push(
PathBuf::from(program_data)
.join("Epic")
.join("EpicGamesLauncher")
.join("Data")
.join("Manifests"),
);
}
#[cfg(target_os = "macos")]
if let Some(home) = super::home_dir() {
dirs.push(
home.join("Library")
.join("Application Support")
.join("Epic")
.join("EpicGamesLauncher")
.join("Data")
.join("Manifests"),
);
}
#[cfg(target_os = "linux")]
if let Some(home) = super::home_dir() {
dirs.push(
home.join(".config")
.join("Epic")
.join("EpicGamesLauncher")
.join("Data")
.join("Manifests"),
);
}
dirs.into_iter().filter(|path| path.is_dir()).collect()
}
fn parse_manifest(path: &Path) -> Result<Option<GameCandidate>, String> {
let contents = fs::read_to_string(path)
.map_err(|err| format!("Failed to read Epic manifest {}: {err}", path.display()))?;
let value: Value = serde_json::from_str(&contents)
.map_err(|err| format!("Failed to parse Epic manifest {}: {err}", path.display()))?;
let Some(display_name) = value.get("DisplayName").and_then(Value::as_str) else {
return Ok(None);
};
let Some(install_location) = value.get("InstallLocation").and_then(Value::as_str) else {
return Ok(None);
};
let launch_executable = value
.get("LaunchExecutable")
.and_then(Value::as_str)
.filter(|value| !value.is_empty());
let catalog_item_id = value
.get("CatalogItemId")
.or_else(|| value.get("MainGameCatalogItemId"))
.and_then(Value::as_str)
.map(ToString::to_string);
let install_path = PathBuf::from(install_location);
let executable_path = launch_executable.map(|executable| install_path.join(executable));
Ok(Some(GameCandidate {
title: display_name.to_string(),
source: GameSource::Epic,
app_id: catalog_item_id,
install_path,
executable_path,
launch_command: None,
}))
}
+153
View File
@@ -0,0 +1,153 @@
use super::{is_probably_game_executable, title_from_path, ScannerProvider};
use crate::library::models::{GameCandidate, GameSource};
use crate::library::scanners::ScanContext;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
pub struct GogScanner;
impl GogScanner {
pub fn new() -> Self {
Self
}
}
impl ScannerProvider for GogScanner {
fn source(&self) -> GameSource {
GameSource::Gog
}
fn scan(&self, _context: &ScanContext) -> Result<Vec<GameCandidate>, String> {
let mut candidates = Vec::new();
for root in discover_gog_roots() {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let install_path = entry.path();
if !install_path.is_dir() {
continue;
}
if let Some(candidate) = inspect_install_dir(&install_path)? {
candidates.push(candidate);
}
}
}
Ok(candidates)
}
}
fn discover_gog_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(target_os = "windows")]
{
if let Some(program_files_x86) = std::env::var_os("PROGRAMFILES(X86)") {
let program_files_x86 = PathBuf::from(program_files_x86);
roots.push(program_files_x86.join("GOG Galaxy").join("Games"));
roots.push(program_files_x86.join("GOG.com").join("Games"));
}
if let Some(program_files) = std::env::var_os("PROGRAMFILES") {
let program_files = PathBuf::from(program_files);
roots.push(program_files.join("GOG Galaxy").join("Games"));
roots.push(program_files.join("GOG.com").join("Games"));
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
if let Some(home) = super::home_dir() {
roots.push(home.join("GOG Games"));
roots.push(home.join("Games").join("GOG"));
}
roots.into_iter().filter(|path| path.is_dir()).collect()
}
fn inspect_install_dir(install_path: &Path) -> Result<Option<GameCandidate>, String> {
let metadata = read_gog_metadata(install_path)?;
let title = metadata
.as_ref()
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
.map(ToString::to_string)
.unwrap_or_else(|| title_from_path(install_path));
let app_id = metadata
.as_ref()
.and_then(|value| value.get("gameId").or_else(|| value.get("game_id")))
.and_then(|value| {
value
.as_i64()
.map(|id| id.to_string())
.or_else(|| value.as_str().map(ToString::to_string))
});
let executable_path = find_executable(install_path);
Ok(Some(GameCandidate {
title,
source: GameSource::Gog,
app_id,
install_path: install_path.to_path_buf(),
executable_path,
launch_command: None,
}))
}
fn read_gog_metadata(install_path: &Path) -> Result<Option<Value>, String> {
let entries = match fs::read_dir(install_path) {
Ok(entries) => entries,
Err(_) => return Ok(None),
};
for entry in entries.flatten() {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if !file_name.starts_with("goggame-") || !file_name.ends_with(".info") {
continue;
}
let contents = fs::read_to_string(&path)
.map_err(|err| format!("Failed to read GOG metadata {}: {err}", path.display()))?;
let value = serde_json::from_str(&contents)
.map_err(|err| format!("Failed to parse GOG metadata {}: {err}", path.display()))?;
return Ok(Some(value));
}
Ok(None)
}
fn find_executable(install_path: &Path) -> Option<PathBuf> {
let entries = fs::read_dir(install_path).ok()?;
entries
.flatten()
.map(|entry| entry.path())
.find(|path| path.is_file() && is_launchable(path) && is_probably_game_executable(path))
}
fn is_launchable(path: &Path) -> bool {
#[cfg(target_os = "windows")]
{
return path.extension().and_then(|value| value.to_str()) == Some("exe");
}
#[cfg(target_os = "macos")]
{
return path.extension().and_then(|value| value.to_str()) == Some("app");
}
#[cfg(target_os = "linux")]
{
if path.extension().and_then(|value| value.to_str()) == Some("desktop") {
return true;
}
return true;
}
#[allow(unreachable_code)]
false
}
+146
View File
@@ -0,0 +1,146 @@
use super::{is_probably_game_executable, title_from_path, ScannerProvider};
use crate::library::models::{GameCandidate, GameSource};
use crate::library::scanners::ScanContext;
use std::fs;
use std::path::{Path, PathBuf};
pub struct LocalAppScanner;
impl LocalAppScanner {
pub fn new() -> Self {
Self
}
}
impl ScannerProvider for LocalAppScanner {
fn source(&self) -> GameSource {
GameSource::Local
}
fn scan(&self, context: &ScanContext) -> Result<Vec<GameCandidate>, String> {
let mut candidates = Vec::new();
for folder in &context.local_folders {
if !folder.is_dir() {
continue;
}
scan_folder(folder, 0, &mut candidates)?;
}
Ok(candidates)
}
}
fn scan_folder(
folder: &Path,
depth: usize,
candidates: &mut Vec<GameCandidate>,
) -> Result<(), String> {
if depth > 4 {
return Ok(());
}
let entries = match fs::read_dir(folder) {
Ok(entries) => entries,
Err(_) => return Ok(()),
};
let mut executables = Vec::new();
let mut child_dirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if should_descend(&path) {
child_dirs.push(path);
}
continue;
}
if is_launchable_file(&path) && is_probably_game_executable(&path) {
executables.push(path);
}
}
if let Some(executable_path) = pick_best_executable(&executables) {
let title_path = executable_path.parent().unwrap_or(folder);
candidates.push(GameCandidate {
title: title_from_path(title_path),
source: GameSource::Local,
app_id: None,
install_path: title_path.to_path_buf(),
executable_path: Some(executable_path),
launch_command: None,
});
return Ok(());
}
for child in child_dirs {
scan_folder(&child, depth + 1, candidates)?;
}
Ok(())
}
fn should_descend(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
!matches!(
name.as_str(),
"_commonredist" | "redist" | "redistributables" | "support" | "directx" | "vc_redist"
)
}
fn is_launchable_file(path: &Path) -> bool {
#[cfg(target_os = "windows")]
{
return path.extension().and_then(|value| value.to_str()) == Some("exe");
}
#[cfg(target_os = "macos")]
{
if path.extension().and_then(|value| value.to_str()) == Some("app") {
return true;
}
}
#[cfg(target_os = "linux")]
{
if path.extension().and_then(|value| value.to_str()) == Some("desktop") {
return true;
}
return has_executable_bit(path);
}
#[allow(unreachable_code)]
false
}
fn pick_best_executable(executables: &[PathBuf]) -> Option<PathBuf> {
executables
.iter()
.min_by_key(|path| {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
if file_name.contains("launcher") {
1
} else {
0
}
})
.cloned()
}
#[cfg(target_os = "linux")]
fn has_executable_bit(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path)
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
+69
View File
@@ -0,0 +1,69 @@
use super::models::{GameCandidate, GameSource};
use std::path::PathBuf;
pub mod epic;
pub mod gog;
pub mod local;
pub mod steam;
#[derive(Debug, Clone)]
pub struct ScanContext {
pub local_folders: Vec<PathBuf>,
}
pub trait ScannerProvider: Send + Sync {
fn source(&self) -> GameSource;
fn scan(&self, context: &ScanContext) -> Result<Vec<GameCandidate>, String>;
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
}
pub fn is_probably_game_executable(path: &std::path::Path) -> bool {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_lowercase();
if file_name.is_empty() {
return false;
}
let ignored_fragments = [
"unins",
"uninstall",
"setup",
"install",
"redist",
"vcredist",
"crash",
"reporter",
"benchmark",
"launcherhelper",
"unitycrashhandler",
];
!ignored_fragments
.iter()
.any(|fragment| file_name.contains(fragment))
}
pub fn title_from_path(path: &std::path::Path) -> String {
path.file_stem()
.or_else(|| path.file_name())
.and_then(|name| name.to_str())
.map(|value| {
value
.replace(['_', '-', '.'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "Unknown Game".to_string())
}
+227
View File
@@ -0,0 +1,227 @@
use super::ScannerProvider;
use crate::library::models::{GameCandidate, GameSource};
use crate::library::scanners::ScanContext;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
pub struct SteamScanner;
impl SteamScanner {
pub fn new() -> Self {
Self
}
}
impl ScannerProvider for SteamScanner {
fn source(&self) -> GameSource {
GameSource::Steam
}
fn scan(&self, _context: &ScanContext) -> Result<Vec<GameCandidate>, String> {
let mut candidates = Vec::new();
let mut library_paths = Vec::new();
for steam_root in discover_steam_roots() {
library_paths.extend(discover_library_paths(&steam_root));
}
library_paths.extend(discover_drive_steam_libraries());
for library_path in unique_existing_dirs(library_paths) {
candidates.extend(scan_library_path(&library_path)?);
}
Ok(candidates)
}
}
fn discover_steam_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(target_os = "windows")]
{
if let Some(program_files_x86) = std::env::var_os("PROGRAMFILES(X86)") {
roots.push(PathBuf::from(program_files_x86).join("Steam"));
}
if let Some(program_files) = std::env::var_os("PROGRAMFILES") {
roots.push(PathBuf::from(program_files).join("Steam"));
}
}
#[cfg(target_os = "linux")]
if let Some(home) = super::home_dir() {
roots.push(home.join(".steam").join("steam"));
roots.push(home.join(".local").join("share").join("Steam"));
}
#[cfg(target_os = "macos")]
if let Some(home) = super::home_dir() {
roots.push(
home.join("Library")
.join("Application Support")
.join("Steam"),
);
}
unique_existing_dirs(roots)
}
fn discover_library_paths(steam_root: &Path) -> Vec<PathBuf> {
let mut libraries = vec![steam_root.to_path_buf()];
let library_file = steam_root.join("steamapps").join("libraryfolders.vdf");
if let Ok(contents) = fs::read_to_string(library_file) {
for path in extract_vdf_values(&contents, "path") {
libraries.push(PathBuf::from(path.replace("\\\\", "\\")));
}
// Older Steam clients used numeric keys directly for library paths.
for (key, value) in extract_vdf_pairs(&contents) {
if key.parse::<u32>().is_ok() && value.contains(['\\', '/']) {
libraries.push(PathBuf::from(value.replace("\\\\", "\\")));
}
}
}
unique_existing_dirs(libraries)
}
fn scan_library_path(library_path: &Path) -> Result<Vec<GameCandidate>, String> {
let steamapps = library_path.join("steamapps");
let manifests = match fs::read_dir(&steamapps) {
Ok(entries) => entries,
Err(_) => return Ok(Vec::new()),
};
let mut candidates = Vec::new();
for entry in manifests.flatten() {
let path = entry.path();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if !file_name.starts_with("appmanifest_") || !file_name.ends_with(".acf") {
continue;
}
if let Some(candidate) = parse_app_manifest(&steamapps, &path)? {
candidates.push(candidate);
}
}
Ok(candidates)
}
fn discover_drive_steam_libraries() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
('A'..='Z')
.map(|letter| PathBuf::from(format!("{letter}:\\SteamLibrary")))
.filter(|path| path.join("steamapps").is_dir())
.collect()
}
#[cfg(not(target_os = "windows"))]
{
Vec::new()
}
}
fn parse_app_manifest(
steamapps: &Path,
manifest_path: &Path,
) -> Result<Option<GameCandidate>, String> {
let contents = fs::read_to_string(manifest_path).map_err(|err| {
format!(
"Failed to read Steam manifest {}: {err}",
manifest_path.display()
)
})?;
let app_id = extract_vdf_values(&contents, "appid").into_iter().next();
let title = extract_vdf_values(&contents, "name").into_iter().next();
let install_dir = extract_vdf_values(&contents, "installdir")
.into_iter()
.next();
let Some(app_id) = app_id else {
return Ok(None);
};
let Some(title) = title else {
return Ok(None);
};
let Some(install_dir) = install_dir else {
return Ok(None);
};
Ok(Some(GameCandidate {
title,
source: GameSource::Steam,
app_id: Some(app_id.clone()),
install_path: steamapps.join("common").join(install_dir),
executable_path: None,
launch_command: Some(format!("steam://rungameid/{app_id}")),
}))
}
fn extract_vdf_values(contents: &str, wanted_key: &str) -> Vec<String> {
extract_vdf_pairs(contents)
.into_iter()
.filter_map(|(key, value)| if key == wanted_key { Some(value) } else { None })
.collect()
}
fn extract_vdf_pairs(contents: &str) -> Vec<(String, String)> {
contents
.lines()
.filter_map(|line| {
let parts = quoted_tokens(line);
if parts.len() >= 2 {
Some((parts[0].clone(), parts[1].clone()))
} else {
None
}
})
.collect()
}
fn quoted_tokens(line: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut escaped = false;
for ch in line.chars() {
if escaped {
current.push(ch);
escaped = false;
continue;
}
if ch == '\\' && in_quote {
escaped = true;
continue;
}
if ch == '"' {
if in_quote {
tokens.push(current.clone());
current.clear();
}
in_quote = !in_quote;
continue;
}
if in_quote {
current.push(ch);
}
}
tokens
}
fn unique_existing_dirs(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
paths
.into_iter()
.filter(|path| path.is_dir())
.filter(|path| seen.insert(path.to_string_lossy().to_lowercase()))
.collect()
}
+48
View File
@@ -0,0 +1,48 @@
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
#[derive(Clone)]
pub struct AppStorage {
#[allow(dead_code)]
pub app_data_dir: PathBuf,
#[allow(dead_code)]
pub databases_dir: PathBuf,
#[allow(dead_code)]
pub cache_dir: PathBuf,
pub image_cache_dir: PathBuf,
pub users_db_path: PathBuf,
pub library_db_path: PathBuf,
}
impl AppStorage {
pub fn new(app_data_dir: PathBuf) -> Result<Self, String> {
let databases_dir = app_data_dir.join("databases");
let cache_dir = app_data_dir.join("cache");
let image_cache_dir = cache_dir.join("images");
fs::create_dir_all(&databases_dir)
.map_err(|err| format!("Failed to create database directory: {err}"))?;
fs::create_dir_all(&image_cache_dir)
.map_err(|err| format!("Failed to create image cache directory: {err}"))?;
Ok(Self {
users_db_path: databases_dir.join("users.db"),
library_db_path: databases_dir.join("library.db"),
app_data_dir,
databases_dir,
cache_dir,
image_cache_dir,
})
}
pub fn connect_users(&self) -> Result<Connection, String> {
Connection::open(&self.users_db_path)
.map_err(|err| format!("Failed to open users database: {err}"))
}
pub fn connect_library(&self) -> Result<Connection, String> {
Connection::open(&self.library_db_path)
.map_err(|err| format!("Failed to open library database: {err}"))
}
}
+5 -1
View File
@@ -16,7 +16,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/**"]
}
}
},
"bundle": {