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