diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index db3377e..88dc581 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1495,6 +1495,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2106,6 +2112,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "ureq", ] [[package]] @@ -2994,6 +3001,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -3030,6 +3051,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3440,6 +3496,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3580,6 +3642,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", @@ -4231,6 +4294,41 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4262,6 +4360,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4527,6 +4631,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4757,6 +4870,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5301,6 +5423,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3f35f5b..5d207b4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,9 +18,10 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.31", features = ["bundled"] } +ureq = "3.3.0" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04780fb..0976803 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,13 @@ +mod library; +mod storage; + +use library::commands::{list_library_games, scan_library_command, update_library_game}; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; -use std::fs; -use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; +use storage::AppStorage; use tauri::Manager; -struct DbState { - db_path: PathBuf, -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct UserRecord { @@ -19,12 +18,6 @@ struct UserRecord { created_at_unix_ms: i64, } -impl DbState { - fn connect(&self) -> Result { - Connection::open(&self.db_path).map_err(|err| format!("Failed to open database: {err}")) - } -} - fn now_unix_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -120,8 +113,8 @@ fn ensure_users_schema(conn: &Connection) -> Result<(), String> { } #[tauri::command] -fn get_first_user(state: tauri::State<'_, DbState>) -> Result, String> { - let conn = state.connect()?; +fn get_first_user(storage: tauri::State<'_, AppStorage>) -> Result, String> { + let conn = storage.connect_users()?; ensure_users_schema(&conn)?; conn.query_row( "SELECT @@ -155,13 +148,13 @@ fn get_first_user(state: tauri::State<'_, DbState>) -> Result fn create_user( first_name: String, last_name: Option, - state: tauri::State<'_, DbState>, + storage: tauri::State<'_, AppStorage>, ) -> Result { let safe_first_name = sanitize_name(&first_name)?; let safe_last_name = sanitize_optional_name(last_name)?; let display_name = build_display_name(&safe_first_name, safe_last_name.as_deref()); let created_at_unix_ms = now_unix_ms(); - let conn = state.connect()?; + let conn = storage.connect_users()?; ensure_users_schema(&conn)?; conn.execute( "INSERT INTO users (name, first_name, last_name, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4)", @@ -193,19 +186,22 @@ pub fn run() { .path() .app_data_dir() .map_err(|err| format!("Failed to resolve app data dir: {err}"))?; - fs::create_dir_all(&app_data_dir) - .map_err(|err| format!("Failed to create app data dir: {err}"))?; + let storage = AppStorage::new(app_data_dir)?; + let users_conn = storage.connect_users()?; + ensure_users_schema(&users_conn)?; + drop(users_conn); + library::initialize(&storage)?; - let db_path = app_data_dir.join("nebula.db"); - let conn = Connection::open(&db_path) - .map_err(|err| format!("Failed to initialize database: {err}"))?; - ensure_users_schema(&conn)?; - drop(conn); - - app.manage(DbState { db_path }); + app.manage(storage); Ok(()) }) - .invoke_handler(tauri::generate_handler![get_first_user, create_user]) + .invoke_handler(tauri::generate_handler![ + get_first_user, + create_user, + list_library_games, + scan_library_command, + update_library_game + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs new file mode 100644 index 0000000..b93dde0 --- /dev/null +++ b/src-tauri/src/library/commands.rs @@ -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 { + let storage = storage.inner().clone(); + let local_folders = request + .local_folders + .into_iter() + .map(PathBuf::from) + .collect::>(); + + 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, 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, 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}"))? +} diff --git a/src-tauri/src/library/db.rs b/src-tauri/src/library/db.rs new file mode 100644 index 0000000..28182eb --- /dev/null +++ b/src-tauri/src/library/db.rs @@ -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, 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 = 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::, _>>() + .map_err(|err| format!("Failed to parse library games: {err}")) +} + +pub fn update_customization( + conn: &Connection, + request: GameCustomizationRequest, +) -> Result, 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 = 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::, _>>() + .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 { + serde_json::from_str(value).unwrap_or_default() +} diff --git a/src-tauri/src/library/images.rs b/src-tauri/src/library/images.rs new file mode 100644 index 0000000..262fb57 --- /dev/null +++ b/src-tauri/src/library/images.rs @@ -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 { + let safe_slug = app_id_or_slug + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect::(); + 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 { + 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, 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, 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") +} diff --git a/src-tauri/src/library/metadata/mod.rs b/src-tauri/src/library/metadata/mod.rs new file mode 100644 index 0000000..b38629e --- /dev/null +++ b/src-tauri/src/library/metadata/mod.rs @@ -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, 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, String> { + if candidate.source == GameSource::Steam { + return self.resolve_steam(candidate); + } + + Ok(None) + } +} + +impl LocalCacheMetadataProvider { + fn resolve_steam(&self, candidate: &GameCandidate) -> Result, 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::>() + }) + .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::>() + }) + .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::>(); + 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, 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 { + value + .and_then(|value| value.get(key)) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + .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, + } + } +} diff --git a/src-tauri/src/library/mod.rs b/src-tauri/src/library/mod.rs new file mode 100644 index 0000000..f0d5e6e --- /dev/null +++ b/src-tauri/src/library/mod.rs @@ -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, String> { + let conn = storage.connect_library()?; + ensure_library_schema(&conn)?; + list_games(&conn, false) +} + +pub fn scan_library( + storage: AppStorage, + local_folders: Vec, +) -> Result { + let conn = storage.connect_library()?; + ensure_library_schema(&conn)?; + + let context = ScanContext { local_folders }; + + let scanners: Vec> = 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) -> Vec { + let mut by_identity: HashMap = 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 = 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>; +} diff --git a/src-tauri/src/library/models.rs b/src-tauri/src/library/models.rs new file mode 100644 index 0000000..262f911 --- /dev/null +++ b/src-tauri/src/library/models.rs @@ -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, + pub install_path: PathBuf, + pub executable_path: Option, + pub launch_command: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GameMetadata { + pub title: Option, + pub description: Option, + pub app_kind: LibraryItemKind, + pub steam_app_type: Option, + pub genres: Vec, + pub steam_categories: Vec, + pub release_date: Option, + pub developer: Option, + pub publisher: Option, + pub cover_image: Option, + pub hero_image: Option, + pub icon_image: Option, + 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, + pub install_path: String, + pub executable_path: Option, + pub launch_command: Option, + pub description: Option, + pub app_kind: LibraryItemKind, + pub steam_app_type: Option, + pub genres: Vec, + pub steam_categories: Vec, + pub release_date: Option, + pub developer: Option, + pub publisher: Option, + pub cover_image: Option, + pub hero_image: Option, + pub icon_image: Option, + pub metadata_status: MetadataStatus, + pub last_scanned: i64, + pub user_hidden: bool, + pub user_favourite: bool, + pub user_title: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanProviderReport { + pub source: GameSource, + pub discovered: usize, + pub error: Option, +} + +#[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, + pub games: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryScanRequest { + #[serde(default)] + pub local_folders: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GameCustomizationRequest { + pub game_id: i64, + pub title: Option, + pub executable_path: Option, + pub hidden: Option, + pub favourite: Option, +} diff --git a/src-tauri/src/library/scanners/epic.rs b/src-tauri/src/library/scanners/epic.rs new file mode 100644 index 0000000..6af014a --- /dev/null +++ b/src-tauri/src/library/scanners/epic.rs @@ -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, 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 { + 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, 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, + })) +} diff --git a/src-tauri/src/library/scanners/gog.rs b/src-tauri/src/library/scanners/gog.rs new file mode 100644 index 0000000..e95876d --- /dev/null +++ b/src-tauri/src/library/scanners/gog.rs @@ -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, 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 { + 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, 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, 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 { + 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 +} diff --git a/src-tauri/src/library/scanners/local.rs b/src-tauri/src/library/scanners/local.rs new file mode 100644 index 0000000..b7133ef --- /dev/null +++ b/src-tauri/src/library/scanners/local.rs @@ -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, 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, +) -> 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 { + 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) +} diff --git a/src-tauri/src/library/scanners/mod.rs b/src-tauri/src/library/scanners/mod.rs new file mode 100644 index 0000000..c72fb8f --- /dev/null +++ b/src-tauri/src/library/scanners/mod.rs @@ -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, +} + +pub trait ScannerProvider: Send + Sync { + fn source(&self) -> GameSource; + fn scan(&self, context: &ScanContext) -> Result, String>; +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn home_dir() -> Option { + 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::>() + .join(" ") + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Unknown Game".to_string()) +} diff --git a/src-tauri/src/library/scanners/steam.rs b/src-tauri/src/library/scanners/steam.rs new file mode 100644 index 0000000..ea69143 --- /dev/null +++ b/src-tauri/src/library/scanners/steam.rs @@ -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, 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 { + 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 { + 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::().is_ok() && value.contains(['\\', '/']) { + libraries.push(PathBuf::from(value.replace("\\\\", "\\"))); + } + } + } + + unique_existing_dirs(libraries) +} + +fn scan_library_path(library_path: &Path) -> Result, 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 { + #[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, 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 { + 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 { + 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) -> Vec { + let mut seen = HashSet::new(); + paths + .into_iter() + .filter(|path| path.is_dir()) + .filter(|path| seen.insert(path.to_string_lossy().to_lowercase())) + .collect() +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..406b06f --- /dev/null +++ b/src-tauri/src/storage.rs @@ -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 { + 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::open(&self.users_db_path) + .map_err(|err| format!("Failed to open users database: {err}")) + } + + pub fn connect_library(&self) -> Result { + Connection::open(&self.library_db_path) + .map_err(|err| format!("Failed to open library database: {err}")) + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e7d91f3..6c47ee1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["$APPDATA/**"] + } } }, "bundle": { diff --git a/src/core/users.js b/src/core/users.js index 00ba269..a5b8149 100644 --- a/src/core/users.js +++ b/src/core/users.js @@ -1,6 +1,11 @@ const LEGACY_USER_STORAGE_KEY = "nebula.user.v1"; const getInvoke = async () => { + const globalInvoke = window.__TAURI__?.core?.invoke; + if (typeof globalInvoke === "function") { + return globalInvoke; + } + try { const tauriCore = await import("@tauri-apps/api/core"); return typeof tauriCore.invoke === "function" ? tauriCore.invoke : null; diff --git a/src/views/home/home.css b/src/views/home/home.css index 41c56f3..4611b87 100644 --- a/src/views/home/home.css +++ b/src/views/home/home.css @@ -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 { position: absolute; inset: 0; @@ -187,6 +193,11 @@ font-style: italic; } +.hero-art.has-image .hero-art-mid, +.hero-art.has-image .hero-art-character { + display: none; +} + /* Controller overlay */ .hero-ctrl-overlay { position: absolute; @@ -247,6 +258,15 @@ 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 { display: flex; gap: 10px; @@ -333,7 +353,9 @@ flex: 1; height: 68px; 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); cursor: pointer; transition: border-color var(--nebula-duration-fast); @@ -341,6 +363,12 @@ 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 { border-color: var(--nebula-color-accent); transform: translateY(-2px); @@ -415,11 +443,38 @@ .quick-tile-art { 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; 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 { position: absolute; bottom: 4px; @@ -499,6 +554,13 @@ flex-wrap: wrap; } +.home-placeholder-copy { + margin: 0; + color: var(--nebula-color-muted); + font-size: 11px; + line-height: 1.4; +} + .friend-avatar { width: 28px; height: 28px; diff --git a/src/views/home/home.js b/src/views/home/home.js index 813480c..7acdfa4 100644 --- a/src/views/home/home.js +++ b/src/views/home/home.js @@ -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 = `
@@ -50,14 +97,12 @@ const HOME_TEMPLATE = ` -
- -

System Activity

- Home - Y + 0 Games
-

Press A to select

+

Scan from Library to populate Home.

@@ -228,11 +266,10 @@ const HOME_TEMPLATE = ` export const createHomeView = ({ state, renderView, openPowerMenu }) => ({ id: "home", render: () => HOME_TEMPLATE, - mount: () => { + mount: async () => { const view = document.querySelector("[data-view='home']"); if (!view) return; - // Tab switching behaviour view.querySelectorAll(".home-tab").forEach((tab) => { tab.addEventListener("click", () => { view.querySelectorAll(".home-tab").forEach((t) => { @@ -243,6 +280,8 @@ export const createHomeView = ({ state, renderView, openPowerMenu }) => ({ tab.setAttribute("aria-selected", "true"); }); }); + + await hydrateHomeLibrary(view); }, getNavigationContract: () => { const root = document.querySelector("[data-view='home']"); @@ -268,7 +307,12 @@ export const createHomeView = ({ state, renderView, openPowerMenu }) => ({ 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") { 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."; +}; diff --git a/src/views/library/library.css b/src/views/library/library.css index a7ade77..a5a2ff3 100644 --- a/src/views/library/library.css +++ b/src/views/library/library.css @@ -1,10 +1,177 @@ -.stub-view { - justify-content: center; +.library-view { + gap: var(--nebula-spacing-lg); } -.stub-panel { - max-width: 720px; +.library-actions { + 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; flex-direction: column; 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; +} diff --git a/src/views/library/library.js b/src/views/library/library.js index b0d92ad..6921eb5 100644 --- a/src/views/library/library.js +++ b/src/views/library/library.js @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +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 ` + +`; +}; + +const renderProviderReport = (provider) => ` +
  • + ${sourceLabel(provider.source)} + ${provider.error ? "Error" : `${provider.discovered} found`} +
  • +`; + +const renderLibrarySection = (title, eyebrow, games, startIndex, convertFileSrc) => ` +
    +
    +

    ${escapeHtml(eyebrow)}

    +

    ${escapeHtml(title)}

    +
    +
    + ${games.map((game, offset) => renderGameCard(game, startIndex + offset, convertFileSrc)).join("")} +
    +
    +`; + const LIBRARY_TEMPLATE = ` -
    +

    Nebula OS

    @@ -12,13 +161,29 @@ const LIBRARY_TEMPLATE = `
    -

    Nebula App

    +

    Unified Library

    Library

    +
    + + +
    -
    - -

    Library integration stub for v0 shell navigation.

    +
    +
    +
    +

    Scanner

    +

    Ready to scan

    +
    +

    Scan Steam, Epic, GOG, and local folders into the isolated library database.

    +
      +
      +
      +
      +

      No apps discovered yet.

      +

      Press Scan Device to build your NebulaOS library.

      +
      +
      `; @@ -26,17 +191,28 @@ const LIBRARY_TEMPLATE = ` export const createLibraryView = ({ state, renderView }) => ({ id: "library", render: () => LIBRARY_TEMPLATE, + mount: async () => { + document.querySelector("[data-action='scan']")?.addEventListener("click", scanLibrary); + document.querySelector("[data-action='refresh']")?.addEventListener("click", refreshLibrary); + await refreshLibrary(); + }, getNavigationContract: () => { - const root = document.querySelector("[data-focus-root]"); + const root = document.querySelector("[data-view='library']"); return { focusRoot: root, - defaultFocus: root?.querySelector("[data-action='back']") ?? null, - layout: { type: "list", rows: 1 }, - hintsTemplate: "#minimal-hints-template", + defaultFocus: root?.querySelector("[data-action='scan']") ?? null, + layout: { type: "grid", cols: 4, rows: 4 }, + hintsTemplate: "#global-hints-template", nebulaNavigation: state.nebula.navigation, - onAccept: () => { - state.activeView = "home"; - renderView("home"); + useNebulaNavigation: false, + onAccept: async (element) => { + if (element?.dataset.action === "scan") { + await scanLibrary(); + return; + } + if (element?.dataset.action === "refresh") { + await refreshLibrary(); + } }, onBack: () => { 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 = ` +
      +

      No apps discovered yet.

      +

      Press Scan Device to build your NebulaOS library.

      +
      + `; + 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)); + } +};