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", "pin-project-lite",
] ]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -2106,6 +2112,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"ureq",
] ]
[[package]] [[package]]
@@ -2994,6 +3001,20 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.31.0" version = "0.31.0"
@@ -3030,6 +3051,41 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3440,6 +3496,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3580,6 +3642,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range",
"jni", "jni",
"libc", "libc",
"log", "log",
@@ -4231,6 +4294,41 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4262,6 +4360,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -4527,6 +4631,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4757,6 +4870,15 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5301,6 +5423,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"
+2 -1
View File
@@ -18,9 +18,10 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] } 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 rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize; use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use storage::AppStorage;
use tauri::Manager; use tauri::Manager;
struct DbState {
db_path: PathBuf,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct UserRecord { struct UserRecord {
@@ -19,12 +18,6 @@ struct UserRecord {
created_at_unix_ms: i64, 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 { fn now_unix_ms() -> i64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -120,8 +113,8 @@ fn ensure_users_schema(conn: &Connection) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>, String> { fn get_first_user(storage: tauri::State<'_, AppStorage>) -> Result<Option<UserRecord>, String> {
let conn = state.connect()?; let conn = storage.connect_users()?;
ensure_users_schema(&conn)?; ensure_users_schema(&conn)?;
conn.query_row( conn.query_row(
"SELECT "SELECT
@@ -155,13 +148,13 @@ fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>
fn create_user( fn create_user(
first_name: String, first_name: String,
last_name: Option<String>, last_name: Option<String>,
state: tauri::State<'_, DbState>, storage: tauri::State<'_, AppStorage>,
) -> Result<UserRecord, String> { ) -> Result<UserRecord, String> {
let safe_first_name = sanitize_name(&first_name)?; let safe_first_name = sanitize_name(&first_name)?;
let safe_last_name = sanitize_optional_name(last_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 display_name = build_display_name(&safe_first_name, safe_last_name.as_deref());
let created_at_unix_ms = now_unix_ms(); let created_at_unix_ms = now_unix_ms();
let conn = state.connect()?; let conn = storage.connect_users()?;
ensure_users_schema(&conn)?; ensure_users_schema(&conn)?;
conn.execute( conn.execute(
"INSERT INTO users (name, first_name, last_name, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4)", "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() .path()
.app_data_dir() .app_data_dir()
.map_err(|err| format!("Failed to resolve app data dir: {err}"))?; .map_err(|err| format!("Failed to resolve app data dir: {err}"))?;
fs::create_dir_all(&app_data_dir) let storage = AppStorage::new(app_data_dir)?;
.map_err(|err| format!("Failed to create app data dir: {err}"))?; 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"); app.manage(storage);
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 });
Ok(()) 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .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": { "security": {
"csp": null "csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/**"]
}
} }
}, },
"bundle": { "bundle": {
+5
View File
@@ -1,6 +1,11 @@
const LEGACY_USER_STORAGE_KEY = "nebula.user.v1"; const LEGACY_USER_STORAGE_KEY = "nebula.user.v1";
const getInvoke = async () => { const getInvoke = async () => {
const globalInvoke = window.__TAURI__?.core?.invoke;
if (typeof globalInvoke === "function") {
return globalInvoke;
}
try { try {
const tauriCore = await import("@tauri-apps/api/core"); const tauriCore = await import("@tauri-apps/api/core");
return typeof tauriCore.invoke === "function" ? tauriCore.invoke : null; return typeof tauriCore.invoke === "function" ? tauriCore.invoke : null;
+64 -2
View File
@@ -147,6 +147,12 @@
); );
} }
.hero-art.has-image .hero-art-bg {
background:
linear-gradient(165deg, rgba(7, 10, 20, 0.36), rgba(7, 10, 20, 0.86)),
var(--hero-image) center / cover no-repeat;
}
.hero-art-mid { .hero-art-mid {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -187,6 +193,11 @@
font-style: italic; font-style: italic;
} }
.hero-art.has-image .hero-art-mid,
.hero-art.has-image .hero-art-character {
display: none;
}
/* Controller overlay */ /* Controller overlay */
.hero-ctrl-overlay { .hero-ctrl-overlay {
position: absolute; position: absolute;
@@ -247,6 +258,15 @@
line-height: 1.1; line-height: 1.1;
} }
.hero-game-meta {
margin: 0;
color: var(--nebula-color-muted);
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hero-actions { .hero-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -333,7 +353,9 @@
flex: 1; flex: 1;
height: 68px; height: 68px;
border-radius: var(--nebula-radius-md); border-radius: var(--nebula-radius-md);
background: linear-gradient(135deg, var(--thumb-a, #1a1a2e), var(--thumb-b, #2a1050)); background:
linear-gradient(135deg, rgba(79, 216, 255, 0.18), rgba(125, 89, 255, 0.18)),
linear-gradient(135deg, var(--thumb-a, #1a1a2e), var(--thumb-b, #2a1050));
border: 2px solid var(--nebula-color-border); border: 2px solid var(--nebula-color-border);
cursor: pointer; cursor: pointer;
transition: border-color var(--nebula-duration-fast); transition: border-color var(--nebula-duration-fast);
@@ -341,6 +363,12 @@
padding: 0; padding: 0;
} }
.featured-thumb.has-image {
background:
linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.64)),
var(--featured-image) center / cover no-repeat;
}
.featured-thumb.is-focused { .featured-thumb.is-focused {
border-color: var(--nebula-color-accent); border-color: var(--nebula-color-accent);
transform: translateY(-2px); transform: translateY(-2px);
@@ -415,11 +443,38 @@
.quick-tile-art { .quick-tile-art {
height: 70px; height: 70px;
background: linear-gradient(135deg, var(--ta, #1a1a2e), var(--tb, #2a2050)); display: grid;
place-items: center;
background:
radial-gradient(circle at top left, rgba(79, 216, 255, 0.22), transparent 46%),
linear-gradient(135deg, var(--ta, #1a1a2e), var(--tb, #2a2050));
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.quick-tile-art.has-image {
background:
linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.48)),
var(--quick-image) center / cover no-repeat;
}
.quick-tile-initials {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.24);
color: var(--nebula-color-text);
font-size: 13px;
font-weight: 900;
letter-spacing: 0.04em;
}
.quick-tile-art.has-image .quick-tile-initials {
display: none;
}
.tile-badge { .tile-badge {
position: absolute; position: absolute;
bottom: 4px; bottom: 4px;
@@ -499,6 +554,13 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.home-placeholder-copy {
margin: 0;
color: var(--nebula-color-muted);
font-size: 11px;
line-height: 1.4;
}
.friend-avatar { .friend-avatar {
width: 28px; width: 28px;
height: 28px; height: 28px;
+227 -55
View File
@@ -1,3 +1,50 @@
const getTauriCore = async () => {
const globalCore = window.__TAURI__?.core;
if (typeof globalCore?.invoke === "function") {
return {
invoke: globalCore.invoke,
convertFileSrc: typeof globalCore.convertFileSrc === "function" ? globalCore.convertFileSrc : null,
};
}
try {
const tauriCore = await import("@tauri-apps/api/core");
return {
invoke: typeof tauriCore.invoke === "function" ? tauriCore.invoke : null,
convertFileSrc: typeof tauriCore.convertFileSrc === "function" ? tauriCore.convertFileSrc : null,
};
} catch (_error) {
return { invoke: null, convertFileSrc: null };
}
};
const sourceLabel = (source) => {
const labels = {
steam: "Steam",
epic: "Epic Games",
gog: "GOG",
local: "Local",
unknown: "Unknown",
};
return labels[source] ?? "Library";
};
const gameTitle = (game) => game?.userTitle || game?.title || "No games scanned yet";
const gameArtUrl = (game, convertFileSrc, key = "heroImage") => {
const path = game?.[key] || game?.coverImage || game?.iconImage;
if (!path || !convertFileSrc) return "";
return convertFileSrc(path);
};
const initialsForTitle = (title) =>
title
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || "OS";
const HOME_TEMPLATE = ` const HOME_TEMPLATE = `
<section class="view home-view" data-view="home"> <section class="view home-view" data-view="home">
@@ -50,14 +97,12 @@ const HOME_TEMPLATE = `
</nav> </nav>
<!-- Hero game card --> <!-- Hero game card -->
<article class="hero-card" aria-label="Now playing: Cyberpunk 2077"> <article class="hero-card" aria-label="Library hero" data-home-hero>
<!-- Game art (placeholder — would be a real cover image) --> <div class="hero-art" aria-hidden="true" data-hero-art>
<div class="hero-art" aria-hidden="true">
<div class="hero-art-bg"></div> <div class="hero-art-bg"></div>
<div class="hero-art-mid"></div> <div class="hero-art-mid"></div>
<div class="hero-art-character"></div> <div class="hero-art-character"></div>
<div class="hero-title-watermark" aria-hidden="true">Cyberpunk<br>2077</div> <div class="hero-title-watermark" aria-hidden="true" data-hero-watermark>NEBULA<br>LIBRARY</div>
<!-- Controller overlay icons -->
<div class="hero-ctrl-overlay" aria-hidden="true"> <div class="hero-ctrl-overlay" aria-hidden="true">
<svg class="ctrl-glyph ctrl-analog" width="48" height="48" viewBox="0 0 48 48" fill="none"> <svg class="ctrl-glyph ctrl-analog" width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="22" stroke="rgba(255,255,255,0.3)" stroke-width="2"/> <circle cx="24" cy="24" r="22" stroke="rgba(255,255,255,0.3)" stroke-width="2"/>
@@ -76,31 +121,35 @@ const HOME_TEMPLATE = `
<!-- Gradient overlay + info --> <!-- Gradient overlay + info -->
<div class="hero-overlay"> <div class="hero-overlay">
<div class="hero-info"> <div class="hero-info">
<h1 class="hero-game-title">Cyberpunk 2077</h1> <p class="hero-game-meta" data-hero-meta>Scan your device to populate Home</p>
<h1 class="hero-game-title" data-hero-title>No games scanned yet</h1>
<div class="hero-actions"> <div class="hero-actions">
<button <button
class="hero-btn hero-btn-primary focusable" class="hero-btn hero-btn-primary focusable"
data-focusable="true" data-row="1" data-col="0" data-focusable="true" data-row="1" data-col="0"
data-focus-key="btn-continue" data-focus-key="btn-continue"
data-target="library"
> >
<span class="btn-prompt btn-a" aria-label="A button">A</span> <span class="btn-prompt btn-a" aria-label="A button">A</span>
Continue Game Open Library
</button> </button>
<button <button
class="hero-btn focusable" class="hero-btn focusable"
data-focusable="true" data-row="1" data-col="1" data-focusable="true" data-row="1" data-col="1"
data-focus-key="btn-progress" data-focus-key="btn-progress"
data-target="library"
> >
<span class="btn-prompt btn-x" aria-label="X button">X</span> <span class="btn-prompt btn-x" aria-label="X button">X</span>
View Progress (68%) Manage Games
</button> </button>
<button <button
class="hero-btn focusable" class="hero-btn focusable"
data-focusable="true" data-row="1" data-col="2" data-focusable="true" data-row="1" data-col="2"
data-focus-key="btn-community" data-focus-key="btn-community"
data-target="library"
> >
<span class="btn-prompt btn-y" aria-label="Y button">Y</span> <span class="btn-prompt btn-y" aria-label="Y button">Y</span>
Community Hub Review Matches
</button> </button>
</div> </div>
</div> </div>
@@ -118,10 +167,10 @@ const HOME_TEMPLATE = `
<section class="featured-strip" aria-label="Featured games"> <section class="featured-strip" aria-label="Featured games">
<h2 class="section-label">Featured</h2> <h2 class="section-label">Featured</h2>
<div class="featured-row"> <div class="featured-row">
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="0" data-focus-key="feat-0" style="--thumb-a:#1a0a2e;--thumb-b:#2e1050;" aria-label="Featured game 1"></button> <button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="0" data-focus-key="feat-0" data-featured-slot="0" aria-label="Featured game"></button>
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="1" data-focus-key="feat-1" style="--thumb-a:#0a1a0a;--thumb-b:#0a3a18;" aria-label="Featured game 2"></button> <button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="1" data-focus-key="feat-1" data-featured-slot="1" aria-label="Featured game"></button>
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="2" data-focus-key="feat-2" style="--thumb-a:#2e1500;--thumb-b:#4a2800;" aria-label="Featured game 3"></button> <button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="2" data-focus-key="feat-2" data-featured-slot="2" aria-label="Featured game"></button>
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="3" data-focus-key="feat-3" style="--thumb-a:#001020;--thumb-b:#002040;" aria-label="Featured game 4"></button> <button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="3" data-focus-key="feat-3" data-featured-slot="3" aria-label="Featured game"></button>
</div> </div>
</section> </section>
</div> </div>
@@ -141,54 +190,46 @@ const HOME_TEMPLATE = `
</svg> </svg>
</div> </div>
<div class="quick-grid"> <div class="quick-grid">
<button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="3" data-focus-key="ql-0" aria-label="Elden Ring"> <button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="3" data-focus-key="ql-0" data-quick-slot="0" aria-label="Library slot">
<div class="quick-tile-art" style="--ta:#3d1a00;--tb:#6b2e00;" aria-hidden="true"></div> <div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
<div class="quick-tile-footer"> <div class="quick-tile-footer">
<p class="quick-tile-name">Elden Ring</p> <p class="quick-tile-name">Scan Library</p>
<p class="quick-tile-meta">Progress</p> <p class="quick-tile-meta">No game yet</p>
</div> </div>
</button> </button>
<button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="4" data-focus-key="ql-1" aria-label="Forza Horizon 5"> <button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="4" data-focus-key="ql-1" data-quick-slot="1" aria-label="Library slot">
<div class="quick-tile-art" style="--ta:#0a2200;--tb:#1a4a00;" aria-hidden="true"> <div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
<span class="tile-badge" aria-label="Trophy">🏆</span>
</div>
<div class="quick-tile-footer"> <div class="quick-tile-footer">
<p class="quick-tile-name">Forza Horizon 5</p> <p class="quick-tile-name">Scan Library</p>
<p class="quick-tile-meta">Progress <span aria-label="Trophy">🏆</span></p> <p class="quick-tile-meta">No game yet</p>
</div> </div>
</button> </button>
<button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="5" data-focus-key="ql-2" aria-label="Hades II"> <button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="5" data-focus-key="ql-2" data-quick-slot="2" aria-label="Library slot">
<div class="quick-tile-art" style="--ta:#1a0a30;--tb:#3a0a60;" aria-hidden="true"> <div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
<span class="tile-badge" aria-label="Trophy">🏆</span>
</div>
<div class="quick-tile-footer"> <div class="quick-tile-footer">
<p class="quick-tile-name">Hades II</p> <p class="quick-tile-name">Scan Library</p>
<p class="quick-tile-meta">35 Achievements</p> <p class="quick-tile-meta">No game yet</p>
</div> </div>
</button> </button>
<button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="3" data-focus-key="ql-3" aria-label="Forza Horizon 4"> <button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="3" data-focus-key="ql-3" data-quick-slot="3" aria-label="Library slot">
<div class="quick-tile-art" style="--ta:#001830;--tb:#002850;" aria-hidden="true"></div> <div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
<div class="quick-tile-footer"> <div class="quick-tile-footer">
<p class="quick-tile-name">Forza Horizon 4</p> <p class="quick-tile-name">Scan Library</p>
<p class="quick-tile-meta quick-tile-bar" style="--pct:20%">20% Progress</p> <p class="quick-tile-meta">No game yet</p>
</div> </div>
</button> </button>
<button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="4" data-focus-key="ql-4" aria-label="Forza Horizon 5 alt"> <button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="4" data-focus-key="ql-4" data-quick-slot="4" aria-label="Library slot">
<div class="quick-tile-art" style="--ta:#200800;--tb:#401000;" aria-hidden="true"> <div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
<span class="tile-badge" aria-label="Trophy">🏆</span>
</div>
<div class="quick-tile-footer"> <div class="quick-tile-footer">
<p class="quick-tile-name">Forza Horizon 5</p> <p class="quick-tile-name">Scan Library</p>
<p class="quick-tile-meta quick-tile-bar" style="--pct:30%">30% Progress</p> <p class="quick-tile-meta">No game yet</p>
</div> </div>
</button> </button>
<button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="5" data-focus-key="ql-5" aria-label="Cuitfroots"> <button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="5" data-focus-key="ql-5" data-quick-slot="5" aria-label="Library slot">
<div class="quick-tile-art" style="--ta:#101e00;--tb:#203e00;" aria-hidden="true"> <div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
<span class="tile-badge" aria-label="Trophy">🏆</span>
</div>
<div class="quick-tile-footer"> <div class="quick-tile-footer">
<p class="quick-tile-name">Cuitfroots</p> <p class="quick-tile-name">Scan Library</p>
<p class="quick-tile-meta quick-tile-bar" style="--pct:50%">50% Achievements</p> <p class="quick-tile-meta">No game yet</p>
</div> </div>
</button> </button>
</div> </div>
@@ -203,20 +244,17 @@ const HOME_TEMPLATE = `
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg> </svg>
</div> </div>
<div class="friends-avatars" aria-label="Online friends"> <div class="friends-avatars" aria-label="Future account integrations">
<div class="friend-avatar" style="--fc:#4fd8ff;" aria-hidden="true"></div> <p class="home-placeholder-copy">Account integrations coming later.</p>
<div class="friend-avatar" style="--fc:#4fff88;" aria-hidden="true"></div>
<div class="friend-avatar" style="--fc:#ff9800;" aria-hidden="true"></div>
</div> </div>
</section> </section>
<section class="activity-panel" aria-label="System activity"> <section class="activity-panel" aria-label="System activity">
<h3 class="panel-heading-sm">System Activity</h3> <h3 class="panel-heading-sm">System Activity</h3>
<div class="activity-row"> <div class="activity-row">
<span class="activity-label">Home</span> <span class="activity-label" data-home-library-count>0 Games</span>
<span class="btn-prompt btn-y" aria-label="Y button">Y</span>
</div> </div>
<p class="activity-hint">Press <span class="btn-prompt btn-a" aria-label="A button">A</span> to select</p> <p class="activity-hint" data-home-activity-hint>Scan from Library to populate Home.</p>
</section> </section>
</div> </div>
@@ -228,11 +266,10 @@ const HOME_TEMPLATE = `
export const createHomeView = ({ state, renderView, openPowerMenu }) => ({ export const createHomeView = ({ state, renderView, openPowerMenu }) => ({
id: "home", id: "home",
render: () => HOME_TEMPLATE, render: () => HOME_TEMPLATE,
mount: () => { mount: async () => {
const view = document.querySelector("[data-view='home']"); const view = document.querySelector("[data-view='home']");
if (!view) return; if (!view) return;
// Tab switching behaviour
view.querySelectorAll(".home-tab").forEach((tab) => { view.querySelectorAll(".home-tab").forEach((tab) => {
tab.addEventListener("click", () => { tab.addEventListener("click", () => {
view.querySelectorAll(".home-tab").forEach((t) => { view.querySelectorAll(".home-tab").forEach((t) => {
@@ -243,6 +280,8 @@ export const createHomeView = ({ state, renderView, openPowerMenu }) => ({
tab.setAttribute("aria-selected", "true"); tab.setAttribute("aria-selected", "true");
}); });
}); });
await hydrateHomeLibrary(view);
}, },
getNavigationContract: () => { getNavigationContract: () => {
const root = document.querySelector("[data-view='home']"); const root = document.querySelector("[data-view='home']");
@@ -268,7 +307,12 @@ export const createHomeView = ({ state, renderView, openPowerMenu }) => ({
return; return;
} }
// Hero action buttons if (element.dataset.gameId || element.dataset.quickSlot || element.dataset.featuredSlot) {
state.activeView = "library";
renderView("library");
return;
}
if (focusKey === "tab-now-playing" || focusKey === "tab-featured") { if (focusKey === "tab-now-playing" || focusKey === "tab-featured") {
element.click(); element.click();
} }
@@ -282,3 +326,131 @@ export const createHomeView = ({ state, renderView, openPowerMenu }) => ({
}; };
}, },
}); });
const hydrateHomeLibrary = async (view) => {
const { invoke, convertFileSrc } = await getTauriCore();
if (!invoke) {
setEmptyHomeState(view, "Run NebulaOS with Tauri to load your library.");
return;
}
try {
const games = await invoke("list_library_games");
if (!Array.isArray(games) || games.length === 0) {
setEmptyHomeState(view, "Open Library and scan your device to populate Home.");
return;
}
const sortedGames = [...games].sort((left, right) => {
if (left.userFavourite !== right.userFavourite) {
return left.userFavourite ? -1 : 1;
}
return gameTitle(left).localeCompare(gameTitle(right));
});
renderHeroGame(view, sortedGames[0], convertFileSrc);
renderQuickLaunch(view, sortedGames.slice(0, 6), convertFileSrc);
renderFeatured(view, sortedGames.slice(0, 4), convertFileSrc);
renderActivity(view, sortedGames);
} catch (error) {
setEmptyHomeState(view, String(error));
}
};
const setEmptyHomeState = (view, message) => {
view.querySelector("[data-hero-title]").textContent = "No games scanned yet";
view.querySelector("[data-hero-meta]").textContent = message;
view.querySelector("[data-home-library-count]").textContent = "0 Games";
view.querySelector("[data-home-activity-hint]").textContent = "Scan from Library to populate Home.";
};
const renderHeroGame = (view, game, convertFileSrc) => {
const title = gameTitle(game);
const hero = view.querySelector("[data-home-hero]");
const heroArt = view.querySelector("[data-hero-art]");
const titleNode = view.querySelector("[data-hero-title]");
const metaNode = view.querySelector("[data-hero-meta]");
const watermark = view.querySelector("[data-hero-watermark]");
const imageUrl = gameArtUrl(game, convertFileSrc, "heroImage");
hero?.setAttribute("aria-label", `Featured game: ${title}`);
titleNode.textContent = title;
metaNode.textContent = `${sourceLabel(game.platformSource)} · ${game.metadataStatus === "needs_review" ? "Needs review" : "Ready"}`;
watermark.textContent = title.toUpperCase();
if (imageUrl) {
heroArt.style.setProperty("--hero-image", `url("${imageUrl}")`);
heroArt.classList.add("has-image");
} else {
heroArt.style.removeProperty("--hero-image");
heroArt.classList.remove("has-image");
}
};
const renderQuickLaunch = (view, games, convertFileSrc) => {
view.querySelectorAll("[data-quick-slot]").forEach((tile, index) => {
const game = games[index];
const art = tile.querySelector(".quick-tile-art");
const initials = tile.querySelector(".quick-tile-initials");
const name = tile.querySelector(".quick-tile-name");
const meta = tile.querySelector(".quick-tile-meta");
if (!game) {
tile.removeAttribute("data-game-id");
tile.setAttribute("aria-label", "Empty library slot");
art.style.removeProperty("--quick-image");
art.classList.remove("has-image");
initials.textContent = "OS";
name.textContent = "Empty Slot";
meta.textContent = "Scan for more games";
return;
}
const title = gameTitle(game);
const imageUrl = gameArtUrl(game, convertFileSrc, "coverImage");
tile.dataset.gameId = String(game.id);
tile.setAttribute("aria-label", title);
name.textContent = title;
meta.textContent = sourceLabel(game.platformSource);
initials.textContent = initialsForTitle(title);
if (imageUrl) {
art.style.setProperty("--quick-image", `url("${imageUrl}")`);
art.classList.add("has-image");
} else {
art.style.removeProperty("--quick-image");
art.classList.remove("has-image");
}
});
};
const renderFeatured = (view, games, convertFileSrc) => {
view.querySelectorAll("[data-featured-slot]").forEach((tile, index) => {
const game = games[index];
if (!game) {
tile.removeAttribute("data-game-id");
tile.setAttribute("aria-label", "Empty featured slot");
tile.style.removeProperty("--featured-image");
tile.classList.remove("has-image");
return;
}
const title = gameTitle(game);
const imageUrl = gameArtUrl(game, convertFileSrc, "heroImage");
tile.dataset.gameId = String(game.id);
tile.setAttribute("aria-label", title);
if (imageUrl) {
tile.style.setProperty("--featured-image", `url("${imageUrl}")`);
tile.classList.add("has-image");
} else {
tile.style.removeProperty("--featured-image");
tile.classList.remove("has-image");
}
});
};
const renderActivity = (view, games) => {
const count = games.length;
view.querySelector("[data-home-library-count]").textContent = `${count} ${count === 1 ? "Game" : "Games"}`;
view.querySelector("[data-home-activity-hint]").textContent = "Home is showing your scanned local library.";
};
+171 -4
View File
@@ -1,10 +1,177 @@
.stub-view { .library-view {
justify-content: center; gap: var(--nebula-spacing-lg);
} }
.stub-panel { .library-actions {
max-width: 720px; display: flex;
gap: var(--nebula-spacing-sm);
align-items: center;
}
.library-layout {
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr;
gap: var(--nebula-spacing-lg);
min-height: 0;
}
.library-status-panel {
align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--nebula-spacing-md); gap: var(--nebula-spacing-md);
} }
.library-status-title {
margin: 0;
font-size: clamp(22px, 2vw, 30px);
}
.library-status-copy {
margin: 0;
color: var(--nebula-color-muted);
line-height: 1.5;
}
.library-provider-list {
display: flex;
flex-direction: column;
gap: var(--nebula-spacing-xs);
margin: 0;
padding: 0;
list-style: none;
}
.library-provider-row {
display: flex;
justify-content: space-between;
gap: var(--nebula-spacing-md);
padding: var(--nebula-spacing-sm);
border-radius: var(--nebula-radius-sm);
background: rgba(255, 255, 255, 0.04);
color: var(--nebula-color-muted);
}
.library-provider-row strong {
color: var(--nebula-color-text);
}
.library-grid {
display: grid;
gap: var(--nebula-spacing-md);
align-content: start;
min-width: 0;
}
.library-section {
display: flex;
flex-direction: column;
gap: var(--nebula-spacing-sm);
min-width: 0;
}
.library-section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: var(--nebula-spacing-md);
}
.library-section-heading h2 {
margin: 0;
font-size: clamp(20px, 1.6vw, 26px);
}
.library-section-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: var(--nebula-spacing-md);
}
.library-card {
min-height: 220px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
background: var(--nebula-color-panel-alt);
color: var(--nebula-color-text);
border: 2px solid var(--nebula-color-border);
border-radius: var(--nebula-radius-md);
text-align: left;
}
.library-card.is-focused {
border-color: var(--nebula-color-accent);
transform: scale(1.04) translateZ(0);
}
.library-card-art {
min-height: 140px;
display: grid;
place-items: center;
background:
radial-gradient(circle at top left, rgba(79, 216, 255, 0.38), transparent 45%),
linear-gradient(135deg, rgba(80, 214, 255, 0.18), rgba(125, 89, 255, 0.18));
position: relative;
overflow: hidden;
}
.library-card-art.has-image {
min-height: 180px;
}
.library-card-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.library-card-art.has-image::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.42));
}
.library-card-art span {
display: grid;
place-items: center;
width: 64px;
height: 64px;
border-radius: 18px;
background: rgba(0, 0, 0, 0.28);
color: var(--nebula-color-text);
font-size: 34px;
font-weight: 800;
z-index: 1;
}
.library-card-art.has-image span {
display: none;
}
.library-card-body {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--nebula-spacing-md);
}
.library-card-title {
margin: 0;
font-size: 18px;
font-weight: 800;
}
.library-card-meta {
margin: 0;
color: var(--nebula-color-muted);
font-size: 13px;
}
.library-empty {
grid-column: 1 / -1;
}
+301 -12
View File
@@ -1,5 +1,154 @@
const getTauriCore = async () => {
const globalCore = window.__TAURI__?.core;
if (typeof globalCore?.invoke === "function") {
return {
invoke: globalCore.invoke,
convertFileSrc: typeof globalCore.convertFileSrc === "function" ? globalCore.convertFileSrc : null,
};
}
try {
const tauriCore = await import("@tauri-apps/api/core");
return {
invoke: typeof tauriCore.invoke === "function" ? tauriCore.invoke : null,
convertFileSrc: typeof tauriCore.convertFileSrc === "function" ? tauriCore.convertFileSrc : null,
};
} catch (_error) {
return { invoke: null, convertFileSrc: null };
}
};
const defaultLocalFolders = () => {
const folders = ["C:/Games", "D:/Games"];
return folders;
};
const sourceLabel = (source) => {
const labels = {
steam: "Steam",
epic: "Epic Games",
gog: "GOG",
local: "Local",
unknown: "Unknown",
};
return labels[source] ?? source;
};
const statusLabel = (status) => {
const labels = {
pending: "Pending metadata",
matched: "Matched",
needs_review: "Needs review",
manual: "Manual",
};
return labels[status] ?? "Pending metadata";
};
const kindLabel = (kind) => {
const labels = {
game: "Game",
tool: "Tool",
software: "Software",
unknown: "Unknown",
};
return labels[kind] ?? "Game";
};
const gameTitle = (game) => game.userTitle || game.title || "Unknown App";
const escapeHtml = (value) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const imageUrlForGame = (game, convertFileSrc) => {
const imagePath = game.coverImage || game.heroImage || game.iconImage;
return imagePath && convertFileSrc ? convertFileSrc(imagePath) : "";
};
const TOOL_CATEGORY_LABELS = new Set([
"animation & modeling",
"audio production",
"design & illustration",
"education",
"game development",
"photo editing",
"software training",
"utilities",
"video production",
"web publishing",
"includes level editor",
]);
const isTool = (game) => game.appKind === "tool" || game.appKind === "software";
const toolCategoryForGame = (game) => {
const labels = [
...(Array.isArray(game.genres) ? game.genres : []),
...(Array.isArray(game.steamCategories) ? game.steamCategories : []),
].filter(Boolean);
const toolLabel = labels.find((label) => TOOL_CATEGORY_LABELS.has(label.toLowerCase()));
return toolLabel || labels[0] || "Other Tools";
};
const renderGameCard = (game, index, convertFileSrc) => {
const title = gameTitle(game);
const imageUrl = imageUrlForGame(game, convertFileSrc);
const hasImage = Boolean(imageUrl);
const metaParts = [
sourceLabel(game.platformSource),
kindLabel(game.appKind),
game.developer,
Array.isArray(game.genres) ? game.genres.slice(0, 2).join(", ") : "",
].filter(Boolean);
return `
<button
class="library-card focusable"
data-focusable="true"
data-row="${Math.floor(index / 4) + 1}"
data-col="${index % 4}"
data-focus-key="library-game-${game.id}"
data-game-id="${game.id}"
aria-label="${escapeHtml(title)}"
>
<div class="library-card-art ${hasImage ? "has-image" : ""}" aria-hidden="true">
${hasImage ? `<img class="library-card-image" src="${escapeHtml(imageUrl)}" alt="" loading="lazy">` : ""}
<span>${escapeHtml(sourceLabel(game.platformSource).slice(0, 1))}</span>
</div>
<div class="library-card-body">
<p class="library-card-title">${escapeHtml(title)}</p>
<p class="library-card-meta">${escapeHtml(metaParts.join(" · "))}</p>
<p class="library-card-meta">${escapeHtml(statusLabel(game.metadataStatus))}</p>
</div>
</button>
`;
};
const renderProviderReport = (provider) => `
<li class="library-provider-row">
<span>${sourceLabel(provider.source)}</span>
<strong>${provider.error ? "Error" : `${provider.discovered} found`}</strong>
</li>
`;
const renderLibrarySection = (title, eyebrow, games, startIndex, convertFileSrc) => `
<section class="library-section">
<div class="library-section-heading">
<p class="muted">${escapeHtml(eyebrow)}</p>
<h2>${escapeHtml(title)}</h2>
</div>
<div class="library-section-grid">
${games.map((game, offset) => renderGameCard(game, startIndex + offset, convertFileSrc)).join("")}
</div>
</section>
`;
const LIBRARY_TEMPLATE = ` const LIBRARY_TEMPLATE = `
<section class="view stub-view" data-view="library"> <section class="view library-view" data-view="library">
<header class="shell-topbar"> <header class="shell-topbar">
<div class="shell-topbar-content"> <div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p> <p class="shell-brand">Nebula OS</p>
@@ -12,13 +161,29 @@ const LIBRARY_TEMPLATE = `
</header> </header>
<section class="view-header"> <section class="view-header">
<div> <div>
<p class="muted">Nebula App</p> <p class="muted">Unified Library</p>
<h1 class="view-title">Library</h1> <h1 class="view-title">Library</h1>
</div> </div>
<div class="library-actions">
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="0" data-action="scan" data-focus-key="library-scan">Scan Device</button>
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="1" data-action="refresh" data-focus-key="library-refresh">Refresh</button>
</div>
</section> </section>
<section class="panel stub-panel" data-focus-root> <section class="library-layout">
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="0" data-action="back" data-focus-key="back">Back to Home</button> <section class="panel library-status-panel">
<p class="muted">Library integration stub for v0 shell navigation.</p> <div>
<p class="muted">Scanner</p>
<h2 class="library-status-title" data-library-status>Ready to scan</h2>
</div>
<p class="library-status-copy" data-library-summary>Scan Steam, Epic, GOG, and local folders into the isolated library database.</p>
<ul class="library-provider-list" data-library-providers></ul>
</section>
<section class="library-grid" data-library-grid>
<article class="panel library-empty">
<p class="muted">No apps discovered yet.</p>
<p>Press Scan Device to build your NebulaOS library.</p>
</article>
</section>
</section> </section>
</section> </section>
`; `;
@@ -26,17 +191,28 @@ const LIBRARY_TEMPLATE = `
export const createLibraryView = ({ state, renderView }) => ({ export const createLibraryView = ({ state, renderView }) => ({
id: "library", id: "library",
render: () => LIBRARY_TEMPLATE, render: () => LIBRARY_TEMPLATE,
mount: async () => {
document.querySelector("[data-action='scan']")?.addEventListener("click", scanLibrary);
document.querySelector("[data-action='refresh']")?.addEventListener("click", refreshLibrary);
await refreshLibrary();
},
getNavigationContract: () => { getNavigationContract: () => {
const root = document.querySelector("[data-focus-root]"); const root = document.querySelector("[data-view='library']");
return { return {
focusRoot: root, focusRoot: root,
defaultFocus: root?.querySelector("[data-action='back']") ?? null, defaultFocus: root?.querySelector("[data-action='scan']") ?? null,
layout: { type: "list", rows: 1 }, layout: { type: "grid", cols: 4, rows: 4 },
hintsTemplate: "#minimal-hints-template", hintsTemplate: "#global-hints-template",
nebulaNavigation: state.nebula.navigation, nebulaNavigation: state.nebula.navigation,
onAccept: () => { useNebulaNavigation: false,
state.activeView = "home"; onAccept: async (element) => {
renderView("home"); if (element?.dataset.action === "scan") {
await scanLibrary();
return;
}
if (element?.dataset.action === "refresh") {
await refreshLibrary();
}
}, },
onBack: () => { onBack: () => {
state.activeView = "home"; state.activeView = "home";
@@ -46,3 +222,116 @@ export const createLibraryView = ({ state, renderView }) => ({
}; };
}, },
}); });
const setStatus = (title, summary) => {
const status = document.querySelector("[data-library-status]");
const summaryNode = document.querySelector("[data-library-summary]");
if (status) status.textContent = title;
if (summaryNode) summaryNode.textContent = summary;
};
const renderGames = (games = [], convertFileSrc = null) => {
const grid = document.querySelector("[data-library-grid]");
if (!grid) return;
if (!games.length) {
grid.innerHTML = `
<article class="panel library-empty">
<p class="muted">No apps discovered yet.</p>
<p>Press Scan Device to build your NebulaOS library.</p>
</article>
`;
return;
}
const gameItems = games.filter((game) => !isTool(game));
const toolItems = games.filter(isTool);
const sections = [];
let cardIndex = 0;
if (gameItems.length) {
sections.push(renderLibrarySection("Games", "Library", gameItems, cardIndex, convertFileSrc));
cardIndex += gameItems.length;
}
const toolGroups = toolItems.reduce((groups, game) => {
const category = toolCategoryForGame(game);
if (!groups.has(category)) groups.set(category, []);
groups.get(category).push(game);
return groups;
}, new Map());
[...toolGroups.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.forEach(([category, items]) => {
sections.push(renderLibrarySection(category, "Tools", items, cardIndex, convertFileSrc));
cardIndex += items.length;
});
grid.innerHTML = sections.join("");
bindImageFallbacks(grid);
};
const bindImageFallbacks = (root) => {
root.querySelectorAll(".library-card-image").forEach((image) => {
image.addEventListener("error", () => {
const art = image.closest(".library-card-art");
image.remove();
art?.classList.remove("has-image");
});
});
};
const renderProviders = (providers = []) => {
const list = document.querySelector("[data-library-providers]");
if (!list) return;
list.innerHTML = providers.map(renderProviderReport).join("");
};
const refreshLibrary = async () => {
const { invoke, convertFileSrc } = await getTauriCore();
if (!invoke) {
setStatus("Desktop bridge unavailable", "Run inside Tauri to scan installed apps.");
return;
}
try {
const games = await invoke("list_library_games");
renderGames(games, convertFileSrc);
const tools = games.filter(isTool).length;
const gameCount = games.length - tools;
setStatus(
`${games.length} apps in library`,
`${gameCount} games · ${tools} tools/software · hidden apps stay restorable`,
);
} catch (error) {
setStatus("Library unavailable", String(error));
}
};
const scanLibrary = async () => {
const { invoke, convertFileSrc } = await getTauriCore();
if (!invoke) {
setStatus("Desktop bridge unavailable", "Run inside Tauri to scan installed apps.");
return;
}
setStatus("Scanning device...", "Checking Steam libraries, Epic manifests, GOG installs, and local folders.");
renderProviders([]);
try {
const summary = await invoke("scan_library_command", {
request: {
localFolders: defaultLocalFolders(),
},
});
renderProviders(summary.providers);
renderGames(summary.games, convertFileSrc);
setStatus(
`${summary.discovered} discovered`,
`${summary.insertedOrUpdated} saved · ${summary.metadataMatched} metadata matches · ${summary.unmatched} need review`,
);
} catch (error) {
setStatus("Scan failed", String(error));
}
};