Added game scanning and Steam Metadata grabbing
This commit is contained in:
Generated
+128
@@ -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"
|
||||
|
||||
@@ -18,9 +18,10 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
ureq = "3.3.0"
|
||||
|
||||
|
||||
+22
-26
@@ -1,14 +1,13 @@
|
||||
mod library;
|
||||
mod storage;
|
||||
|
||||
use library::commands::{list_library_games, scan_library_command, update_library_game};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::AppStorage;
|
||||
use tauri::Manager;
|
||||
|
||||
struct DbState {
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UserRecord {
|
||||
@@ -19,12 +18,6 @@ struct UserRecord {
|
||||
created_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
impl DbState {
|
||||
fn connect(&self) -> Result<Connection, String> {
|
||||
Connection::open(&self.db_path).map_err(|err| format!("Failed to open database: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -120,8 +113,8 @@ fn ensure_users_schema(conn: &Connection) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>, String> {
|
||||
let conn = state.connect()?;
|
||||
fn get_first_user(storage: tauri::State<'_, AppStorage>) -> Result<Option<UserRecord>, String> {
|
||||
let conn = storage.connect_users()?;
|
||||
ensure_users_schema(&conn)?;
|
||||
conn.query_row(
|
||||
"SELECT
|
||||
@@ -155,13 +148,13 @@ fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>
|
||||
fn create_user(
|
||||
first_name: String,
|
||||
last_name: Option<String>,
|
||||
state: tauri::State<'_, DbState>,
|
||||
storage: tauri::State<'_, AppStorage>,
|
||||
) -> Result<UserRecord, String> {
|
||||
let safe_first_name = sanitize_name(&first_name)?;
|
||||
let safe_last_name = sanitize_optional_name(last_name)?;
|
||||
let display_name = build_display_name(&safe_first_name, safe_last_name.as_deref());
|
||||
let created_at_unix_ms = now_unix_ms();
|
||||
let conn = state.connect()?;
|
||||
let conn = storage.connect_users()?;
|
||||
ensure_users_schema(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO users (name, first_name, last_name, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4)",
|
||||
@@ -193,19 +186,22 @@ pub fn run() {
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|err| format!("Failed to resolve app data dir: {err}"))?;
|
||||
fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|err| format!("Failed to create app data dir: {err}"))?;
|
||||
let storage = AppStorage::new(app_data_dir)?;
|
||||
let users_conn = storage.connect_users()?;
|
||||
ensure_users_schema(&users_conn)?;
|
||||
drop(users_conn);
|
||||
library::initialize(&storage)?;
|
||||
|
||||
let db_path = app_data_dir.join("nebula.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|err| format!("Failed to initialize database: {err}"))?;
|
||||
ensure_users_schema(&conn)?;
|
||||
drop(conn);
|
||||
|
||||
app.manage(DbState { db_path });
|
||||
app.manage(storage);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![get_first_user, create_user])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_first_user,
|
||||
create_user,
|
||||
list_library_games,
|
||||
scan_library_command,
|
||||
update_library_game
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -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}"))?
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+64
-2
@@ -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;
|
||||
|
||||
+227
-55
@@ -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 = `
|
||||
<section class="view home-view" data-view="home">
|
||||
|
||||
@@ -50,14 +97,12 @@ const HOME_TEMPLATE = `
|
||||
</nav>
|
||||
|
||||
<!-- Hero game card -->
|
||||
<article class="hero-card" aria-label="Now playing: Cyberpunk 2077">
|
||||
<!-- Game art (placeholder — would be a real cover image) -->
|
||||
<div class="hero-art" aria-hidden="true">
|
||||
<article class="hero-card" aria-label="Library hero" data-home-hero>
|
||||
<div class="hero-art" aria-hidden="true" data-hero-art>
|
||||
<div class="hero-art-bg"></div>
|
||||
<div class="hero-art-mid"></div>
|
||||
<div class="hero-art-character"></div>
|
||||
<div class="hero-title-watermark" aria-hidden="true">Cyberpunk<br>2077</div>
|
||||
<!-- Controller overlay icons -->
|
||||
<div class="hero-title-watermark" aria-hidden="true" data-hero-watermark>NEBULA<br>LIBRARY</div>
|
||||
<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">
|
||||
<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 -->
|
||||
<div class="hero-overlay">
|
||||
<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">
|
||||
<button
|
||||
class="hero-btn hero-btn-primary focusable"
|
||||
data-focusable="true" data-row="1" data-col="0"
|
||||
data-focus-key="btn-continue"
|
||||
data-target="library"
|
||||
>
|
||||
<span class="btn-prompt btn-a" aria-label="A button">A</span>
|
||||
Continue Game
|
||||
Open Library
|
||||
</button>
|
||||
<button
|
||||
class="hero-btn focusable"
|
||||
data-focusable="true" data-row="1" data-col="1"
|
||||
data-focus-key="btn-progress"
|
||||
data-target="library"
|
||||
>
|
||||
<span class="btn-prompt btn-x" aria-label="X button">X</span>
|
||||
View Progress (68%)
|
||||
Manage Games
|
||||
</button>
|
||||
<button
|
||||
class="hero-btn focusable"
|
||||
data-focusable="true" data-row="1" data-col="2"
|
||||
data-focus-key="btn-community"
|
||||
data-target="library"
|
||||
>
|
||||
<span class="btn-prompt btn-y" aria-label="Y button">Y</span>
|
||||
Community Hub
|
||||
Review Matches
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,10 +167,10 @@ const HOME_TEMPLATE = `
|
||||
<section class="featured-strip" aria-label="Featured games">
|
||||
<h2 class="section-label">Featured</h2>
|
||||
<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="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="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="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="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" 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" 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" data-featured-slot="3" aria-label="Featured game"></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -141,54 +190,46 @@ const HOME_TEMPLATE = `
|
||||
</svg>
|
||||
</div>
|
||||
<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">
|
||||
<div class="quick-tile-art" style="--ta:#3d1a00;--tb:#6b2e00;" aria-hidden="true"></div>
|
||||
<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" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Elden Ring</p>
|
||||
<p class="quick-tile-meta">Progress</p>
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</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">
|
||||
<div class="quick-tile-art" style="--ta:#0a2200;--tb:#1a4a00;" aria-hidden="true">
|
||||
<span class="tile-badge" aria-label="Trophy">🏆</span>
|
||||
</div>
|
||||
<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" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Forza Horizon 5</p>
|
||||
<p class="quick-tile-meta">Progress <span aria-label="Trophy">🏆</span></p>
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="5" data-focus-key="ql-2" aria-label="Hades II">
|
||||
<div class="quick-tile-art" style="--ta:#1a0a30;--tb:#3a0a60;" aria-hidden="true">
|
||||
<span class="tile-badge" aria-label="Trophy">🏆</span>
|
||||
</div>
|
||||
<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" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Hades II</p>
|
||||
<p class="quick-tile-meta">35 Achievements</p>
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</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">
|
||||
<div class="quick-tile-art" style="--ta:#001830;--tb:#002850;" aria-hidden="true"></div>
|
||||
<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" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Forza Horizon 4</p>
|
||||
<p class="quick-tile-meta quick-tile-bar" style="--pct:20%">20% Progress</p>
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</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">
|
||||
<div class="quick-tile-art" style="--ta:#200800;--tb:#401000;" aria-hidden="true">
|
||||
<span class="tile-badge" aria-label="Trophy">🏆</span>
|
||||
</div>
|
||||
<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" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Forza Horizon 5</p>
|
||||
<p class="quick-tile-meta quick-tile-bar" style="--pct:30%">30% Progress</p>
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="5" data-focus-key="ql-5" aria-label="Cuitfroots">
|
||||
<div class="quick-tile-art" style="--ta:#101e00;--tb:#203e00;" aria-hidden="true">
|
||||
<span class="tile-badge" aria-label="Trophy">🏆</span>
|
||||
</div>
|
||||
<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" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Cuitfroots</p>
|
||||
<p class="quick-tile-meta quick-tile-bar" style="--pct:50%">50% Achievements</p>
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
</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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="friends-avatars" aria-label="Online friends">
|
||||
<div class="friend-avatar" style="--fc:#4fd8ff;" aria-hidden="true"></div>
|
||||
<div class="friend-avatar" style="--fc:#4fff88;" aria-hidden="true"></div>
|
||||
<div class="friend-avatar" style="--fc:#ff9800;" aria-hidden="true"></div>
|
||||
<div class="friends-avatars" aria-label="Future account integrations">
|
||||
<p class="home-placeholder-copy">Account integrations coming later.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-label="System activity">
|
||||
<h3 class="panel-heading-sm">System Activity</h3>
|
||||
<div class="activity-row">
|
||||
<span class="activity-label">Home</span>
|
||||
<span class="btn-prompt btn-y" aria-label="Y button">Y</span>
|
||||
<span class="activity-label" data-home-library-count>0 Games</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -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.";
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+301
-12
@@ -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 `
|
||||
<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 = `
|
||||
<section class="view stub-view" data-view="library">
|
||||
<section class="view library-view" data-view="library">
|
||||
<header class="shell-topbar">
|
||||
<div class="shell-topbar-content">
|
||||
<p class="shell-brand">Nebula OS</p>
|
||||
@@ -12,13 +161,29 @@ const LIBRARY_TEMPLATE = `
|
||||
</header>
|
||||
<section class="view-header">
|
||||
<div>
|
||||
<p class="muted">Nebula App</p>
|
||||
<p class="muted">Unified Library</p>
|
||||
<h1 class="view-title">Library</h1>
|
||||
</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 class="panel stub-panel" data-focus-root>
|
||||
<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>
|
||||
<p class="muted">Library integration stub for v0 shell navigation.</p>
|
||||
<section class="library-layout">
|
||||
<section class="panel library-status-panel">
|
||||
<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>
|
||||
`;
|
||||
@@ -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 = `
|
||||
<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));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user