Add mode READMEs and move Bigscreen app
Add comprehensive READMEs for Bigscreen and Desktop modes, and relocate the Tauri frontend prototype into a new Bigscreen/ subdirectory. This moves package.json/package-lock, src, src-tauri, assets, views, styles and related files into Bigscreen/ to separate the controller-first shell from top-level docs. Update the root README.md to reframe the project as "NebulaOS", describe Bigscreen/Desktop modes, the vision, tech stack and development notes. This reorganizes the repo layout for clearer mode separation and documentation.
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "nebula-os"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "nebula_os_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
ureq = "3.3.0"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,210 @@
|
||||
mod library;
|
||||
mod storage;
|
||||
|
||||
use library::commands::{
|
||||
launch_library_game, list_library_games, scan_library_command, update_library_game,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::AppStorage;
|
||||
use tauri::Manager;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UserRecord {
|
||||
id: i64,
|
||||
name: String,
|
||||
first_name: String,
|
||||
last_name: Option<String>,
|
||||
created_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> Result<String, String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("User name cannot be empty.".to_string());
|
||||
}
|
||||
if trimmed.chars().count() > 64 {
|
||||
return Err("User name is too long (max 64 chars).".to_string());
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_optional_name(name: Option<String>) -> Result<Option<String>, String> {
|
||||
let Some(value) = name else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > 64 {
|
||||
return Err("Name is too long (max 64 chars).".to_string());
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn build_display_name(first_name: &str, last_name: Option<&str>) -> String {
|
||||
let last = last_name.map(str::trim).unwrap_or("");
|
||||
if last.is_empty() {
|
||||
first_name.to_string()
|
||||
} else {
|
||||
format!("{first_name} {last}")
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_users_schema(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at_unix_ms INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|err| format!("Failed to prepare users table: {err}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(users)")
|
||||
.map_err(|err| format!("Failed to inspect users schema: {err}"))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|err| format!("Failed to read users schema: {err}"))?;
|
||||
|
||||
let mut has_first_name = false;
|
||||
let mut has_last_name = false;
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|err| format!("Failed to iterate users schema: {err}"))?
|
||||
{
|
||||
let column_name: String = row
|
||||
.get(1)
|
||||
.map_err(|err| format!("Failed to parse users schema: {err}"))?;
|
||||
if column_name == "first_name" {
|
||||
has_first_name = true;
|
||||
}
|
||||
if column_name == "last_name" {
|
||||
has_last_name = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !has_first_name {
|
||||
conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT", [])
|
||||
.map_err(|err| format!("Failed to add first_name column: {err}"))?;
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name = TRIM(name) WHERE first_name IS NULL OR TRIM(first_name) = ''",
|
||||
[],
|
||||
)
|
||||
.map_err(|err| format!("Failed to backfill first_name: {err}"))?;
|
||||
}
|
||||
|
||||
if !has_last_name {
|
||||
conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT", [])
|
||||
.map_err(|err| format!("Failed to add last_name column: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_first_user(storage: tauri::State<'_, AppStorage>) -> Result<Option<UserRecord>, String> {
|
||||
let conn = storage.connect_users()?;
|
||||
ensure_users_schema(&conn)?;
|
||||
conn.query_row(
|
||||
"SELECT
|
||||
id,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')), ''),
|
||||
name
|
||||
) AS display_name,
|
||||
COALESCE(NULLIF(TRIM(first_name), ''), name) AS first_name,
|
||||
NULLIF(TRIM(COALESCE(last_name, '')), '') AS last_name,
|
||||
created_at_unix_ms
|
||||
FROM users
|
||||
ORDER BY id ASC
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(UserRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
first_name: row.get(2)?,
|
||||
last_name: row.get(3)?,
|
||||
created_at_unix_ms: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| format!("Failed to load user: {err}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_user(
|
||||
first_name: String,
|
||||
last_name: Option<String>,
|
||||
storage: tauri::State<'_, AppStorage>,
|
||||
) -> Result<UserRecord, String> {
|
||||
let safe_first_name = sanitize_name(&first_name)?;
|
||||
let safe_last_name = sanitize_optional_name(last_name)?;
|
||||
let display_name = build_display_name(&safe_first_name, safe_last_name.as_deref());
|
||||
let created_at_unix_ms = now_unix_ms();
|
||||
let conn = storage.connect_users()?;
|
||||
ensure_users_schema(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO users (name, first_name, last_name, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
display_name,
|
||||
safe_first_name,
|
||||
safe_last_name,
|
||||
created_at_unix_ms
|
||||
],
|
||||
)
|
||||
.map_err(|err| format!("Failed to create user: {err}"))?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(UserRecord {
|
||||
id,
|
||||
name: build_display_name(&safe_first_name, safe_last_name.as_deref()),
|
||||
first_name: safe_first_name,
|
||||
last_name: safe_last_name,
|
||||
created_at_unix_ms,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|err| format!("Failed to resolve app data dir: {err}"))?;
|
||||
let storage = AppStorage::new(app_data_dir)?;
|
||||
let users_conn = storage.connect_users()?;
|
||||
ensure_users_schema(&users_conn)?;
|
||||
drop(users_conn);
|
||||
library::initialize(&storage)?;
|
||||
|
||||
app.manage(storage);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_first_user,
|
||||
create_user,
|
||||
list_library_games,
|
||||
launch_library_game,
|
||||
scan_library_command,
|
||||
update_library_game
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use super::db::{ensure_library_schema, list_games, update_customization};
|
||||
use super::models::{GameCustomizationRequest, LibraryGame, LibraryScanRequest, ScanSummary};
|
||||
use super::{list_visible_games, scan_library};
|
||||
use crate::storage::AppStorage;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LaunchLibraryGameResult {
|
||||
pub launched: bool,
|
||||
pub action: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[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}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn launch_library_game(
|
||||
game_id: i64,
|
||||
storage: tauri::State<'_, AppStorage>,
|
||||
) -> Result<LaunchLibraryGameResult, String> {
|
||||
let storage = storage.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let conn = storage.connect_library()?;
|
||||
ensure_library_schema(&conn)?;
|
||||
let game = list_games(&conn, true)?
|
||||
.into_iter()
|
||||
.find(|game| game.id == game_id)
|
||||
.ok_or_else(|| "Library app was not found.".to_string())?;
|
||||
|
||||
let launch_target = game
|
||||
.launch_command
|
||||
.as_deref()
|
||||
.or(game.executable_path.as_deref())
|
||||
.unwrap_or(game.install_path.as_str());
|
||||
|
||||
// Future provider launchers plug in here:
|
||||
// Steam: steam://run/<appid>, GOG/Epic URI handlers, emulator profiles,
|
||||
// and native executable spawning with per-app environment overrides.
|
||||
println!(
|
||||
"[NebulaOS] launch placeholder: {} via {}",
|
||||
game.user_title.as_deref().unwrap_or(&game.title),
|
||||
launch_target
|
||||
);
|
||||
|
||||
Ok(LaunchLibraryGameResult {
|
||||
launched: true,
|
||||
action: "placeholder".to_string(),
|
||||
message: format!(
|
||||
"Launch requested for {}.",
|
||||
game.user_title.unwrap_or(game.title)
|
||||
),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Library launch 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,
|
||||
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,9 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(
|
||||
all(target_os = "windows", not(debug_assertions)),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
nebula_os_lib::run()
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nebula-os",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.user.nebula-os",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "nebula-os",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"fullscreen": true,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||