From 76bfea819b93d638e712c079de551b53454123e4 Mon Sep 17 00:00:00 2001 From: Tickbase Date: Sat, 17 May 2025 22:49:09 +0200 Subject: [PATCH] formatting --- .github/ISSUE_TEMPLATE/bug_report.md | 19 +- .github/ISSUE_TEMPLATE/feature_request.md | 5 + .github/workflows/build.yml | 20 +- .prettierignore | 3 + .prettierrc | 2 +- README.md | 10 +- eslint.config.js | 9 +- src-tauri/Cargo.toml | 7 +- src-tauri/build.rs | 2 +- src-tauri/capabilities/default.json | 8 +- src-tauri/src/cache.rs | 136 +-- src-tauri/src/dlc_manager.rs | 295 +++--- src-tauri/src/installer.rs | 753 +++++++------- src-tauri/src/main.rs | 776 +++++++------- src-tauri/src/searcher.rs | 751 +++++++------- src-tauri/tauri.conf.json | 6 +- src/App.tsx | 946 +++++++++--------- src/components/ActionButton.tsx | 59 +- src/components/AnimatedBackground.tsx | 165 ++- src/components/AnimatedCheckbox.tsx | 36 +- src/components/DlcSelectionDialog.tsx | 187 ++-- src/components/GameItem.tsx | 152 +-- src/components/GameList.tsx | 93 +- src/components/Header.tsx | 35 +- src/components/ImagePreloader.tsx | 51 +- src/components/InitialLoadingScreen.tsx | 22 +- src/components/ProgressDialog.tsx | 164 ++- src/components/Sidebar.tsx | 26 +- src/main.tsx | 2 +- src/services/ImageService.ts | 149 ++- src/styles/_fonts.scss | 18 +- src/styles/_layout.scss | 29 +- src/styles/_mixins.scss | 24 +- src/styles/_reset.scss | 25 +- src/styles/_variables.scss | 175 ++-- src/styles/components/_animated_checkbox.scss | 10 +- src/styles/components/_background.scss | 4 +- src/styles/components/_dialog.scss | 57 +- src/styles/components/_dlc_dialog.scss | 103 +- src/styles/components/_gamecard.scss | 59 +- src/styles/components/_header.scss | 144 ++- src/styles/components/_loading_screen.scss | 41 +- src/styles/components/_sidebar.scss | 48 +- src/styles/main.scss | 4 +- tsconfig.json | 5 +- vite.config.ts | 13 +- 46 files changed, 2905 insertions(+), 2743 deletions(-) create mode 100644 .prettierignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dcaeeff..1d14dcf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,37 +7,46 @@ assignees: '' --- ## Bug Description + A clear and concise description of what the bug is. ## Steps To Reproduce + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected Behavior + A clear and concise description of what you expected to happen. ## Screenshots + If applicable, add screenshots to help explain your problem. ## System Information - - OS: [e.g. Ubuntu 22.04, Arch Linux, etc.] - - Desktop Environment: [e.g. GNOME, KDE, etc.] - - CreamLinux Version: [e.g. 0.1.0] - - Steam Version: [e.g. latest] + +- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.] +- Desktop Environment: [e.g. GNOME, KDE, etc.] +- CreamLinux Version: [e.g. 0.1.0] +- Steam Version: [e.g. latest] ## Game Information + - Game name: - Game ID (if known): - Native Linux or Proton: -- Steam installation path: +- Steam installation path: ## Additional Context + Add any other context about the problem here. ## Logs + If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file. + ``` Paste log content here ``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 4bc4ab3..703699d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -7,17 +7,22 @@ assignees: '' --- ## Feature Description + A clear and concise description of what you want to happen. ## Problem This Feature Solves + Is your feature request related to a problem? Please describe. Ex. I'm always frustrated when [...] ## Alternatives You've Considered + A clear and concise description of any alternative solutions or features you've considered. ## Additional Context + Add any other context or screenshots about the feature request here. ## Implementation Ideas (Optional) + If you have any ideas on how this feature could be implemented, please share them here. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae58ec3..33b6542 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,10 +1,10 @@ -name: "Build CreamLinux" +name: 'Build CreamLinux' on: push: - branches: [ "main" ] + branches: ['main'] pull_request: - branches: [ "main" ] + branches: ['main'] env: CARGO_TERM_COLOR: always @@ -19,32 +19,32 @@ jobs: runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 19 - + - name: Install Rust uses: actions-rs/toolchain@v1 with: toolchain: stable profile: minimal - + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - + - name: Install frontend dependencies run: npm install - + - name: Run ESLint run: npm run lint - + - name: Build the app run: npm run tauri build - + - name: Upload binary artifacts uses: actions/upload-artifact@v4 with: diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..62eea0d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +src-tauri/target \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index a2ca0d5..2924079 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "semi": false, - "singleQuote": false, + "singleQuote": true, "printWidth": 100, "trailingComma": "es5" } diff --git a/README.md b/README.md index 41bb944..c5d0aca 100644 --- a/README.md +++ b/README.md @@ -29,23 +29,28 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC ### Building from Source #### Prerequisites + - Rust 1.77.2 or later - Node.js 18 or later - npm or yarn #### Steps + 1. Clone the repository: + ```bash git clone https://github.com/yourusername/creamlinux.git cd creamlinux ``` 2. Install dependencies: + ```bash npm install # or yarn ``` 3. Build the application: + ```bash NO_STRIP=true npm run tauri build ``` @@ -57,11 +62,13 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC If you're using the AppImage version, you can integrate it into your desktop environment: 1. Create a desktop entry file: + ```bash mkdir -p ~/.local/share/applications ``` 2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage): + ``` [Desktop Entry] Name=Creamlinux @@ -73,6 +80,7 @@ If you're using the AppImage version, you can integrate it into your desktop env ``` 3. Update your desktop database so creamlinux appears in your app launcher: + ```bash update-desktop-database ~/.local/share/applications ``` @@ -114,4 +122,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support - [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support - [Tauri](https://tauri.app/) - Framework for building the desktop application -- [React](https://reactjs.org/) - UI library \ No newline at end of file +- [React](https://reactjs.org/) - UI library diff --git a/eslint.config.js b/eslint.config.js index 092408a..a0e288d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ['dist', 'node_modules', 'src-tauri/target'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], @@ -19,10 +19,7 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, - }, + } ) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a8a8ccf..52b74ff 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -8,9 +8,6 @@ repository = "" edition = "2021" rust-version = "1.77.2" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - - [build-dependencies] tauri-build = { version = "2.2.0", features = [] } @@ -37,6 +34,4 @@ num_cpus = "1.16.0" futures = "0.3.31" [features] -# this feature is used for production builds or when `devPath` points to the filesystem -# DO NOT REMOVE!! -custom-protocol = ["tauri/custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] \ No newline at end of file diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..8e906f7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,10 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": [ - "main" - ], - "permissions": [ - "core:default" - ] + "windows": ["main"], + "permissions": ["core:default"] } diff --git a/src-tauri/src/cache.rs b/src-tauri/src/cache.rs index e2a19df..1a9ef37 100644 --- a/src-tauri/src/cache.rs +++ b/src-tauri/src/cache.rs @@ -1,13 +1,11 @@ -// src/cache.rs - -use serde::{Serialize, Deserialize}; +use crate::dlc_manager::DlcInfoWithState; +use log::{info, warn}; +use serde::{Deserialize, Serialize}; use serde_json::json; -use std::path::{PathBuf}; use std::fs; use std::io; -use std::time::{SystemTime}; -use log::{info, warn}; -use crate::dlc_manager::DlcInfoWithState; +use std::path::PathBuf; +use std::time::SystemTime; // Cache entry with timestamp for expiration #[derive(Serialize, Deserialize)] @@ -20,14 +18,14 @@ struct CacheEntry { fn get_cache_dir() -> io::Result { let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux") .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - + let cache_dir = xdg_dirs.get_cache_home(); - + // Make sure the cache directory exists if !cache_dir.exists() { fs::create_dir_all(&cache_dir)?; } - + Ok(cache_dir) } @@ -38,26 +36,26 @@ where { let cache_dir = get_cache_dir()?; let cache_file = cache_dir.join(format!("{}.cache", key)); - + // Get current timestamp let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - + // Create a JSON object with timestamp and data directly let json_data = json!({ "timestamp": now, "data": data // No clone needed here }); - + // Serialize and write to file - let serialized = serde_json::to_string(&json_data) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - + let serialized = + serde_json::to_string(&json_data).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + fs::write(cache_file, serialized)?; info!("Saved cache for key: {}", key); - + Ok(()) } @@ -73,14 +71,14 @@ where return None; } }; - + let cache_file = cache_dir.join(format!("{}.cache", key)); - + // Check if cache file exists if !cache_file.exists() { return None; } - + // Read and deserialize let cached_data = match fs::read_to_string(&cache_file) { Ok(data) => data, @@ -89,54 +87,58 @@ where return None; } }; - + // Parse the JSON let json_value: serde_json::Value = match serde_json::from_str(&cached_data) { - Ok(v) => v, - Err(e) => { - warn!("Failed to parse cache file {}: {}", cache_file.display(), e); - return None; - } - }; - - // Extract timestamp - let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) { - Some(ts) => ts, - None => { - warn!("Invalid timestamp in cache file {}", cache_file.display()); - return None; - } - }; - - // Check expiration - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let age_hours = (now - timestamp) / 3600; - - if age_hours > ttl_hours { - info!("Cache for key {} is expired ({} hours old)", key, age_hours); - return None; - } - - // Extract data - let data: T = match serde_json::from_value(json_value["data"].clone()) { - Ok(d) => d, - Err(e) => { - warn!("Failed to parse data in cache file {}: {}", cache_file.display(), e); - return None; - } - }; - - info!("Using cache for key {} ({} hours old)", key, age_hours); - Some(data) + Ok(v) => v, + Err(e) => { + warn!("Failed to parse cache file {}: {}", cache_file.display(), e); + return None; + } + }; + + // Extract timestamp + let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) { + Some(ts) => ts, + None => { + warn!("Invalid timestamp in cache file {}", cache_file.display()); + return None; + } + }; + + // Check expiration + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let age_hours = (now - timestamp) / 3600; + + if age_hours > ttl_hours { + info!("Cache for key {} is expired ({} hours old)", key, age_hours); + return None; + } + + // Extract data + let data: T = match serde_json::from_value(json_value["data"].clone()) { + Ok(d) => d, + Err(e) => { + warn!( + "Failed to parse data in cache file {}: {}", + cache_file.display(), + e + ); + return None; + } + }; + + info!("Using cache for key {} ({} hours old)", key, age_hours); + Some(data) } // Cache game scanning results pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> { - save_to_cache("games", games, 24) // Cache games for 24 hours + save_to_cache("games", games, 24) // Cache games for 24 hours } // Load cached game scanning results @@ -146,7 +148,7 @@ pub fn load_cached_games() -> Option> { // Cache DLC list for a game pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> { - save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours) + save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours) } // Load cached DLC list @@ -157,11 +159,11 @@ pub fn load_cached_dlcs(game_id: &str) -> Option> { // Clear all caches pub fn clear_all_caches() -> io::Result<()> { let cache_dir = get_cache_dir()?; - + for entry in fs::read_dir(cache_dir)? { let entry = entry?; let path = entry.path(); - + if path.is_file() && path.extension().map_or(false, |ext| ext == "cache") { if let Err(e) = fs::remove_file(&path) { warn!("Failed to remove cache file {}: {}", path.display(), e); @@ -170,7 +172,7 @@ pub fn clear_all_caches() -> io::Result<()> { } } } - + info!("All caches cleared"); Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/dlc_manager.rs b/src-tauri/src/dlc_manager.rs index a98e411..ba91e41 100644 --- a/src-tauri/src/dlc_manager.rs +++ b/src-tauri/src/dlc_manager.rs @@ -1,12 +1,11 @@ -// src/dlc_manager.rs -use serde::{Serialize, Deserialize}; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; -use log::{info, error}; -use std::collections::{HashMap, HashSet}; use tauri::Manager; -/// More detailed DLC information with enabled state +// More detailed DLC information with enabled state #[derive(Serialize, Deserialize, Debug, Clone)] pub struct DlcInfoWithState { pub appid: String, @@ -14,39 +13,42 @@ pub struct DlcInfoWithState { pub enabled: bool, } -/// Parse the cream_api.ini file to extract both enabled and disabled DLCs +// Parse the cream_api.ini file to extract both enabled and disabled DLCs pub fn get_enabled_dlcs(game_path: &str) -> Result, String> { info!("Reading enabled DLCs from {}", game_path); - + let cream_api_path = Path::new(game_path).join("cream_api.ini"); if !cream_api_path.exists() { - return Err(format!("cream_api.ini not found at {}", cream_api_path.display())); + return Err(format!( + "cream_api.ini not found at {}", + cream_api_path.display() + )); } - + let contents = match fs::read_to_string(&cream_api_path) { Ok(c) => c, - Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)) + Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)), }; - - // Extract DLCs - they are in the [dlc] section with format "appid = name" + + // Extract DLCs let mut in_dlc_section = false; let mut enabled_dlcs = Vec::new(); - + for line in contents.lines() { let trimmed = line.trim(); - + // Check if we're in the DLC section if trimmed == "[dlc]" { in_dlc_section = true; continue; } - - // Check if we're leaving the DLC section (another section begins) + + // Check if we're leaving the DLC section if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') { in_dlc_section = false; continue; } - + // Skip empty lines and non-DLC comments if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') { // Extract the DLC app ID @@ -59,44 +61,47 @@ pub fn get_enabled_dlcs(game_path: &str) -> Result, String> { } } } - + info!("Found {} enabled DLCs", enabled_dlcs.len()); Ok(enabled_dlcs) } -/// Get all DLCs (both enabled and disabled) from cream_api.ini +// Get all DLCs (both enabled and disabled) from cream_api.ini pub fn get_all_dlcs(game_path: &str) -> Result, String> { info!("Reading all DLCs from {}", game_path); - + let cream_api_path = Path::new(game_path).join("cream_api.ini"); if !cream_api_path.exists() { - return Err(format!("cream_api.ini not found at {}", cream_api_path.display())); + return Err(format!( + "cream_api.ini not found at {}", + cream_api_path.display() + )); } - + let contents = match fs::read_to_string(&cream_api_path) { Ok(c) => c, - Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)) + Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)), }; - - // Extract DLCs - both enabled and disabled + + // Extract DLCs let mut in_dlc_section = false; let mut all_dlcs = Vec::new(); - + for line in contents.lines() { let trimmed = line.trim(); - + // Check if we're in the DLC section if trimmed == "[dlc]" { in_dlc_section = true; continue; } - - // Check if we're leaving the DLC section (another section begins) + + // Check if we're leaving the DLC section if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') { in_dlc_section = false; continue; } - + // Process DLC entries (both enabled and commented/disabled) if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') { let is_commented = trimmed.starts_with("#"); @@ -105,12 +110,12 @@ pub fn get_all_dlcs(game_path: &str) -> Result, String> { } else { trimmed }; - + let parts: Vec<&str> = actual_line.splitn(2, '=').collect(); if parts.len() == 2 { let appid = parts[0].trim(); let name = parts[1].trim(); - + all_dlcs.push(DlcInfoWithState { appid: appid.to_string(), name: name.to_string().trim_matches('"').to_string(), @@ -119,56 +124,65 @@ pub fn get_all_dlcs(game_path: &str) -> Result, String> { } } } - - info!("Found {} total DLCs ({} enabled, {} disabled)", - all_dlcs.len(), - all_dlcs.iter().filter(|d| d.enabled).count(), - all_dlcs.iter().filter(|d| !d.enabled).count()); - + + info!( + "Found {} total DLCs ({} enabled, {} disabled)", + all_dlcs.len(), + all_dlcs.iter().filter(|d| d.enabled).count(), + all_dlcs.iter().filter(|d| !d.enabled).count() + ); + Ok(all_dlcs) } -/// Update the cream_api.ini file with the user's DLC selections -pub fn update_dlc_configuration(game_path: &str, dlcs: Vec) -> Result<(), String> { +// Update the cream_api.ini file with the user's DLC selections +pub fn update_dlc_configuration( + game_path: &str, + dlcs: Vec, +) -> Result<(), String> { info!("Updating DLC configuration for {}", game_path); - + let cream_api_path = Path::new(game_path).join("cream_api.ini"); if !cream_api_path.exists() { - return Err(format!("cream_api.ini not found at {}", cream_api_path.display())); + return Err(format!( + "cream_api.ini not found at {}", + cream_api_path.display() + )); } - + // Read the current file contents let current_contents = match fs::read_to_string(&cream_api_path) { Ok(c) => c, - Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)) + Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)), }; - + // Create a mapping of DLC appid to its state for easy lookup - let dlc_states: HashMap = dlcs.iter() + let dlc_states: HashMap = dlcs + .iter() .map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone()))) .collect(); - + // Keep track of processed DLCs to avoid duplicates let mut processed_dlcs = HashSet::new(); - + // Process the file line by line to retain most of the original structure let mut new_contents = Vec::new(); let mut in_dlc_section = false; - + for line in current_contents.lines() { let trimmed = line.trim(); - + // Add section markers directly if trimmed == "[dlc]" { in_dlc_section = true; new_contents.push(line.to_string()); continue; } - - // Check if we're leaving the DLC section (another section begins) + + // Check if we're leaving the DLC section if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') { in_dlc_section = false; - + // Before leaving the DLC section, add any DLCs that weren't processed yet for (appid, (enabled, name)) in &dlc_states { if !processed_dlcs.contains(appid) { @@ -179,21 +193,21 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec) -> } } } - + // Now add the section marker new_contents.push(line.to_string()); continue; } - + if in_dlc_section && !trimmed.is_empty() { let is_comment_line = trimmed.starts_with(';'); - + // If it's a regular comment line (not a DLC), keep it as is if is_comment_line { new_contents.push(line.to_string()); continue; } - + // Check if it's a commented-out DLC line or a regular DLC line let is_commented = trimmed.starts_with("#"); let actual_line = if is_commented { @@ -201,13 +215,13 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec) -> } else { trimmed }; - + // Extract appid and name let parts: Vec<&str> = actual_line.splitn(2, '=').collect(); if parts.len() == 2 { let appid = parts[0].trim(); let name = parts[1].trim(); - + // Check if this DLC exists in our updated list if let Some((enabled, _)) = dlc_states.get(appid) { // Add the DLC with its updated state @@ -218,19 +232,19 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec) -> } processed_dlcs.insert(appid.to_string()); } else { - // Not in our list - keep the original line + // Not in our list keep the original line new_contents.push(line.to_string()); } } else { - // Invalid format or not a DLC line - keep as is + // Invalid format or not a DLC line keep as is new_contents.push(line.to_string()); } } else if !in_dlc_section || trimmed.is_empty() { - // Not a DLC line or empty line - keep as is + // Not a DLC line or empty line keep as is new_contents.push(line.to_string()); } } - + // If we never left the DLC section, make sure we add any unprocessed DLCs if in_dlc_section { for (appid, (enabled, name)) in &dlc_states { @@ -243,13 +257,16 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec) -> } } } - + // Write the updated file match fs::write(&cream_api_path, new_contents.join("\n")) { Ok(_) => { - info!("Successfully updated DLC configuration at {}", cream_api_path.display()); + info!( + "Successfully updated DLC configuration at {}", + cream_api_path.display() + ); Ok(()) - }, + } Err(e) => { error!("Failed to write updated cream_api.ini: {}", e); Err(format!("Failed to write updated cream_api.ini: {}", e)) @@ -257,7 +274,7 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec) -> } } -/// Get app ID from game path by reading cream_api.ini +// Get app ID from game path by reading cream_api.ini #[allow(dead_code)] fn extract_app_id_from_config(game_path: &str) -> Option { if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) { @@ -269,71 +286,83 @@ fn extract_app_id_from_config(game_path: &str) -> Option { None } -/// Create a custom installation with selected DLCs +// Create a custom installation with selected DLCs pub async fn install_cream_with_dlcs( - game_id: String, - app_handle: tauri::AppHandle, - selected_dlcs: Vec + game_id: String, + app_handle: tauri::AppHandle, + selected_dlcs: Vec, ) -> Result<(), String> { - use crate::AppState; - - // Count enabled DLCs for logging - let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count(); - info!("Starting installation of CreamLinux with {} selected DLCs", enabled_dlc_count); - - // Get the game from state - let game = { - let state = app_handle.state::(); - let games = state.games.lock(); - match games.get(&game_id) { - Some(g) => g.clone(), - None => return Err(format!("Game with ID {} not found", game_id)) - } - }; - - info!("Installing CreamLinux for game: {} ({})", game.title, game_id); - - // Install CreamLinux first - but provide the DLCs directly instead of fetching them again - use crate::installer::install_creamlinux_with_dlcs; - - // Convert DlcInfoWithState to installer::DlcInfo for those that are enabled - let enabled_dlcs = selected_dlcs.iter() - .filter(|dlc| dlc.enabled) - .map(|dlc| crate::installer::DlcInfo { - appid: dlc.appid.clone(), - name: dlc.name.clone(), - }) - .collect::>(); - - let app_handle_clone = app_handle.clone(); - let game_title = game.title.clone(); - - // Use direct installation with provided DLCs instead of re-fetching - match install_creamlinux_with_dlcs( - &game.path, - &game_id, - enabled_dlcs, - move |progress, message| { - // Emit progress updates during installation - use crate::installer::emit_progress; - emit_progress( - &app_handle_clone, - &format!("Installing CreamLinux for {}", game_title), - message, - progress * 100.0, // Scale progress from 0 to 100% - false, - false, - None - ); - } - ).await { - Ok(_) => { - info!("CreamLinux installation completed successfully for game: {}", game.title); - Ok(()) - }, - Err(e) => { - error!("Failed to install CreamLinux: {}", e); - Err(format!("Failed to install CreamLinux: {}", e)) - } - } -} \ No newline at end of file + use crate::AppState; + + // Count enabled DLCs for logging + let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count(); + info!( + "Starting installation of CreamLinux with {} selected DLCs", + enabled_dlc_count + ); + + // Get the game from state + let game = { + let state = app_handle.state::(); + let games = state.games.lock(); + match games.get(&game_id) { + Some(g) => g.clone(), + None => return Err(format!("Game with ID {} not found", game_id)), + } + }; + + info!( + "Installing CreamLinux for game: {} ({})", + game.title, game_id + ); + + // Install CreamLinux first - but provide the DLCs directly instead of fetching them again + use crate::installer::install_creamlinux_with_dlcs; + + // Convert DlcInfoWithState to installer::DlcInfo for those that are enabled + let enabled_dlcs = selected_dlcs + .iter() + .filter(|dlc| dlc.enabled) + .map(|dlc| crate::installer::DlcInfo { + appid: dlc.appid.clone(), + name: dlc.name.clone(), + }) + .collect::>(); + + let app_handle_clone = app_handle.clone(); + let game_title = game.title.clone(); + + // Use direct installation with provided DLCs instead of re-fetching + match install_creamlinux_with_dlcs( + &game.path, + &game_id, + enabled_dlcs, + move |progress, message| { + // Emit progress updates during installation + use crate::installer::emit_progress; + emit_progress( + &app_handle_clone, + &format!("Installing CreamLinux for {}", game_title), + message, + progress * 100.0, // Scale progress from 0 to 100% + false, + false, + None, + ); + }, + ) + .await + { + Ok(_) => { + info!( + "CreamLinux installation completed successfully for game: {}", + game.title + ); + Ok(()) + } + Err(e) => { + error!("Failed to install CreamLinux: {}", e); + Err(format!("Failed to install CreamLinux: {}", e)) + } + } +} diff --git a/src-tauri/src/installer.rs b/src-tauri/src/installer.rs index 2776944..dbabcf8 100644 --- a/src-tauri/src/installer.rs +++ b/src-tauri/src/installer.rs @@ -1,35 +1,35 @@ -// src/installer.rs -use serde::{Serialize, Deserialize}; +use crate::AppState; +use log::{error, info, warn}; +use reqwest; +use serde::{Deserialize, Serialize}; +use serde_json::json; use std::fs; use std::io; use std::path::Path; -use log::{info, error, warn}; -use reqwest; +use std::sync::atomic::Ordering; +use std::time::Duration; +use tauri::Manager; use tauri::{AppHandle, Emitter}; use tempfile::tempdir; use zip::ZipArchive; -use std::time::Duration; -use serde_json::json; -use std::sync::atomic::Ordering; -use crate::AppState; -use tauri::Manager; // Constants for API endpoints and downloads -const CREAMLINUX_RELEASE_URL: &str = "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip"; +const CREAMLINUX_RELEASE_URL: &str = + "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip"; const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI"; // Type of installer #[derive(Debug, Clone, Copy)] pub enum InstallerType { Cream, - Smoke + Smoke, } // Action to perform #[derive(Debug, Clone, Copy)] pub enum InstallerAction { Install, - Uninstall + Uninstall, } // Error type combining all possible errors @@ -72,14 +72,14 @@ impl std::fmt::Display for InstallerError { impl std::error::Error for InstallerError {} -/// DLC Information structure +// DLC Information structure #[derive(Serialize, Deserialize, Debug, Clone)] pub struct DlcInfo { pub appid: String, pub name: String, } -/// Struct to hold installation instructions for the frontend +// Struct to hold installation instructions for the frontend #[derive(Serialize, Debug, Clone)] pub struct InstallationInstructions { #[serde(rename = "type")] @@ -89,7 +89,7 @@ pub struct InstallationInstructions { pub dlc_count: Option, } -/// Game information structure from searcher module +// Game information structure from searcher module #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Game { pub id: String, @@ -102,7 +102,7 @@ pub struct Game { pub installing: bool, } -/// Emit a progress update to the frontend +// Emit a progress update to the frontend pub fn emit_progress( app_handle: &AppHandle, title: &str, @@ -110,7 +110,7 @@ pub fn emit_progress( progress: f32, complete: bool, show_instructions: bool, - instructions: Option + instructions: Option, ) { let mut payload = json!({ "title": title, @@ -119,23 +119,23 @@ pub fn emit_progress( "complete": complete, "show_instructions": show_instructions }); - + if let Some(inst) = instructions { payload["instructions"] = serde_json::to_value(inst).unwrap_or_default(); } - + if let Err(e) = app_handle.emit("installation-progress", payload) { warn!("Failed to emit progress event: {}", e); } } -/// Process a single game action (install/uninstall Cream/Smoke) +// Process a single game action (install/uninstall Cream/Smoke) pub async fn process_action( _game_id: String, installer_type: InstallerType, action: InstallerAction, game: Game, - app_handle: AppHandle + app_handle: AppHandle, ) -> Result<(), String> { match (installer_type, action) { (InstallerType::Cream, InstallerAction::Install) => { @@ -143,10 +143,10 @@ pub async fn process_action( if !game.native { return Err("CreamLinux can only be installed on native Linux games".to_string()); } - + info!("Installing CreamLinux for game: {}", game.title); let game_title = game.title.clone(); - + emit_progress( &app_handle, &format!("Installing CreamLinux for {}", game_title), @@ -154,9 +154,9 @@ pub async fn process_action( 10.0, false, false, - None + None, ); - + // Fetch DLC list let dlcs = match fetch_dlc_details(&game.id).await { Ok(dlcs) => dlcs, @@ -165,10 +165,10 @@ pub async fn process_action( return Err(format!("Failed to fetch DLC details: {}", e)); } }; - + let dlc_count = dlcs.len(); info!("Found {} DLCs for {}", dlc_count, game_title); - + emit_progress( &app_handle, &format!("Installing CreamLinux for {}", game_title), @@ -176,13 +176,13 @@ pub async fn process_action( 30.0, false, false, - None + None, ); - + // Install CreamLinux let app_handle_clone = app_handle.clone(); let game_title_clone = game_title.clone(); - + match install_creamlinux(&game.path, &game.id, dlcs, move |progress, message| { // Emit progress updates during installation emit_progress( @@ -192,18 +192,20 @@ pub async fn process_action( 30.0 + (progress * 60.0), // Scale progress from 30% to 90% false, false, - None + None, ); - }).await { + }) + .await + { Ok(_) => { // Emit completion with instructions let instructions = InstallationInstructions { type_: "cream_install".to_string(), command: "sh ./cream.sh %command%".to_string(), game_title: game_title.clone(), - dlc_count: Some(dlc_count) + dlc_count: Some(dlc_count), }; - + emit_progress( &app_handle, &format!("Installation Completed: {}", game_title), @@ -211,27 +213,29 @@ pub async fn process_action( 100.0, true, true, - Some(instructions) + Some(instructions), ); - + info!("CreamLinux installation completed for: {}", game_title); Ok(()) - }, + } Err(e) => { error!("Failed to install CreamLinux: {}", e); Err(format!("Failed to install CreamLinux: {}", e)) } } - }, + } (InstallerType::Cream, InstallerAction::Uninstall) => { // Ensure this is a native game if !game.native { - return Err("CreamLinux can only be uninstalled from native Linux games".to_string()); + return Err( + "CreamLinux can only be uninstalled from native Linux games".to_string() + ); } - + let game_title = game.title.clone(); info!("Uninstalling CreamLinux from game: {}", game_title); - + emit_progress( &app_handle, &format!("Uninstalling CreamLinux from {}", game_title), @@ -239,9 +243,9 @@ pub async fn process_action( 30.0, false, false, - None + None, ); - + // Uninstall CreamLinux match uninstall_creamlinux(&game.path) { Ok(_) => { @@ -250,9 +254,9 @@ pub async fn process_action( type_: "cream_uninstall".to_string(), command: "sh ./cream.sh %command%".to_string(), game_title: game_title.clone(), - dlc_count: None + dlc_count: None, }; - + emit_progress( &app_handle, &format!("Uninstallation Completed: {}", game_title), @@ -260,32 +264,34 @@ pub async fn process_action( 100.0, true, true, - Some(instructions) + Some(instructions), ); - + info!("CreamLinux uninstallation completed for: {}", game_title); Ok(()) - }, + } Err(e) => { error!("Failed to uninstall CreamLinux: {}", e); Err(format!("Failed to uninstall CreamLinux: {}", e)) } } - }, + } (InstallerType::Smoke, InstallerAction::Install) => { // We only allow SmokeAPI for Proton/Windows games if game.native { return Err("SmokeAPI can only be installed on Proton/Windows games".to_string()); } - + // Check if we have any Steam API DLLs to patch if game.api_files.is_empty() { - return Err("No Steam API DLLs found to patch. SmokeAPI cannot be installed.".to_string()); + return Err( + "No Steam API DLLs found to patch. SmokeAPI cannot be installed.".to_string(), + ); } - + let game_title = game.title.clone(); info!("Installing SmokeAPI for game: {}", game_title); - + emit_progress( &app_handle, &format!("Installing SmokeAPI for {}", game_title), @@ -293,14 +299,14 @@ pub async fn process_action( 10.0, false, false, - None + None, ); - + // Create clones for the closure let app_handle_clone = app_handle.clone(); let game_title_clone = game_title.clone(); let api_files = game.api_files.clone(); - + // Call the SmokeAPI installation with progress updates match install_smokeapi(&game.path, &api_files, move |progress, message| { // Emit progress updates during installation @@ -311,18 +317,21 @@ pub async fn process_action( 10.0 + (progress * 90.0), // Scale progress from 10% to 100% false, false, - None + None, ); - }).await { + }) + .await + { Ok(_) => { // Emit completion with instructions let instructions = InstallationInstructions { type_: "smoke_install".to_string(), - command: "No additional steps needed. SmokeAPI will work automatically.".to_string(), + command: "No additional steps needed. SmokeAPI will work automatically." + .to_string(), game_title: game_title.clone(), - dlc_count: Some(game.api_files.len()) + dlc_count: Some(game.api_files.len()), }; - + emit_progress( &app_handle, &format!("Installation Completed: {}", game_title), @@ -330,27 +339,29 @@ pub async fn process_action( 100.0, true, true, - Some(instructions) + Some(instructions), ); - + info!("SmokeAPI installation completed for: {}", game_title); Ok(()) - }, + } Err(e) => { error!("Failed to install SmokeAPI: {}", e); Err(format!("Failed to install SmokeAPI: {}", e)) } } - }, + } (InstallerType::Smoke, InstallerAction::Uninstall) => { // Ensure this is a non-native game if game.native { - return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string()); + return Err( + "SmokeAPI can only be uninstalled from Proton/Windows games".to_string() + ); } - + let game_title = game.title.clone(); info!("Uninstalling SmokeAPI from game: {}", game_title); - + emit_progress( &app_handle, &format!("Uninstalling SmokeAPI from {}", game_title), @@ -358,9 +369,9 @@ pub async fn process_action( 30.0, false, false, - None + None, ); - + // Uninstall SmokeAPI match uninstall_smokeapi(&game.path, &game.api_files) { Ok(_) => { @@ -369,9 +380,9 @@ pub async fn process_action( type_: "smoke_uninstall".to_string(), command: "Original Steam API files have been restored.".to_string(), game_title: game_title.clone(), - dlc_count: None + dlc_count: None, }; - + emit_progress( &app_handle, &format!("Uninstallation Completed: {}", game_title), @@ -379,12 +390,12 @@ pub async fn process_action( 100.0, true, true, - Some(instructions) + Some(instructions), ); - + info!("SmokeAPI uninstallation completed for: {}", game_title); Ok(()) - }, + } Err(e) => { error!("Failed to uninstall SmokeAPI: {}", e); Err(format!("Failed to uninstall SmokeAPI: {}", e)) @@ -394,54 +405,52 @@ pub async fn process_action( } } -// -// CreamLinux specific functions -// - -/// Install CreamLinux for a game +// Install CreamLinux for a game async fn install_creamlinux( - game_path: &str, - app_id: &str, + game_path: &str, + app_id: &str, dlcs: Vec, - progress_callback: F -) -> Result<(), InstallerError> + progress_callback: F, +) -> Result<(), InstallerError> where - F: Fn(f32, &str) + Send + 'static + F: Fn(f32, &str) + Send + 'static, { // Progress update progress_callback(0.1, "Preparing to download CreamLinux..."); - + // Download CreamLinux zip let client = reqwest::Client::new(); progress_callback(0.2, "Downloading CreamLinux..."); - - let response = client.get(CREAMLINUX_RELEASE_URL) + + let response = client + .get(CREAMLINUX_RELEASE_URL) .timeout(Duration::from_secs(30)) .send() .await?; - + if !response.status().is_success() { - return Err(InstallerError::InstallationError( - format!("Failed to download CreamLinux: HTTP {}", response.status()) - )); + return Err(InstallerError::InstallationError(format!( + "Failed to download CreamLinux: HTTP {}", + response.status() + ))); } - + // Save to temporary file progress_callback(0.4, "Saving downloaded files..."); let temp_dir = tempdir()?; let zip_path = temp_dir.path().join("creamlinux.zip"); let content = response.bytes().await?; fs::write(&zip_path, &content)?; - + // Extract the zip progress_callback(0.5, "Extracting CreamLinux files..."); let file = fs::File::open(&zip_path)?; let mut archive = ZipArchive::new(file)?; - + for i in 0..archive.len() { let mut file = archive.by_index(i)?; let outpath = Path::new(game_path).join(file.name()); - + if file.name().ends_with('/') { fs::create_dir_all(&outpath)?; } else { @@ -453,7 +462,7 @@ where let mut outfile = fs::File::create(&outpath)?; io::copy(&mut file, &mut outfile)?; } - + // Set executable permissions for cream.sh if file.name() == "cream.sh" { progress_callback(0.6, "Setting executable permissions..."); @@ -466,132 +475,133 @@ where } } } - + // Create cream_api.ini with DLC info progress_callback(0.8, "Creating configuration file..."); let cream_api_path = Path::new(game_path).join("cream_api.ini"); let mut config = String::new(); - + config.push_str(&format!("APPID = {}\n[config]\n", app_id)); config.push_str("issubscribedapp_on_false_use_real = true\n"); config.push_str("[methods]\n"); config.push_str("disable_steamapps_issubscribedapp = false\n"); config.push_str("[dlc]\n"); - + for dlc in dlcs { config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); } - + fs::write(cream_api_path, config)?; progress_callback(1.0, "Installation completed successfully!"); - + Ok(()) } -/// Install CreamLinux for a game with pre-fetched DLC list -/// This avoids the redundant network calls to Steam API +// Install CreamLinux for a game with pre-fetched DLC list pub async fn install_creamlinux_with_dlcs( - game_path: &str, - app_id: &str, - dlcs: Vec, - progress_callback: F -) -> Result<(), InstallerError> + game_path: &str, + app_id: &str, + dlcs: Vec, + progress_callback: F, +) -> Result<(), InstallerError> where - F: Fn(f32, &str) + Send + 'static + F: Fn(f32, &str) + Send + 'static, { - // Progress update - progress_callback(0.1, "Preparing to download CreamLinux..."); - - // Download CreamLinux zip - let client = reqwest::Client::new(); - progress_callback(0.2, "Downloading CreamLinux..."); - - let response = client.get(CREAMLINUX_RELEASE_URL) - .timeout(Duration::from_secs(30)) - .send() - .await?; - - if !response.status().is_success() { - return Err(InstallerError::InstallationError( - format!("Failed to download CreamLinux: HTTP {}", response.status()) - )); - } - - // Save to temporary file - progress_callback(0.4, "Saving downloaded files..."); - let temp_dir = tempdir()?; - let zip_path = temp_dir.path().join("creamlinux.zip"); - let content = response.bytes().await?; - fs::write(&zip_path, &content)?; - - // Extract the zip - progress_callback(0.5, "Extracting CreamLinux files..."); - let file = fs::File::open(&zip_path)?; - let mut archive = ZipArchive::new(file)?; - - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let outpath = Path::new(game_path).join(file.name()); - - if file.name().ends_with('/') { - fs::create_dir_all(&outpath)?; - } else { - if let Some(p) = outpath.parent() { - if !p.exists() { - fs::create_dir_all(p)?; - } - } - let mut outfile = fs::File::create(&outpath)?; - io::copy(&mut file, &mut outfile)?; - } - - // Set executable permissions for cream.sh - if file.name() == "cream.sh" { - progress_callback(0.6, "Setting executable permissions..."); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&outpath)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&outpath, perms)?; - } - } - } - - // Create cream_api.ini with DLC info - using the provided DLCs directly - progress_callback(0.8, "Creating configuration file..."); - let cream_api_path = Path::new(game_path).join("cream_api.ini"); - let mut config = String::new(); - - config.push_str(&format!("APPID = {}\n[config]\n", app_id)); - config.push_str("issubscribedapp_on_false_use_real = true\n"); - config.push_str("[methods]\n"); - config.push_str("disable_steamapps_issubscribedapp = false\n"); - config.push_str("[dlc]\n"); - - for dlc in dlcs { - config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); - } - - fs::write(cream_api_path, config)?; - progress_callback(1.0, "Installation completed successfully!"); - - Ok(()) + // Progress update + progress_callback(0.1, "Preparing to download CreamLinux..."); + + // Download CreamLinux zip + let client = reqwest::Client::new(); + progress_callback(0.2, "Downloading CreamLinux..."); + + let response = client + .get(CREAMLINUX_RELEASE_URL) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + if !response.status().is_success() { + return Err(InstallerError::InstallationError(format!( + "Failed to download CreamLinux: HTTP {}", + response.status() + ))); + } + + // Save to temporary file + progress_callback(0.4, "Saving downloaded files..."); + let temp_dir = tempdir()?; + let zip_path = temp_dir.path().join("creamlinux.zip"); + let content = response.bytes().await?; + fs::write(&zip_path, &content)?; + + // Extract the zip + progress_callback(0.5, "Extracting CreamLinux files..."); + let file = fs::File::open(&zip_path)?; + let mut archive = ZipArchive::new(file)?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let outpath = Path::new(game_path).join(file.name()); + + if file.name().ends_with('/') { + fs::create_dir_all(&outpath)?; + } else { + if let Some(p) = outpath.parent() { + if !p.exists() { + fs::create_dir_all(p)?; + } + } + let mut outfile = fs::File::create(&outpath)?; + io::copy(&mut file, &mut outfile)?; + } + + // Set executable permissions for cream.sh + if file.name() == "cream.sh" { + progress_callback(0.6, "Setting executable permissions..."); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&outpath)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&outpath, perms)?; + } + } + } + + // Create cream_api.ini with DLC info - using the provided DLCs directly + progress_callback(0.8, "Creating configuration file..."); + let cream_api_path = Path::new(game_path).join("cream_api.ini"); + let mut config = String::new(); + + config.push_str(&format!("APPID = {}\n[config]\n", app_id)); + config.push_str("issubscribedapp_on_false_use_real = true\n"); + config.push_str("[methods]\n"); + config.push_str("disable_steamapps_issubscribedapp = false\n"); + config.push_str("[dlc]\n"); + + for dlc in dlcs { + config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); + } + + fs::write(cream_api_path, config)?; + progress_callback(1.0, "Installation completed successfully!"); + + Ok(()) } -/// Uninstall CreamLinux from a game +// Uninstall CreamLinux from a game fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> { info!("Uninstalling CreamLinux from: {}", game_path); - + // Files to remove during uninstallation let files_to_remove = [ - "cream.sh", - "cream_api.ini", + "cream.sh", + "cream_api.ini", "cream_api.so", "lib32Creamlinux.so", - "lib64Creamlinux.so" + "lib64Creamlinux.so", ]; - + for file in &files_to_remove { let file_path = Path::new(game_path).join(file); if file_path.exists() { @@ -604,74 +614,82 @@ fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> { } } } - + info!("CreamLinux uninstallation completed for: {}", game_path); Ok(()) } -/// Fetch DLC details from Steam API +// Fetch DLC details from Steam API pub async fn fetch_dlc_details(app_id: &str) -> Result, InstallerError> { let client = reqwest::Client::new(); - let base_url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id); - - let response = client.get(&base_url) + let base_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + app_id + ); + + let response = client + .get(&base_url) .timeout(Duration::from_secs(10)) .send() .await?; - + if !response.status().is_success() { - return Err(InstallerError::InstallationError( - format!("Failed to fetch game details: HTTP {}", response.status()) - )); + return Err(InstallerError::InstallationError(format!( + "Failed to fetch game details: HTTP {}", + response.status() + ))); } - + let data: serde_json::Value = response.json().await?; - let dlc_ids = match data.get(app_id) + let dlc_ids = match data + .get(app_id) .and_then(|app| app.get("data")) .and_then(|data| data.get("dlc")) { - Some(dlc_array) => { - match dlc_array.as_array() { - Some(array) => array.iter() - .filter_map(|id| id.as_u64().map(|n| n.to_string())) - .collect::>(), - _ => Vec::new(), - } + Some(dlc_array) => match dlc_array.as_array() { + Some(array) => array + .iter() + .filter_map(|id| id.as_u64().map(|n| n.to_string())) + .collect::>(), + _ => Vec::new(), }, _ => Vec::new(), }; - + info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); - + let mut dlc_details = Vec::new(); - + for dlc_id in dlc_ids { - let dlc_url = format!("https://store.steampowered.com/api/appdetails?appids={}", dlc_id); - + let dlc_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + dlc_id + ); + // Add a small delay to avoid rate limiting tokio::time::sleep(Duration::from_millis(300)).await; - - let dlc_response = client.get(&dlc_url) + + let dlc_response = client + .get(&dlc_url) .timeout(Duration::from_secs(10)) .send() .await?; - + if dlc_response.status().is_success() { let dlc_data: serde_json::Value = dlc_response.json().await?; - - let dlc_name = match dlc_data.get(&dlc_id) + + let dlc_name = match dlc_data + .get(&dlc_id) .and_then(|app| app.get("data")) .and_then(|data| data.get("name")) { - Some(name) => { - match name.as_str() { - Some(s) => s.to_string(), - _ => "Unknown DLC".to_string(), - } + Some(name) => match name.as_str() { + Some(s) => s.to_string(), + _ => "Unknown DLC".to_string(), }, _ => "Unknown DLC".to_string(), }; - + info!("Found DLC: {} ({})", dlc_name, dlc_id); dlc_details.push(DlcInfo { appid: dlc_id, @@ -683,27 +701,40 @@ pub async fn fetch_dlc_details(app_id: &str) -> Result, InstallerEr tokio::time::sleep(Duration::from_secs(10)).await; } } - - info!("Successfully retrieved details for {} DLCs", dlc_details.len()); + + info!( + "Successfully retrieved details for {} DLCs", + dlc_details.len() + ); Ok(dlc_details) } -/// Fetch DLC details from Steam API with progress updates -pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::AppHandle) -> Result, InstallerError> { - info!("Starting DLC details fetch with progress for game ID: {}", app_id); - +// Fetch DLC details from Steam API with progress updates +pub async fn fetch_dlc_details_with_progress( + app_id: &str, + app_handle: &tauri::AppHandle, +) -> Result, InstallerError> { + info!( + "Starting DLC details fetch with progress for game ID: {}", + app_id + ); + // Get a reference to a cancellation flag from app state let state = app_handle.state::(); let should_cancel = state.fetch_cancellation.clone(); - + let client = reqwest::Client::new(); - let base_url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id); - + let base_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + app_id + ); + // Emit initial progress emit_dlc_progress(app_handle, "Looking up game details...", 5, None); info!("Emitted initial DLC progress: 5%"); - let response = client.get(&base_url) + let response = client + .get(&base_url) .timeout(Duration::from_secs(10)) .send() .await?; @@ -715,38 +746,45 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A } let data: serde_json::Value = response.json().await?; - let dlc_ids = match data.get(app_id) + let dlc_ids = match data + .get(app_id) .and_then(|app| app.get("data")) .and_then(|data| data.get("dlc")) { - Some(dlc_array) => { - match dlc_array.as_array() { - Some(array) => array.iter() - .filter_map(|id| id.as_u64().map(|n| n.to_string())) - .collect::>(), - _ => Vec::new(), - } + Some(dlc_array) => match dlc_array.as_array() { + Some(array) => array + .iter() + .filter_map(|id| id.as_u64().map(|n| n.to_string())) + .collect::>(), + _ => Vec::new(), }, _ => Vec::new(), }; info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); - emit_dlc_progress(app_handle, &format!("Found {} DLCs. Fetching details...", dlc_ids.len()), 10, None); + emit_dlc_progress( + app_handle, + &format!("Found {} DLCs. Fetching details...", dlc_ids.len()), + 10, + None, + ); info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len()); - + let mut dlc_details = Vec::new(); let total_dlcs = dlc_ids.len(); - + for (index, dlc_id) in dlc_ids.iter().enumerate() { - // Check if cancellation was requested - if should_cancel.load(Ordering::SeqCst) { - info!("DLC fetch cancelled for game {}", app_id); - return Err(InstallerError::InstallationError("Operation cancelled by user".to_string())); - } + // Check if cancellation was requested + if should_cancel.load(Ordering::SeqCst) { + info!("DLC fetch cancelled for game {}", app_id); + return Err(InstallerError::InstallationError( + "Operation cancelled by user".to_string(), + )); + } let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0; let progress_rounded = progress_percent as u32; let remaining_dlcs = total_dlcs - index; - + // Estimate time remaining (rough calculation - 300ms per DLC) let est_time_left = if remaining_dlcs > 0 { let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32; @@ -758,47 +796,55 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A } else { "almost done".to_string() }; - - info!("Processing DLC {}/{} - Progress: {}%", index + 1, total_dlcs, progress_rounded); + + info!( + "Processing DLC {}/{} - Progress: {}%", + index + 1, + total_dlcs, + progress_rounded + ); emit_dlc_progress( - app_handle, + app_handle, &format!("Processing DLC {}/{}", index + 1, total_dlcs), progress_rounded, - Some(&est_time_left) + Some(&est_time_left), ); - - let dlc_url = format!("https://store.steampowered.com/api/appdetails?appids={}", dlc_id); - + + let dlc_url = format!( + "https://store.steampowered.com/api/appdetails?appids={}", + dlc_id + ); + // Add a small delay to avoid rate limiting tokio::time::sleep(Duration::from_millis(300)).await; - - let dlc_response = client.get(&dlc_url) + + let dlc_response = client + .get(&dlc_url) .timeout(Duration::from_secs(10)) .send() .await?; - + if dlc_response.status().is_success() { let dlc_data: serde_json::Value = dlc_response.json().await?; - - let dlc_name = match dlc_data.get(&dlc_id) + + let dlc_name = match dlc_data + .get(&dlc_id) .and_then(|app| app.get("data")) .and_then(|data| data.get("name")) { - Some(name) => { - match name.as_str() { - Some(s) => s.to_string(), - _ => "Unknown DLC".to_string(), - } + Some(name) => match name.as_str() { + Some(s) => s.to_string(), + _ => "Unknown DLC".to_string(), }, _ => "Unknown DLC".to_string(), }; - + info!("Found DLC: {} ({})", dlc_name, dlc_id); let dlc_info = DlcInfo { appid: dlc_id.clone(), name: dlc_name, }; - + // Emit each DLC as we find it if let Ok(json) = serde_json::to_string(&dlc_info) { if let Err(e) = app_handle.emit("dlc-found", json) { @@ -807,182 +853,213 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A info!("Emitted dlc-found event for DLC: {}", dlc_id); } } - + dlc_details.push(dlc_info); } else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { // If rate limited, wait longer error!("Rate limited by Steam API, waiting 10 seconds"); - emit_dlc_progress(app_handle, "Rate limited by Steam. Waiting...", progress_rounded, None); + emit_dlc_progress( + app_handle, + "Rate limited by Steam. Waiting...", + progress_rounded, + None, + ); tokio::time::sleep(Duration::from_secs(10)).await; } } - + // Final progress update - info!("Completed DLC fetch. Found {} DLCs in total", dlc_details.len()); - emit_dlc_progress(app_handle, &format!("Completed! Found {} DLCs", dlc_details.len()), 100, None); + info!( + "Completed DLC fetch. Found {} DLCs in total", + dlc_details.len() + ); + emit_dlc_progress( + app_handle, + &format!("Completed! Found {} DLCs", dlc_details.len()), + 100, + None, + ); info!("Emitted final DLC progress: 100%"); - + Ok(dlc_details) } -/// Emit DLC progress updates to the frontend +// Emit DLC progress updates to the frontend fn emit_dlc_progress( app_handle: &tauri::AppHandle, message: &str, progress: u32, - time_left: Option<&str> + time_left: Option<&str>, ) { let mut payload = json!({ "message": message, "progress": progress }); - + if let Some(time) = time_left { payload["timeLeft"] = json!(time); } - + if let Err(e) = app_handle.emit("dlc-progress", payload) { warn!("Failed to emit dlc-progress event: {}", e); } } -// -// SmokeAPI specific functions -// - -/// Install SmokeAPI for a game +// Install SmokeAPI for a game async fn install_smokeapi( - game_path: &str, + game_path: &str, api_files: &[String], - progress_callback: F -) -> Result<(), InstallerError> + progress_callback: F, +) -> Result<(), InstallerError> where - F: Fn(f32, &str) + Send + 'static + F: Fn(f32, &str) + Send + 'static, { - // 1. Get the latest SmokeAPI release + // Get the latest SmokeAPI release progress_callback(0.1, "Fetching latest SmokeAPI release..."); let client = reqwest::Client::new(); - let releases_url = format!("https://api.github.com/repos/{}/releases/latest", SMOKEAPI_REPO); - - let response = client.get(&releases_url) + let releases_url = format!( + "https://api.github.com/repos/{}/releases/latest", + SMOKEAPI_REPO + ); + + let response = client + .get(&releases_url) .header("User-Agent", "CreamLinux") .timeout(Duration::from_secs(10)) .send() .await?; - + if !response.status().is_success() { - return Err(InstallerError::InstallationError( - format!("Failed to fetch SmokeAPI releases: HTTP {}", response.status()) - )); + return Err(InstallerError::InstallationError(format!( + "Failed to fetch SmokeAPI releases: HTTP {}", + response.status() + ))); } - + let release_info: serde_json::Value = response.json().await?; let latest_version = match release_info.get("tag_name") { Some(tag) => tag.as_str().unwrap_or("latest"), _ => "latest", }; - + info!("Latest SmokeAPI version: {}", latest_version); - - // 2. Construct download URL + + // Construct download URL let zip_url = format!( "https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip", SMOKEAPI_REPO, latest_version, latest_version ); - - // 3. Download the zip + + // Download the zip progress_callback(0.3, "Downloading SmokeAPI..."); - let response = client.get(&zip_url) + let response = client + .get(&zip_url) .timeout(Duration::from_secs(30)) .send() .await?; - + if !response.status().is_success() { - return Err(InstallerError::InstallationError( - format!("Failed to download SmokeAPI: HTTP {}", response.status()) - )); + return Err(InstallerError::InstallationError(format!( + "Failed to download SmokeAPI: HTTP {}", + response.status() + ))); } - - // 4. Save to temporary file + + // Save to temporary file progress_callback(0.5, "Saving downloaded files..."); let temp_dir = tempdir()?; let zip_path = temp_dir.path().join("smokeapi.zip"); let content = response.bytes().await?; fs::write(&zip_path, &content)?; - - // 5. Extract and install for each API file + + // Extract and install for each API file progress_callback(0.6, "Extracting SmokeAPI files..."); let file = fs::File::open(&zip_path)?; let mut archive = ZipArchive::new(file)?; - + for (i, api_file) in api_files.iter().enumerate() { let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3; progress_callback(progress, &format!("Installing SmokeAPI for {}", api_file)); - - let api_dir = Path::new(game_path).join(Path::new(api_file).parent().unwrap_or_else(|| Path::new(""))); + + let api_dir = Path::new(game_path).join( + Path::new(api_file) + .parent() + .unwrap_or_else(|| Path::new("")), + ); let api_name = Path::new(api_file).file_name().unwrap_or_default(); - + // Backup original file let original_path = api_dir.join(api_name); let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll")); - + info!("Processing: {}", original_path.display()); info!("Backup path: {}", backup_path.display()); - + // Only backup if not already backed up if !backup_path.exists() && original_path.exists() { fs::copy(&original_path, &backup_path)?; info!("Created backup: {}", backup_path.display()); } - + // Extract the appropriate DLL directly to the game directory if let Ok(mut file) = archive.by_name(&api_name.to_string_lossy()) { let mut outfile = fs::File::create(&original_path)?; io::copy(&mut file, &mut outfile)?; info!("Installed SmokeAPI as: {}", original_path.display()); } else { - return Err(InstallerError::InstallationError( - format!("Could not find {} in the SmokeAPI zip file", api_name.to_string_lossy()) - )); + return Err(InstallerError::InstallationError(format!( + "Could not find {} in the SmokeAPI zip file", + api_name.to_string_lossy() + ))); } } - + progress_callback(1.0, "SmokeAPI installation completed!"); info!("SmokeAPI installation completed for: {}", game_path); Ok(()) } -/// Uninstall SmokeAPI from a game +// Uninstall SmokeAPI from a game fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> { info!("Uninstalling SmokeAPI from: {}", game_path); - + for api_file in api_files { let api_path = Path::new(game_path).join(api_file); let api_dir = api_path.parent().unwrap_or_else(|| Path::new(game_path)); let api_name = api_path.file_name().unwrap_or_default(); - + let original_path = api_dir.join(api_name); let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll")); - + info!("Processing: {}", original_path.display()); info!("Backup path: {}", backup_path.display()); - + if backup_path.exists() { // Remove the SmokeAPI version if original_path.exists() { match fs::remove_file(&original_path) { Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()), - Err(e) => error!("Failed to remove SmokeAPI file: {}, error: {}", original_path.display(), e) + Err(e) => error!( + "Failed to remove SmokeAPI file: {}, error: {}", + original_path.display(), + e + ), } } - + // Restore the original file match fs::rename(&backup_path, &original_path) { Ok(_) => info!("Restored original file: {}", original_path.display()), Err(e) => { - error!("Failed to restore original file: {}, error: {}", original_path.display(), e); + error!( + "Failed to restore original file: {}, error: {}", + original_path.display(), + e + ); // Try to copy instead if rename fails - if let Err(copy_err) = fs::copy(&backup_path, &original_path).and_then(|_| fs::remove_file(&backup_path)) { + if let Err(copy_err) = fs::copy(&backup_path, &original_path) + .and_then(|_| fs::remove_file(&backup_path)) + { error!("Failed to copy backup file: {}", copy_err); } } @@ -991,7 +1068,7 @@ fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), Insta info!("No backup found for: {}", api_file); } } - + info!("SmokeAPI uninstallation completed for: {}", game_path); Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index cdc6dfe..3760d2d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,100 +1,130 @@ -// src/main.rs #![cfg_attr( - all(not(debug_assertions), target_os = "windows"), - windows_subsystem = "windows" + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" )] -mod searcher; -mod installer; +mod cache; mod dlc_manager; -mod cache; // Keep the module for now, but we won't use its functionality +mod installer; +mod searcher; // Keep the module for now -use serde::{Serialize, Deserialize}; -use std::collections::HashMap; -use parking_lot::Mutex; -use tokio::time::Instant; -use tokio::time::Duration; -use tauri::State; -use tauri::{Manager, Emitter}; -use log::{info, warn, error, debug}; -use installer::{InstallerType, InstallerAction, Game}; use dlc_manager::DlcInfoWithState; -use std::sync::Arc; +use installer::{Game, InstallerAction, InstallerType}; +use log::{debug, error, info, warn}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use std::sync::Arc; +use tauri::State; +use tauri::{Emitter, Manager}; +use tokio::time::Duration; +use tokio::time::Instant; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GameAction { -game_id: String, -action: String, + game_id: String, + action: String, } #[derive(Debug, Clone)] struct DlcCache { - data: Vec, - timestamp: Instant, + data: Vec, + timestamp: Instant, } // Structure to hold the state of installed games struct AppState { -games: Mutex>, -dlc_cache: Mutex>, -fetch_cancellation: Arc, + games: Mutex>, + dlc_cache: Mutex>, + fetch_cancellation: Arc, } #[tauri::command] fn get_all_dlcs_command(game_path: String) -> Result, String> { - info!("Getting all DLCs (enabled and disabled) for: {}", game_path); - dlc_manager::get_all_dlcs(&game_path) + info!("Getting all DLCs (enabled and disabled) for: {}", game_path); + dlc_manager::get_all_dlcs(&game_path) } // Scan and get the list of Steam games #[tauri::command] -async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result, String> { +async fn scan_steam_games( + state: State<'_, AppState>, + app_handle: tauri::AppHandle, +) -> Result, String> { info!("Starting Steam games scan"); emit_scan_progress(&app_handle, "Locating Steam libraries...", 10); - + // Get default Steam paths let paths = searcher::get_default_steam_paths(); - + // Find Steam libraries emit_scan_progress(&app_handle, "Finding Steam libraries...", 15); let libraries = searcher::find_steam_libraries(&paths); - + // Group libraries by path to avoid duplicates in logs let mut unique_libraries = std::collections::HashSet::new(); for lib in &libraries { unique_libraries.insert(lib.to_string_lossy().to_string()); } - - info!("Found {} Steam library directories:", unique_libraries.len()); + + info!( + "Found {} Steam library directories:", + unique_libraries.len() + ); for (i, lib) in unique_libraries.iter().enumerate() { - info!(" Library {}: {}", i+1, lib); + info!(" Library {}: {}", i + 1, lib); } - - emit_scan_progress(&app_handle, &format!("Found {} Steam libraries. Starting game scan...", unique_libraries.len()), 20); - + + emit_scan_progress( + &app_handle, + &format!( + "Found {} Steam libraries. Starting game scan...", + unique_libraries.len() + ), + 20, + ); + // Find installed games let games_info = searcher::find_installed_games(&libraries).await; - - emit_scan_progress(&app_handle, &format!("Found {} games. Processing...", games_info.len()), 90); - + + emit_scan_progress( + &app_handle, + &format!("Found {} games. Processing...", games_info.len()), + 90, + ); + // Log summary of games found info!("Games scan complete - Found {} games", games_info.len()); - info!("Native games: {}", games_info.iter().filter(|g| g.native).count()); - info!("Proton games: {}", games_info.iter().filter(|g| !g.native).count()); - info!("Games with CreamLinux: {}", games_info.iter().filter(|g| g.cream_installed).count()); - info!("Games with SmokeAPI: {}", games_info.iter().filter(|g| g.smoke_installed).count()); - + info!( + "Native games: {}", + games_info.iter().filter(|g| g.native).count() + ); + info!( + "Proton games: {}", + games_info.iter().filter(|g| !g.native).count() + ); + info!( + "Games with CreamLinux: {}", + games_info.iter().filter(|g| g.cream_installed).count() + ); + info!( + "Games with SmokeAPI: {}", + games_info.iter().filter(|g| g.smoke_installed).count() + ); + // Convert to our Game struct let mut result = Vec::new(); - + info!("Processing games into application state..."); for game_info in games_info { // Only log detailed game info at Debug level to keep Info logs cleaner - debug!("Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}", - game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed); - + debug!( + "Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}", + game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed + ); + let game = Game { id: game_info.id, title: game_info.title, @@ -105,383 +135,413 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand smoke_installed: game_info.smoke_installed, installing: false, }; - + result.push(game.clone()); - + // Store in state for later use state.games.lock().insert(game.id.clone(), game); } - - emit_scan_progress(&app_handle, &format!("Scan complete. Found {} games.", result.len()), 100); - + + emit_scan_progress( + &app_handle, + &format!("Scan complete. Found {} games.", result.len()), + 100, + ); + info!("Game scan completed successfully"); Ok(result) } // Helper function to emit scan progress events fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) { - // Log first, then emit the event - info!("Scan progress: {}% - {}", progress, message); - - let payload = serde_json::json!({ - "message": message, - "progress": progress - }); - - if let Err(e) = app_handle.emit("scan-progress", payload) { - warn!("Failed to emit scan-progress event: {}", e); - } + // Log first, then emit the event + info!("Scan progress: {}% - {}", progress, message); + + let payload = serde_json::json!({ + "message": message, + "progress": progress + }); + + if let Err(e) = app_handle.emit("scan-progress", payload) { + warn!("Failed to emit scan-progress event: {}", e); + } } // Fetch game info by ID - useful for single game updates #[tauri::command] fn get_game_info(game_id: String, state: State) -> Result { - let games = state.games.lock(); - games.get(&game_id) - .cloned() - .ok_or_else(|| format!("Game with ID {} not found", game_id)) + let games = state.games.lock(); + games + .get(&game_id) + .cloned() + .ok_or_else(|| format!("Game with ID {} not found", game_id)) } // Unified action handler for installation and uninstallation #[tauri::command] async fn process_game_action( -game_action: GameAction, -state: State<'_, AppState>, -app_handle: tauri::AppHandle + game_action: GameAction, + state: State<'_, AppState>, + app_handle: tauri::AppHandle, ) -> Result { -// Clone the information we need from state to avoid lifetime issues -let game = { - let games = state.games.lock(); - games.get(&game_action.game_id) - .cloned() - .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? -}; + // Clone the information we need from state to avoid lifetime issues + let game = { + let games = state.games.lock(); + games + .get(&game_action.game_id) + .cloned() + .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? + }; -// Parse the action string to determine type and operation -let (installer_type, action) = match game_action.action.as_str() { - "install_cream" => (InstallerType::Cream, InstallerAction::Install), - "uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall), - "install_smoke" => (InstallerType::Smoke, InstallerAction::Install), - "uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall), - _ => return Err(format!("Invalid action: {}", game_action.action)) -}; + // Parse the action string to determine type and operation + let (installer_type, action) = match game_action.action.as_str() { + "install_cream" => (InstallerType::Cream, InstallerAction::Install), + "uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall), + "install_smoke" => (InstallerType::Smoke, InstallerAction::Install), + "uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall), + _ => return Err(format!("Invalid action: {}", game_action.action)), + }; -// Execute the action -installer::process_action( - game_action.game_id.clone(), - installer_type, - action, - game.clone(), - app_handle.clone() -).await?; + // Execute the action + installer::process_action( + game_action.game_id.clone(), + installer_type, + action, + game.clone(), + app_handle.clone(), + ) + .await?; -// Update game status in state based on the action -let updated_game = { - let mut games_map = state.games.lock(); - let game = games_map.get_mut(&game_action.game_id) - .ok_or_else(|| format!("Game with ID {} not found after action", game_action.game_id))?; - - // Update installation status - match (installer_type, action) { - (InstallerType::Cream, InstallerAction::Install) => { - game.cream_installed = true; - }, - (InstallerType::Cream, InstallerAction::Uninstall) => { - game.cream_installed = false; - }, - (InstallerType::Smoke, InstallerAction::Install) => { - game.smoke_installed = true; - }, - (InstallerType::Smoke, InstallerAction::Uninstall) => { - game.smoke_installed = false; + // Update game status in state based on the action + let updated_game = { + let mut games_map = state.games.lock(); + let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| { + format!( + "Game with ID {} not found after action", + game_action.game_id + ) + })?; + + // Update installation status + match (installer_type, action) { + (InstallerType::Cream, InstallerAction::Install) => { + game.cream_installed = true; + } + (InstallerType::Cream, InstallerAction::Uninstall) => { + game.cream_installed = false; + } + (InstallerType::Smoke, InstallerAction::Install) => { + game.smoke_installed = true; + } + (InstallerType::Smoke, InstallerAction::Uninstall) => { + game.smoke_installed = false; + } } + + // Reset installing flag + game.installing = false; + + // Return updated game info + game.clone() + }; + + // Emit an event to update the UI for this specific game + if let Err(e) = app_handle.emit("game-updated", &updated_game) { + warn!("Failed to emit game-updated event: {}", e); } - - // Reset installing flag - game.installing = false; - - // Return updated game info - game.clone() -}; -// Removed cache update - -// Emit an event to update the UI for this specific game -if let Err(e) = app_handle.emit("game-updated", &updated_game) { - warn!("Failed to emit game-updated event: {}", e); -} - -Ok(updated_game) + Ok(updated_game) } // Fetch DLC list for a game #[tauri::command] -async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result, String> { - info!("Fetching DLCs for game ID: {}", game_id); - - // Removed cache checking - - // Always fetch fresh DLC data instead of using cache - match installer::fetch_dlc_details(&game_id).await { - Ok(dlcs) => { - // Convert to DlcInfoWithState (all enabled by default) - let dlcs_with_state = dlcs.into_iter() - .map(|dlc| DlcInfoWithState { - appid: dlc.appid, - name: dlc.name, - enabled: true, - }) - .collect::>(); - - // Cache in memory for this session (but not on disk) - let state = app_handle.state::(); - let mut cache = state.dlc_cache.lock(); - cache.insert(game_id.clone(), DlcCache { - data: dlcs_with_state.clone(), - timestamp: Instant::now(), - }); - - Ok(dlcs_with_state) - }, - Err(e) => Err(format!("Failed to fetch DLC details: {}", e)) - } +async fn fetch_game_dlcs( + game_id: String, + app_handle: tauri::AppHandle, +) -> Result, String> { + info!("Fetching DLCs for game ID: {}", game_id); + + // Fetch DLC data + match installer::fetch_dlc_details(&game_id).await { + Ok(dlcs) => { + // Convert to DlcInfoWithState + let dlcs_with_state = dlcs + .into_iter() + .map(|dlc| DlcInfoWithState { + appid: dlc.appid, + name: dlc.name, + enabled: true, + }) + .collect::>(); + + // Cache in memory for this session (but not on disk) + let state = app_handle.state::(); + let mut cache = state.dlc_cache.lock(); + cache.insert( + game_id.clone(), + DlcCache { + data: dlcs_with_state.clone(), + timestamp: Instant::now(), + }, + ); + + Ok(dlcs_with_state) + } + Err(e) => Err(format!("Failed to fetch DLC details: {}", e)), + } } #[tauri::command] fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { - info!("Request to abort DLC fetch for game ID: {}", game_id); - - let state = app_handle.state::(); - state.fetch_cancellation.store(true, Ordering::SeqCst); - - // Reset after a short delay - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(500)); + info!("Request to abort DLC fetch for game ID: {}", game_id); + let state = app_handle.state::(); - state.fetch_cancellation.store(false, Ordering::SeqCst); - }); - - Ok(()) + state.fetch_cancellation.store(true, Ordering::SeqCst); + + // Reset after a short delay + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + let state = app_handle.state::(); + state.fetch_cancellation.store(false, Ordering::SeqCst); + }); + + Ok(()) } // Fetch DLC list with progress updates (streaming) #[tauri::command] async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { - info!("Streaming DLCs for game ID: {}", game_id); - - // Removed cached DLC check - always fetch fresh data - - // Always fetch fresh DLC data from API - match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await { - Ok(dlcs) => { - info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id); - - // Convert to DLCInfoWithState for in-memory caching only - let dlcs_with_state = dlcs.into_iter() - .map(|dlc| DlcInfoWithState { - appid: dlc.appid, - name: dlc.name, - enabled: true, - }) - .collect::>(); - - // Update in-memory cache without storing to disk - let state = app_handle.state::(); - let mut dlc_cache = state.dlc_cache.lock(); - dlc_cache.insert(game_id.clone(), DlcCache { - data: dlcs_with_state, - timestamp: tokio::time::Instant::now(), - }); - - Ok(()) - }, - Err(e) => { - error!("Failed to stream DLC details: {}", e); - // Emit error event - let error_payload = serde_json::json!({ - "error": format!("Failed to fetch DLC details: {}", e) - }); - - if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) { - warn!("Failed to emit dlc-error event: {}", emit_err); - } - - Err(format!("Failed to fetch DLC details: {}", e)) - } - } + info!("Streaming DLCs for game ID: {}", game_id); + + // Fetch DLC data from API + match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await { + Ok(dlcs) => { + info!( + "Successfully streamed {} DLCs for game {}", + dlcs.len(), + game_id + ); + + // Convert to DLCInfoWithState for in-memory caching only + let dlcs_with_state = dlcs + .into_iter() + .map(|dlc| DlcInfoWithState { + appid: dlc.appid, + name: dlc.name, + enabled: true, + }) + .collect::>(); + + // Update in-memory cache without storing to disk + let state = app_handle.state::(); + let mut dlc_cache = state.dlc_cache.lock(); + dlc_cache.insert( + game_id.clone(), + DlcCache { + data: dlcs_with_state, + timestamp: tokio::time::Instant::now(), + }, + ); + + Ok(()) + } + Err(e) => { + error!("Failed to stream DLC details: {}", e); + // Emit error event + let error_payload = serde_json::json!({ + "error": format!("Failed to fetch DLC details: {}", e) + }); + + if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) { + warn!("Failed to emit dlc-error event: {}", emit_err); + } + + Err(format!("Failed to fetch DLC details: {}", e)) + } + } } // Clear caches command renamed to flush_data for clarity #[tauri::command] fn clear_caches() -> Result<(), String> { - info!("Data flush requested - cleaning in-memory state only"); - Ok(()) + info!("Data flush requested - cleaning in-memory state only"); + Ok(()) } // Get the list of enabled DLCs for a game #[tauri::command] fn get_enabled_dlcs_command(game_path: String) -> Result, String> { - info!("Getting enabled DLCs for: {}", game_path); - dlc_manager::get_enabled_dlcs(&game_path) + info!("Getting enabled DLCs for: {}", game_path); + dlc_manager::get_enabled_dlcs(&game_path) } // Update the DLC configuration for a game #[tauri::command] -fn update_dlc_configuration_command(game_path: String, dlcs: Vec) -> Result<(), String> { - info!("Updating DLC configuration for: {}", game_path); - dlc_manager::update_dlc_configuration(&game_path, dlcs) +fn update_dlc_configuration_command( + game_path: String, + dlcs: Vec, +) -> Result<(), String> { + info!("Updating DLC configuration for: {}", game_path); + dlc_manager::update_dlc_configuration(&game_path, dlcs) } // Install CreamLinux with selected DLCs #[tauri::command] async fn install_cream_with_dlcs_command( - game_id: String, - selected_dlcs: Vec, - app_handle: tauri::AppHandle + game_id: String, + selected_dlcs: Vec, + app_handle: tauri::AppHandle, ) -> Result { - info!("Installing CreamLinux with selected DLCs for game: {}", game_id); - - // Clone selected_dlcs for later use - let selected_dlcs_clone = selected_dlcs.clone(); - - // Install CreamLinux with the selected DLCs - match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs).await { - Ok(_) => { - // Return updated game info - let state = app_handle.state::(); - - // Get a mutable reference and update the game - let game = { - let mut games_map = state.games.lock(); - let game = games_map.get_mut(&game_id) - .ok_or_else(|| format!("Game with ID {} not found after installation", game_id))?; - - // Update installation status - game.cream_installed = true; - game.installing = false; - - // Clone the game for returning later - game.clone() - }; // mutable borrow ends here - - // Removed game caching - - // Emit an event to update the UI - if let Err(e) = app_handle.emit("game-updated", &game) { - warn!("Failed to emit game-updated event: {}", e); - } - - // Show installation complete dialog with instructions - let instructions = installer::InstallationInstructions { - type_: "cream_install".to_string(), - command: "sh ./cream.sh %command%".to_string(), - game_title: game.title.clone(), - dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()) - }; - - installer::emit_progress( - &app_handle, - &format!("Installation Completed: {}", game.title), - "CreamLinux has been installed successfully!", - 100.0, - true, - true, - Some(instructions) - ); - - Ok(game) - }, - Err(e) => { - error!("Failed to install CreamLinux with selected DLCs: {}", e); - Err(format!("Failed to install CreamLinux with selected DLCs: {}", e)) - } - } + info!( + "Installing CreamLinux with selected DLCs for game: {}", + game_id + ); + + // Clone selected_dlcs for later use + let selected_dlcs_clone = selected_dlcs.clone(); + + // Install CreamLinux with the selected DLCs + match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs) + .await + { + Ok(_) => { + // Return updated game info + let state = app_handle.state::(); + + // Get a mutable reference and update the game + let game = { + let mut games_map = state.games.lock(); + let game = games_map.get_mut(&game_id).ok_or_else(|| { + format!("Game with ID {} not found after installation", game_id) + })?; + + // Update installation status + game.cream_installed = true; + game.installing = false; + + // Clone the game for returning later + game.clone() + }; + + // Emit an event to update the UI + if let Err(e) = app_handle.emit("game-updated", &game) { + warn!("Failed to emit game-updated event: {}", e); + } + + // Show installation complete dialog with instructions + let instructions = installer::InstallationInstructions { + type_: "cream_install".to_string(), + command: "sh ./cream.sh %command%".to_string(), + game_title: game.title.clone(), + dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()), + }; + + installer::emit_progress( + &app_handle, + &format!("Installation Completed: {}", game.title), + "CreamLinux has been installed successfully!", + 100.0, + true, + true, + Some(instructions), + ); + + Ok(game) + } + Err(e) => { + error!("Failed to install CreamLinux with selected DLCs: {}", e); + Err(format!( + "Failed to install CreamLinux with selected DLCs: {}", + e + )) + } + } } // Setup logging fn setup_logging() -> Result<(), Box> { - use log::LevelFilter; - use log4rs::append::file::FileAppender; - use log4rs::config::{Appender, Config, Root}; - use log4rs::encode::pattern::PatternEncoder; - use std::fs; - - // Get XDG cache directory - let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?; - let log_path = xdg_dirs.place_cache_file("creamlinux.log")?; - - // Clear the log file on startup - if log_path.exists() { - if let Err(e) = fs::write(&log_path, "") { - eprintln!("Warning: Failed to clear log file: {}", e); - } - } - - // Create a file appender with improved log format - let file = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new( - "[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n" - ))) - .build(log_path)?; - - // Build the config - let config = Config::builder() - .appender(Appender::builder().build("file", Box::new(file))) - .build(Root::builder().appender("file").build(LevelFilter::Info))?; - - // Initialize log4rs with this config - log4rs::init_config(config)?; - - info!("CreamLinux started with a clean log file"); - Ok(()) + use log::LevelFilter; + use log4rs::append::file::FileAppender; + use log4rs::config::{Appender, Config, Root}; + use log4rs::encode::pattern::PatternEncoder; + use std::fs; + + // Get XDG cache directory + let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?; + let log_path = xdg_dirs.place_cache_file("creamlinux.log")?; + + // Clear the log file on startup + if log_path.exists() { + if let Err(e) = fs::write(&log_path, "") { + eprintln!("Warning: Failed to clear log file: {}", e); + } + } + + // Create a file appender + let file = FileAppender::builder() + .encoder(Box::new(PatternEncoder::new( + "[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n", + ))) + .build(log_path)?; + + // Build the config + let config = Config::builder() + .appender(Appender::builder().build("file", Box::new(file))) + .build(Root::builder().appender("file").build(LevelFilter::Info))?; + + // Initialize log4rs with this config + log4rs::init_config(config)?; + + info!("CreamLinux started with a clean log file"); + Ok(()) } fn main() { - // Set up logging first - if let Err(e) = setup_logging() { - eprintln!("Warning: Failed to initialize logging: {}", e); - } - - info!("Initializing CreamLinux application"); - - let app_state = AppState { - games: Mutex::new(HashMap::new()), - dlc_cache: Mutex::new(HashMap::new()), - fetch_cancellation: Arc::new(AtomicBool::new(false)), - }; + // Set up logging first + if let Err(e) = setup_logging() { + eprintln!("Warning: Failed to initialize logging: {}", e); + } - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) - .manage(app_state) - .invoke_handler(tauri::generate_handler![ - scan_steam_games, - get_game_info, - process_game_action, - fetch_game_dlcs, - stream_game_dlcs, - get_enabled_dlcs_command, - update_dlc_configuration_command, - install_cream_with_dlcs_command, - get_all_dlcs_command, - clear_caches, - abort_dlc_fetch, - ]) - .setup(|app| { - // Add a setup handler to do any initialization work - info!("Tauri application setup"); - - #[cfg(debug_assertions)] - { - if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") { - if let Some(window) = app.get_webview_window("main") { - window.open_devtools(); - } - } - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} \ No newline at end of file + info!("Initializing CreamLinux application"); + + let app_state = AppState { + games: Mutex::new(HashMap::new()), + dlc_cache: Mutex::new(HashMap::new()), + fetch_cancellation: Arc::new(AtomicBool::new(false)), + }; + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + scan_steam_games, + get_game_info, + process_game_action, + fetch_game_dlcs, + stream_game_dlcs, + get_enabled_dlcs_command, + update_dlc_configuration_command, + install_cream_with_dlcs_command, + get_all_dlcs_command, + clear_caches, + abort_dlc_fetch, + ]) + .setup(|app| { + // Add a setup handler to do any initialization work + info!("Tauri application setup"); + + #[cfg(debug_assertions)] + { + if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") { + if let Some(window) = app.get_webview_window("main") { + window.open_devtools(); + } + } + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/searcher.rs b/src-tauri/src/searcher.rs index df730cd..107a115 100644 --- a/src-tauri/src/searcher.rs +++ b/src-tauri/src/searcher.rs @@ -1,15 +1,14 @@ -// src/searcher.rs +use log::{debug, error, info, warn}; +use regex::Regex; +use std::collections::HashSet; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; -use std::collections::HashSet; -use log::{info, debug, warn, error}; -use regex::Regex; -use walkdir::WalkDir; -use tokio::sync::mpsc; use std::sync::Arc; +use tokio::sync::mpsc; +use walkdir::WalkDir; -/// Game information structure +// Game information structure #[derive(Debug, Clone)] pub struct GameInfo { pub id: String, @@ -21,24 +20,24 @@ pub struct GameInfo { pub smoke_installed: bool, } -/// Find potential Steam installation directories +// Find potential Steam installation directories pub fn get_default_steam_paths() -> Vec { let mut paths = Vec::new(); - + // Get user's home directory if let Ok(home) = std::env::var("HOME") { info!("Searching for Steam in home directory: {}", home); - + // Common Steam installation locations on Linux let common_paths = [ - ".steam/steam", // Steam symlink directory - ".steam/root", // Alternative symlink - ".local/share/Steam", // Flatpak Steam installation - ".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path - ".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path - "/run/media/mmcblk0p1", // Removable Storage path + ".steam/steam", // Steam symlink directory + ".steam/root", // Alternative symlink + ".local/share/Steam", // Flatpak Steam installation + ".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path + ".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path + "/run/media/mmcblk0p1", // Removable Storage path ]; - + for path in &common_paths { let full_path = PathBuf::from(&home).join(path); if full_path.exists() { @@ -47,13 +46,10 @@ pub fn get_default_steam_paths() -> Vec { } } } - - // Add Steam Deck paths if they exist (these don't rely on HOME) - let deck_paths = [ - "/home/deck/.steam/steam", - "/home/deck/.local/share/Steam", - ]; - + + // Add Steam Deck paths if they exist + let deck_paths = ["/home/deck/.steam/steam", "/home/deck/.local/share/Steam"]; + for path in &deck_paths { let p = PathBuf::from(path); if p.exists() && !paths.contains(&p) { @@ -61,7 +57,7 @@ pub fn get_default_steam_paths() -> Vec { paths.push(p); } } - + // Try to extract paths from Steam registry file if let Some(registry_paths) = read_steam_registry() { for path in registry_paths { @@ -71,39 +67,39 @@ pub fn get_default_steam_paths() -> Vec { } } } - + info!("Found {} potential Steam directories", paths.len()); paths } -/// Try to read the Steam registry file to find installation paths +// Try to read the Steam registry file to find installation paths fn read_steam_registry() -> Option> { let home = match std::env::var("HOME") { Ok(h) => h, Err(_) => return None, }; - + let registry_paths = [ format!("{}/.steam/registry.vdf", home), format!("{}/.steam/steam/registry.vdf", home), format!("{}/.local/share/Steam/registry.vdf", home), ]; - + for registry_path in registry_paths { let path = Path::new(®istry_path); if path.exists() { debug!("Found Steam registry at: {}", path.display()); - + if let Ok(content) = fs::read_to_string(path) { let mut paths = Vec::new(); - + // Extract Steam installation paths let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap(); if let Some(cap) = re_steam_path.captures(&content) { let steam_path = PathBuf::from(&cap[1]); paths.push(steam_path); } - + // Look for install path let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap(); if let Some(cap) = re_install_path.captures(&content) { @@ -112,84 +108,84 @@ fn read_steam_registry() -> Option> { paths.push(install_path); } } - + if !paths.is_empty() { return Some(paths); } } } } - + None } -/// Find all Steam library folders from base Steam installation paths +// Find all Steam library folders from base Steam installation paths pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec { let mut libraries = HashSet::new(); - + for base_path in base_paths { debug!("Looking for Steam libraries in: {}", base_path.display()); - + // Check if this path contains a steamapps directory let steamapps_path = base_path.join("steamapps"); if steamapps_path.exists() && steamapps_path.is_dir() { debug!("Found steamapps directory: {}", steamapps_path.display()); libraries.insert(steamapps_path.clone()); - + // Check for additional libraries in libraryfolders.vdf parse_library_folders_vdf(&steamapps_path, &mut libraries); } - + // Also check for steamapps in common locations relative to this path let possible_steamapps = [ base_path.join("steam/steamapps"), base_path.join("Steam/steamapps"), ]; - + for path in &possible_steamapps { if path.exists() && path.is_dir() && !libraries.contains(path) { debug!("Found steamapps directory: {}", path.display()); libraries.insert(path.clone()); - + // Check for additional libraries in libraryfolders.vdf parse_library_folders_vdf(path, &mut libraries); } } } - + let result: Vec = libraries.into_iter().collect(); info!("Found {} Steam library directories", result.len()); for (i, lib) in result.iter().enumerate() { - info!(" Library {}: {}", i+1, lib.display()); + info!(" Library {}: {}", i + 1, lib.display()); } result } -/// Parse libraryfolders.vdf to extract additional library paths +// Parse libraryfolders.vdf to extract additional library paths fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet) { // Check both possible locations of the VDF file let vdf_paths = [ steamapps_path.join("libraryfolders.vdf"), steamapps_path.join("config/libraryfolders.vdf"), ]; - + for vdf_path in &vdf_paths { if vdf_path.exists() { debug!("Found library folders VDF: {}", vdf_path.display()); - + if let Ok(content) = fs::read_to_string(vdf_path) { // Extract library paths using regex for both new and old format VDFs let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap(); for cap in re_path.captures_iter(&content) { let path_str = &cap[1]; let lib_path = PathBuf::from(path_str).join("steamapps"); - + if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) { debug!("Found library from VDF: {}", lib_path.display()); // Clone lib_path before inserting to avoid ownership issues let lib_path_clone = lib_path.clone(); libraries.insert(lib_path_clone); - + // Recursively check this library for more libraries parse_library_folders_vdf(&lib_path, libraries); } @@ -199,7 +195,7 @@ fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet Option<(String, String, String)> { match fs::read_to_string(path) { Ok(content) => { @@ -207,16 +203,16 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> { let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap(); let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap(); let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap(); - + if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = ( re_appid.captures(&content), re_name.captures(&content), - re_installdir.captures(&content) + re_installdir.captures(&content), ) { let app_id = app_id_cap[1].to_string(); let name = name_cap[1].to_string(); let install_dir = dir_cap[1].to_string(); - + return Some((app_id, name, install_dir)); } } @@ -224,364 +220,387 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> { error!("Failed to read ACF file {}: {}", path.display(), e); } } - + None } -/// Check if a file is a Linux ELF binary +// Check if a file is a Linux ELF binary fn is_elf_binary(path: &Path) -> bool { if let Ok(mut file) = fs::File::open(path) { let mut buffer = [0; 4]; if file.read_exact(&mut buffer).is_ok() { // Check for ELF magic number (0x7F 'E' 'L' 'F') - return buffer[0] == 0x7F && buffer[1] == b'E' && buffer[2] == b'L' && buffer[3] == b'F'; + return buffer[0] == 0x7F + && buffer[1] == b'E' + && buffer[2] == b'L' + && buffer[3] == b'F'; } } - + false } -/// Check if a game has CreamLinux installed +// Check if a game has CreamLinux installed fn check_creamlinux_installed(game_path: &Path) -> bool { - let cream_files = [ - "cream.sh", - "cream_api.ini", - "cream_api.so", - ]; - + let cream_files = ["cream.sh", "cream_api.ini", "cream_api.so"]; + for file in &cream_files { if game_path.join(file).exists() { debug!("CreamLinux installation detected: {}", file); return true; } } - + false } -/// Check if a game has SmokeAPI installed +// Check if a game has SmokeAPI installed fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool { if api_files.is_empty() { return false; } - + // SmokeAPI creates backups with _o.dll suffix for api_file in api_files { let api_path = game_path.join(api_file); let api_dir = api_path.parent().unwrap_or(game_path); let api_filename = api_path.file_name().unwrap_or_default(); - + // Check for backup file (original file renamed with _o.dll suffix) let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll"); let backup_path = api_dir.join(backup_name); - + if backup_path.exists() { debug!("SmokeAPI backup file found: {}", backup_path.display()); return true; } } - + false } -/// Scan a game directory to determine if it's native or needs Proton -/// Also collect any Steam API DLLs for potential SmokeAPI installation +// Scan a game directory to determine if it's native or needs Proton +// Also collect any Steam API DLLs for potential SmokeAPI installation fn scan_game_directory(game_path: &Path) -> (bool, Vec) { - let mut found_exe = false; - let mut found_linux_binary = false; - let mut steam_api_files = Vec::new(); - - // Directories to skip for better performance - let skip_dirs = [ - "videos", "video", "movies", "movie", - "sound", "sounds", "audio", - "textures", "music", "localization", - "shaders", "logs", "assets/audio", - "assets/video", "assets/textures" - ]; - - // Only scan to a reasonable depth (avoid extreme recursion) - const MAX_DEPTH: usize = 8; - - // File extensions to check for (executable and Steam API files) - let exe_extensions = ["exe", "bat", "cmd", "msi"]; - let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"]; - - // Recursively walk through the game directory with optimized settings - for entry in WalkDir::new(game_path) - .max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep - .follow_links(false) // Don't follow symlinks to prevent cycles - .into_iter() - .filter_entry(|e| { - // Skip certain directories for performance - if e.file_type().is_dir() { - let file_name = e.file_name().to_string_lossy().to_lowercase(); - if skip_dirs.iter().any(|&dir| file_name == dir) { - debug!("Skipping directory: {}", e.path().display()); - return false; - } - } - true - }) - .filter_map(Result::ok) { - - let path = entry.path(); - if !path.is_file() { - continue; - } - - // Check file extension - if let Some(ext) = path.extension() { - let ext_str = ext.to_string_lossy().to_lowercase(); - - // Check for Windows executables - if exe_extensions.iter().any(|&e| ext_str == e) { - found_exe = true; - } - - // Check for Steam API DLLs - if ext_str == "dll" { - let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); - if filename == "steam_api.dll" || filename == "steam_api64.dll" { - if let Ok(rel_path) = path.strip_prefix(game_path) { - let rel_path_str = rel_path.to_string_lossy().to_string(); - debug!("Found Steam API DLL: {}", rel_path_str); - steam_api_files.push(rel_path_str); - } - } - } - - // Check for Linux binary files - if binary_extensions.iter().any(|&e| ext_str == e) { - found_linux_binary = true; - - // Check if it's actually an ELF binary for more certainty - if ext_str == "so" && is_elf_binary(path) { - found_linux_binary = true; - } - } - } - - // Check for Linux executables (no extension) - #[cfg(unix)] - if !path.extension().is_some() { - use std::os::unix::fs::PermissionsExt; - - if let Ok(metadata) = path.metadata() { - let is_executable = metadata.permissions().mode() & 0o111 != 0; - - // Check executable permission and ELF format - if is_executable && is_elf_binary(path) { - found_linux_binary = true; - } - } - } - - // If we've found enough evidence for both platforms and Steam API DLLs, we can stop - // This early break greatly improves performance for large game directories - if found_exe && found_linux_binary && !steam_api_files.is_empty() { - debug!("Found sufficient evidence, breaking scan early"); - break; - } - } - - // A game is considered native if it has Linux binaries but no Windows executables - let is_native = found_linux_binary && !found_exe; - - debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len()); - (is_native, steam_api_files) + let mut found_exe = false; + let mut found_linux_binary = false; + let mut steam_api_files = Vec::new(); + + // Directories to skip for better performance + let skip_dirs = [ + "videos", + "video", + "movies", + "movie", + "sound", + "sounds", + "audio", + "textures", + "music", + "localization", + "shaders", + "logs", + "assets/audio", + "assets/video", + "assets/textures", + ]; + + // Only scan to a reasonable depth (avoid extreme recursion) + const MAX_DEPTH: usize = 8; + + // File extensions to check for (executable and Steam API files) + let exe_extensions = ["exe", "bat", "cmd", "msi"]; + let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"]; + + // Recursively walk through the game directory + for entry in WalkDir::new(game_path) + .max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep + .follow_links(false) // Don't follow symlinks to prevent cycles + .into_iter() + .filter_entry(|e| { + // Skip certain directories for performance + if e.file_type().is_dir() { + let file_name = e.file_name().to_string_lossy().to_lowercase(); + if skip_dirs.iter().any(|&dir| file_name == dir) { + debug!("Skipping directory: {}", e.path().display()); + return false; + } + } + true + }) + .filter_map(Result::ok) + { + let path = entry.path(); + if !path.is_file() { + continue; + } + + // Check file extension + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + + // Check for Windows executables + if exe_extensions.iter().any(|&e| ext_str == e) { + found_exe = true; + } + + // Check for Steam API DLLs + if ext_str == "dll" { + let filename = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + if filename == "steam_api.dll" || filename == "steam_api64.dll" { + if let Ok(rel_path) = path.strip_prefix(game_path) { + let rel_path_str = rel_path.to_string_lossy().to_string(); + debug!("Found Steam API DLL: {}", rel_path_str); + steam_api_files.push(rel_path_str); + } + } + } + + // Check for Linux binary files + if binary_extensions.iter().any(|&e| ext_str == e) { + found_linux_binary = true; + + // Check if it's actually an ELF binary for more certainty + if ext_str == "so" && is_elf_binary(path) { + found_linux_binary = true; + } + } + } + + // Check for Linux executables (no extension) + #[cfg(unix)] + if !path.extension().is_some() { + use std::os::unix::fs::PermissionsExt; + + if let Ok(metadata) = path.metadata() { + let is_executable = metadata.permissions().mode() & 0o111 != 0; + + // Check executable permission and ELF format + if is_executable && is_elf_binary(path) { + found_linux_binary = true; + } + } + } + + // If we've found enough evidence for both platforms and Steam API DLLs, we can stop + if found_exe && found_linux_binary && !steam_api_files.is_empty() { + debug!("Found sufficient evidence, breaking scan early"); + break; + } + } + + // A game is considered native if it has Linux binaries but no Windows executables + let is_native = found_linux_binary && !found_exe; + + debug!( + "Game scan results: native={}, exe={}, api_dlls={}", + is_native, + found_exe, + steam_api_files.len() + ); + (is_native, steam_api_files) } -/// Find all installed Steam games from library folders +// Find all installed Steam games from library folders pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec { - - let mut games = Vec::new(); - let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new())); - - // IDs to skip (tools, redistributables, etc.) - let skip_ids = Arc::new([ - "228980", // Steamworks Common Redistributables - "1070560", // Steam Linux Runtime - "1391110", // Steam Linux Runtime - Soldier - "1628350", // Steam Linux Runtime - Sniper - "1493710", // Proton Experimental - "2180100", // Steam Linux Runtime - Scout - ].iter().copied().collect::>()); - - // Name patterns to skip (case insensitive) - let skip_patterns = Arc::new( - [ - r"(?i)steam linux runtime", - r"(?i)proton", - r"(?i)steamworks common", - r"(?i)redistributable", - r"(?i)dotnet", - r"(?i)vc redist", - ] - .iter() - .map(|pat| Regex::new(pat).unwrap()) - .collect::>() - ); - - info!("Scanning for installed games in parallel..."); - - // Create a channel to collect results - let (tx, mut rx) = mpsc::channel(32); - - // First collect all appmanifest files to process - let mut app_manifests = Vec::new(); - for steamapps_dir in steamapps_paths { - if let Ok(entries) = fs::read_dir(steamapps_dir) { - for entry in entries.flatten() { - let path = entry.path(); - let filename = path.file_name().unwrap_or_default().to_string_lossy(); - - // Check for appmanifest files - if filename.starts_with("appmanifest_") && filename.ends_with(".acf") { - app_manifests.push((path, steamapps_dir.clone())); - } - } - } - } - - info!("Found {} appmanifest files to process", app_manifests.len()); - - // Process each appmanifest file in parallel with a maximum concurrency - let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores - info!("Using {} concurrent scanners", max_concurrent); - - // Use a semaphore to limit concurrency - let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent)); - - // Create a Vec to store all our task handles - let mut handles = Vec::new(); - - // Process each manifest file - for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() { - // Clone what we need for the task - let path = path.clone(); - let steamapps_dir = steamapps_dir.clone(); - let skip_patterns = Arc::clone(&skip_patterns); - let tx = tx.clone(); - let seen_ids = Arc::clone(&seen_ids); - let semaphore = Arc::clone(&semaphore); - let skip_ids = Arc::clone(&skip_ids); - - // Create a new task - let handle = tokio::spawn(async move { - // Acquire a permit from the semaphore - let _permit = semaphore.acquire().await.unwrap(); - - // Parse the appmanifest file - if let Some((id, name, install_dir)) = parse_appmanifest(&path) { - // Skip if in exclusion list - if skip_ids.contains(id.as_str()) { - return; - } - - // Add a guard against duplicates - { - let mut seen = seen_ids.lock().await; - if seen.contains(&id) { - return; - } - seen.insert(id.clone()); - } - - // Skip if the name matches any exclusion patterns - if skip_patterns.iter().any(|re| re.is_match(&name)) { - debug!("Skipping runtime/tool: {} ({})", name, id); - return; - } - - // Full path to the game directory - let game_path = steamapps_dir.join("common").join(&install_dir); - - // Skip if game directory doesn't exist - if !game_path.exists() { - warn!("Game directory not found: {}", game_path.display()); - return; - } - - // Scan the game directory to determine platform and find Steam API DLLs - info!("Scanning game: {} at {}", name, game_path.display()); - - // Scanning is I/O heavy but not CPU heavy, so we can just do it directly - let (is_native, api_files) = scan_game_directory(&game_path); - - // Check for CreamLinux installation - let cream_installed = check_creamlinux_installed(&game_path); - - // Check for SmokeAPI installation (only for non-native games with Steam API DLLs) - let smoke_installed = if !is_native && !api_files.is_empty() { - check_smokeapi_installed(&game_path, &api_files) - } else { - false - }; - - // Create the game info - let game_info = GameInfo { - id, - title: name, - path: game_path, - native: is_native, - api_files, - cream_installed, - smoke_installed, - }; - - // Send the game info through the channel - if tx.send(game_info).await.is_err() { - error!("Failed to send game info through channel"); - } - } - }); - - handles.push(handle); - - // Every 10 files, yield to allow progress updates - if manifest_idx % 10 == 0 { - // We would update progress here in a full implementation - tokio::task::yield_now().await; - } - } - - // Drop the original sender so the receiver knows when we're done - drop(tx); - - // Spawn a task to collect all the results - let receiver_task = tokio::spawn(async move { - let mut results = Vec::new(); - while let Some(game) = rx.recv().await { - info!("Found game: {} ({})", game.title, game.id); - info!(" Path: {}", game.path.display()); - info!(" Status: Native={}, Cream={}, Smoke={}", - game.native, game.cream_installed, game.smoke_installed); - - // Log Steam API DLLs if any - if !game.api_files.is_empty() { - info!(" Steam API files:"); - for api_file in &game.api_files { - info!(" - {}", api_file); - } - } - - results.push(game); - } - results - }); - - // Wait for all scan tasks to complete - but don't wait for the results yet - for handle in handles { - // Ignore errors - the receiver task will just get fewer results - let _ = handle.await; - } - - // Now wait for all results to be collected - if let Ok(results) = receiver_task.await { - games = results; - } - - info!("Found {} installed games", games.len()); - games -} \ No newline at end of file + let mut games = Vec::new(); + let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new())); + + // IDs to skip (tools, redistributables, etc.) + let skip_ids = Arc::new( + [ + "228980", // Steamworks Common Redistributables + "1070560", // Steam Linux Runtime + "1391110", // Steam Linux Runtime - Soldier + "1628350", // Steam Linux Runtime - Sniper + "1493710", // Proton Experimental + "2180100", // Steam Linux Runtime - Scout + ] + .iter() + .copied() + .collect::>(), + ); + + // Name patterns to skip (case insensitive) + let skip_patterns = Arc::new( + [ + r"(?i)steam linux runtime", + r"(?i)proton", + r"(?i)steamworks common", + r"(?i)redistributable", + r"(?i)dotnet", + r"(?i)vc redist", + ] + .iter() + .map(|pat| Regex::new(pat).unwrap()) + .collect::>(), + ); + + info!("Scanning for installed games in parallel..."); + + // Create a channel to collect results + let (tx, mut rx) = mpsc::channel(32); + + // First collect all appmanifest files to process + let mut app_manifests = Vec::new(); + for steamapps_dir in steamapps_paths { + if let Ok(entries) = fs::read_dir(steamapps_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let filename = path.file_name().unwrap_or_default().to_string_lossy(); + + // Check for appmanifest files + if filename.starts_with("appmanifest_") && filename.ends_with(".acf") { + app_manifests.push((path, steamapps_dir.clone())); + } + } + } + } + + info!("Found {} appmanifest files to process", app_manifests.len()); + + // Process appmanifest files + let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores + info!("Using {} concurrent scanners", max_concurrent); + + // Use a semaphore to limit concurrency + let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent)); + + // Create a Vec to store all our task handles + let mut handles = Vec::new(); + + // Process each manifest file + for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() { + // Clone what we need for the task + let path = path.clone(); + let steamapps_dir = steamapps_dir.clone(); + let skip_patterns = Arc::clone(&skip_patterns); + let tx = tx.clone(); + let seen_ids = Arc::clone(&seen_ids); + let semaphore = Arc::clone(&semaphore); + let skip_ids = Arc::clone(&skip_ids); + + // Create a new task + let handle = tokio::spawn(async move { + // Acquire a permit from the semaphore + let _permit = semaphore.acquire().await.unwrap(); + + // Parse the appmanifest file + if let Some((id, name, install_dir)) = parse_appmanifest(&path) { + // Skip if in exclusion list + if skip_ids.contains(id.as_str()) { + return; + } + + // Add a guard against duplicates + { + let mut seen = seen_ids.lock().await; + if seen.contains(&id) { + return; + } + seen.insert(id.clone()); + } + + // Skip if the name matches any exclusion patterns + if skip_patterns.iter().any(|re| re.is_match(&name)) { + debug!("Skipping runtime/tool: {} ({})", name, id); + return; + } + + // Full path to the game directory + let game_path = steamapps_dir.join("common").join(&install_dir); + + // Skip if game directory doesn't exist + if !game_path.exists() { + warn!("Game directory not found: {}", game_path.display()); + return; + } + + // Scan the game directory to determine platform and find Steam API DLLs + info!("Scanning game: {} at {}", name, game_path.display()); + + // Scanning is I/O heavy but not CPU heavy, so we can just do it directly + let (is_native, api_files) = scan_game_directory(&game_path); + + // Check for CreamLinux installation + let cream_installed = check_creamlinux_installed(&game_path); + + // Check for SmokeAPI installation (only for non-native games with Steam API DLLs) + let smoke_installed = if !is_native && !api_files.is_empty() { + check_smokeapi_installed(&game_path, &api_files) + } else { + false + }; + + // Create the game info + let game_info = GameInfo { + id, + title: name, + path: game_path, + native: is_native, + api_files, + cream_installed, + smoke_installed, + }; + + // Send the game info through the channel + if tx.send(game_info).await.is_err() { + error!("Failed to send game info through channel"); + } + } + }); + + handles.push(handle); + + // Every 10 files, yield to allow progress updates + if manifest_idx % 10 == 0 { + // We would update progress here in a full implementation + tokio::task::yield_now().await; + } + } + + // Drop the original sender so the receiver knows when we're done + drop(tx); + + // Spawn a task to collect all the results + let receiver_task = tokio::spawn(async move { + let mut results = Vec::new(); + while let Some(game) = rx.recv().await { + info!("Found game: {} ({})", game.title, game.id); + info!(" Path: {}", game.path.display()); + info!( + " Status: Native={}, Cream={}, Smoke={}", + game.native, game.cream_installed, game.smoke_installed + ); + + // Log Steam API DLLs if any + if !game.api_files.is_empty() { + info!(" Steam API files:"); + for api_file in &game.api_files { + info!(" - {}", api_file); + } + } + + results.push(game); + } + results + }); + + // Wait for all scan tasks to complete but don't wait for the results yet + for handle in handles { + // Ignore errors the receiver task will just get fewer results + let _ = handle.await; + } + + // Now wait for all results to be collected + if let Ok(results) = receiver_task.await { + games = results; + } + + info!("Found {} installed games", games.len()); + games +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e2829d4..c9c55a8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,11 +10,7 @@ "active": true, "targets": "all", "category": "Utility", - "icon": [ - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.png" - ] + "icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"] }, "productName": "Creamlinux", "mainBinaryName": "creamlinux", diff --git a/src/App.tsx b/src/App.tsx index fcec414..c550648 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,60 +1,60 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import './styles/main.scss'; -import GameList from './components/GameList'; -import Header from './components/Header'; -import Sidebar from './components/Sidebar'; -import ProgressDialog from './components/ProgressDialog'; -import DlcSelectionDialog from './components/DlcSelectionDialog'; -import AnimatedBackground from './components/AnimatedBackground'; -import InitialLoadingScreen from './components/InitialLoadingScreen'; -import { ActionType } from './components/ActionButton'; +import { useState, useEffect, useRef, useCallback } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import './styles/main.scss' +import GameList from './components/GameList' +import Header from './components/Header' +import Sidebar from './components/Sidebar' +import ProgressDialog from './components/ProgressDialog' +import DlcSelectionDialog from './components/DlcSelectionDialog' +import AnimatedBackground from './components/AnimatedBackground' +import InitialLoadingScreen from './components/InitialLoadingScreen' +import { ActionType } from './components/ActionButton' // Game interface interface Game { - id: string; - title: string; - path: string; - native: boolean; - platform?: string; - api_files: string[]; - cream_installed?: boolean; - smoke_installed?: boolean; - installing?: boolean; + id: string + title: string + path: string + native: boolean + platform?: string + api_files: string[] + cream_installed?: boolean + smoke_installed?: boolean + installing?: boolean } // Interface for installation instructions interface InstructionInfo { - type: string; - command: string; - game_title: string; - dlc_count?: number; + type: string + command: string + game_title: string + dlc_count?: number } // Interface for DLC information interface DlcInfo { - appid: string; - name: string; - enabled: boolean; + appid: string + name: string + enabled: boolean } function App() { - const [games, setGames] = useState([]); - const [filter, setFilter] = useState("all"); - const [searchQuery, setSearchQuery] = useState(""); // Added search query state - const [isLoading, setIsLoading] = useState(true); - const [isInitialLoad, setIsInitialLoad] = useState(true); + const [games, setGames] = useState([]) + const [filter, setFilter] = useState('all') + const [searchQuery, setSearchQuery] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [isInitialLoad, setIsInitialLoad] = useState(true) const [scanProgress, setScanProgress] = useState({ - message: "Initializing...", - progress: 0 - }); - const [error, setError] = useState(null); - const refreshInProgress = useRef(false); - const [isFetchingDlcs, setIsFetchingDlcs] = useState(false); - const dlcFetchController = useRef(null); - const activeDlcFetchId = useRef(null); - + message: 'Initializing...', + progress: 0, + }) + const [error, setError] = useState(null) + const refreshInProgress = useRef(false) + const [isFetchingDlcs, setIsFetchingDlcs] = useState(false) + const dlcFetchController = useRef(null) + const activeDlcFetchId = useRef(null) + // Progress dialog state const [progressDialog, setProgressDialog] = useState({ visible: false, @@ -62,9 +62,9 @@ function App() { message: '', progress: 0, showInstructions: false, - instructions: undefined as InstructionInfo | undefined - }); - + instructions: undefined as InstructionInfo | undefined, + }) + // DLC selection dialog state const [dlcDialog, setDlcDialog] = useState({ visible: false, @@ -77,87 +77,81 @@ function App() { progress: 0, progressMessage: '', timeLeft: '', - error: null as string | null - }); + error: null as string | null, + }) // Handle search query changes const handleSearchChange = (query: string) => { - setSearchQuery(query); - }; + setSearchQuery(query) + } - // Move the loadGames function outside of the useEffect to make it reusable + // LoadGames function outside of the useEffect to make it reusable const loadGames = useCallback(async () => { try { - setIsLoading(true); - setError(null); - - console.log("Invoking scan_steam_games"); - const steamGames = await invoke('scan_steam_games').catch(err => { - console.error('Error from scan_steam_games:', err); - throw err; - }); - - // Add platform property to match the GameList component's expectation - const gamesWithPlatform = steamGames.map(game => ({ + setIsLoading(true) + setError(null) + + console.log('Invoking scan_steam_games') + const steamGames = await invoke('scan_steam_games').catch((err) => { + console.error('Error from scan_steam_games:', err) + throw err + }) + + // Platform property to match the GameList component's expectation + const gamesWithPlatform = steamGames.map((game) => ({ ...game, - platform: 'Steam' - })); - - console.log(`Loaded ${gamesWithPlatform.length} games`); - setGames(gamesWithPlatform); - setIsInitialLoad(false); // Mark initial load as complete - return true; + platform: 'Steam', + })) + + console.log(`Loaded ${gamesWithPlatform.length} games`) + setGames(gamesWithPlatform) + setIsInitialLoad(false) // Mark initial load as complete + return true } catch (error) { - console.error('Error loading games:', error); - setError(`Failed to load games: ${error}`); - setIsInitialLoad(false); // Mark initial load as complete even on error - return false; + console.error('Error loading games:', error) + setError(`Failed to load games: ${error}`) + setIsInitialLoad(false) // Mark initial load as complete even on error + return false } finally { - setIsLoading(false); + setIsLoading(false) } - }, []); + }, []) useEffect(() => { // Set up event listeners first const setupEventListeners = async () => { try { - console.log("Setting up event listeners"); - + console.log('Setting up event listeners') + // Listen for progress updates from the backend const unlistenProgress = await listen('installation-progress', (event) => { - console.log("Received installation-progress event:", event); - - const { - title, - message, - progress, - complete, - show_instructions, - instructions - } = event.payload as { - title: string; - message: string; - progress: number; - complete: boolean; - show_instructions?: boolean; - instructions?: InstructionInfo; - }; - + console.log('Received installation-progress event:', event) + + const { title, message, progress, complete, show_instructions, instructions } = + event.payload as { + title: string + message: string + progress: number + complete: boolean + show_instructions?: boolean + instructions?: InstructionInfo + } + if (complete && !show_instructions) { // Hide dialog when complete if no instructions setTimeout(() => { - setProgressDialog(prev => ({ ...prev, visible: false })); - + setProgressDialog((prev) => ({ ...prev, visible: false })) + // Only refresh games list if dialog is closing without instructions if (!refreshInProgress.current) { - refreshInProgress.current = true; + refreshInProgress.current = true setTimeout(() => { loadGames().then(() => { - refreshInProgress.current = false; - }); - }, 100); + refreshInProgress.current = false + }) + }, 100) } - }, 1000); + }, 1000) } else { // Update progress dialog setProgressDialog({ @@ -166,101 +160,104 @@ function App() { message, progress, showInstructions: show_instructions || false, - instructions - }); + instructions, + }) } - }); - + }) + // Listen for scan progress events const unlistenScanProgress = await listen('scan-progress', (event) => { const { message, progress } = event.payload as { - message: string; - progress: number; - }; - - console.log("Received scan-progress event:", message, progress); - + message: string + progress: number + } + + console.log('Received scan-progress event:', message, progress) + // Update scan progress state setScanProgress({ message, - progress - }); - }); - + progress, + }) + }) + // Listen for individual game updates const unlistenGameUpdated = await listen('game-updated', (event) => { - console.log("Received game-updated event:", event); - - const updatedGame = event.payload as Game; - + console.log('Received game-updated event:', event) + + const updatedGame = event.payload as Game + // Update only the specific game in the state - setGames(prevGames => - prevGames.map(game => + setGames((prevGames) => + prevGames.map((game) => game.id === updatedGame.id ? { ...updatedGame, platform: 'Steam' } : game ) - ); - }); - + ) + }) + return () => { - unlistenProgress(); - unlistenScanProgress(); - unlistenGameUpdated(); - }; + unlistenProgress() + unlistenScanProgress() + unlistenGameUpdated() + } } catch (error) { - console.error("Error setting up event listeners:", error); - return () => {}; + console.error('Error setting up event listeners:', error) + return () => {} } - }; - + } + // First set up event listeners, then load games - let unlisten: (() => void) | null = null; - + let unlisten: (() => void) | null = null + setupEventListeners() - .then(unlistenFn => { - unlisten = unlistenFn; - return loadGames(); + .then((unlistenFn) => { + unlisten = unlistenFn + return loadGames() }) - .catch(error => { - console.error("Failed to initialize:", error); - }); - + .catch((error) => { + console.error('Failed to initialize:', error) + }) + return () => { if (unlisten) { - unlisten(); + unlisten() } - }; - }, [loadGames]); + } + }, [loadGames]) // Debugging for state changes useEffect(() => { // Debug state changes if (games.length > 0) { // Count native and installed games - const nativeCount = games.filter(g => g.native).length; - const creamInstalledCount = games.filter(g => g.cream_installed).length; - const smokeInstalledCount = games.filter(g => g.smoke_installed).length; - - console.log(`Game state updated: ${games.length} total games, ${nativeCount} native, ${creamInstalledCount} with CreamLinux, ${smokeInstalledCount} with SmokeAPI`); - + const nativeCount = games.filter((g) => g.native).length + const creamInstalledCount = games.filter((g) => g.cream_installed).length + const smokeInstalledCount = games.filter((g) => g.smoke_installed).length + + console.log( + `Game state updated: ${games.length} total games, ${nativeCount} native, ${creamInstalledCount} with CreamLinux, ${smokeInstalledCount} with SmokeAPI` + ) + // Log any games with unexpected states - const problematicGames = games.filter(g => { + const problematicGames = games.filter((g) => { // Native games that have SmokeAPI installed (shouldn't happen) - if (g.native && g.smoke_installed) return true; - + if (g.native && g.smoke_installed) return true + // Non-native games with CreamLinux installed (shouldn't happen) - if (!g.native && g.cream_installed) return true; - + if (!g.native && g.cream_installed) return true + // Non-native games without API files but with SmokeAPI installed (shouldn't happen) - if (!g.native && (!g.api_files || g.api_files.length === 0) && g.smoke_installed) return true; - - return false; - }); - + if (!g.native && (!g.api_files || g.api_files.length === 0) && g.smoke_installed) + return true + + return false + }) + if (problematicGames.length > 0) { - console.warn("Found games with unexpected states:", problematicGames); + console.warn('Found games with unexpected states:', problematicGames) } } - }, [games]); + }, [games]) // Set up event listeners for DLC streaming useEffect(() => { @@ -269,75 +266,75 @@ function App() { try { // This event is emitted for each DLC as it's found const unlistenDlcFound = await listen('dlc-found', (event) => { - const dlc = JSON.parse(event.payload as string) as { appid: string, name: string }; - + const dlc = JSON.parse(event.payload as string) as { appid: string; name: string } + // Add the DLC to the current list with enabled=true - setDlcDialog(prev => ({ + setDlcDialog((prev) => ({ ...prev, - dlcs: [...prev.dlcs, { ...dlc, enabled: true }] - })); - }); - - // When progress is 100%, mark loading as complete and reset fetch state - const unlistenDlcProgress = await listen('dlc-progress', (event) => { - const { message, progress, timeLeft } = event.payload as { - message: string, - progress: number, - timeLeft?: string - }; - - // Update the progress indicator - setDlcDialog(prev => ({ - ...prev, - progress, - progressMessage: message, - timeLeft: timeLeft || '' - })); - - // If progress is 100%, mark loading as complete - if (progress === 100) { - setTimeout(() => { - setDlcDialog(prev => ({ - ...prev, - isLoading: false - })); - - // Reset fetch state - setIsFetchingDlcs(false); - activeDlcFetchId.current = null; - }, 500); - } - }); - + dlcs: [...prev.dlcs, { ...dlc, enabled: true }], + })) + }) + + // When progress is 100%, mark loading as complete and reset fetch state + const unlistenDlcProgress = await listen('dlc-progress', (event) => { + const { message, progress, timeLeft } = event.payload as { + message: string + progress: number + timeLeft?: string + } + + // Update the progress indicator + setDlcDialog((prev) => ({ + ...prev, + progress, + progressMessage: message, + timeLeft: timeLeft || '', + })) + + // If progress is 100%, mark loading as complete + if (progress === 100) { + setTimeout(() => { + setDlcDialog((prev) => ({ + ...prev, + isLoading: false, + })) + + // Reset fetch state + setIsFetchingDlcs(false) + activeDlcFetchId.current = null + }, 500) + } + }) + // This event is emitted if there's an error const unlistenDlcError = await listen('dlc-error', (event) => { - const { error } = event.payload as { error: string }; - console.error('DLC streaming error:', error); - + const { error } = event.payload as { error: string } + console.error('DLC streaming error:', error) + // Show error in dialog - setDlcDialog(prev => ({ + setDlcDialog((prev) => ({ ...prev, error, - isLoading: false - })); - }); - + isLoading: false, + })) + }) + return () => { - unlistenDlcFound(); - unlistenDlcProgress(); - unlistenDlcError(); - }; + unlistenDlcFound() + unlistenDlcProgress() + unlistenDlcError() + } } catch (error) { - console.error("Error setting up DLC event listeners:", error); - return () => {}; + console.error('Error setting up DLC event listeners:', error) + return () => {} } - }; - - const unlisten = setupDlcEventListeners(); + } + + const unlisten = setupDlcEventListeners() return () => { - unlisten.then(fn => fn()); - }; - }, []); + unlisten.then((fn) => fn()) + } + }, []) // Listen for scan progress events useEffect(() => { @@ -345,105 +342,105 @@ function App() { try { const unlistenScanProgress = await listen('scan-progress', (event) => { const { message, progress } = event.payload as { - message: string; - progress: number; - }; + message: string + progress: number + } // Update loading message - setProgressDialog(prev => ({ + setProgressDialog((prev) => ({ ...prev, visible: true, - title: "Scanning for Games", + title: 'Scanning for Games', message, progress, showInstructions: false, - instructions: undefined - })); + instructions: undefined, + })) // Auto-close when complete if (progress >= 100) { setTimeout(() => { - setProgressDialog(prev => ({ ...prev, visible: false })); - }, 1500); + setProgressDialog((prev) => ({ ...prev, visible: false })) + }, 1500) } - }); + }) - return unlistenScanProgress; + return unlistenScanProgress } catch (error) { - console.error("Error setting up scan progress listener:", error); - return () => {}; + console.error('Error setting up scan progress listener:', error) + return () => {} } - }; + } - const unlistenPromise = listenToScanProgress(); + const unlistenPromise = listenToScanProgress() return () => { - unlistenPromise.then(unlisten => unlisten()); - }; - }, []); + unlistenPromise.then((unlisten) => unlisten()) + } + }, []) const handleCloseProgressDialog = () => { // Just hide the dialog without refreshing game list - setProgressDialog(prev => ({ ...prev, visible: false })); - + setProgressDialog((prev) => ({ ...prev, visible: false })) + // Only refresh if we need to (instructions didn't trigger update) if (progressDialog.showInstructions === false && !refreshInProgress.current) { - refreshInProgress.current = true; + refreshInProgress.current = true setTimeout(() => { loadGames().then(() => { - refreshInProgress.current = false; - }); - }, 100); + refreshInProgress.current = false + }) + }, 100) } - }; + } // Function to fetch DLCs for a game with streaming updates const streamGameDlcs = async (gameId: string): Promise => { try { // Set up flag to indicate we're fetching DLCs - setIsFetchingDlcs(true); - activeDlcFetchId.current = gameId; - + setIsFetchingDlcs(true) + activeDlcFetchId.current = gameId + // Start streaming DLCs - this won't return DLCs directly // Instead, it triggers events that we'll listen for - await invoke('stream_game_dlcs', { gameId }); - - return; + await invoke('stream_game_dlcs', { gameId }) + + return } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { - console.log('DLC fetching was aborted'); + console.log('DLC fetching was aborted') } else { - console.error('Error starting DLC stream:', error); - throw error; + console.error('Error starting DLC stream:', error) + throw error } } finally { // Reset state when done or on error - setIsFetchingDlcs(false); - activeDlcFetchId.current = null; + setIsFetchingDlcs(false) + activeDlcFetchId.current = null } - }; + } // Clean up if component unmounts during a fetch useEffect(() => { return () => { // Clean up any ongoing fetch operations if (dlcFetchController.current) { - dlcFetchController.current.abort(); - dlcFetchController.current = null; + dlcFetchController.current.abort() + dlcFetchController.current = null } - }; - }, []); + } + }, []) // Handle game edit (show DLC management dialog) const handleGameEdit = async (gameId: string) => { - const game = games.find(g => g.id === gameId); - if (!game || !game.cream_installed) return; - + const game = games.find((g) => g.id === gameId) + if (!game || !game.cream_installed) return + // Check if we're already fetching DLCs for this game if (isFetchingDlcs && activeDlcFetchId.current === gameId) { - console.log(`Already fetching DLCs for ${gameId}, ignoring duplicate request`); - return; + console.log(`Already fetching DLCs for ${gameId}, ignoring duplicate request`) + return } - + try { // Show dialog immediately with empty DLC list setDlcDialog({ @@ -457,78 +454,79 @@ function App() { progress: 0, progressMessage: 'Reading DLC configuration...', timeLeft: '', - error: null - }); - + error: null, + }) + // Try to read all DLCs from the configuration file first (including disabled ones) try { - const allDlcs = await invoke('get_all_dlcs_command', { gamePath: game.path }) - .catch(() => [] as DlcInfo[]); - + const allDlcs = await invoke('get_all_dlcs_command', { + gamePath: game.path, + }).catch(() => [] as DlcInfo[]) + if (allDlcs.length > 0) { // If we have DLCs from the config file, use them - console.log("Loaded existing DLC configuration:", allDlcs); - - setDlcDialog(prev => ({ + console.log('Loaded existing DLC configuration:', allDlcs) + + setDlcDialog((prev) => ({ ...prev, dlcs: allDlcs, isLoading: false, progress: 100, - progressMessage: 'Loaded existing DLC configuration' - })); - return; + progressMessage: 'Loaded existing DLC configuration', + })) + return } } catch (error) { - console.warn("Could not read existing DLC configuration, falling back to API:", error); + console.warn('Could not read existing DLC configuration, falling back to API:', error) // Continue with API loading if config reading fails } - + // Mark that we're fetching DLCs for this game - setIsFetchingDlcs(true); - activeDlcFetchId.current = gameId; - + setIsFetchingDlcs(true) + activeDlcFetchId.current = gameId + // Create abort controller for fetch operation - dlcFetchController.current = new AbortController(); - + dlcFetchController.current = new AbortController() + // Start streaming DLCs - await streamGameDlcs(gameId).catch(error => { + await streamGameDlcs(gameId).catch((error) => { if (error.name !== 'AbortError') { - console.error('Error streaming DLCs:', error); - setDlcDialog(prev => ({ + console.error('Error streaming DLCs:', error) + setDlcDialog((prev) => ({ ...prev, error: `Failed to load DLCs: ${error}`, - isLoading: false - })); + isLoading: false, + })) } - }); - - // In parallel, try to get the enabled DLCs - const enabledDlcs = await invoke('get_enabled_dlcs_command', { gamePath: game.path }) - .catch(() => [] as string[]); - + }) + + // Try to get the enabled DLCs + const enabledDlcs = await invoke('get_enabled_dlcs_command', { + gamePath: game.path, + }).catch(() => [] as string[]) + // We'll update the enabled state of DLCs as they come in - setDlcDialog(prev => ({ + setDlcDialog((prev) => ({ ...prev, - enabledDlcs - })); - + enabledDlcs, + })) } catch (error) { - console.error('Error preparing DLC edit:', error); - setDlcDialog(prev => ({ + console.error('Error preparing DLC edit:', error) + setDlcDialog((prev) => ({ ...prev, error: `Failed to prepare DLC editor: ${error}`, - isLoading: false - })); + isLoading: false, + })) } - }; + } // Unified handler for all game actions (install/uninstall cream/smoke) const handleGameAction = async (gameId: string, action: ActionType) => { try { // Find game to get title - const game = games.find(g => g.id === gameId); - if (!game) return; - + const game = games.find((g) => g.id === gameId) + if (!game) return + // If we're installing CreamLinux, show DLC selection first if (action === 'install_cream') { try { @@ -544,57 +542,56 @@ function App() { progress: 0, progressMessage: 'Fetching DLC list...', timeLeft: '', - error: null - }); - + error: null, + }) + // Start streaming DLCs - only once - await streamGameDlcs(gameId).catch(error => { - console.error('Error streaming DLCs:', error); - setDlcDialog(prev => ({ + await streamGameDlcs(gameId).catch((error) => { + console.error('Error streaming DLCs:', error) + setDlcDialog((prev) => ({ ...prev, error: `Failed to load DLCs: ${error}`, - isLoading: false - })); - }); - + isLoading: false, + })) + }) } catch (error) { - console.error('Error fetching DLCs:', error); - + console.error('Error fetching DLCs:', error) + // If DLC fetching fails, close dialog and show error - setDlcDialog(prev => ({ + setDlcDialog((prev) => ({ ...prev, visible: false, - isLoading: false - })); - + isLoading: false, + })) + setProgressDialog({ visible: true, title: `Error fetching DLCs for ${game.title}`, message: `Failed to fetch DLCs: ${error}`, progress: 100, showInstructions: false, - instructions: undefined - }); - + instructions: undefined, + }) + setTimeout(() => { - setProgressDialog(prev => ({ ...prev, visible: false })); - }, 3000); + setProgressDialog((prev) => ({ ...prev, visible: false })) + }, 3000) } - return; + return } - + // For other actions, proceed directly // Update local state to show installation in progress - setGames(prevGames => prevGames.map(g => - g.id === gameId ? { ...g, installing: true } : g - )); - + setGames((prevGames) => + prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g)) + ) + // Get title based on action - const isCream = action.includes('cream'); - const isInstall = action.includes('install'); - const product = isCream ? "CreamLinux" : "SmokeAPI"; - const operation = isInstall ? "Installing" : "Uninstalling"; - + const isCream = action.includes('cream') + const isInstall = action.includes('install') + const product = isCream ? 'CreamLinux' : 'SmokeAPI' + const operation = isInstall ? 'Installing' : 'Uninstalling' + // Show progress dialog setProgressDialog({ visible: true, @@ -602,93 +599,92 @@ function App() { message: isInstall ? 'Downloading required files...' : 'Removing files...', progress: isInstall ? 0 : 30, showInstructions: false, - instructions: undefined - }); - - console.log(`Invoking process_game_action for game ${gameId} with action ${action}`); - + instructions: undefined, + }) + + console.log(`Invoking process_game_action for game ${gameId} with action ${action}`) + // Call the backend with the unified action - const updatedGame = await invoke('process_game_action', { - gameAction: { - game_id: gameId, - action - } - }).catch(err => { - console.error(`Error from process_game_action:`, err); - throw err; - }); - - console.log('Game action completed, updated game:', updatedGame); - + const updatedGame = await invoke('process_game_action', { + gameAction: { + game_id: gameId, + action, + }, + }).catch((err) => { + console.error(`Error from process_game_action:`, err) + throw err + }) + + console.log('Game action completed, updated game:', updatedGame) + // Update our local state with the result from the backend if (updatedGame) { - setGames(prevGames => prevGames.map(g => - g.id === gameId ? { ...g, installing: false } : g - )); + setGames((prevGames) => + prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) + ) } - } catch (error) { - console.error(`Error processing action ${action} for game ${gameId}:`, error); - + console.error(`Error processing action ${action} for game ${gameId}:`, error) + // Show error in progress dialog - setProgressDialog(prev => ({ + setProgressDialog((prev) => ({ ...prev, message: `Error: ${error}`, - progress: 100 - })); - + progress: 100, + })) + // Reset installing state - setGames(prevGames => prevGames.map(game => - game.id === gameId ? { ...game, installing: false } : game - )); - + setGames((prevGames) => + prevGames.map((game) => (game.id === gameId ? { ...game, installing: false } : game)) + ) + // Hide dialog after a delay setTimeout(() => { - setProgressDialog(prev => ({ ...prev, visible: false })); - }, 3000); + setProgressDialog((prev) => ({ ...prev, visible: false })) + }, 3000) } - }; + } // Handle DLC selection dialog close const handleDlcDialogClose = () => { // Cancel any in-progress DLC fetching if (isFetchingDlcs && activeDlcFetchId.current) { - console.log(`Aborting DLC fetch for game ${activeDlcFetchId.current}`); - + console.log(`Aborting DLC fetch for game ${activeDlcFetchId.current}`) + // This will signal to the Rust backend that we want to stop the process - // You could implement this on the backend if needed with something like: - invoke('abort_dlc_fetch', { gameId: activeDlcFetchId.current }) - .catch(err => console.error('Error aborting DLC fetch:', err)); - + invoke('abort_dlc_fetch', { gameId: activeDlcFetchId.current }).catch((err) => + console.error('Error aborting DLC fetch:', err) + ) + // Reset state - activeDlcFetchId.current = null; - setIsFetchingDlcs(false); + activeDlcFetchId.current = null + setIsFetchingDlcs(false) } - + // Clear controller if (dlcFetchController.current) { - dlcFetchController.current.abort(); - dlcFetchController.current = null; + dlcFetchController.current.abort() + dlcFetchController.current = null } - + // Close dialog - setDlcDialog(prev => ({ ...prev, visible: false })); - }; + setDlcDialog((prev) => ({ ...prev, visible: false })) + } // Handle DLC selection confirmation const handleDlcConfirm = async (selectedDlcs: DlcInfo[]) => { // Close the dialog first - setDlcDialog(prev => ({ ...prev, visible: false })); - - const gameId = dlcDialog.gameId; - const game = games.find(g => g.id === gameId); - if (!game) return; - + setDlcDialog((prev) => ({ ...prev, visible: false })) + + const gameId = dlcDialog.gameId + const game = games.find((g) => g.id === gameId) + if (!game) return + // Update local state to show installation in progress - setGames(prevGames => prevGames.map(g => - g.id === gameId ? { ...g, installing: true } : g - )); - + setGames((prevGames) => + prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g)) + ) + try { if (dlcDialog.isEditMode) { // If in edit mode, we're updating existing cream_api.ini @@ -699,31 +695,31 @@ function App() { message: 'Updating DLC configuration...', progress: 30, showInstructions: false, - instructions: undefined - }); - + instructions: undefined, + }) + // Call the backend to update the DLC configuration - await invoke('update_dlc_configuration_command', { + await invoke('update_dlc_configuration_command', { gamePath: game.path, - dlcs: selectedDlcs - }); - + dlcs: selectedDlcs, + }) + // Update progress dialog for completion - setProgressDialog(prev => ({ + setProgressDialog((prev) => ({ ...prev, title: `Update Complete: ${game.title}`, message: 'DLC configuration updated successfully!', - progress: 100 - })); - + progress: 100, + })) + // Hide dialog after a delay setTimeout(() => { - setProgressDialog(prev => ({ ...prev, visible: false })); + setProgressDialog((prev) => ({ ...prev, visible: false })) // Reset installing state - setGames(prevGames => prevGames.map(g => - g.id === gameId ? { ...g, installing: false } : g - )); - }, 2000); + setGames((prevGames) => + prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) + ) + }, 2000) } else { // We're doing a fresh install with selected DLCs // Show progress dialog for installation right away @@ -733,91 +729,83 @@ function App() { message: 'Processing...', progress: 0, showInstructions: false, - instructions: undefined - }); - + instructions: undefined, + }) + // Invoke the installation with the selected DLCs - await invoke('install_cream_with_dlcs_command', { - gameId, - selectedDlcs - }).catch(err => { - console.error(`Error installing CreamLinux with selected DLCs:`, err); - throw err; - }); - - // Note: we don't need to manually close the dialog or update the game state + await invoke('install_cream_with_dlcs_command', { + gameId, + selectedDlcs, + }).catch((err) => { + console.error(`Error installing CreamLinux with selected DLCs:`, err) + throw err + }) + + // We don't need to manually close the dialog or update the game state // because the backend will emit progress events that handle this } } catch (error) { - console.error('Error processing DLC selection:', error); - + console.error('Error processing DLC selection:', error) + // Show error in progress dialog - setProgressDialog(prev => ({ + setProgressDialog((prev) => ({ ...prev, message: `Error: ${error}`, - progress: 100 - })); - + progress: 100, + })) + // Reset installing state - setGames(prevGames => prevGames.map(g => - g.id === gameId ? { ...g, installing: false } : g - )); - + setGames((prevGames) => + prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) + ) + // Hide dialog after a delay setTimeout(() => { - setProgressDialog(prev => ({ ...prev, visible: false })); - }, 3000); + setProgressDialog((prev) => ({ ...prev, visible: false })) + }, 3000) } - }; + } // Update DLCs being streamed with enabled state useEffect(() => { if (dlcDialog.enabledDlcs.length > 0) { - setDlcDialog(prev => ({ + setDlcDialog((prev) => ({ ...prev, - dlcs: prev.dlcs.map(dlc => ({ + dlcs: prev.dlcs.map((dlc) => ({ ...dlc, - enabled: prev.enabledDlcs.length === 0 || prev.enabledDlcs.includes(dlc.appid) - })) - })); + enabled: prev.enabledDlcs.length === 0 || prev.enabledDlcs.includes(dlc.appid), + })), + })) } - }, [dlcDialog.dlcs, dlcDialog.enabledDlcs]); + }, [dlcDialog.dlcs, dlcDialog.enabledDlcs]) - // Filter games based on sidebar filter AND search query - const filteredGames = games.filter(game => { + // Filter games based on sidebar filter and search query + const filteredGames = games.filter((game) => { // First filter by the platform/type - const platformMatch = filter === "all" || - (filter === "native" && game.native) || - (filter === "proton" && !game.native); - + const platformMatch = + filter === 'all' || + (filter === 'native' && game.native) || + (filter === 'proton' && !game.native) + // Then filter by search query (if any) - const searchMatch = searchQuery.trim() === '' || - game.title.toLowerCase().includes(searchQuery.toLowerCase()); - + const searchMatch = + searchQuery.trim() === '' || game.title.toLowerCase().includes(searchQuery.toLowerCase()) + // Both filters must match - return platformMatch && searchMatch; - }); - + return platformMatch && searchMatch + }) + // Check if we should show the initial loading screen if (isInitialLoad) { - return ( - - ); + return } return (
{/* Animated background */} - -
+ +
{error ? ( @@ -827,17 +815,17 @@ function App() {
) : ( - )}
- + {/* Progress Dialog */} - - + {/* DLC Selection Dialog */} - - ); + ) } -export default App; \ No newline at end of file +export default App diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index a084680..ca6a8d9 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -1,46 +1,41 @@ -// src/components/ActionButton.tsx -import React from 'react'; +import React from 'react' -export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'; +export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' interface ActionButtonProps { - action: ActionType; - isInstalled: boolean; - isWorking: boolean; - onClick: () => void; - disabled?: boolean; + action: ActionType + isInstalled: boolean + isWorking: boolean + onClick: () => void + disabled?: boolean } -const ActionButton: React.FC = ({ - action, - isInstalled, - isWorking, - onClick, - disabled = false +const ActionButton: React.FC = ({ + action, + isInstalled, + isWorking, + onClick, + disabled = false, }) => { const getButtonText = () => { - if (isWorking) return "Working..."; - - const isCream = action.includes('cream'); - const product = isCream ? "CreamLinux" : "SmokeAPI"; - - return isInstalled ? `Uninstall ${product}` : `Install ${product}`; - }; + if (isWorking) return 'Working...' + + const isCream = action.includes('cream') + const product = isCream ? 'CreamLinux' : 'SmokeAPI' + + return isInstalled ? `Uninstall ${product}` : `Install ${product}` + } const getButtonClass = () => { - const baseClass = "action-button"; - return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`; - }; + const baseClass = 'action-button' + return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}` + } return ( - - ); -}; + ) +} -export default ActionButton; \ No newline at end of file +export default ActionButton diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx index 5ce2fea..13c2af5 100644 --- a/src/components/AnimatedBackground.tsx +++ b/src/components/AnimatedBackground.tsx @@ -1,46 +1,45 @@ -// src/components/AnimatedBackground.tsx -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react' const AnimatedBackground: React.FC = () => { - const canvasRef = useRef(null); - + const canvasRef = useRef(null) + useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + // Set canvas size to match window const setCanvasSize = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - - setCanvasSize(); - window.addEventListener('resize', setCanvasSize); - - // Create particles - const particles: Particle[] = []; - const particleCount = 30; - - interface Particle { - x: number; - y: number; - size: number; - speedX: number; - speedY: number; - opacity: number; - color: string; + canvas.width = window.innerWidth + canvas.height = window.innerHeight } - + + setCanvasSize() + window.addEventListener('resize', setCanvasSize) + + // Create particles + const particles: Particle[] = [] + const particleCount = 30 + + interface Particle { + x: number + y: number + size: number + speedX: number + speedY: number + opacity: number + color: string + } + // Color palette const colors = [ - 'rgba(74, 118, 196, 0.5)', // primary blue + 'rgba(74, 118, 196, 0.5)', // primary blue 'rgba(155, 125, 255, 0.5)', // purple - 'rgba(251, 177, 60, 0.5)', // gold - ]; - + 'rgba(251, 177, 60, 0.5)', // gold + ] + // Create initial particles for (let i = 0; i < particleCount; i++) { particles.push({ @@ -50,65 +49,65 @@ const AnimatedBackground: React.FC = () => { speedX: Math.random() * 0.2 - 0.1, speedY: Math.random() * 0.2 - 0.1, opacity: Math.random() * 0.07 + 0.03, - color: colors[Math.floor(Math.random() * colors.length)] - }); + color: colors[Math.floor(Math.random() * colors.length)], + }) } - + // Animation loop const animate = () => { // Clear canvas with transparent black to create fade effect - ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - + ctx.fillStyle = 'rgba(15, 15, 15, 0.1)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + // Update and draw particles - particles.forEach(particle => { + particles.forEach((particle) => { // Update position - particle.x += particle.speedX; - particle.y += particle.speedY; - + particle.x += particle.speedX + particle.y += particle.speedY + // Wrap around edges - if (particle.x < 0) particle.x = canvas.width; - if (particle.x > canvas.width) particle.x = 0; - if (particle.y < 0) particle.y = canvas.height; - if (particle.y > canvas.height) particle.y = 0; - + if (particle.x < 0) particle.x = canvas.width + if (particle.x > canvas.width) particle.x = 0 + if (particle.y < 0) particle.y = canvas.height + if (particle.y > canvas.height) particle.y = 0 + // Draw particle - ctx.beginPath(); - ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); - ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`); - ctx.fill(); - + ctx.beginPath() + ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2) + ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`) + ctx.fill() + // Connect particles - particles.forEach(otherParticle => { - const dx = particle.x - otherParticle.x; - const dy = particle.y - otherParticle.y; - const distance = Math.sqrt(dx * dx + dy * dy); - + particles.forEach((otherParticle) => { + const dx = particle.x - otherParticle.x + const dy = particle.y - otherParticle.y + const distance = Math.sqrt(dx * dx + dy * dy) + if (distance < 100) { - ctx.beginPath(); - ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`); - ctx.lineWidth = 0.2; - ctx.moveTo(particle.x, particle.y); - ctx.lineTo(otherParticle.x, otherParticle.y); - ctx.stroke(); + ctx.beginPath() + ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`) + ctx.lineWidth = 0.2 + ctx.moveTo(particle.x, particle.y) + ctx.lineTo(otherParticle.x, otherParticle.y) + ctx.stroke() } - }); - }); - - requestAnimationFrame(animate); - }; - + }) + }) + + requestAnimationFrame(animate) + } + // Start animation - animate(); - + animate() + return () => { - window.removeEventListener('resize', setCanvasSize); - }; - }, []); - + window.removeEventListener('resize', setCanvasSize) + } + }, []) + return ( - { height: '100%', pointerEvents: 'none', zIndex: 0, - opacity: 0.4 + opacity: 0.4, }} /> - ); -}; + ) +} -export default AnimatedBackground; \ No newline at end of file +export default AnimatedBackground diff --git a/src/components/AnimatedCheckbox.tsx b/src/components/AnimatedCheckbox.tsx index e14bf63..2acb67a 100644 --- a/src/components/AnimatedCheckbox.tsx +++ b/src/components/AnimatedCheckbox.tsx @@ -1,12 +1,11 @@ -// src/components/AnimatedCheckbox.tsx -import React from 'react'; +import React from 'react' interface AnimatedCheckboxProps { - checked: boolean; - onChange: () => void; - label?: string; - sublabel?: string; - className?: string; + checked: boolean + onChange: () => void + label?: string + sublabel?: string + className?: string } const AnimatedCheckbox: React.FC = ({ @@ -14,25 +13,20 @@ const AnimatedCheckbox: React.FC = ({ onChange, label, sublabel, - className = '' + className = '', }) => { return ( - ); -}; + ) +} -export default AnimatedCheckbox; \ No newline at end of file +export default AnimatedCheckbox diff --git a/src/components/DlcSelectionDialog.tsx b/src/components/DlcSelectionDialog.tsx index fe4006f..0091db4 100644 --- a/src/components/DlcSelectionDialog.tsx +++ b/src/components/DlcSelectionDialog.tsx @@ -1,23 +1,22 @@ -// src/components/DlcSelectionDialog.tsx -import React, { useState, useEffect, useMemo } from 'react'; -import AnimatedCheckbox from './AnimatedCheckbox'; +import React, { useState, useEffect, useMemo } from 'react' +import AnimatedCheckbox from './AnimatedCheckbox' interface DlcInfo { - appid: string; - name: string; - enabled: boolean; + appid: string + name: string + enabled: boolean } interface DlcSelectionDialogProps { - visible: boolean; - gameTitle: string; - dlcs: DlcInfo[]; - onClose: () => void; - onConfirm: (selectedDlcs: DlcInfo[]) => void; - isLoading: boolean; - isEditMode?: boolean; - loadingProgress?: number; - estimatedTimeLeft?: string; + visible: boolean + gameTitle: string + dlcs: DlcInfo[] + onClose: () => void + onConfirm: (selectedDlcs: DlcInfo[]) => void + isLoading: boolean + isEditMode?: boolean + loadingProgress?: number + estimatedTimeLeft?: string } const DlcSelectionDialog: React.FC = ({ @@ -29,122 +28,125 @@ const DlcSelectionDialog: React.FC = ({ isLoading, isEditMode = false, loadingProgress = 0, - estimatedTimeLeft = '' + estimatedTimeLeft = '', }) => { - const [selectedDlcs, setSelectedDlcs] = useState([]); - const [showContent, setShowContent] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [selectAll, setSelectAll] = useState(true); - const [initialized, setInitialized] = useState(false); + const [selectedDlcs, setSelectedDlcs] = useState([]) + const [showContent, setShowContent] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [selectAll, setSelectAll] = useState(true) + const [initialized, setInitialized] = useState(false) // Initialize selected DLCs when DLC list changes useEffect(() => { if (visible && dlcs.length > 0 && !initialized) { - setSelectedDlcs(dlcs); - + setSelectedDlcs(dlcs) + // Determine initial selectAll state based on if all DLCs are enabled - const allSelected = dlcs.every(dlc => dlc.enabled); - setSelectAll(allSelected); - + const allSelected = dlcs.every((dlc) => dlc.enabled) + setSelectAll(allSelected) + // Mark as initialized so we don't reset selections on subsequent DLC additions - setInitialized(true); + setInitialized(true) } - }, [visible, dlcs, initialized]); + }, [visible, dlcs, initialized]) // Handle visibility changes useEffect(() => { if (visible) { // Show content immediately for better UX const timer = setTimeout(() => { - setShowContent(true); - }, 50); - return () => clearTimeout(timer); + setShowContent(true) + }, 50) + return () => clearTimeout(timer) } else { - setShowContent(false); - setInitialized(false); // Reset initialized state when dialog closes + setShowContent(false) + setInitialized(false) // Reset initialized state when dialog closes } - }, [visible]); + }, [visible]) // Memoize filtered DLCs to avoid unnecessary recalculations const filteredDlcs = useMemo(() => { - return searchQuery.trim() === '' - ? selectedDlcs - : selectedDlcs.filter(dlc => - dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) || - dlc.appid.includes(searchQuery) - ); - }, [selectedDlcs, searchQuery]); + return searchQuery.trim() === '' + ? selectedDlcs + : selectedDlcs.filter( + (dlc) => + dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) || + dlc.appid.includes(searchQuery) + ) + }, [selectedDlcs, searchQuery]) // Update DLC selection status const handleToggleDlc = (appid: string) => { - setSelectedDlcs(prev => prev.map(dlc => - dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc - )); - }; + setSelectedDlcs((prev) => + prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc)) + ) + } // Update selectAll state when individual DLC selections change useEffect(() => { - const allSelected = selectedDlcs.every(dlc => dlc.enabled); - setSelectAll(allSelected); - }, [selectedDlcs]); + const allSelected = selectedDlcs.every((dlc) => dlc.enabled) + setSelectAll(allSelected) + }, [selectedDlcs]) // Handle new DLCs being added while dialog is already open useEffect(() => { if (initialized && dlcs.length > selectedDlcs.length) { // Find new DLCs that aren't in our current selection - const currentAppIds = new Set(selectedDlcs.map(dlc => dlc.appid)); - const newDlcs = dlcs.filter(dlc => !currentAppIds.has(dlc.appid)); - + const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid)) + const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid)) + // Add new DLCs to our selection, maintaining their enabled state if (newDlcs.length > 0) { - setSelectedDlcs(prev => [...prev, ...newDlcs]); + setSelectedDlcs((prev) => [...prev, ...newDlcs]) } } - }, [dlcs, selectedDlcs, initialized]); + }, [dlcs, selectedDlcs, initialized]) const handleToggleSelectAll = () => { - const newSelectAllState = !selectAll; - setSelectAll(newSelectAllState); - - setSelectedDlcs(prev => prev.map(dlc => ({ - ...dlc, - enabled: newSelectAllState - }))); - }; + const newSelectAllState = !selectAll + setSelectAll(newSelectAllState) + + setSelectedDlcs((prev) => + prev.map((dlc) => ({ + ...dlc, + enabled: newSelectAllState, + })) + ) + } const handleConfirm = () => { - onConfirm(selectedDlcs); - }; + onConfirm(selectedDlcs) + } // Modified to prevent closing when loading const handleOverlayClick = (e: React.MouseEvent) => { // Prevent clicks from propagating through the overlay - e.stopPropagation(); - + e.stopPropagation() + // Only allow closing via overlay click if not loading if (e.target === e.currentTarget && !isLoading) { - onClose(); + onClose() } - }; + } // Count selected DLCs - const selectedCount = selectedDlcs.filter(dlc => dlc.enabled).length; - + const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length + // Format loading message to show total number of DLCs found const getLoadingInfoText = () => { if (isLoading && loadingProgress < 100) { - return ` (Loading more DLCs...)`; + return ` (Loading more DLCs...)` } else if (dlcs.length > 0) { - return ` (Total DLCs: ${dlcs.length})`; + return ` (Total DLCs: ${dlcs.length})` } - return ''; - }; + return '' + } - if (!visible) return null; + if (!visible) return null return ( -
@@ -160,9 +162,9 @@ const DlcSelectionDialog: React.FC = ({
- setSearchQuery(e.target.value)} className="dlc-search-input" @@ -179,14 +181,13 @@ const DlcSelectionDialog: React.FC = ({ {isLoading && (
-
+
Loading DLCs: {loadingProgress}% - {estimatedTimeLeft && Est. time left: {estimatedTimeLeft}} + {estimatedTimeLeft && ( + Est. time left: {estimatedTimeLeft} + )}
)} @@ -194,7 +195,7 @@ const DlcSelectionDialog: React.FC = ({
{selectedDlcs.length > 0 ? (
    - {filteredDlcs.map(dlc => ( + {filteredDlcs.map((dlc) => (
  • = ({
- -
- ); -}; + ) +} -export default DlcSelectionDialog; \ No newline at end of file +export default DlcSelectionDialog diff --git a/src/components/GameItem.tsx b/src/components/GameItem.tsx index e625685..1460f44 100644 --- a/src/components/GameItem.tsx +++ b/src/components/GameItem.tsx @@ -1,98 +1,100 @@ -// src/components/GameItem.tsx -import React, { useState, useEffect } from 'react'; -import { findBestGameImage } from '../services/ImageService'; -import { ActionType } from './ActionButton'; +import React, { useState, useEffect } from 'react' +import { findBestGameImage } from '../services/ImageService' +import { ActionType } from './ActionButton' interface Game { - id: string; - title: string; - path: string; - platform?: string; - native: boolean; - api_files: string[]; - cream_installed?: boolean; - smoke_installed?: boolean; - installing?: boolean; + id: string + title: string + path: string + platform?: string + native: boolean + api_files: string[] + cream_installed?: boolean + smoke_installed?: boolean + installing?: boolean } interface GameItemProps { - game: Game; - onAction: (gameId: string, action: ActionType) => Promise; - onEdit?: (gameId: string) => void; + game: Game + onAction: (gameId: string, action: ActionType) => Promise + onEdit?: (gameId: string) => void } const GameItem: React.FC = ({ game, onAction, onEdit }) => { - const [imageUrl, setImageUrl] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); + const [imageUrl, setImageUrl] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) useEffect(() => { // Function to fetch the game cover/image const fetchGameImage = async () => { // First check if we already have it (to prevent flickering on re-renders) - if (imageUrl) return; - - setIsLoading(true); + if (imageUrl) return + + setIsLoading(true) try { // Try to find the best available image for this game - const bestImageUrl = await findBestGameImage(game.id); - + const bestImageUrl = await findBestGameImage(game.id) + if (bestImageUrl) { - setImageUrl(bestImageUrl); - setHasError(false); + setImageUrl(bestImageUrl) + setHasError(false) } else { - setHasError(true); + setHasError(true) } } catch (error) { - console.error('Error fetching game image:', error); - setHasError(true); + console.error('Error fetching game image:', error) + setHasError(true) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } if (game.id) { - fetchGameImage(); + fetchGameImage() } - }, [game.id, imageUrl]); + }, [game.id, imageUrl]) // Determine if we should show CreamLinux buttons (only for native games) - const shouldShowCream = game.native === true; - + const shouldShowCream = game.native === true + // Determine if we should show SmokeAPI buttons (only for non-native games with API files) - const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0; - + const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0 + // Check if this is a Proton game without API files - const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0); + const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0) const handleCreamAction = () => { - if (game.installing) return; - const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'; - onAction(game.id, action); - }; + if (game.installing) return + const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream' + onAction(game.id, action) + } const handleSmokeAction = () => { - if (game.installing) return; - const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'; - onAction(game.id, action); - }; - + if (game.installing) return + const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke' + onAction(game.id, action) + } + // Handle edit button click const handleEdit = () => { if (onEdit && game.cream_installed) { - onEdit(game.id); + onEdit(game.id) } - }; + } // Determine background image - const backgroundImage = !isLoading && imageUrl ? - `url(${imageUrl})` : - hasError ? 'linear-gradient(135deg, #232323, #1A1A1A)' : 'linear-gradient(135deg, #232323, #1A1A1A)'; + const backgroundImage = + !isLoading && imageUrl + ? `url(${imageUrl})` + : hasError + ? 'linear-gradient(135deg, #232323, #1A1A1A)' + : 'linear-gradient(135deg, #232323, #1A1A1A)' return ( -
= ({ game, onAction, onEdit }) => { {game.native ? 'Native' : 'Proton'} - {game.cream_installed && ( - CreamLinux - )} - {game.smoke_installed && ( - SmokeAPI - )} + {game.cream_installed && CreamLinux} + {game.smoke_installed && SmokeAPI}
- +

{game.title}

@@ -118,31 +116,39 @@ const GameItem: React.FC = ({ game, onAction, onEdit }) => {
{/* Show CreamLinux button only for native games */} {shouldShowCream && ( - )} {/* Show SmokeAPI button only for Proton/Windows games with API files */} {shouldShowSmoke && ( - )} - + {/* Show message for Proton games without API files */} {isProtonNoApi && (
Steam API DLL not found -
)} - + {/* Edit button - only enabled if CreamLinux is installed */} {game.cream_installed && ( -
- ); -}; + ) +} -export default GameItem; \ No newline at end of file +export default GameItem diff --git a/src/components/GameList.tsx b/src/components/GameList.tsx index d8a5321..108e82e 100644 --- a/src/components/GameList.tsx +++ b/src/components/GameList.tsx @@ -1,92 +1,81 @@ -// src/components/GameList.tsx -import React, { useState, useEffect, useMemo } from 'react'; -import GameItem from './GameItem'; -import ImagePreloader from './ImagePreloader'; -import { ActionType } from './ActionButton'; +import React, { useState, useEffect, useMemo } from 'react' +import GameItem from './GameItem' +import ImagePreloader from './ImagePreloader' +import { ActionType } from './ActionButton' interface Game { - id: string; - title: string; - path: string; - platform?: string; - native: boolean; - api_files: string[]; - cream_installed?: boolean; - smoke_installed?: boolean; - installing?: boolean; + id: string + title: string + path: string + platform?: string + native: boolean + api_files: string[] + cream_installed?: boolean + smoke_installed?: boolean + installing?: boolean } interface GameListProps { - games: Game[]; - isLoading: boolean; - onAction: (gameId: string, action: ActionType) => Promise; - onEdit?: (gameId: string) => void; + games: Game[] + isLoading: boolean + onAction: (gameId: string, action: ActionType) => Promise + onEdit?: (gameId: string) => void } -const GameList: React.FC = ({ - games, - isLoading, - onAction, - onEdit -}) => { - const [imagesPreloaded, setImagesPreloaded] = useState(false); - - // Sort games alphabetically by title - using useMemo to avoid re-sorting on each render +const GameList: React.FC = ({ games, isLoading, onAction, onEdit }) => { + const [imagesPreloaded, setImagesPreloaded] = useState(false) + + // Sort games alphabetically by title using useMemo to avoid re-sorting on each render const sortedGames = useMemo(() => { - return [...games].sort((a, b) => a.title.localeCompare(b.title)); - }, [games]); - + return [...games].sort((a, b) => a.title.localeCompare(b.title)) + }, [games]) + // Reset preloaded state when games change useEffect(() => { - setImagesPreloaded(false); - }, [games]); + setImagesPreloaded(false) + }, [games]) // Debug log to help diagnose game states useEffect(() => { if (games.length > 0) { - console.log("Games state in GameList:", games.length, "games"); + console.log('Games state in GameList:', games.length, 'games') } - }, [games]); - + }, [games]) + if (isLoading) { return (
Scanning for games...
- ); + ) } const handlePreloadComplete = () => { - setImagesPreloaded(true); - }; + setImagesPreloaded(true) + } return (

Games ({games.length})

- + {!imagesPreloaded && games.length > 0 && ( - game.id)} + game.id)} onComplete={handlePreloadComplete} /> )} - + {games.length === 0 ? (
No games found
) : (
- {sortedGames.map(game => ( - + {sortedGames.map((game) => ( + ))}
)}
- ); -}; + ) +} -export default GameList; \ No newline at end of file +export default GameList diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5d8c306..03e20ad 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,40 +1,35 @@ -// src/components/Header.tsx -import React from 'react'; +import React from 'react' interface HeaderProps { - onRefresh: () => void; - refreshDisabled?: boolean; - onSearch: (query: string) => void; - searchQuery: string; + onRefresh: () => void + refreshDisabled?: boolean + onSearch: (query: string) => void + searchQuery: string } -const Header: React.FC = ({ - onRefresh, +const Header: React.FC = ({ + onRefresh, refreshDisabled = false, onSearch, - searchQuery + searchQuery, }) => { return (

CreamLinux

- - onSearch(e.target.value)} />
- ); -}; + ) +} -export default Header; \ No newline at end of file +export default Header diff --git a/src/components/ImagePreloader.tsx b/src/components/ImagePreloader.tsx index 1c75fd3..a03ffa2 100644 --- a/src/components/ImagePreloader.tsx +++ b/src/components/ImagePreloader.tsx @@ -1,10 +1,9 @@ -// src/components/ImagePreloader.tsx -import React, { useEffect } from 'react'; -import { findBestGameImage } from '../services/ImageService'; +import React, { useEffect } from 'react' +import { findBestGameImage } from '../services/ImageService' interface ImagePreloaderProps { - gameIds: string[]; - onComplete?: () => void; + gameIds: string[] + onComplete?: () => void } const ImagePreloader: React.FC = ({ gameIds, onComplete }) => { @@ -12,37 +11,31 @@ const ImagePreloader: React.FC = ({ gameIds, onComplete }) const preloadImages = async () => { try { // Only preload the first batch for performance (10 images max) - const batchToPreload = gameIds.slice(0, 10); - + const batchToPreload = gameIds.slice(0, 10) + // Load images in parallel - await Promise.allSettled( - batchToPreload.map(id => findBestGameImage(id)) - ); - + await Promise.allSettled(batchToPreload.map((id) => findBestGameImage(id))) + if (onComplete) { - onComplete(); + onComplete() } } catch (error) { - console.error("Error preloading images:", error); + console.error('Error preloading images:', error) // Continue even if there's an error if (onComplete) { - onComplete(); + onComplete() } } - }; - - if (gameIds.length > 0) { - preloadImages(); - } else if (onComplete) { - onComplete(); } - }, [gameIds, onComplete]); - - return ( -
- {/* Hidden element, just used for preloading */} -
- ); -}; -export default ImagePreloader; \ No newline at end of file + if (gameIds.length > 0) { + preloadImages() + } else if (onComplete) { + onComplete() + } + }, [gameIds, onComplete]) + + return
{/* Hidden element, just used for preloading */}
+} + +export default ImagePreloader diff --git a/src/components/InitialLoadingScreen.tsx b/src/components/InitialLoadingScreen.tsx index 4fcfb2a..51b67d0 100644 --- a/src/components/InitialLoadingScreen.tsx +++ b/src/components/InitialLoadingScreen.tsx @@ -1,14 +1,11 @@ -import React from 'react'; +import React from 'react' interface InitialLoadingScreenProps { - message: string; - progress: number; + message: string + progress: number } -const InitialLoadingScreen: React.FC = ({ - message, - progress -}) => { +const InitialLoadingScreen: React.FC = ({ message, progress }) => { return (
@@ -22,15 +19,12 @@ const InitialLoadingScreen: React.FC = ({

{message}

-
+
{Math.round(progress)}%
- ); -}; + ) +} -export default InitialLoadingScreen; \ No newline at end of file +export default InitialLoadingScreen diff --git a/src/components/ProgressDialog.tsx b/src/components/ProgressDialog.tsx index 2bbfa9f..412476c 100644 --- a/src/components/ProgressDialog.tsx +++ b/src/components/ProgressDialog.tsx @@ -1,100 +1,100 @@ -// src/components/ProgressDialog.tsx -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react' interface InstructionInfo { - type: string; - command: string; - game_title: string; - dlc_count?: number; + type: string + command: string + game_title: string + dlc_count?: number } interface ProgressDialogProps { - title: string; - message: string; - progress: number; // 0-100 - visible: boolean; - showInstructions?: boolean; - instructions?: InstructionInfo; - onClose?: () => void; + title: string + message: string + progress: number // 0-100 + visible: boolean + showInstructions?: boolean + instructions?: InstructionInfo + onClose?: () => void } -const ProgressDialog: React.FC = ({ - title, - message, - progress, +const ProgressDialog: React.FC = ({ + title, + message, + progress, visible, showInstructions = false, instructions, - onClose + onClose, }) => { - const [copySuccess, setCopySuccess] = useState(false); - const [showContent, setShowContent] = useState(false); + const [copySuccess, setCopySuccess] = useState(false) + const [showContent, setShowContent] = useState(false) // Reset copy state when dialog visibility changes useEffect(() => { if (!visible) { - setCopySuccess(false); - setShowContent(false); + setCopySuccess(false) + setShowContent(false) } else { // Add a small delay to trigger the entrance animation const timer = setTimeout(() => { - setShowContent(true); - }, 50); - return () => clearTimeout(timer); + setShowContent(true) + }, 50) + return () => clearTimeout(timer) } - }, [visible]); + }, [visible]) - if (!visible) return null; + if (!visible) return null const handleCopyCommand = () => { if (instructions?.command) { - navigator.clipboard.writeText(instructions.command); - setCopySuccess(true); - + navigator.clipboard.writeText(instructions.command) + setCopySuccess(true) + // Reset the success message after 2 seconds setTimeout(() => { - setCopySuccess(false); - }, 2000); + setCopySuccess(false) + }, 2000) } - }; + } const handleClose = () => { - setShowContent(false); + setShowContent(false) // Delay closing to allow exit animation setTimeout(() => { if (onClose) { - onClose(); + onClose() } - }, 300); - }; + }, 300) + } - // Modified to prevent closing when in progress + // Prevent closing when in progress const handleOverlayClick = (e: React.MouseEvent) => { // Always prevent propagation - e.stopPropagation(); - - // Only allow clicking outside to close if we're done processing (100%) + e.stopPropagation() + + // Only allow clicking outside to close if we're done processing (100%) // and showing instructions or if explicitly allowed via a prop if (e.target === e.currentTarget && progress >= 100 && showInstructions) { - handleClose(); + handleClose() } // Otherwise, do nothing - require using the close button - }; + } // Determine if we should show the copy button (for CreamLinux but not SmokeAPI) - const showCopyButton = instructions?.type === 'cream_install' || - instructions?.type === 'cream_uninstall'; + const showCopyButton = + instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall' // Format instruction message based on type const getInstructionText = () => { - if (!instructions) return null; - + if (!instructions) return null + switch (instructions.type) { case 'cream_install': return ( <>

- In Steam, set the following launch options for {instructions.game_title}: + In Steam, set the following launch options for{' '} + {instructions.game_title}:

{instructions.dlc_count !== undefined && (
@@ -102,13 +102,14 @@ const ProgressDialog: React.FC = ({
)} - ); + ) case 'cream_uninstall': return (

- For {instructions.game_title}, open Steam properties and remove the following launch option: + For {instructions.game_title}, open Steam properties and remove the + following launch option:

- ); + ) case 'smoke_install': return ( <> @@ -121,71 +122,67 @@ const ProgressDialog: React.FC = ({
)} - ); + ) case 'smoke_uninstall': return (

SmokeAPI has been uninstalled from {instructions.game_title}

- ); + ) default: return (

Done processing {instructions.game_title}

- ); + ) } - }; + } // Determine the CSS class for the command box based on instruction type const getCommandBoxClass = () => { - return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'; - }; + return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box' + } // Determine if close button should be enabled - const isCloseButtonEnabled = showInstructions || progress >= 100; + const isCloseButtonEnabled = showInstructions || progress >= 100 return ( -
-
+

{title}

{message}

- +
-
+
{Math.round(progress)}%
- + {showInstructions && instructions && (

- {instructions.type.includes('uninstall') - ? 'Uninstallation Instructions' + {instructions.type.includes('uninstall') + ? 'Uninstallation Instructions' : 'Installation Instructions'}

{getInstructionText()} - +
{instructions.command}
- +
{showCopyButton && ( - )} - -
)} - + {/* Show close button even if no instructions */} {!showInstructions && progress >= 100 && (
-
)}
- ); -}; + ) +} -export default ProgressDialog; \ No newline at end of file +export default ProgressDialog diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 50e353a..3b4b461 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,9 +1,8 @@ -// src/components/Sidebar.tsx -import React from 'react'; +import React from 'react' interface SidebarProps { - setFilter: (filter: string) => void; - currentFilter: string; + setFilter: (filter: string) => void + currentFilter: string } const Sidebar: React.FC = ({ setFilter, currentFilter }) => { @@ -11,27 +10,24 @@ const Sidebar: React.FC = ({ setFilter, currentFilter }) => {

Library

    -
  • setFilter('all')} - > +
  • setFilter('all')}> All Games
  • -
  • setFilter('native')} > Native
  • -
  • setFilter('proton')} > Proton Required
- ); -}; + ) +} -export default Sidebar; \ No newline at end of file +export default Sidebar diff --git a/src/main.tsx b/src/main.tsx index 4aff025..a436f98 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,5 +5,5 @@ import App from './App.tsx' createRoot(document.getElementById('root')!).render( - , + ) diff --git a/src/services/ImageService.ts b/src/services/ImageService.ts index d9ee883..5d07b5d 100644 --- a/src/services/ImageService.ts +++ b/src/services/ImageService.ts @@ -1,5 +1,3 @@ -// src/services/ImageService.ts - /** * Game image sources from Steam's CDN */ @@ -9,88 +7,87 @@ export const SteamImageType = { LOGO: 'logo', // Game logo with transparency LIBRARY_HERO: 'library_hero', // 1920x620 LIBRARY_CAPSULE: 'library_600x900', // 600x900 -} as const; +} as const -export type SteamImageTypeKey = keyof typeof SteamImageType; +export type SteamImageTypeKey = keyof typeof SteamImageType // Cache for images to prevent flickering -const imageCache: Map = new Map(); +const imageCache: Map = new Map() /** -* Builds a Steam CDN URL for game images -* @param appId Steam application ID -* @param type Image type from SteamImageType enum -* @returns URL string for the image -*/ -export const getSteamImageUrl = (appId: string, type: typeof SteamImageType[SteamImageTypeKey]) => { -return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`; -}; + * Builds a Steam CDN URL for game images + * @param appId Steam application ID + * @param type Image type from SteamImageType enum + * @returns URL string for the image + */ +export const getSteamImageUrl = ( + appId: string, + type: (typeof SteamImageType)[SteamImageTypeKey] +) => { + return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg` +} /** -* Checks if an image exists by performing a HEAD request -* @param url Image URL to check -* @returns Promise resolving to a boolean indicating if the image exists -*/ + * Checks if an image exists by performing a HEAD request + * @param url Image URL to check + * @returns Promise resolving to a boolean indicating if the image exists + */ export const checkImageExists = async (url: string): Promise => { -try { - const response = await fetch(url, { method: 'HEAD' }); - return response.ok; -} catch (error) { - console.error('Error checking image existence:', error); - return false; -} -}; - -/** -* Preloads an image for faster rendering -* @param url URL of image to preload -* @returns Promise that resolves when image is loaded -*/ -const preloadImage = (url: string): Promise => { -return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(url); - img.onerror = reject; - img.src = url; -}); -}; - -/** -* Attempts to find a valid image for a Steam game, trying different image types -* @param appId Steam application ID -* @returns Promise resolving to a valid image URL or null if none found -*/ -export const findBestGameImage = async (appId: string): Promise => { -// Check cache first -if (imageCache.has(appId)) { - return imageCache.get(appId) || null; -} - -// Try these image types in order of preference -const typesToTry = [ - SteamImageType.HEADER, - SteamImageType.CAPSULE, - SteamImageType.LIBRARY_CAPSULE -]; - -for (const type of typesToTry) { - const url = getSteamImageUrl(appId, type); - const exists = await checkImageExists(url); - if (exists) { - try { - // Preload the image to prevent flickering - const preloadedUrl = await preloadImage(url); - // Store in cache - imageCache.set(appId, preloadedUrl); - return preloadedUrl; - } catch { - // If preloading fails, just return the URL - imageCache.set(appId, url); - return url; - } + try { + const response = await fetch(url, { method: 'HEAD' }) + return response.ok + } catch (error) { + console.error('Error checking image existence:', error) + return false } } -// If we've reached here, no valid image was found -return null; -}; \ No newline at end of file +/** + * Preloads an image for faster rendering + * @param url URL of image to preload + * @returns Promise that resolves when image is loaded + */ +const preloadImage = (url: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(url) + img.onerror = reject + img.src = url + }) +} + +/** + * Attempts to find a valid image for a Steam game, trying different image types + * @param appId Steam application ID + * @returns Promise resolving to a valid image URL or null if none found + */ +export const findBestGameImage = async (appId: string): Promise => { + // Check cache first + if (imageCache.has(appId)) { + return imageCache.get(appId) || null + } + + // Try these image types in order of preference + const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE] + + for (const type of typesToTry) { + const url = getSteamImageUrl(appId, type) + const exists = await checkImageExists(url) + if (exists) { + try { + // Preload the image to prevent flickering + const preloadedUrl = await preloadImage(url) + // Store in cache + imageCache.set(appId, preloadedUrl) + return preloadedUrl + } catch { + // If preloading fails, just return the URL + imageCache.set(appId, url) + return url + } + } + } + + // If we've reached here, no valid image was found + return null +} diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss index 14099cd..6985d1c 100644 --- a/src/styles/_fonts.scss +++ b/src/styles/_fonts.scss @@ -1,10 +1,10 @@ @font-face { - font-family: 'Satoshi'; - src: url('../assets/fonts/Satoshi.ttf') format('ttf'), - url('../assets/fonts/Roboto.ttf') format('ttf'), - url('../assets/fonts/WorkSans.ttf') format('ttf'); - font-weight: 400; // adjust as needed - font-style: normal; - font-display: swap; - } - \ No newline at end of file + font-family: 'Satoshi'; + src: + url('../assets/fonts/Satoshi.ttf') format('ttf'), + url('../assets/fonts/Roboto.ttf') format('ttf'), + url('../assets/fonts/WorkSans.ttf') format('ttf'); + font-weight: 400; + font-style: normal; + font-display: swap; +} diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index fa99020..389def2 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -1,5 +1,3 @@ -// src/styles/_layout.scss - @use './variables' as *; @use './mixins' as *; @@ -23,7 +21,7 @@ left: 0; right: 0; bottom: 0; - background-image: + background-image: radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%), radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%); pointer-events: none; @@ -41,7 +39,7 @@ position: relative; z-index: var(--z-header); height: var(--header-height); - + h1 { font-size: 1.5rem; font-weight: 600; @@ -57,7 +55,12 @@ left: 0; right: 0; height: 3px; - background: linear-gradient(90deg, var(--cream-color), var(--primary-color), var(--smoke-color)); + background: linear-gradient( + 90deg, + var(--cream-color), + var(--primary-color), + var(--smoke-color) + ); opacity: 0.7; } @@ -71,7 +74,7 @@ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); } } - + .header-controls { display: flex; gap: 1rem; @@ -88,7 +91,7 @@ z-index: var(--z-elevate); } -/* Sidebar */ +// Sidebar .sidebar { width: var(--sidebar-width); min-width: var(--sidebar-width); @@ -161,7 +164,8 @@ } // Loading and empty state -.loading-indicator, .no-games-message { +.loading-indicator, +.no-games-message { @include flex-center; height: 250px; width: 100%; @@ -185,12 +189,7 @@ left: -100%; width: 50%; height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.05), - transparent - ); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent); animation: loading-shimmer 2s infinite; } } @@ -259,4 +258,4 @@ to { left: 100%; } -} \ No newline at end of file +} diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 3f03291..6738092 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -1,9 +1,5 @@ -// src/styles/_mixins.scss - @use './variables' as *; -// src/styles/_mixins.scss - // Basic flex helpers @mixin flex-center { display: flex; @@ -43,7 +39,7 @@ } @mixin shadow-hover { - box-shadow: var(--shadow-hover);; + box-shadow: var(--shadow-hover); } @mixin text-shadow { @@ -60,19 +56,27 @@ // Responsive mixins @mixin media-sm { - @media (min-width: 576px) { @content; } + @media (min-width: 576px) { + @content; + } } @mixin media-md { - @media (min-width: 768px) { @content; } + @media (min-width: 768px) { + @content; + } } @mixin media-lg { - @media (min-width: 992px) { @content; } + @media (min-width: 992px) { + @content; + } } @mixin media-xl { - @media (min-width: 1200px) { @content; } + @media (min-width: 1200px) { + @content; + } } // Card base styling @@ -104,4 +108,4 @@ &::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, white 10%, var(--primary-color)); } -} \ No newline at end of file +} diff --git a/src/styles/_reset.scss b/src/styles/_reset.scss index 693b443..c1d0a1b 100644 --- a/src/styles/_reset.scss +++ b/src/styles/_reset.scss @@ -1,9 +1,6 @@ -// src/styles/_reset.scss - @use './variables' as *; @use './mixins' as *; @use './fonts' as *; -// src/styles/_reset.scss * { box-sizing: border-box; @@ -11,7 +8,8 @@ padding: 0; } -html, body { +html, +body { height: 100%; width: 100%; overflow: hidden; @@ -23,7 +21,7 @@ body { -moz-osx-font-smoothing: grayscale; background-color: var(--primary-bg); color: var(--text-primary); - /* Prevent text selection by default */ + // Prevent text selection by default user-select: none; -webkit-user-select: none; -moz-user-select: none; @@ -51,15 +49,24 @@ a { text-decoration: none; } -ul, ol { +ul, +ol { list-style: none; } -input, button, textarea, select { +input, +button, +textarea, +select { font: inherit; } -h1, h2, h3, h4, h5, h6 { +h1, +h2, +h3, +h4, +h5, +h6 { font-weight: inherit; font-size: inherit; -} \ No newline at end of file +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 676c252..77b639e 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -1,111 +1,102 @@ -// src/styles/_variables.scss - @use './fonts' as *; // Color palette :root { - // Primary colors - --primary-color: #ffc896; - --secondary-color: #ffb278; + // Primary colors + --primary-color: #ffc896; + --secondary-color: #ffb278; - // Background - --primary-bg: #0f0f0f; - --secondary-bg: #151515; - --tertiary-bg: #121212; - --elevated-bg: #1a1a1a; - --disabled: #5E5E5E; + // Background + --primary-bg: #0f0f0f; + --secondary-bg: #151515; + --tertiary-bg: #121212; + --elevated-bg: #1a1a1a; + --disabled: #5e5e5e; - // Text - --text-primary: #f0f0f0; - --text-secondary: #c8c8c8; - --text-soft: #afafaf; - --text-heavy: #1a1a1a; - --text-muted: #4b4b4b; + // Text + --text-primary: #f0f0f0; + --text-secondary: #c8c8c8; + --text-soft: #afafaf; + --text-heavy: #1a1a1a; + --text-muted: #4b4b4b; - // Borders - --border-dark: #1a1a1a; - --border-soft: #282828; - --border: #323232; - - // Status colors - more vibrant - --success: #8cc893; - --warning: #ffc896; - --danger: #d96b6b; - --info: #80b4ff; + // Borders + --border-dark: #1a1a1a; + --border-soft: #282828; + --border: #323232; - --success-light: #b0e0a9; - --warning-light: #ffdcb9; - --danger-light: #e69691; - --info-light: #a8d2ff; + // Status colors + --success: #8cc893; + --warning: #ffc896; + --danger: #d96b6b; + --info: #80b4ff; - --success-soft: rgba(176, 224, 169, 0.15); - --warning-soft: rgba(247, 200, 111, 0.15); - --danger-soft: rgba(230, 150, 145, 0.15); - --info-soft: rgba(168, 210, 255, 0.15); + --success-light: #b0e0a9; + --warning-light: #ffdcb9; + --danger-light: #e69691; + --info-light: #a8d2ff; - // Feature colors - --native: #8cc893; - --proton: #ffc896; - --cream: #80b4ff; - --smoke: #fff096; - - --modal-backdrop: rgba(30, 30, 30, 0.95); - - // Animation durations - --duration-fast: 100ms; - --duration-normal: 200ms; - --duration-slow: 300ms; - - // Animation easings - --easing-ease-out: cubic-bezier(0, 0, 0.2, 1); - --easing-ease-in: cubic-bezier(0.4, 0, 1, 1); - --easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + --success-soft: rgba(176, 224, 169, 0.15); + --warning-soft: rgba(247, 200, 111, 0.15); + --danger-soft: rgba(230, 150, 145, 0.15); + --info-soft: rgba(168, 210, 255, 0.15); - // Layout values - --header-height: 64px; - --sidebar-width: 250px; - --card-height: 200px; + // Feature colors + --native: #8cc893; + --proton: #ffc896; + --cream: #80b4ff; + --smoke: #fff096; - // Border radius - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; + --modal-backdrop: rgba(30, 30, 30, 0.95); - // Font weights - --thin: 100; - --extralight: 200; - --light: 300; - --normal: 400; - --medium: 500; - --semibold: 600; - --bold: 700; - --extrabold: 800; + // Animation durations + --duration-fast: 100ms; + --duration-normal: 200ms; + --duration-slow: 300ms; - --family: 'Satoshi'; + // Animation easings + --easing-ease-out: cubic-bezier(0, 0, 0.2, 1); + --easing-ease-in: cubic-bezier(0.4, 0, 1, 1); + --easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); - // Shadows - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); - --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3); - --shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5); - --shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7); + // Layout values + --header-height: 64px; + --sidebar-width: 250px; + --card-height: 200px; - // Z-index levels - //--z-index-bg: 0; - //--z-index-content: 1; - //--z-index-header: 100; - //--z-index-modal: 1000; - //--z-index-tooltip: 1500; + // Border radius + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; - // Z-index levels - --z-bg: 0; - --z-elevate: 1; - --z-header: 100; - --z-modal: 1000; - --z-tooltip: 1500; + // Font weights + --thin: 100; + --extralight: 200; + --light: 300; + --normal: 400; + --medium: 500; + --semibold: 600; + --bold: 700; + --extrabold: 800; + + --family: 'Satoshi'; + + // Shadows + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); + --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3); + --shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5); + --shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7); + + // Z-index levels + --z-bg: 0; + --z-elevate: 1; + --z-header: 100; + --z-modal: 1000; + --z-tooltip: 1500; } $success-color: #55e07a; @@ -113,4 +104,4 @@ $danger-color: #ff5252; $primary-color: #4a76c4; $cream-color: #9b7dff; $smoke-color: #fbb13c; -$warning-color: #fbb13c; \ No newline at end of file +$warning-color: #fbb13c; diff --git a/src/styles/components/_animated_checkbox.scss b/src/styles/components/_animated_checkbox.scss index c376a37..b6564ad 100644 --- a/src/styles/components/_animated_checkbox.scss +++ b/src/styles/components/_animated_checkbox.scss @@ -1,5 +1,3 @@ -// src/styles/components/_animated_checkbox.scss - @use '../variables' as *; @use '../mixins' as *; @@ -9,7 +7,7 @@ cursor: pointer; width: 100%; position: relative; - + &:hover .checkbox-custom { border-color: rgba(255, 255, 255, 0.3); } @@ -35,7 +33,7 @@ margin-right: 15px; flex-shrink: 0; position: relative; - + &.checked { background-color: var(--primary-color, #ffc896); border-color: var(--primary-color, #ffc896); @@ -53,7 +51,7 @@ stroke-dashoffset: 30; opacity: 0; transition: stroke-dashoffset 0.3s ease; - + &.checked { stroke-dashoffset: 0; opacity: 1; @@ -95,4 +93,4 @@ stroke-dashoffset: 0; opacity: 1; } -} \ No newline at end of file +} diff --git a/src/styles/components/_background.scss b/src/styles/components/_background.scss index 8d6a3a3..2615c3b 100644 --- a/src/styles/components/_background.scss +++ b/src/styles/components/_background.scss @@ -1,5 +1,3 @@ -// src/styles/_components/_background.scss - @use '../variables' as *; @use '../mixins' as *; @use 'sass:color'; @@ -13,4 +11,4 @@ pointer-events: none; z-index: var(--z-bg); opacity: 0.4; -} \ No newline at end of file +} diff --git a/src/styles/components/_dialog.scss b/src/styles/components/_dialog.scss index 08ca926..a1801e9 100644 --- a/src/styles/components/_dialog.scss +++ b/src/styles/components/_dialog.scss @@ -1,9 +1,7 @@ -// src/styles/_components/_dialog.scss - @use '../variables' as *; @use '../mixins' as *; -/* Progress Dialog */ +// Progress Dialog .progress-dialog-overlay { position: fixed; top: 0; @@ -17,22 +15,28 @@ opacity: 0; animation: modal-appear 0.2s ease-out; cursor: pointer; - + &.visible { opacity: 1; } @keyframes modal-appear { - 0% { opacity: 0; transform: scale(0.95); } - 100% { opacity: 1; transform: scale(1); } + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } } } - + .progress-dialog { background-color: var(--elevated-bg); border-radius: 8px; padding: 1.5rem; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); /* shadow-glow */ + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); // shadow-glow width: 450px; max-width: 90vw; border: 1px solid var(--border-soft); @@ -43,17 +47,17 @@ transform: scale(1); opacity: 1; } - + &.with-instructions { width: 500px; } - + h3 { font-weight: 700; margin-bottom: 1rem; color: var(--text-primary); } - + p { margin-bottom: 1rem; color: var(--text-secondary); @@ -85,7 +89,7 @@ margin-bottom: 1rem; } -/* Instruction container in progress dialog */ +// Instruction container in progress dialog .instruction-container { margin-top: 1.5rem; padding-top: 1rem; @@ -112,7 +116,7 @@ color: var(--info); border-radius: 4px; font-size: 0.8rem; - + &::before { content: ''; display: inline-block; @@ -143,7 +147,7 @@ max-width: 100%; } } - + .selectable-text { font-size: 0.9rem; line-height: 1.5; @@ -164,7 +168,8 @@ justify-content: flex-end; } -.copy-button, .close-button { +.copy-button, +.close-button { padding: 0.6rem 1.2rem; border-radius: var(--radius-sm); font-weight: 600; @@ -180,7 +185,7 @@ &:hover { background-color: var(--primary-color); - transform: translateY(-2px) scale(1.02); /* hover-lift */ + transform: translateY(-2px) scale(1.02); // hover-lift box-shadow: 0 6px 14px var(--info-soft); } } @@ -191,7 +196,7 @@ &:hover { background-color: var(--border); - transform: translateY(-2px) scale(1.02); /* hover-lift */ + transform: translateY(-2px) scale(1.02); // hover-lift box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3); } } @@ -210,20 +215,20 @@ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); backdrop-filter: blur(5px); text-align: center; - + h3 { color: var(--danger); font-weight: 700; margin-bottom: 1rem; } - + p { margin-bottom: 1.5rem; color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; } - + button { background-color: var(--primary-color); color: var(--text-primary); @@ -234,7 +239,7 @@ letter-spacing: 0.5px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); @include transition-standard; - + &:hover { transform: translateY(-2px); box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4); @@ -244,6 +249,10 @@ // Animation for progress bar @keyframes progress-shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } -} \ No newline at end of file + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} diff --git a/src/styles/components/_dlc_dialog.scss b/src/styles/components/_dlc_dialog.scss index 49bd559..2e8976c 100644 --- a/src/styles/components/_dlc_dialog.scss +++ b/src/styles/components/_dlc_dialog.scss @@ -1,5 +1,3 @@ -// src/styles/components/_dlc_dialog.scss - @use '../variables' as *; @use '../mixins' as *; @@ -15,7 +13,7 @@ z-index: var(--z-modal); opacity: 0; cursor: pointer; - + &.visible { opacity: 1; animation: modal-appear 0.2s ease-out; @@ -35,18 +33,20 @@ cursor: default; opacity: 0; transform: scale(0.95); - + &.dialog-visible { transform: scale(1); opacity: 1; - transition: transform 0.2s var(--easing-bounce), opacity 0.2s ease-out; + transition: + transform 0.2s var(--easing-bounce), + opacity 0.2s ease-out; } } .dlc-dialog-header { padding: 1.5rem; border-bottom: 1px solid var(--border-soft); - + h3 { font-size: 1.2rem; font-weight: 700; @@ -60,12 +60,12 @@ justify-content: space-between; align-items: center; margin-top: 0.5rem; - + .game-title { font-weight: 500; color: var(--text-secondary); } - + .dlc-count { font-size: 0.9rem; padding: 0.3rem 0.6rem; @@ -94,13 +94,13 @@ padding: 0.6rem 1rem; font-size: 0.9rem; @include transition-standard; - + &:focus { border-color: var(--primary-color); outline: none; box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2); } - + &::placeholder { color: var(--text-muted); } @@ -110,12 +110,12 @@ display: flex; align-items: center; min-width: 100px; - + // Custom styling for the select all checkbox :global(.animated-checkbox) { margin-left: auto; } - + :global(.checkbox-label) { font-size: 0.9rem; color: var(--text-secondary); @@ -126,7 +126,7 @@ padding: 0.75rem 1.5rem; background-color: rgba(0, 0, 0, 0.05); border-bottom: 1px solid var(--border-soft); - + .progress-bar-container { height: 6px; background-color: var(--border-soft); @@ -134,7 +134,7 @@ overflow: hidden; margin-bottom: 0.5rem; } - + .progress-bar { height: 100%; background-color: var(--primary-color); @@ -143,13 +143,13 @@ background: var(--primary-color); box-shadow: 0px 0px 6px rgba(128, 181, 255, 0.3); } - + .loading-details { display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--text-secondary); - + .time-left { color: var(--text-muted); } @@ -171,54 +171,56 @@ padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--border-soft); @include transition-standard; - + &:hover { background-color: rgba(255, 255, 255, 0.03); } - + &:last-child { border-bottom: none; } - + &.dlc-item-loading { height: 30px; display: flex; align-items: center; justify-content: center; - + .loading-pulse { width: 70%; height: 20px; - background: linear-gradient(90deg, - var(--border-soft) 0%, - var(--border) 50%, - var(--border-soft) 100%); + background: linear-gradient( + 90deg, + var(--border-soft) 0%, + var(--border) 50%, + var(--border-soft) 100% + ); background-size: 200% 100%; border-radius: 4px; animation: loading-pulse 1.5s infinite; } } - - // Enhanced styling for the checkbox component inside dlc-item + + // Styling for the checkbox component inside dlc-item :global(.animated-checkbox) { width: 100%; - + .checkbox-label { color: var(--text-primary); font-weight: 500; transition: color 0.15s ease; } - + .checkbox-sublabel { color: var(--text-muted); } - + // Optional hover effect &:hover { .checkbox-label { color: var(--primary-color); } - + .checkbox-custom { border-color: var(--primary-color, #ffc896); transform: scale(1.05); @@ -232,7 +234,7 @@ @include flex-center; flex-direction: column; gap: 1rem; - + .loading-spinner { width: 40px; height: 40px; @@ -241,7 +243,7 @@ border-radius: 50%; animation: spin 1s linear infinite; } - + p { color: var(--text-secondary); } @@ -261,7 +263,8 @@ gap: 1rem; } -.cancel-button, .confirm-button { +.cancel-button, +.confirm-button { padding: 0.6rem 1.2rem; border-radius: var(--radius-sm); font-weight: 600; @@ -274,7 +277,7 @@ .cancel-button { background-color: var(--border-soft); color: var(--text-primary); - + &:hover { background-color: var(--border); transform: translateY(-2px); @@ -285,12 +288,12 @@ .confirm-button { background-color: var(--primary-color); color: white; - + &:hover { transform: translateY(-2px); box-shadow: 0 6px 14px var(--info-soft); } - + &:disabled { opacity: 0.7; cursor: not-allowed; @@ -299,16 +302,30 @@ } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } @keyframes modal-appear { - 0% { opacity: 0; transform: scale(0.95); } - 100% { opacity: 1; transform: scale(1); } + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } } @keyframes loading-pulse { - 0% { background-position: 200% 50%; } - 100% { background-position: 0% 50%; } -} \ No newline at end of file + 0% { + background-position: 200% 50%; + } + 100% { + background-position: 0% 50%; + } +} diff --git a/src/styles/components/_gamecard.scss b/src/styles/components/_gamecard.scss index 4c78e90..0be4662 100644 --- a/src/styles/components/_gamecard.scss +++ b/src/styles/components/_gamecard.scss @@ -1,5 +1,3 @@ -// src/styles/components/_gamecard.scss - @use '../variables' as *; @use '../mixins' as *; @@ -12,7 +10,7 @@ @include shadow-standard; @include transition-standard; transform-origin: center; - + // Simple image loading animation opacity: 0; animation: fadeIn 0.5s forwards; @@ -23,19 +21,19 @@ transform: translateY(-8px) scale(1.02); @include shadow-hover; z-index: 5; - + .status-badge.native { - box-shadow: 0 0 10px rgba(85, 224, 122, 0.5) + box-shadow: 0 0 10px rgba(85, 224, 122, 0.5); } - + .status-badge.proton { box-shadow: 0 0 10px rgba(255, 201, 150, 0.5); } - + .status-badge.cream { box-shadow: 0 0 10px rgba(128, 181, 255, 0.5); } - + .status-badge.smoke { box-shadow: 0 0 10px rgba(255, 239, 150, 0.5); } @@ -43,11 +41,15 @@ // Special styling for cards with different statuses .game-item-card:has(.status-badge.cream) { - box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15); + box-shadow: + var(--shadow-standard), + 0 0 15px rgba(128, 181, 255, 0.15); } .game-item-card:has(.status-badge.smoke) { - box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15); + box-shadow: + var(--shadow-standard), + 0 0 15px rgba(255, 239, 150, 0.15); } // Simple clean overlay @@ -57,7 +59,8 @@ left: 0; width: 100%; height: 100%; - background: linear-gradient(to bottom, + background: linear-gradient( + to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.6) 50%, rgba(0, 0, 0, 0.8) 100% @@ -70,7 +73,7 @@ font-family: var(--family); -webkit-font-smoothing: subpixel-antialiased; text-rendering: geometricPrecision; - color: var(--text-heavy);; + color: var(--text-heavy); z-index: 1; } @@ -92,7 +95,7 @@ font-family: var(--family); -webkit-font-smoothing: subpixel-antialiased; text-rendering: geometricPrecision; - color: var(--text-heavy);; + color: var(--text-heavy); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); @include transition-standard; border: 1px solid rgba(255, 255, 255, 0.1); @@ -129,7 +132,7 @@ margin: 0; -webkit-font-smoothing: subpixel-antialiased; text-rendering: geometricPrecision; - transform: translateZ(0); // or + transform: translateZ(0); will-change: opacity, transform; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); overflow: hidden; @@ -176,7 +179,7 @@ .action-button.uninstall:hover { background-color: var(--danger-light); transform: translateY(-2px) scale(1.02); - box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3) + box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3); } .action-button:active { @@ -241,11 +244,11 @@ font-size: 0.85rem; color: var(--text-primary); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - + span { flex: 1; } - + .rescan-button { background-color: var(--warning); color: var(--text-heavy); @@ -257,12 +260,12 @@ margin-left: 0.5rem; cursor: pointer; transition: all 0.2s ease; - + &:hover { background-color: var(--warning-light); transform: translateY(-2px); } - + &:active { transform: translateY(0); } @@ -271,17 +274,23 @@ // Apply staggered delay to cards @for $i from 1 through 12 { - .game-grid .game-item-card:nth-child(#{$i}) { - animation-delay: #{$i * 0.05}s; + .game-grid .game-item-card:nth-child(#{$i}) { + animation-delay: #{$i * 0.05}s; } } // Simple animations @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes button-loading { - to { left: 100%; } -} \ No newline at end of file + to { + left: 100%; + } +} diff --git a/src/styles/components/_header.scss b/src/styles/components/_header.scss index 1015d60..0763e4c 100644 --- a/src/styles/components/_header.scss +++ b/src/styles/components/_header.scss @@ -1,82 +1,80 @@ -// src/styles/_components/_header.scss - @use '../variables' as *; @use '../mixins' as *; .app-container { - display: flex; - flex-direction: column; - height: 100vh; - width: 100vw; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--primary-bg); - position: relative; - } - + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--primary-bg); + position: relative; +} + // Header .app-header { - @include flex-between; - padding: 1rem 2rem; - background-color: var(--tertiary-bg); - border-bottom: 1px solid rgba(255, 255, 255, 0.07); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); - position: relative; - z-index: var(--z-header); - height: var(--header-height); - - h1 { - font-size: 1.5rem; - font-weight: 600; - color: var(--text-primary); - letter-spacing: 0.5px; - @include text-shadow; - } - } - - .header-controls { - display: flex; - gap: 1rem; - align-items: center; - } - - .refresh-button { - background-color: var(--primary-color); + @include flex-between; + padding: 1rem 2rem; + background-color: var(--tertiary-bg); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + position: relative; + z-index: var(--z-header); + height: var(--header-height); + + h1 { + font-size: 1.5rem; + font-weight: 600; color: var(--text-primary); - border: none; - border-radius: 4px; - padding: 0.6rem 1.2rem; - font-weight: var(--bold); letter-spacing: 0.5px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); - transition: all 0.2s ease; + @include text-shadow; } - - .refresh-button:hover { - transform: translateY(-2px); - box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3); - background-color: var(--primary-color); - } - - .refresh-button:active { - transform: translateY(0); - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); - } - - .search-input { - padding: 0.5rem 1rem; - border: 1px solid var(--border-soft); - border-radius: 4px; - min-width: 200px; - background-color: var(--border-dark); - color: var(--text-primary); - } - - .search-input:focus { - border-color: var(--primary-color); - outline: none; - box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2); -} \ No newline at end of file +} + +.header-controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.refresh-button { + background-color: var(--primary-color); + color: var(--text-primary); + border: none; + border-radius: 4px; + padding: 0.6rem 1.2rem; + font-weight: var(--bold); + letter-spacing: 0.5px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease; +} + +.refresh-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3); + background-color: var(--primary-color); +} + +.refresh-button:active { + transform: translateY(0); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); +} + +.search-input { + padding: 0.5rem 1rem; + border: 1px solid var(--border-soft); + border-radius: 4px; + min-width: 200px; + background-color: var(--border-dark); + color: var(--text-primary); +} + +.search-input:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2); +} diff --git a/src/styles/components/_loading_screen.scss b/src/styles/components/_loading_screen.scss index 6d7223f..a13239d 100644 --- a/src/styles/components/_loading_screen.scss +++ b/src/styles/components/_loading_screen.scss @@ -9,13 +9,13 @@ align-items: center; justify-content: center; z-index: var(--z-modal) + 1; - + .loading-content { text-align: center; padding: 2rem; max-width: 500px; width: 90%; - + h1 { font-size: 2.5rem; margin-bottom: 2rem; @@ -23,46 +23,46 @@ color: var(--primary-color); text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4); } - + .loading-animation { margin-bottom: 2rem; } - + .loading-circles { display: flex; justify-content: center; gap: 1rem; margin-bottom: 1rem; - + .circle { width: 20px; height: 20px; border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; - + &.circle-1 { background-color: var(--primary-color); animation-delay: -0.32s; } - + &.circle-2 { background-color: var(--cream-color); animation-delay: -0.16s; } - + &.circle-3 { background-color: var(--smoke-color); } } } - + .loading-message { font-size: 1.1rem; color: var(--text-secondary); margin-bottom: 1.5rem; min-height: 3rem; } - + .progress-bar-container { height: 8px; background-color: var(--border-soft); @@ -70,16 +70,21 @@ overflow: hidden; margin-bottom: 0.5rem; } - + .progress-bar { height: 100%; background-color: var(--primary-color); border-radius: 4px; transition: width 0.5s ease; - background: linear-gradient(to right, var(--cream-color), var(--primary-color), var(--smoke-color)); + background: linear-gradient( + to right, + var(--cream-color), + var(--primary-color), + var(--smoke-color) + ); box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4); } - + .progress-percentage { text-align: right; font-size: 0.875rem; @@ -91,10 +96,12 @@ // Animation for the bouncing circles @keyframes bounce { - 0%, 80%, 100% { + 0%, + 80%, + 100% { transform: scale(0); } - 40% { - transform: scale(1.0); + 40% { + transform: scale(1); } -} \ No newline at end of file +} diff --git a/src/styles/components/_sidebar.scss b/src/styles/components/_sidebar.scss index 474f653..da6bd53 100644 --- a/src/styles/components/_sidebar.scss +++ b/src/styles/components/_sidebar.scss @@ -1,12 +1,10 @@ -// src/styles/_components/_sidebar.scss - @use '../variables' as *; @use '../mixins' as *; .filter-list { list-style: none; margin-bottom: 1.5rem; - + li { @include transition-standard; border-radius: var(--radius-sm); @@ -14,11 +12,11 @@ margin-bottom: 0.3rem; font-weight: 500; cursor: pointer; - + &:hover { background-color: rgba(255, 255, 255, 0.07); } - + &.active { @include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color))); box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3); @@ -30,7 +28,7 @@ .custom-select { position: relative; display: inline-block; - + .select-selected { background-color: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.1); @@ -45,18 +43,18 @@ justify-content: space-between; gap: 10px; min-width: 150px; - + &:after { content: '⯆'; font-size: 0.7rem; opacity: 0.7; } - + &:hover { background-color: rgba(255, 255, 255, 0.1); } } - + .select-items { position: absolute; top: 100%; @@ -71,20 +69,20 @@ max-height: 0; overflow: hidden; transition: max-height 0.3s ease; - + &.show { max-height: 300px; } - + .select-item { padding: 0.5rem 1rem; cursor: pointer; @include transition-standard; - + &:hover { background-color: rgba(255, 255, 255, 0.07); } - + &.selected { background-color: var(--primary-color); color: var(--text-primary); @@ -98,7 +96,7 @@ display: flex; align-items: center; gap: 10px; - + svg { width: 28px; height: 28px; @@ -111,13 +109,13 @@ .tooltip { position: relative; display: inline-block; - + &:hover .tooltip-content { visibility: visible; opacity: 1; transform: translateY(0); } - + .tooltip-content { visibility: hidden; width: 200px; @@ -133,14 +131,16 @@ margin-left: -100px; opacity: 0; transform: translateY(10px); - transition: opacity 0.3s, transform 0.3s; + transition: + opacity 0.3s, + transform 0.3s; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1); font-size: 0.8rem; pointer-events: none; - + &::after { - content: ""; + content: ''; position: absolute; top: 100%; left: 50%; @@ -192,15 +192,17 @@ @include transition-standard; box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2); min-width: 200px; - + &:focus { border-color: var(--primary-color); background-color: rgba(255, 255, 255, 0.1); outline: none; - box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2); + box-shadow: + 0 0 0 2px rgba(var(--primary-color), 0.3), + inset 0 2px 5px rgba(0, 0, 0, 0.2); } - + &::placeholder { color: rgba(255, 255, 255, 0.4); } -} \ No newline at end of file +} diff --git a/src/styles/main.scss b/src/styles/main.scss index 3f6accc..5f8d964 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -1,5 +1,3 @@ -// src/styles/main.scss - // Import variables and mixins first @use './variables' as *; @use './mixins' as *; @@ -18,4 +16,4 @@ @use './components/sidebar'; @use './components/dlc_dialog'; @use './components/loading_screen'; -@use './components/animated_checkbox'; \ No newline at end of file +@use './components/animated_checkbox'; diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..d32ff68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 4105660..2cd2f6a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,10 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -// Removed unused import: loadEnv +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], - - // Vite options tailored for Tauri development + clearScreen: false, server: { port: 1420, @@ -14,11 +12,8 @@ export default defineConfig({ }, envPrefix: ['VITE_', 'TAURI_'], build: { - // Tauri supports es2021 target: ['es2021', 'chrome105', 'safari13'], - // Don't minify for debug builds minify: 'esbuild', - // Produce sourcemaps for debug builds sourcemap: true, }, -}); \ No newline at end of file +})