formatting

This commit is contained in:
Tickbase
2025-05-17 22:49:09 +02:00
parent ecd05f1980
commit 76bfea819b
46 changed files with 2905 additions and 2743 deletions

View File

@@ -7,37 +7,46 @@ assignees: ''
--- ---
## Bug Description ## Bug Description
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
## Steps To Reproduce ## Steps To Reproduce
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
4. See error 4. See error
## Expected Behavior ## Expected Behavior
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
## Screenshots ## Screenshots
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
## System Information ## System Information
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.] - OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
- Desktop Environment: [e.g. GNOME, KDE, etc.] - Desktop Environment: [e.g. GNOME, KDE, etc.]
- CreamLinux Version: [e.g. 0.1.0] - CreamLinux Version: [e.g. 0.1.0]
- Steam Version: [e.g. latest] - Steam Version: [e.g. latest]
## Game Information ## Game Information
- Game name: - Game name:
- Game ID (if known): - Game ID (if known):
- Native Linux or Proton: - Native Linux or Proton:
- Steam installation path: - Steam installation path:
## Additional Context ## Additional Context
Add any other context about the problem here. Add any other context about the problem here.
## Logs ## Logs
If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file. If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
``` ```
Paste log content here Paste log content here
``` ```

View File

@@ -7,17 +7,22 @@ assignees: ''
--- ---
## Feature Description ## Feature Description
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
## Problem This Feature Solves ## Problem This Feature Solves
Is your feature request related to a problem? Please describe. Is your feature request related to a problem? Please describe.
Ex. I'm always frustrated when [...] Ex. I'm always frustrated when [...]
## Alternatives You've Considered ## Alternatives You've Considered
A clear and concise description of any alternative solutions or features you've considered. A clear and concise description of any alternative solutions or features you've considered.
## Additional Context ## Additional Context
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.
## Implementation Ideas (Optional) ## Implementation Ideas (Optional)
If you have any ideas on how this feature could be implemented, please share them here. If you have any ideas on how this feature could be implemented, please share them here.

View File

@@ -1,10 +1,10 @@
name: "Build CreamLinux" name: 'Build CreamLinux'
on: on:
push: push:
branches: [ "main" ] branches: ['main']
pull_request: pull_request:
branches: [ "main" ] branches: ['main']
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
dist
node_modules
src-tauri/target

View File

@@ -1,6 +1,6 @@
{ {
"semi": false, "semi": false,
"singleQuote": false, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"trailingComma": "es5" "trailingComma": "es5"
} }

View File

@@ -29,23 +29,28 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
### Building from Source ### Building from Source
#### Prerequisites #### Prerequisites
- Rust 1.77.2 or later - Rust 1.77.2 or later
- Node.js 18 or later - Node.js 18 or later
- npm or yarn - npm or yarn
#### Steps #### Steps
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://github.com/yourusername/creamlinux.git git clone https://github.com/yourusername/creamlinux.git
cd creamlinux cd creamlinux
``` ```
2. Install dependencies: 2. Install dependencies:
```bash ```bash
npm install # or yarn npm install # or yarn
``` ```
3. Build the application: 3. Build the application:
```bash ```bash
NO_STRIP=true npm run tauri build 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: If you're using the AppImage version, you can integrate it into your desktop environment:
1. Create a desktop entry file: 1. Create a desktop entry file:
```bash ```bash
mkdir -p ~/.local/share/applications mkdir -p ~/.local/share/applications
``` ```
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage): 2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
``` ```
[Desktop Entry] [Desktop Entry]
Name=Creamlinux 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: 3. Update your desktop database so creamlinux appears in your app launcher:
```bash ```bash
update-desktop-database ~/.local/share/applications update-desktop-database ~/.local/share/applications
``` ```

View File

@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist'] }, { ignores: ['dist', 'node_modules', 'src-tauri/target'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
@@ -19,10 +19,7 @@ export default tseslint.config(
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'warn',
{ allowConstantExport: true },
],
},
}, },
}
) )

View File

@@ -8,9 +8,6 @@ repository = ""
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.2.0", features = [] } tauri-build = { version = "2.2.0", features = [] }
@@ -37,6 +34,4 @@ num_cpus = "1.16.0"
futures = "0.3.31" futures = "0.3.31"
[features] [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"]

View File

@@ -2,10 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "enables the default permissions", "description": "enables the default permissions",
"windows": [ "windows": ["main"],
"main" "permissions": ["core:default"]
],
"permissions": [
"core:default"
]
} }

View File

@@ -1,13 +1,11 @@
// src/cache.rs use crate::dlc_manager::DlcInfoWithState;
use log::{info, warn};
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::path::{PathBuf};
use std::fs; use std::fs;
use std::io; use std::io;
use std::time::{SystemTime}; use std::path::PathBuf;
use log::{info, warn}; use std::time::SystemTime;
use crate::dlc_manager::DlcInfoWithState;
// Cache entry with timestamp for expiration // Cache entry with timestamp for expiration
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -52,8 +50,8 @@ where
}); });
// Serialize and write to file // Serialize and write to file
let serialized = serde_json::to_string(&json_data) let serialized =
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; serde_json::to_string(&json_data).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(cache_file, serialized)?; fs::write(cache_file, serialized)?;
info!("Saved cache for key: {}", key); info!("Saved cache for key: {}", key);
@@ -125,7 +123,11 @@ where
let data: T = match serde_json::from_value(json_value["data"].clone()) { let data: T = match serde_json::from_value(json_value["data"].clone()) {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
warn!("Failed to parse data in cache file {}: {}", cache_file.display(), e); warn!(
"Failed to parse data in cache file {}: {}",
cache_file.display(),
e
);
return None; return None;
} }
}; };

View File

@@ -1,12 +1,11 @@
// src/dlc_manager.rs use log::{error, info};
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use log::{info, error};
use std::collections::{HashMap, HashSet};
use tauri::Manager; use tauri::Manager;
/// More detailed DLC information with enabled state // More detailed DLC information with enabled state
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DlcInfoWithState { pub struct DlcInfoWithState {
pub appid: String, pub appid: String,
@@ -14,21 +13,24 @@ pub struct DlcInfoWithState {
pub enabled: bool, 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<Vec<String>, String> { pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
info!("Reading enabled DLCs from {}", game_path); info!("Reading enabled DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini"); let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() { 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) { let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c, 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 in_dlc_section = false;
let mut enabled_dlcs = Vec::new(); let mut enabled_dlcs = Vec::new();
@@ -41,7 +43,7 @@ pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
continue; 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(']') { if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false; in_dlc_section = false;
continue; continue;
@@ -64,21 +66,24 @@ pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
Ok(enabled_dlcs) 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<Vec<DlcInfoWithState>, String> { pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
info!("Reading all DLCs from {}", game_path); info!("Reading all DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini"); let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() { 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) { let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c, 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 in_dlc_section = false;
let mut all_dlcs = Vec::new(); let mut all_dlcs = Vec::new();
@@ -91,7 +96,7 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
continue; 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(']') { if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false; in_dlc_section = false;
continue; continue;
@@ -120,31 +125,40 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
} }
} }
info!("Found {} total DLCs ({} enabled, {} disabled)", info!(
"Found {} total DLCs ({} enabled, {} disabled)",
all_dlcs.len(), all_dlcs.len(),
all_dlcs.iter().filter(|d| d.enabled).count(), all_dlcs.iter().filter(|d| d.enabled).count(),
all_dlcs.iter().filter(|d| !d.enabled).count()); all_dlcs.iter().filter(|d| !d.enabled).count()
);
Ok(all_dlcs) Ok(all_dlcs)
} }
/// Update the cream_api.ini file with the user's DLC selections // Update the cream_api.ini file with the user's DLC selections
pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> { pub fn update_dlc_configuration(
game_path: &str,
dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for {}", game_path); info!("Updating DLC configuration for {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini"); let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() { 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 // Read the current file contents
let current_contents = match fs::read_to_string(&cream_api_path) { let current_contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c, 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 // Create a mapping of DLC appid to its state for easy lookup
let dlc_states: HashMap<String, (bool, String)> = dlcs.iter() let dlc_states: HashMap<String, (bool, String)> = dlcs
.iter()
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone()))) .map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
.collect(); .collect();
@@ -165,7 +179,7 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
continue; 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(']') { if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false; in_dlc_section = false;
@@ -218,15 +232,15 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
} }
processed_dlcs.insert(appid.to_string()); processed_dlcs.insert(appid.to_string());
} else { } else {
// Not in our list - keep the original line // Not in our list keep the original line
new_contents.push(line.to_string()); new_contents.push(line.to_string());
} }
} else { } 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()); new_contents.push(line.to_string());
} }
} else if !in_dlc_section || trimmed.is_empty() { } 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()); new_contents.push(line.to_string());
} }
} }
@@ -247,9 +261,12 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
// Write the updated file // Write the updated file
match fs::write(&cream_api_path, new_contents.join("\n")) { match fs::write(&cream_api_path, new_contents.join("\n")) {
Ok(_) => { Ok(_) => {
info!("Successfully updated DLC configuration at {}", cream_api_path.display()); info!(
"Successfully updated DLC configuration at {}",
cream_api_path.display()
);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
error!("Failed to write updated cream_api.ini: {}", e); error!("Failed to write updated cream_api.ini: {}", e);
Err(format!("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<DlcInfoWithState>) ->
} }
} }
/// 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)] #[allow(dead_code)]
fn extract_app_id_from_config(game_path: &str) -> Option<String> { fn extract_app_id_from_config(game_path: &str) -> Option<String> {
if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) { if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) {
@@ -269,17 +286,20 @@ fn extract_app_id_from_config(game_path: &str) -> Option<String> {
None None
} }
/// Create a custom installation with selected DLCs // Create a custom installation with selected DLCs
pub async fn install_cream_with_dlcs( pub async fn install_cream_with_dlcs(
game_id: String, game_id: String,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
selected_dlcs: Vec<DlcInfoWithState> selected_dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::AppState; use crate::AppState;
// Count enabled DLCs for logging // Count enabled DLCs for logging
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count(); let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
info!("Starting installation of CreamLinux with {} selected DLCs", enabled_dlc_count); info!(
"Starting installation of CreamLinux with {} selected DLCs",
enabled_dlc_count
);
// Get the game from state // Get the game from state
let game = { let game = {
@@ -287,17 +307,21 @@ pub async fn install_cream_with_dlcs(
let games = state.games.lock(); let games = state.games.lock();
match games.get(&game_id) { match games.get(&game_id) {
Some(g) => g.clone(), Some(g) => g.clone(),
None => return Err(format!("Game with ID {} not found", game_id)) None => return Err(format!("Game with ID {} not found", game_id)),
} }
}; };
info!("Installing CreamLinux for game: {} ({})", game.title, game_id); info!(
"Installing CreamLinux for game: {} ({})",
game.title, game_id
);
// Install CreamLinux first - but provide the DLCs directly instead of fetching them again // Install CreamLinux first - but provide the DLCs directly instead of fetching them again
use crate::installer::install_creamlinux_with_dlcs; use crate::installer::install_creamlinux_with_dlcs;
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled // Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
let enabled_dlcs = selected_dlcs.iter() let enabled_dlcs = selected_dlcs
.iter()
.filter(|dlc| dlc.enabled) .filter(|dlc| dlc.enabled)
.map(|dlc| crate::installer::DlcInfo { .map(|dlc| crate::installer::DlcInfo {
appid: dlc.appid.clone(), appid: dlc.appid.clone(),
@@ -323,14 +347,19 @@ pub async fn install_cream_with_dlcs(
progress * 100.0, // Scale progress from 0 to 100% progress * 100.0, // Scale progress from 0 to 100%
false, false,
false, false,
None None,
); );
}
).await {
Ok(_) => {
info!("CreamLinux installation completed successfully for game: {}", game.title);
Ok(())
}, },
)
.await
{
Ok(_) => {
info!(
"CreamLinux installation completed successfully for game: {}",
game.title
);
Ok(())
}
Err(e) => { Err(e) => {
error!("Failed to install CreamLinux: {}", e); error!("Failed to install CreamLinux: {}", e);
Err(format!("Failed to install CreamLinux: {}", e)) Err(format!("Failed to install CreamLinux: {}", e))

View File

@@ -1,35 +1,35 @@
// src/installer.rs use crate::AppState;
use serde::{Serialize, Deserialize}; use log::{error, info, warn};
use reqwest;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::Path; use std::path::Path;
use log::{info, error, warn}; use std::sync::atomic::Ordering;
use reqwest; use std::time::Duration;
use tauri::Manager;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tempfile::tempdir; use tempfile::tempdir;
use zip::ZipArchive; 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 // 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"; const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI";
// Type of installer // Type of installer
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum InstallerType { pub enum InstallerType {
Cream, Cream,
Smoke Smoke,
} }
// Action to perform // Action to perform
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum InstallerAction { pub enum InstallerAction {
Install, Install,
Uninstall Uninstall,
} }
// Error type combining all possible errors // Error type combining all possible errors
@@ -72,14 +72,14 @@ impl std::fmt::Display for InstallerError {
impl std::error::Error for InstallerError {} impl std::error::Error for InstallerError {}
/// DLC Information structure // DLC Information structure
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DlcInfo { pub struct DlcInfo {
pub appid: String, pub appid: String,
pub name: String, pub name: String,
} }
/// Struct to hold installation instructions for the frontend // Struct to hold installation instructions for the frontend
#[derive(Serialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]
pub struct InstallationInstructions { pub struct InstallationInstructions {
#[serde(rename = "type")] #[serde(rename = "type")]
@@ -89,7 +89,7 @@ pub struct InstallationInstructions {
pub dlc_count: Option<usize>, pub dlc_count: Option<usize>,
} }
/// Game information structure from searcher module // Game information structure from searcher module
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Game { pub struct Game {
pub id: String, pub id: String,
@@ -102,7 +102,7 @@ pub struct Game {
pub installing: bool, pub installing: bool,
} }
/// Emit a progress update to the frontend // Emit a progress update to the frontend
pub fn emit_progress( pub fn emit_progress(
app_handle: &AppHandle, app_handle: &AppHandle,
title: &str, title: &str,
@@ -110,7 +110,7 @@ pub fn emit_progress(
progress: f32, progress: f32,
complete: bool, complete: bool,
show_instructions: bool, show_instructions: bool,
instructions: Option<InstallationInstructions> instructions: Option<InstallationInstructions>,
) { ) {
let mut payload = json!({ let mut payload = json!({
"title": title, "title": title,
@@ -129,13 +129,13 @@ pub fn emit_progress(
} }
} }
/// Process a single game action (install/uninstall Cream/Smoke) // Process a single game action (install/uninstall Cream/Smoke)
pub async fn process_action( pub async fn process_action(
_game_id: String, _game_id: String,
installer_type: InstallerType, installer_type: InstallerType,
action: InstallerAction, action: InstallerAction,
game: Game, game: Game,
app_handle: AppHandle app_handle: AppHandle,
) -> Result<(), String> { ) -> Result<(), String> {
match (installer_type, action) { match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => { (InstallerType::Cream, InstallerAction::Install) => {
@@ -154,7 +154,7 @@ pub async fn process_action(
10.0, 10.0,
false, false,
false, false,
None None,
); );
// Fetch DLC list // Fetch DLC list
@@ -176,7 +176,7 @@ pub async fn process_action(
30.0, 30.0,
false, false,
false, false,
None None,
); );
// Install CreamLinux // Install CreamLinux
@@ -192,16 +192,18 @@ pub async fn process_action(
30.0 + (progress * 60.0), // Scale progress from 30% to 90% 30.0 + (progress * 60.0), // Scale progress from 30% to 90%
false, false,
false, false,
None None,
); );
}).await { })
.await
{
Ok(_) => { Ok(_) => {
// Emit completion with instructions // Emit completion with instructions
let instructions = InstallationInstructions { let instructions = InstallationInstructions {
type_: "cream_install".to_string(), type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(), command: "sh ./cream.sh %command%".to_string(),
game_title: game_title.clone(), game_title: game_title.clone(),
dlc_count: Some(dlc_count) dlc_count: Some(dlc_count),
}; };
emit_progress( emit_progress(
@@ -211,22 +213,24 @@ pub async fn process_action(
100.0, 100.0,
true, true,
true, true,
Some(instructions) Some(instructions),
); );
info!("CreamLinux installation completed for: {}", game_title); info!("CreamLinux installation completed for: {}", game_title);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
error!("Failed to install CreamLinux: {}", e); error!("Failed to install CreamLinux: {}", e);
Err(format!("Failed to install CreamLinux: {}", e)) Err(format!("Failed to install CreamLinux: {}", e))
} }
} }
}, }
(InstallerType::Cream, InstallerAction::Uninstall) => { (InstallerType::Cream, InstallerAction::Uninstall) => {
// Ensure this is a native game // Ensure this is a native game
if !game.native { 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(); let game_title = game.title.clone();
@@ -239,7 +243,7 @@ pub async fn process_action(
30.0, 30.0,
false, false,
false, false,
None None,
); );
// Uninstall CreamLinux // Uninstall CreamLinux
@@ -250,7 +254,7 @@ pub async fn process_action(
type_: "cream_uninstall".to_string(), type_: "cream_uninstall".to_string(),
command: "sh ./cream.sh %command%".to_string(), command: "sh ./cream.sh %command%".to_string(),
game_title: game_title.clone(), game_title: game_title.clone(),
dlc_count: None dlc_count: None,
}; };
emit_progress( emit_progress(
@@ -260,18 +264,18 @@ pub async fn process_action(
100.0, 100.0,
true, true,
true, true,
Some(instructions) Some(instructions),
); );
info!("CreamLinux uninstallation completed for: {}", game_title); info!("CreamLinux uninstallation completed for: {}", game_title);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
error!("Failed to uninstall CreamLinux: {}", e); error!("Failed to uninstall CreamLinux: {}", e);
Err(format!("Failed to uninstall CreamLinux: {}", e)) Err(format!("Failed to uninstall CreamLinux: {}", e))
} }
} }
}, }
(InstallerType::Smoke, InstallerAction::Install) => { (InstallerType::Smoke, InstallerAction::Install) => {
// We only allow SmokeAPI for Proton/Windows games // We only allow SmokeAPI for Proton/Windows games
if game.native { if game.native {
@@ -280,7 +284,9 @@ pub async fn process_action(
// Check if we have any Steam API DLLs to patch // Check if we have any Steam API DLLs to patch
if game.api_files.is_empty() { 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(); let game_title = game.title.clone();
@@ -293,7 +299,7 @@ pub async fn process_action(
10.0, 10.0,
false, false,
false, false,
None None,
); );
// Create clones for the closure // Create clones for the closure
@@ -311,16 +317,19 @@ pub async fn process_action(
10.0 + (progress * 90.0), // Scale progress from 10% to 100% 10.0 + (progress * 90.0), // Scale progress from 10% to 100%
false, false,
false, false,
None None,
); );
}).await { })
.await
{
Ok(_) => { Ok(_) => {
// Emit completion with instructions // Emit completion with instructions
let instructions = InstallationInstructions { let instructions = InstallationInstructions {
type_: "smoke_install".to_string(), 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(), game_title: game_title.clone(),
dlc_count: Some(game.api_files.len()) dlc_count: Some(game.api_files.len()),
}; };
emit_progress( emit_progress(
@@ -330,22 +339,24 @@ pub async fn process_action(
100.0, 100.0,
true, true,
true, true,
Some(instructions) Some(instructions),
); );
info!("SmokeAPI installation completed for: {}", game_title); info!("SmokeAPI installation completed for: {}", game_title);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
error!("Failed to install SmokeAPI: {}", e); error!("Failed to install SmokeAPI: {}", e);
Err(format!("Failed to install SmokeAPI: {}", e)) Err(format!("Failed to install SmokeAPI: {}", e))
} }
} }
}, }
(InstallerType::Smoke, InstallerAction::Uninstall) => { (InstallerType::Smoke, InstallerAction::Uninstall) => {
// Ensure this is a non-native game // Ensure this is a non-native game
if game.native { 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(); let game_title = game.title.clone();
@@ -358,7 +369,7 @@ pub async fn process_action(
30.0, 30.0,
false, false,
false, false,
None None,
); );
// Uninstall SmokeAPI // Uninstall SmokeAPI
@@ -369,7 +380,7 @@ pub async fn process_action(
type_: "smoke_uninstall".to_string(), type_: "smoke_uninstall".to_string(),
command: "Original Steam API files have been restored.".to_string(), command: "Original Steam API files have been restored.".to_string(),
game_title: game_title.clone(), game_title: game_title.clone(),
dlc_count: None dlc_count: None,
}; };
emit_progress( emit_progress(
@@ -379,12 +390,12 @@ pub async fn process_action(
100.0, 100.0,
true, true,
true, true,
Some(instructions) Some(instructions),
); );
info!("SmokeAPI uninstallation completed for: {}", game_title); info!("SmokeAPI uninstallation completed for: {}", game_title);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
error!("Failed to uninstall SmokeAPI: {}", e); error!("Failed to uninstall SmokeAPI: {}", e);
Err(format!("Failed to uninstall SmokeAPI: {}", e)) Err(format!("Failed to uninstall SmokeAPI: {}", e))
@@ -394,19 +405,15 @@ pub async fn process_action(
} }
} }
// // Install CreamLinux for a game
// CreamLinux specific functions
//
/// Install CreamLinux for a game
async fn install_creamlinux<F>( async fn install_creamlinux<F>(
game_path: &str, game_path: &str,
app_id: &str, app_id: &str,
dlcs: Vec<DlcInfo>, dlcs: Vec<DlcInfo>,
progress_callback: F progress_callback: F,
) -> Result<(), InstallerError> ) -> Result<(), InstallerError>
where where
F: Fn(f32, &str) + Send + 'static F: Fn(f32, &str) + Send + 'static,
{ {
// Progress update // Progress update
progress_callback(0.1, "Preparing to download CreamLinux..."); progress_callback(0.1, "Preparing to download CreamLinux...");
@@ -415,15 +422,17 @@ where
let client = reqwest::Client::new(); let client = reqwest::Client::new();
progress_callback(0.2, "Downloading CreamLinux..."); 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)) .timeout(Duration::from_secs(30))
.send() .send()
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(InstallerError::InstallationError( return Err(InstallerError::InstallationError(format!(
format!("Failed to download CreamLinux: HTTP {}", response.status()) "Failed to download CreamLinux: HTTP {}",
)); response.status()
)));
} }
// Save to temporary file // Save to temporary file
@@ -488,16 +497,15 @@ where
Ok(()) Ok(())
} }
/// Install CreamLinux for a game with pre-fetched DLC list // Install CreamLinux for a game with pre-fetched DLC list
/// This avoids the redundant network calls to Steam API
pub async fn install_creamlinux_with_dlcs<F>( pub async fn install_creamlinux_with_dlcs<F>(
game_path: &str, game_path: &str,
app_id: &str, app_id: &str,
dlcs: Vec<DlcInfo>, dlcs: Vec<DlcInfo>,
progress_callback: F progress_callback: F,
) -> Result<(), InstallerError> ) -> Result<(), InstallerError>
where where
F: Fn(f32, &str) + Send + 'static F: Fn(f32, &str) + Send + 'static,
{ {
// Progress update // Progress update
progress_callback(0.1, "Preparing to download CreamLinux..."); progress_callback(0.1, "Preparing to download CreamLinux...");
@@ -506,15 +514,17 @@ where
let client = reqwest::Client::new(); let client = reqwest::Client::new();
progress_callback(0.2, "Downloading CreamLinux..."); 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)) .timeout(Duration::from_secs(30))
.send() .send()
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(InstallerError::InstallationError( return Err(InstallerError::InstallationError(format!(
format!("Failed to download CreamLinux: HTTP {}", response.status()) "Failed to download CreamLinux: HTTP {}",
)); response.status()
)));
} }
// Save to temporary file // Save to temporary file
@@ -579,7 +589,7 @@ where
Ok(()) Ok(())
} }
/// Uninstall CreamLinux from a game // Uninstall CreamLinux from a game
fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> { fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
info!("Uninstalling CreamLinux from: {}", game_path); info!("Uninstalling CreamLinux from: {}", game_path);
@@ -589,7 +599,7 @@ fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
"cream_api.ini", "cream_api.ini",
"cream_api.so", "cream_api.so",
"lib32Creamlinux.so", "lib32Creamlinux.so",
"lib64Creamlinux.so" "lib64Creamlinux.so",
]; ];
for file in &files_to_remove { for file in &files_to_remove {
@@ -609,34 +619,39 @@ fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
Ok(()) Ok(())
} }
/// Fetch DLC details from Steam API // Fetch DLC details from Steam API
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerError> { pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerError> {
let client = reqwest::Client::new(); 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
);
let response = client.get(&base_url) let response = client
.get(&base_url)
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.send() .send()
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(InstallerError::InstallationError( return Err(InstallerError::InstallationError(format!(
format!("Failed to fetch game details: HTTP {}", response.status()) "Failed to fetch game details: HTTP {}",
)); response.status()
)));
} }
let data: serde_json::Value = response.json().await?; 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(|app| app.get("data"))
.and_then(|data| data.get("dlc")) .and_then(|data| data.get("dlc"))
{ {
Some(dlc_array) => { Some(dlc_array) => match dlc_array.as_array() {
match dlc_array.as_array() { Some(array) => array
Some(array) => array.iter() .iter()
.filter_map(|id| id.as_u64().map(|n| n.to_string())) .filter_map(|id| id.as_u64().map(|n| n.to_string()))
.collect::<Vec<String>>(), .collect::<Vec<String>>(),
_ => Vec::new(), _ => Vec::new(),
}
}, },
_ => Vec::new(), _ => Vec::new(),
}; };
@@ -646,12 +661,16 @@ pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerEr
let mut dlc_details = Vec::new(); let mut dlc_details = Vec::new();
for dlc_id in dlc_ids { 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 // Add a small delay to avoid rate limiting
tokio::time::sleep(Duration::from_millis(300)).await; 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)) .timeout(Duration::from_secs(10))
.send() .send()
.await?; .await?;
@@ -659,15 +678,14 @@ pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerEr
if dlc_response.status().is_success() { if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response.json().await?; 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(|app| app.get("data"))
.and_then(|data| data.get("name")) .and_then(|data| data.get("name"))
{ {
Some(name) => { Some(name) => match name.as_str() {
match name.as_str() {
Some(s) => s.to_string(), Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(), _ => "Unknown DLC".to_string(),
}
}, },
_ => "Unknown DLC".to_string(), _ => "Unknown DLC".to_string(),
}; };
@@ -684,26 +702,39 @@ pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerEr
} }
} }
info!("Successfully retrieved details for {} DLCs", dlc_details.len()); info!(
"Successfully retrieved details for {} DLCs",
dlc_details.len()
);
Ok(dlc_details) Ok(dlc_details)
} }
/// Fetch DLC details from Steam API with progress updates // 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<Vec<DlcInfo>, InstallerError> { pub async fn fetch_dlc_details_with_progress(
info!("Starting DLC details fetch with progress for game ID: {}", app_id); app_id: &str,
app_handle: &tauri::AppHandle,
) -> Result<Vec<DlcInfo>, InstallerError> {
info!(
"Starting DLC details fetch with progress for game ID: {}",
app_id
);
// Get a reference to a cancellation flag from app state // Get a reference to a cancellation flag from app state
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let should_cancel = state.fetch_cancellation.clone(); let should_cancel = state.fetch_cancellation.clone();
let client = reqwest::Client::new(); 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 initial progress
emit_dlc_progress(app_handle, "Looking up game details...", 5, None); emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
info!("Emitted initial DLC progress: 5%"); info!("Emitted initial DLC progress: 5%");
let response = client.get(&base_url) let response = client
.get(&base_url)
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.send() .send()
.await?; .await?;
@@ -715,23 +746,28 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
} }
let data: serde_json::Value = response.json().await?; 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(|app| app.get("data"))
.and_then(|data| data.get("dlc")) .and_then(|data| data.get("dlc"))
{ {
Some(dlc_array) => { Some(dlc_array) => match dlc_array.as_array() {
match dlc_array.as_array() { Some(array) => array
Some(array) => array.iter() .iter()
.filter_map(|id| id.as_u64().map(|n| n.to_string())) .filter_map(|id| id.as_u64().map(|n| n.to_string()))
.collect::<Vec<String>>(), .collect::<Vec<String>>(),
_ => Vec::new(), _ => Vec::new(),
}
}, },
_ => Vec::new(), _ => Vec::new(),
}; };
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id); 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()); info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len());
let mut dlc_details = Vec::new(); let mut dlc_details = Vec::new();
@@ -741,7 +777,9 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
// Check if cancellation was requested // Check if cancellation was requested
if should_cancel.load(Ordering::SeqCst) { if should_cancel.load(Ordering::SeqCst) {
info!("DLC fetch cancelled for game {}", app_id); info!("DLC fetch cancelled for game {}", app_id);
return Err(InstallerError::InstallationError("Operation cancelled by user".to_string())); 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_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0;
let progress_rounded = progress_percent as u32; let progress_rounded = progress_percent as u32;
@@ -759,20 +797,29 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
"almost done".to_string() "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( emit_dlc_progress(
app_handle, app_handle,
&format!("Processing DLC {}/{}", index + 1, total_dlcs), &format!("Processing DLC {}/{}", index + 1, total_dlcs),
progress_rounded, 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 // Add a small delay to avoid rate limiting
tokio::time::sleep(Duration::from_millis(300)).await; 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)) .timeout(Duration::from_secs(10))
.send() .send()
.await?; .await?;
@@ -780,15 +827,14 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
if dlc_response.status().is_success() { if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response.json().await?; 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(|app| app.get("data"))
.and_then(|data| data.get("name")) .and_then(|data| data.get("name"))
{ {
Some(name) => { Some(name) => match name.as_str() {
match name.as_str() {
Some(s) => s.to_string(), Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(), _ => "Unknown DLC".to_string(),
}
}, },
_ => "Unknown DLC".to_string(), _ => "Unknown DLC".to_string(),
}; };
@@ -812,25 +858,38 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { } else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
// If rate limited, wait longer // If rate limited, wait longer
error!("Rate limited by Steam API, waiting 10 seconds"); 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; tokio::time::sleep(Duration::from_secs(10)).await;
} }
} }
// Final progress update // Final progress update
info!("Completed DLC fetch. Found {} DLCs in total", dlc_details.len()); info!(
emit_dlc_progress(app_handle, &format!("Completed! Found {} DLCs", dlc_details.len()), 100, None); "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%"); info!("Emitted final DLC progress: 100%");
Ok(dlc_details) Ok(dlc_details)
} }
/// Emit DLC progress updates to the frontend // Emit DLC progress updates to the frontend
fn emit_dlc_progress( fn emit_dlc_progress(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
message: &str, message: &str,
progress: u32, progress: u32,
time_left: Option<&str> time_left: Option<&str>,
) { ) {
let mut payload = json!({ let mut payload = json!({
"message": message, "message": message,
@@ -846,34 +905,35 @@ fn emit_dlc_progress(
} }
} }
// // Install SmokeAPI for a game
// SmokeAPI specific functions
//
/// Install SmokeAPI for a game
async fn install_smokeapi<F>( async fn install_smokeapi<F>(
game_path: &str, game_path: &str,
api_files: &[String], api_files: &[String],
progress_callback: F progress_callback: F,
) -> Result<(), InstallerError> ) -> Result<(), InstallerError>
where 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..."); progress_callback(0.1, "Fetching latest SmokeAPI release...");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let releases_url = format!("https://api.github.com/repos/{}/releases/latest", SMOKEAPI_REPO); let releases_url = format!(
"https://api.github.com/repos/{}/releases/latest",
SMOKEAPI_REPO
);
let response = client.get(&releases_url) let response = client
.get(&releases_url)
.header("User-Agent", "CreamLinux") .header("User-Agent", "CreamLinux")
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.send() .send()
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(InstallerError::InstallationError( return Err(InstallerError::InstallationError(format!(
format!("Failed to fetch SmokeAPI releases: HTTP {}", response.status()) "Failed to fetch SmokeAPI releases: HTTP {}",
)); response.status()
)));
} }
let release_info: serde_json::Value = response.json().await?; let release_info: serde_json::Value = response.json().await?;
@@ -884,33 +944,35 @@ where
info!("Latest SmokeAPI version: {}", latest_version); info!("Latest SmokeAPI version: {}", latest_version);
// 2. Construct download URL // Construct download URL
let zip_url = format!( let zip_url = format!(
"https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip", "https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip",
SMOKEAPI_REPO, latest_version, latest_version SMOKEAPI_REPO, latest_version, latest_version
); );
// 3. Download the zip // Download the zip
progress_callback(0.3, "Downloading SmokeAPI..."); progress_callback(0.3, "Downloading SmokeAPI...");
let response = client.get(&zip_url) let response = client
.get(&zip_url)
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.send() .send()
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(InstallerError::InstallationError( return Err(InstallerError::InstallationError(format!(
format!("Failed to download SmokeAPI: HTTP {}", response.status()) "Failed to download SmokeAPI: HTTP {}",
)); response.status()
)));
} }
// 4. Save to temporary file // Save to temporary file
progress_callback(0.5, "Saving downloaded files..."); progress_callback(0.5, "Saving downloaded files...");
let temp_dir = tempdir()?; let temp_dir = tempdir()?;
let zip_path = temp_dir.path().join("smokeapi.zip"); let zip_path = temp_dir.path().join("smokeapi.zip");
let content = response.bytes().await?; let content = response.bytes().await?;
fs::write(&zip_path, &content)?; 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..."); progress_callback(0.6, "Extracting SmokeAPI files...");
let file = fs::File::open(&zip_path)?; let file = fs::File::open(&zip_path)?;
let mut archive = ZipArchive::new(file)?; let mut archive = ZipArchive::new(file)?;
@@ -919,7 +981,11 @@ where
let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3; let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3;
progress_callback(progress, &format!("Installing SmokeAPI for {}", api_file)); 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(); let api_name = Path::new(api_file).file_name().unwrap_or_default();
// Backup original file // Backup original file
@@ -941,9 +1007,10 @@ where
io::copy(&mut file, &mut outfile)?; io::copy(&mut file, &mut outfile)?;
info!("Installed SmokeAPI as: {}", original_path.display()); info!("Installed SmokeAPI as: {}", original_path.display());
} else { } else {
return Err(InstallerError::InstallationError( return Err(InstallerError::InstallationError(format!(
format!("Could not find {} in the SmokeAPI zip file", api_name.to_string_lossy()) "Could not find {} in the SmokeAPI zip file",
)); api_name.to_string_lossy()
)));
} }
} }
@@ -952,7 +1019,7 @@ where
Ok(()) Ok(())
} }
/// Uninstall SmokeAPI from a game // Uninstall SmokeAPI from a game
fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> { fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> {
info!("Uninstalling SmokeAPI from: {}", game_path); info!("Uninstalling SmokeAPI from: {}", game_path);
@@ -972,7 +1039,11 @@ fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), Insta
if original_path.exists() { if original_path.exists() {
match fs::remove_file(&original_path) { match fs::remove_file(&original_path) {
Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()), 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
),
} }
} }
@@ -980,9 +1051,15 @@ fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), Insta
match fs::rename(&backup_path, &original_path) { match fs::rename(&backup_path, &original_path) {
Ok(_) => info!("Restored original file: {}", original_path.display()), Ok(_) => info!("Restored original file: {}", original_path.display()),
Err(e) => { 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 // 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); error!("Failed to copy backup file: {}", copy_err);
} }
} }

View File

@@ -1,27 +1,26 @@
// src/main.rs
#![cfg_attr( #![cfg_attr(
all(not(debug_assertions), target_os = "windows"), all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
mod searcher; mod cache;
mod installer;
mod dlc_manager; 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 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::AtomicBool;
use std::sync::atomic::Ordering; 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)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GameAction { pub struct GameAction {
@@ -50,7 +49,10 @@ fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, Stri
// Scan and get the list of Steam games // Scan and get the list of Steam games
#[tauri::command] #[tauri::command]
async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<Vec<Game>, String> { async fn scan_steam_games(
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Vec<Game>, String> {
info!("Starting Steam games scan"); info!("Starting Steam games scan");
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10); emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
@@ -67,24 +69,50 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
unique_libraries.insert(lib.to_string_lossy().to_string()); 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() { 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 // Find installed games
let games_info = searcher::find_installed_games(&libraries).await; 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 // Log summary of games found
info!("Games scan complete - Found {} games", games_info.len()); info!("Games scan complete - Found {} games", games_info.len());
info!("Native games: {}", games_info.iter().filter(|g| g.native).count()); info!(
info!("Proton games: {}", games_info.iter().filter(|g| !g.native).count()); "Native games: {}",
info!("Games with CreamLinux: {}", games_info.iter().filter(|g| g.cream_installed).count()); games_info.iter().filter(|g| g.native).count()
info!("Games with SmokeAPI: {}", games_info.iter().filter(|g| g.smoke_installed).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 // Convert to our Game struct
let mut result = Vec::new(); let mut result = Vec::new();
@@ -92,8 +120,10 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
info!("Processing games into application state..."); info!("Processing games into application state...");
for game_info in games_info { for game_info in games_info {
// Only log detailed game info at Debug level to keep Info logs cleaner // Only log detailed game info at Debug level to keep Info logs cleaner
debug!("Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}", debug!(
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed); "Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
);
let game = Game { let game = Game {
id: game_info.id, id: game_info.id,
@@ -112,7 +142,11 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
state.games.lock().insert(game.id.clone(), game); 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"); info!("Game scan completed successfully");
Ok(result) Ok(result)
@@ -137,7 +171,8 @@ fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u3
#[tauri::command] #[tauri::command]
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> { fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
let games = state.games.lock(); let games = state.games.lock();
games.get(&game_id) games
.get(&game_id)
.cloned() .cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id)) .ok_or_else(|| format!("Game with ID {} not found", game_id))
} }
@@ -147,12 +182,13 @@ fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String
async fn process_game_action( async fn process_game_action(
game_action: GameAction, game_action: GameAction,
state: State<'_, AppState>, state: State<'_, AppState>,
app_handle: tauri::AppHandle app_handle: tauri::AppHandle,
) -> Result<Game, String> { ) -> Result<Game, String> {
// Clone the information we need from state to avoid lifetime issues // Clone the information we need from state to avoid lifetime issues
let game = { let game = {
let games = state.games.lock(); let games = state.games.lock();
games.get(&game_action.game_id) games
.get(&game_action.game_id)
.cloned() .cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
}; };
@@ -163,7 +199,7 @@ let (installer_type, action) = match game_action.action.as_str() {
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall), "uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install), "install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall), "uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
_ => return Err(format!("Invalid action: {}", game_action.action)) _ => return Err(format!("Invalid action: {}", game_action.action)),
}; };
// Execute the action // Execute the action
@@ -172,26 +208,31 @@ installer::process_action(
installer_type, installer_type,
action, action,
game.clone(), game.clone(),
app_handle.clone() app_handle.clone(),
).await?; )
.await?;
// Update game status in state based on the action // Update game status in state based on the action
let updated_game = { let updated_game = {
let mut games_map = state.games.lock(); let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_action.game_id) let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
.ok_or_else(|| format!("Game with ID {} not found after action", game_action.game_id))?; format!(
"Game with ID {} not found after action",
game_action.game_id
)
})?;
// Update installation status // Update installation status
match (installer_type, action) { match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => { (InstallerType::Cream, InstallerAction::Install) => {
game.cream_installed = true; game.cream_installed = true;
}, }
(InstallerType::Cream, InstallerAction::Uninstall) => { (InstallerType::Cream, InstallerAction::Uninstall) => {
game.cream_installed = false; game.cream_installed = false;
}, }
(InstallerType::Smoke, InstallerAction::Install) => { (InstallerType::Smoke, InstallerAction::Install) => {
game.smoke_installed = true; game.smoke_installed = true;
}, }
(InstallerType::Smoke, InstallerAction::Uninstall) => { (InstallerType::Smoke, InstallerAction::Uninstall) => {
game.smoke_installed = false; game.smoke_installed = false;
} }
@@ -204,8 +245,6 @@ let updated_game = {
game.clone() game.clone()
}; };
// Removed cache update
// Emit an event to update the UI for this specific game // Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) { if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e); warn!("Failed to emit game-updated event: {}", e);
@@ -216,16 +255,18 @@ Ok(updated_game)
// Fetch DLC list for a game // Fetch DLC list for a game
#[tauri::command] #[tauri::command]
async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<Vec<DlcInfoWithState>, String> { async fn fetch_game_dlcs(
game_id: String,
app_handle: tauri::AppHandle,
) -> Result<Vec<DlcInfoWithState>, String> {
info!("Fetching DLCs for game ID: {}", game_id); info!("Fetching DLCs for game ID: {}", game_id);
// Removed cache checking // Fetch DLC data
// Always fetch fresh DLC data instead of using cache
match installer::fetch_dlc_details(&game_id).await { match installer::fetch_dlc_details(&game_id).await {
Ok(dlcs) => { Ok(dlcs) => {
// Convert to DlcInfoWithState (all enabled by default) // Convert to DlcInfoWithState
let dlcs_with_state = dlcs.into_iter() let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState { .map(|dlc| DlcInfoWithState {
appid: dlc.appid, appid: dlc.appid,
name: dlc.name, name: dlc.name,
@@ -236,14 +277,17 @@ async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resul
// Cache in memory for this session (but not on disk) // Cache in memory for this session (but not on disk)
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let mut cache = state.dlc_cache.lock(); let mut cache = state.dlc_cache.lock();
cache.insert(game_id.clone(), DlcCache { cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state.clone(), data: dlcs_with_state.clone(),
timestamp: Instant::now(), timestamp: Instant::now(),
}); },
);
Ok(dlcs_with_state) Ok(dlcs_with_state)
}, }
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)) Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
} }
} }
@@ -269,15 +313,18 @@ fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(),
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Streaming DLCs for game ID: {}", game_id); info!("Streaming DLCs for game ID: {}", game_id);
// Removed cached DLC check - always fetch fresh data // Fetch DLC data from API
// Always fetch fresh DLC data from API
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await { match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
Ok(dlcs) => { Ok(dlcs) => {
info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id); info!(
"Successfully streamed {} DLCs for game {}",
dlcs.len(),
game_id
);
// Convert to DLCInfoWithState for in-memory caching only // Convert to DLCInfoWithState for in-memory caching only
let dlcs_with_state = dlcs.into_iter() let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState { .map(|dlc| DlcInfoWithState {
appid: dlc.appid, appid: dlc.appid,
name: dlc.name, name: dlc.name,
@@ -288,13 +335,16 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu
// Update in-memory cache without storing to disk // Update in-memory cache without storing to disk
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let mut dlc_cache = state.dlc_cache.lock(); let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(game_id.clone(), DlcCache { dlc_cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state, data: dlcs_with_state,
timestamp: tokio::time::Instant::now(), timestamp: tokio::time::Instant::now(),
}); },
);
Ok(()) Ok(())
}, }
Err(e) => { Err(e) => {
error!("Failed to stream DLC details: {}", e); error!("Failed to stream DLC details: {}", e);
// Emit error event // Emit error event
@@ -327,7 +377,10 @@ fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
// Update the DLC configuration for a game // Update the DLC configuration for a game
#[tauri::command] #[tauri::command]
fn update_dlc_configuration_command(game_path: String, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> { fn update_dlc_configuration_command(
game_path: String,
dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for: {}", game_path); info!("Updating DLC configuration for: {}", game_path);
dlc_manager::update_dlc_configuration(&game_path, dlcs) dlc_manager::update_dlc_configuration(&game_path, dlcs)
} }
@@ -337,15 +390,20 @@ fn update_dlc_configuration_command(game_path: String, dlcs: Vec<DlcInfoWithStat
async fn install_cream_with_dlcs_command( async fn install_cream_with_dlcs_command(
game_id: String, game_id: String,
selected_dlcs: Vec<DlcInfoWithState>, selected_dlcs: Vec<DlcInfoWithState>,
app_handle: tauri::AppHandle app_handle: tauri::AppHandle,
) -> Result<Game, String> { ) -> Result<Game, String> {
info!("Installing CreamLinux with selected DLCs for game: {}", game_id); info!(
"Installing CreamLinux with selected DLCs for game: {}",
game_id
);
// Clone selected_dlcs for later use // Clone selected_dlcs for later use
let selected_dlcs_clone = selected_dlcs.clone(); let selected_dlcs_clone = selected_dlcs.clone();
// Install CreamLinux with the selected DLCs // Install CreamLinux with the selected DLCs
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs).await { match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
.await
{
Ok(_) => { Ok(_) => {
// Return updated game info // Return updated game info
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
@@ -353,8 +411,9 @@ async fn install_cream_with_dlcs_command(
// Get a mutable reference and update the game // Get a mutable reference and update the game
let game = { let game = {
let mut games_map = state.games.lock(); let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_id) let game = games_map.get_mut(&game_id).ok_or_else(|| {
.ok_or_else(|| format!("Game with ID {} not found after installation", game_id))?; format!("Game with ID {} not found after installation", game_id)
})?;
// Update installation status // Update installation status
game.cream_installed = true; game.cream_installed = true;
@@ -362,9 +421,7 @@ async fn install_cream_with_dlcs_command(
// Clone the game for returning later // Clone the game for returning later
game.clone() game.clone()
}; // mutable borrow ends here };
// Removed game caching
// Emit an event to update the UI // Emit an event to update the UI
if let Err(e) = app_handle.emit("game-updated", &game) { if let Err(e) = app_handle.emit("game-updated", &game) {
@@ -376,7 +433,7 @@ async fn install_cream_with_dlcs_command(
type_: "cream_install".to_string(), type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(), command: "sh ./cream.sh %command%".to_string(),
game_title: game.title.clone(), game_title: game.title.clone(),
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()) dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
}; };
installer::emit_progress( installer::emit_progress(
@@ -386,14 +443,17 @@ async fn install_cream_with_dlcs_command(
100.0, 100.0,
true, true,
true, true,
Some(instructions) Some(instructions),
); );
Ok(game) Ok(game)
}, }
Err(e) => { Err(e) => {
error!("Failed to install CreamLinux with selected DLCs: {}", e); error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!("Failed to install CreamLinux with selected DLCs: {}", e)) Err(format!(
"Failed to install CreamLinux with selected DLCs: {}",
e
))
} }
} }
} }
@@ -417,10 +477,10 @@ fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Create a file appender with improved log format // Create a file appender
let file = FileAppender::builder() let file = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new( .encoder(Box::new(PatternEncoder::new(
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n" "[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
))) )))
.build(log_path)?; .build(log_path)?;

View File

@@ -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::fs;
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; 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 std::sync::Arc;
use tokio::sync::mpsc;
use walkdir::WalkDir;
/// Game information structure // Game information structure
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GameInfo { pub struct GameInfo {
pub id: String, pub id: String,
@@ -21,7 +20,7 @@ pub struct GameInfo {
pub smoke_installed: bool, pub smoke_installed: bool,
} }
/// Find potential Steam installation directories // Find potential Steam installation directories
pub fn get_default_steam_paths() -> Vec<PathBuf> { pub fn get_default_steam_paths() -> Vec<PathBuf> {
let mut paths = Vec::new(); let mut paths = Vec::new();
@@ -48,11 +47,8 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
} }
} }
// Add Steam Deck paths if they exist (these don't rely on HOME) // Add Steam Deck paths if they exist
let deck_paths = [ let deck_paths = ["/home/deck/.steam/steam", "/home/deck/.local/share/Steam"];
"/home/deck/.steam/steam",
"/home/deck/.local/share/Steam",
];
for path in &deck_paths { for path in &deck_paths {
let p = PathBuf::from(path); let p = PathBuf::from(path);
@@ -76,7 +72,7 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
paths 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<Vec<PathBuf>> { fn read_steam_registry() -> Option<Vec<PathBuf>> {
let home = match std::env::var("HOME") { let home = match std::env::var("HOME") {
Ok(h) => h, Ok(h) => h,
@@ -123,7 +119,7 @@ fn read_steam_registry() -> Option<Vec<PathBuf>> {
None 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<PathBuf> { pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
let mut libraries = HashSet::new(); let mut libraries = HashSet::new();
@@ -165,7 +161,7 @@ pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
result 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<PathBuf>) { fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
// Check both possible locations of the VDF file // Check both possible locations of the VDF file
let vdf_paths = [ let vdf_paths = [
@@ -199,7 +195,7 @@ fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<Path
} }
} }
/// Parse an appmanifest ACF file to extract game information // Parse an appmanifest ACF file to extract game information
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> { fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(content) => { Ok(content) => {
@@ -211,7 +207,7 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = ( if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
re_appid.captures(&content), re_appid.captures(&content),
re_name.captures(&content), re_name.captures(&content),
re_installdir.captures(&content) re_installdir.captures(&content),
) { ) {
let app_id = app_id_cap[1].to_string(); let app_id = app_id_cap[1].to_string();
let name = name_cap[1].to_string(); let name = name_cap[1].to_string();
@@ -228,26 +224,25 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
None 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 { fn is_elf_binary(path: &Path) -> bool {
if let Ok(mut file) = fs::File::open(path) { if let Ok(mut file) = fs::File::open(path) {
let mut buffer = [0; 4]; let mut buffer = [0; 4];
if file.read_exact(&mut buffer).is_ok() { if file.read_exact(&mut buffer).is_ok() {
// Check for ELF magic number (0x7F 'E' 'L' 'F') // 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 false
} }
/// Check if a game has CreamLinux installed // Check if a game has CreamLinux installed
fn check_creamlinux_installed(game_path: &Path) -> bool { fn check_creamlinux_installed(game_path: &Path) -> bool {
let cream_files = [ let cream_files = ["cream.sh", "cream_api.ini", "cream_api.so"];
"cream.sh",
"cream_api.ini",
"cream_api.so",
];
for file in &cream_files { for file in &cream_files {
if game_path.join(file).exists() { if game_path.join(file).exists() {
@@ -259,7 +254,7 @@ fn check_creamlinux_installed(game_path: &Path) -> bool {
false 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 { fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
if api_files.is_empty() { if api_files.is_empty() {
return false; return false;
@@ -284,8 +279,8 @@ fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
false false
} }
/// Scan a game directory to determine if it's native or needs Proton // Scan a game directory to determine if it's native or needs Proton
/// Also collect any Steam API DLLs for potential SmokeAPI installation // Also collect any Steam API DLLs for potential SmokeAPI installation
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) { fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
let mut found_exe = false; let mut found_exe = false;
let mut found_linux_binary = false; let mut found_linux_binary = false;
@@ -293,11 +288,21 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
// Directories to skip for better performance // Directories to skip for better performance
let skip_dirs = [ let skip_dirs = [
"videos", "video", "movies", "movie", "videos",
"sound", "sounds", "audio", "video",
"textures", "music", "localization", "movies",
"shaders", "logs", "assets/audio", "movie",
"assets/video", "assets/textures" "sound",
"sounds",
"audio",
"textures",
"music",
"localization",
"shaders",
"logs",
"assets/audio",
"assets/video",
"assets/textures",
]; ];
// Only scan to a reasonable depth (avoid extreme recursion) // Only scan to a reasonable depth (avoid extreme recursion)
@@ -307,7 +312,7 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
let exe_extensions = ["exe", "bat", "cmd", "msi"]; let exe_extensions = ["exe", "bat", "cmd", "msi"];
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"]; let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
// Recursively walk through the game directory with optimized settings // Recursively walk through the game directory
for entry in WalkDir::new(game_path) for entry in WalkDir::new(game_path)
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep .max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
.follow_links(false) // Don't follow symlinks to prevent cycles .follow_links(false) // Don't follow symlinks to prevent cycles
@@ -323,8 +328,8 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
} }
true true
}) })
.filter_map(Result::ok) { .filter_map(Result::ok)
{
let path = entry.path(); let path = entry.path();
if !path.is_file() { if !path.is_file() {
continue; continue;
@@ -341,7 +346,11 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
// Check for Steam API DLLs // Check for Steam API DLLs
if ext_str == "dll" { if ext_str == "dll" {
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if filename == "steam_api.dll" || filename == "steam_api64.dll" { if filename == "steam_api.dll" || filename == "steam_api64.dll" {
if let Ok(rel_path) = path.strip_prefix(game_path) { if let Ok(rel_path) = path.strip_prefix(game_path) {
let rel_path_str = rel_path.to_string_lossy().to_string(); let rel_path_str = rel_path.to_string_lossy().to_string();
@@ -378,7 +387,6 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
} }
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop // 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() { if found_exe && found_linux_binary && !steam_api_files.is_empty() {
debug!("Found sufficient evidence, breaking scan early"); debug!("Found sufficient evidence, breaking scan early");
break; break;
@@ -388,25 +396,34 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
// A game is considered native if it has Linux binaries but no Windows executables // A game is considered native if it has Linux binaries but no Windows executables
let is_native = found_linux_binary && !found_exe; let is_native = found_linux_binary && !found_exe;
debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len()); debug!(
"Game scan results: native={}, exe={}, api_dlls={}",
is_native,
found_exe,
steam_api_files.len()
);
(is_native, steam_api_files) (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<GameInfo> { pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
let mut games = Vec::new(); let mut games = Vec::new();
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new())); let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
// IDs to skip (tools, redistributables, etc.) // IDs to skip (tools, redistributables, etc.)
let skip_ids = Arc::new([ let skip_ids = Arc::new(
[
"228980", // Steamworks Common Redistributables "228980", // Steamworks Common Redistributables
"1070560", // Steam Linux Runtime "1070560", // Steam Linux Runtime
"1391110", // Steam Linux Runtime - Soldier "1391110", // Steam Linux Runtime - Soldier
"1628350", // Steam Linux Runtime - Sniper "1628350", // Steam Linux Runtime - Sniper
"1493710", // Proton Experimental "1493710", // Proton Experimental
"2180100", // Steam Linux Runtime - Scout "2180100", // Steam Linux Runtime - Scout
].iter().copied().collect::<HashSet<&str>>()); ]
.iter()
.copied()
.collect::<HashSet<&str>>(),
);
// Name patterns to skip (case insensitive) // Name patterns to skip (case insensitive)
let skip_patterns = Arc::new( let skip_patterns = Arc::new(
@@ -420,7 +437,7 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
] ]
.iter() .iter()
.map(|pat| Regex::new(pat).unwrap()) .map(|pat| Regex::new(pat).unwrap())
.collect::<Vec<_>>() .collect::<Vec<_>>(),
); );
info!("Scanning for installed games in parallel..."); info!("Scanning for installed games in parallel...");
@@ -446,7 +463,7 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
info!("Found {} appmanifest files to process", app_manifests.len()); info!("Found {} appmanifest files to process", app_manifests.len());
// Process each appmanifest file in parallel with a maximum concurrency // Process appmanifest files
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
info!("Using {} concurrent scanners", max_concurrent); info!("Using {} concurrent scanners", max_concurrent);
@@ -555,8 +572,10 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
while let Some(game) = rx.recv().await { while let Some(game) = rx.recv().await {
info!("Found game: {} ({})", game.title, game.id); info!("Found game: {} ({})", game.title, game.id);
info!(" Path: {}", game.path.display()); info!(" Path: {}", game.path.display());
info!(" Status: Native={}, Cream={}, Smoke={}", info!(
game.native, game.cream_installed, game.smoke_installed); " Status: Native={}, Cream={}, Smoke={}",
game.native, game.cream_installed, game.smoke_installed
);
// Log Steam API DLLs if any // Log Steam API DLLs if any
if !game.api_files.is_empty() { if !game.api_files.is_empty() {
@@ -571,9 +590,9 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
results results
}); });
// Wait for all scan tasks to complete - but don't wait for the results yet // Wait for all scan tasks to complete but don't wait for the results yet
for handle in handles { for handle in handles {
// Ignore errors - the receiver task will just get fewer results // Ignore errors the receiver task will just get fewer results
let _ = handle.await; let _ = handle.await;
} }

View File

@@ -10,11 +10,7 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"category": "Utility", "category": "Utility",
"icon": [ "icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png"
]
}, },
"productName": "Creamlinux", "productName": "Creamlinux",
"mainBinaryName": "creamlinux", "mainBinaryName": "creamlinux",

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
// 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 { interface ActionButtonProps {
action: ActionType; action: ActionType
isInstalled: boolean; isInstalled: boolean
isWorking: boolean; isWorking: boolean
onClick: () => void; onClick: () => void
disabled?: boolean; disabled?: boolean
} }
const ActionButton: React.FC<ActionButtonProps> = ({ const ActionButton: React.FC<ActionButtonProps> = ({
@@ -16,31 +15,27 @@ const ActionButton: React.FC<ActionButtonProps> = ({
isInstalled, isInstalled,
isWorking, isWorking,
onClick, onClick,
disabled = false disabled = false,
}) => { }) => {
const getButtonText = () => { const getButtonText = () => {
if (isWorking) return "Working..."; if (isWorking) return 'Working...'
const isCream = action.includes('cream'); const isCream = action.includes('cream')
const product = isCream ? "CreamLinux" : "SmokeAPI"; const product = isCream ? 'CreamLinux' : 'SmokeAPI'
return isInstalled ? `Uninstall ${product}` : `Install ${product}`; return isInstalled ? `Uninstall ${product}` : `Install ${product}`
}; }
const getButtonClass = () => { const getButtonClass = () => {
const baseClass = "action-button"; const baseClass = 'action-button'
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`; return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`
}; }
return ( return (
<button <button className={getButtonClass()} onClick={onClick} disabled={disabled || isWorking}>
className={getButtonClass()}
onClick={onClick}
disabled={disabled || isWorking}
>
{getButtonText()} {getButtonText()}
</button> </button>
); )
}; }
export default ActionButton; export default ActionButton

View File

@@ -1,37 +1,36 @@
// src/components/AnimatedBackground.tsx import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef } from 'react';
const AnimatedBackground: React.FC = () => { const AnimatedBackground: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current
if (!canvas) return; if (!canvas) return
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d')
if (!ctx) return; if (!ctx) return
// Set canvas size to match window // Set canvas size to match window
const setCanvasSize = () => { const setCanvasSize = () => {
canvas.width = window.innerWidth; canvas.width = window.innerWidth
canvas.height = window.innerHeight; canvas.height = window.innerHeight
}; }
setCanvasSize(); setCanvasSize()
window.addEventListener('resize', setCanvasSize); window.addEventListener('resize', setCanvasSize)
// Create particles // Create particles
const particles: Particle[] = []; const particles: Particle[] = []
const particleCount = 30; const particleCount = 30
interface Particle { interface Particle {
x: number; x: number
y: number; y: number
size: number; size: number
speedX: number; speedX: number
speedY: number; speedY: number
opacity: number; opacity: number
color: string; color: string
} }
// Color palette // Color palette
@@ -39,7 +38,7 @@ const AnimatedBackground: React.FC = () => {
'rgba(74, 118, 196, 0.5)', // primary blue 'rgba(74, 118, 196, 0.5)', // primary blue
'rgba(155, 125, 255, 0.5)', // purple 'rgba(155, 125, 255, 0.5)', // purple
'rgba(251, 177, 60, 0.5)', // gold 'rgba(251, 177, 60, 0.5)', // gold
]; ]
// Create initial particles // Create initial particles
for (let i = 0; i < particleCount; i++) { for (let i = 0; i < particleCount; i++) {
@@ -50,61 +49,61 @@ const AnimatedBackground: React.FC = () => {
speedX: Math.random() * 0.2 - 0.1, speedX: Math.random() * 0.2 - 0.1,
speedY: Math.random() * 0.2 - 0.1, speedY: Math.random() * 0.2 - 0.1,
opacity: Math.random() * 0.07 + 0.03, 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 // Animation loop
const animate = () => { const animate = () => {
// Clear canvas with transparent black to create fade effect // Clear canvas with transparent black to create fade effect
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'; ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height)
// Update and draw particles // Update and draw particles
particles.forEach(particle => { particles.forEach((particle) => {
// Update position // Update position
particle.x += particle.speedX; particle.x += particle.speedX
particle.y += particle.speedY; particle.y += particle.speedY
// Wrap around edges // Wrap around edges
if (particle.x < 0) particle.x = canvas.width; if (particle.x < 0) particle.x = canvas.width
if (particle.x > canvas.width) particle.x = 0; if (particle.x > canvas.width) particle.x = 0
if (particle.y < 0) particle.y = canvas.height; if (particle.y < 0) particle.y = canvas.height
if (particle.y > canvas.height) particle.y = 0; if (particle.y > canvas.height) particle.y = 0
// Draw particle // Draw particle
ctx.beginPath(); ctx.beginPath()
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`); ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
ctx.fill(); ctx.fill()
// Connect particles // Connect particles
particles.forEach(otherParticle => { particles.forEach((otherParticle) => {
const dx = particle.x - otherParticle.x; const dx = particle.x - otherParticle.x
const dy = particle.y - otherParticle.y; const dy = particle.y - otherParticle.y
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < 100) { if (distance < 100) {
ctx.beginPath(); ctx.beginPath()
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`); ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`)
ctx.lineWidth = 0.2; ctx.lineWidth = 0.2
ctx.moveTo(particle.x, particle.y); ctx.moveTo(particle.x, particle.y)
ctx.lineTo(otherParticle.x, otherParticle.y); ctx.lineTo(otherParticle.x, otherParticle.y)
ctx.stroke(); ctx.stroke()
} }
}); })
}); })
requestAnimationFrame(animate); requestAnimationFrame(animate)
}; }
// Start animation // Start animation
animate(); animate()
return () => { return () => {
window.removeEventListener('resize', setCanvasSize); window.removeEventListener('resize', setCanvasSize)
}; }
}, []); }, [])
return ( return (
<canvas <canvas
@@ -118,10 +117,10 @@ const AnimatedBackground: React.FC = () => {
height: '100%', height: '100%',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 0, zIndex: 0,
opacity: 0.4 opacity: 0.4,
}} }}
/> />
); )
}; }
export default AnimatedBackground; export default AnimatedBackground

View File

@@ -1,12 +1,11 @@
// src/components/AnimatedCheckbox.tsx import React from 'react'
import React from 'react';
interface AnimatedCheckboxProps { interface AnimatedCheckboxProps {
checked: boolean; checked: boolean
onChange: () => void; onChange: () => void
label?: string; label?: string
sublabel?: string; sublabel?: string
className?: string; className?: string
} }
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
@@ -14,16 +13,11 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
onChange, onChange,
label, label,
sublabel, sublabel,
className = '' className = '',
}) => { }) => {
return ( return (
<label className={`animated-checkbox ${className}`}> <label className={`animated-checkbox ${className}`}>
<input <input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
type="checkbox"
checked={checked}
onChange={onChange}
className="checkbox-original"
/>
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}> <span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
<svg viewBox="0 0 24 24" className="checkmark-icon"> <svg viewBox="0 0 24 24" className="checkmark-icon">
<path <path
@@ -44,7 +38,7 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
</div> </div>
)} )}
</label> </label>
); )
}; }
export default AnimatedCheckbox; export default AnimatedCheckbox

View File

@@ -1,23 +1,22 @@
// src/components/DlcSelectionDialog.tsx import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo } from 'react'; import AnimatedCheckbox from './AnimatedCheckbox'
import AnimatedCheckbox from './AnimatedCheckbox';
interface DlcInfo { interface DlcInfo {
appid: string; appid: string
name: string; name: string
enabled: boolean; enabled: boolean
} }
interface DlcSelectionDialogProps { interface DlcSelectionDialogProps {
visible: boolean; visible: boolean
gameTitle: string; gameTitle: string
dlcs: DlcInfo[]; dlcs: DlcInfo[]
onClose: () => void; onClose: () => void
onConfirm: (selectedDlcs: DlcInfo[]) => void; onConfirm: (selectedDlcs: DlcInfo[]) => void
isLoading: boolean; isLoading: boolean
isEditMode?: boolean; isEditMode?: boolean
loadingProgress?: number; loadingProgress?: number
estimatedTimeLeft?: string; estimatedTimeLeft?: string
} }
const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
@@ -29,118 +28,121 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
isLoading, isLoading,
isEditMode = false, isEditMode = false,
loadingProgress = 0, loadingProgress = 0,
estimatedTimeLeft = '' estimatedTimeLeft = '',
}) => { }) => {
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([]); const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false)
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true); const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false)
// Initialize selected DLCs when DLC list changes // Initialize selected DLCs when DLC list changes
useEffect(() => { useEffect(() => {
if (visible && dlcs.length > 0 && !initialized) { if (visible && dlcs.length > 0 && !initialized) {
setSelectedDlcs(dlcs); setSelectedDlcs(dlcs)
// Determine initial selectAll state based on if all DLCs are enabled // Determine initial selectAll state based on if all DLCs are enabled
const allSelected = dlcs.every(dlc => dlc.enabled); const allSelected = dlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected); setSelectAll(allSelected)
// Mark as initialized so we don't reset selections on subsequent DLC additions // 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 // Handle visibility changes
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
// Show content immediately for better UX // Show content immediately for better UX
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowContent(true); setShowContent(true)
}, 50); }, 50)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} else { } else {
setShowContent(false); setShowContent(false)
setInitialized(false); // Reset initialized state when dialog closes setInitialized(false) // Reset initialized state when dialog closes
} }
}, [visible]); }, [visible])
// Memoize filtered DLCs to avoid unnecessary recalculations // Memoize filtered DLCs to avoid unnecessary recalculations
const filteredDlcs = useMemo(() => { const filteredDlcs = useMemo(() => {
return searchQuery.trim() === '' return searchQuery.trim() === ''
? selectedDlcs ? selectedDlcs
: selectedDlcs.filter(dlc => : selectedDlcs.filter(
(dlc) =>
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) || dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
dlc.appid.includes(searchQuery) dlc.appid.includes(searchQuery)
); )
}, [selectedDlcs, searchQuery]); }, [selectedDlcs, searchQuery])
// Update DLC selection status // Update DLC selection status
const handleToggleDlc = (appid: string) => { const handleToggleDlc = (appid: string) => {
setSelectedDlcs(prev => prev.map(dlc => setSelectedDlcs((prev) =>
dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
)); )
}; }
// Update selectAll state when individual DLC selections change // Update selectAll state when individual DLC selections change
useEffect(() => { useEffect(() => {
const allSelected = selectedDlcs.every(dlc => dlc.enabled); const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected); setSelectAll(allSelected)
}, [selectedDlcs]); }, [selectedDlcs])
// Handle new DLCs being added while dialog is already open // Handle new DLCs being added while dialog is already open
useEffect(() => { useEffect(() => {
if (initialized && dlcs.length > selectedDlcs.length) { if (initialized && dlcs.length > selectedDlcs.length) {
// Find new DLCs that aren't in our current selection // Find new DLCs that aren't in our current selection
const currentAppIds = new Set(selectedDlcs.map(dlc => dlc.appid)); const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
const newDlcs = dlcs.filter(dlc => !currentAppIds.has(dlc.appid)); const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
// Add new DLCs to our selection, maintaining their enabled state // Add new DLCs to our selection, maintaining their enabled state
if (newDlcs.length > 0) { if (newDlcs.length > 0) {
setSelectedDlcs(prev => [...prev, ...newDlcs]); setSelectedDlcs((prev) => [...prev, ...newDlcs])
} }
} }
}, [dlcs, selectedDlcs, initialized]); }, [dlcs, selectedDlcs, initialized])
const handleToggleSelectAll = () => { const handleToggleSelectAll = () => {
const newSelectAllState = !selectAll; const newSelectAllState = !selectAll
setSelectAll(newSelectAllState); setSelectAll(newSelectAllState)
setSelectedDlcs(prev => prev.map(dlc => ({ setSelectedDlcs((prev) =>
prev.map((dlc) => ({
...dlc, ...dlc,
enabled: newSelectAllState enabled: newSelectAllState,
}))); }))
}; )
}
const handleConfirm = () => { const handleConfirm = () => {
onConfirm(selectedDlcs); onConfirm(selectedDlcs)
}; }
// Modified to prevent closing when loading // Modified to prevent closing when loading
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Prevent clicks from propagating through the overlay // Prevent clicks from propagating through the overlay
e.stopPropagation(); e.stopPropagation()
// Only allow closing via overlay click if not loading // Only allow closing via overlay click if not loading
if (e.target === e.currentTarget && !isLoading) { if (e.target === e.currentTarget && !isLoading) {
onClose(); onClose()
}
} }
};
// Count selected DLCs // 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 // Format loading message to show total number of DLCs found
const getLoadingInfoText = () => { const getLoadingInfoText = () => {
if (isLoading && loadingProgress < 100) { if (isLoading && loadingProgress < 100) {
return ` (Loading more DLCs...)`; return ` (Loading more DLCs...)`
} else if (dlcs.length > 0) { } 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 ( return (
<div <div
@@ -179,14 +181,13 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
{isLoading && ( {isLoading && (
<div className="dlc-loading-progress"> <div className="dlc-loading-progress">
<div className="progress-bar-container"> <div className="progress-bar-container">
<div <div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
className="progress-bar"
style={{ width: `${loadingProgress}%` }}
/>
</div> </div>
<div className="loading-details"> <div className="loading-details">
<span>Loading DLCs: {loadingProgress}%</span> <span>Loading DLCs: {loadingProgress}%</span>
{estimatedTimeLeft && <span className="time-left">Est. time left: {estimatedTimeLeft}</span>} {estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div> </div>
</div> </div>
)} )}
@@ -194,7 +195,7 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
<div className="dlc-list-container"> <div className="dlc-list-container">
{selectedDlcs.length > 0 ? ( {selectedDlcs.length > 0 ? (
<ul className="dlc-list"> <ul className="dlc-list">
{filteredDlcs.map(dlc => ( {filteredDlcs.map((dlc) => (
<li key={dlc.appid} className="dlc-item"> <li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox <AnimatedCheckbox
checked={dlc.enabled} checked={dlc.enabled}
@@ -226,17 +227,13 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
> >
Cancel Cancel
</button> </button>
<button <button className="confirm-button" onClick={handleConfirm} disabled={isLoading}>
className="confirm-button"
onClick={handleConfirm}
disabled={isLoading}
>
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'} {isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default DlcSelectionDialog; export default DlcSelectionDialog

View File

@@ -1,93 +1,95 @@
// src/components/GameItem.tsx import React, { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'; import { findBestGameImage } from '../services/ImageService'
import { findBestGameImage } from '../services/ImageService'; import { ActionType } from './ActionButton'
import { ActionType } from './ActionButton';
interface Game { interface Game {
id: string; id: string
title: string; title: string
path: string; path: string
platform?: string; platform?: string
native: boolean; native: boolean
api_files: string[]; api_files: string[]
cream_installed?: boolean; cream_installed?: boolean
smoke_installed?: boolean; smoke_installed?: boolean
installing?: boolean; installing?: boolean
} }
interface GameItemProps { interface GameItemProps {
game: Game; game: Game
onAction: (gameId: string, action: ActionType) => Promise<void>; onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void; onEdit?: (gameId: string) => void
} }
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => { const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
const [imageUrl, setImageUrl] = useState<string | null>(null); const [imageUrl, setImageUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false)
useEffect(() => { useEffect(() => {
// Function to fetch the game cover/image // Function to fetch the game cover/image
const fetchGameImage = async () => { const fetchGameImage = async () => {
// First check if we already have it (to prevent flickering on re-renders) // First check if we already have it (to prevent flickering on re-renders)
if (imageUrl) return; if (imageUrl) return
setIsLoading(true); setIsLoading(true)
try { try {
// Try to find the best available image for this game // Try to find the best available image for this game
const bestImageUrl = await findBestGameImage(game.id); const bestImageUrl = await findBestGameImage(game.id)
if (bestImageUrl) { if (bestImageUrl) {
setImageUrl(bestImageUrl); setImageUrl(bestImageUrl)
setHasError(false); setHasError(false)
} else { } else {
setHasError(true); setHasError(true)
} }
} catch (error) { } catch (error) {
console.error('Error fetching game image:', error); console.error('Error fetching game image:', error)
setHasError(true); setHasError(true)
} finally { } finally {
setIsLoading(false); setIsLoading(false)
}
} }
};
if (game.id) { if (game.id) {
fetchGameImage(); fetchGameImage()
} }
}, [game.id, imageUrl]); }, [game.id, imageUrl])
// Determine if we should show CreamLinux buttons (only for native games) // 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) // 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 // 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 = () => { const handleCreamAction = () => {
if (game.installing) return; if (game.installing) return
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'; const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
onAction(game.id, action); onAction(game.id, action)
}; }
const handleSmokeAction = () => { const handleSmokeAction = () => {
if (game.installing) return; if (game.installing) return
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'; const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
onAction(game.id, action); onAction(game.id, action)
}; }
// Handle edit button click // Handle edit button click
const handleEdit = () => { const handleEdit = () => {
if (onEdit && game.cream_installed) { if (onEdit && game.cream_installed) {
onEdit(game.id); onEdit(game.id)
}
} }
};
// Determine background image // Determine background image
const backgroundImage = !isLoading && imageUrl ? const backgroundImage =
`url(${imageUrl})` : !isLoading && imageUrl
hasError ? 'linear-gradient(135deg, #232323, #1A1A1A)' : 'linear-gradient(135deg, #232323, #1A1A1A)'; ? `url(${imageUrl})`
: hasError
? 'linear-gradient(135deg, #232323, #1A1A1A)'
: 'linear-gradient(135deg, #232323, #1A1A1A)'
return ( return (
<div <div
@@ -103,12 +105,8 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}> <span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
{game.native ? 'Native' : 'Proton'} {game.native ? 'Native' : 'Proton'}
</span> </span>
{game.cream_installed && ( {game.cream_installed && <span className="status-badge cream">CreamLinux</span>}
<span className="status-badge cream">CreamLinux</span> {game.smoke_installed && <span className="status-badge smoke">SmokeAPI</span>}
)}
{game.smoke_installed && (
<span className="status-badge smoke">SmokeAPI</span>
)}
</div> </div>
<div className="game-title"> <div className="game-title">
@@ -123,7 +121,11 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
onClick={handleCreamAction} onClick={handleCreamAction}
disabled={!!game.installing} disabled={!!game.installing}
> >
{game.installing ? "Working..." : (game.cream_installed ? "Uninstall CreamLinux" : "Install CreamLinux")} {game.installing
? 'Working...'
: game.cream_installed
? 'Uninstall CreamLinux'
: 'Install CreamLinux'}
</button> </button>
)} )}
@@ -134,7 +136,11 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
onClick={handleSmokeAction} onClick={handleSmokeAction}
disabled={!!game.installing} disabled={!!game.installing}
> >
{game.installing ? "Working..." : (game.smoke_installed ? "Uninstall SmokeAPI" : "Install SmokeAPI")} {game.installing
? 'Working...'
: game.smoke_installed
? 'Uninstall SmokeAPI'
: 'Install SmokeAPI'}
</button> </button>
)} )}
@@ -166,7 +172,7 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default GameItem; export default GameItem

View File

@@ -1,64 +1,58 @@
// src/components/GameList.tsx import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo } from 'react'; import GameItem from './GameItem'
import GameItem from './GameItem'; import ImagePreloader from './ImagePreloader'
import ImagePreloader from './ImagePreloader'; import { ActionType } from './ActionButton'
import { ActionType } from './ActionButton';
interface Game { interface Game {
id: string; id: string
title: string; title: string
path: string; path: string
platform?: string; platform?: string
native: boolean; native: boolean
api_files: string[]; api_files: string[]
cream_installed?: boolean; cream_installed?: boolean
smoke_installed?: boolean; smoke_installed?: boolean
installing?: boolean; installing?: boolean
} }
interface GameListProps { interface GameListProps {
games: Game[]; games: Game[]
isLoading: boolean; isLoading: boolean
onAction: (gameId: string, action: ActionType) => Promise<void>; onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void; onEdit?: (gameId: string) => void
} }
const GameList: React.FC<GameListProps> = ({ const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit }) => {
games, const [imagesPreloaded, setImagesPreloaded] = useState(false)
isLoading,
onAction,
onEdit
}) => {
const [imagesPreloaded, setImagesPreloaded] = useState(false);
// Sort games alphabetically by title - using useMemo to avoid re-sorting on each render // Sort games alphabetically by title using useMemo to avoid re-sorting on each render
const sortedGames = useMemo(() => { const sortedGames = useMemo(() => {
return [...games].sort((a, b) => a.title.localeCompare(b.title)); return [...games].sort((a, b) => a.title.localeCompare(b.title))
}, [games]); }, [games])
// Reset preloaded state when games change // Reset preloaded state when games change
useEffect(() => { useEffect(() => {
setImagesPreloaded(false); setImagesPreloaded(false)
}, [games]); }, [games])
// Debug log to help diagnose game states // Debug log to help diagnose game states
useEffect(() => { useEffect(() => {
if (games.length > 0) { 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) { if (isLoading) {
return ( return (
<div className="game-list"> <div className="game-list">
<div className="loading-indicator">Scanning for games...</div> <div className="loading-indicator">Scanning for games...</div>
</div> </div>
); )
} }
const handlePreloadComplete = () => { const handlePreloadComplete = () => {
setImagesPreloaded(true); setImagesPreloaded(true)
}; }
return ( return (
<div className="game-list"> <div className="game-list">
@@ -66,7 +60,7 @@ const GameList: React.FC<GameListProps> = ({
{!imagesPreloaded && games.length > 0 && ( {!imagesPreloaded && games.length > 0 && (
<ImagePreloader <ImagePreloader
gameIds={sortedGames.map(game => game.id)} gameIds={sortedGames.map((game) => game.id)}
onComplete={handlePreloadComplete} onComplete={handlePreloadComplete}
/> />
)} )}
@@ -75,18 +69,13 @@ const GameList: React.FC<GameListProps> = ({
<div className="no-games-message">No games found</div> <div className="no-games-message">No games found</div>
) : ( ) : (
<div className="game-grid"> <div className="game-grid">
{sortedGames.map(game => ( {sortedGames.map((game) => (
<GameItem <GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} />
key={game.id}
game={game}
onAction={onAction}
onEdit={onEdit}
/>
))} ))}
</div> </div>
)} )}
</div> </div>
); )
}; }
export default GameList; export default GameList

View File

@@ -1,28 +1,23 @@
// src/components/Header.tsx import React from 'react'
import React from 'react';
interface HeaderProps { interface HeaderProps {
onRefresh: () => void; onRefresh: () => void
refreshDisabled?: boolean; refreshDisabled?: boolean
onSearch: (query: string) => void; onSearch: (query: string) => void
searchQuery: string; searchQuery: string
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({
onRefresh, onRefresh,
refreshDisabled = false, refreshDisabled = false,
onSearch, onSearch,
searchQuery searchQuery,
}) => { }) => {
return ( return (
<header className="app-header"> <header className="app-header">
<h1>CreamLinux</h1> <h1>CreamLinux</h1>
<div className="header-controls"> <div className="header-controls">
<button <button className="refresh-button" onClick={onRefresh} disabled={refreshDisabled}>
className="refresh-button"
onClick={onRefresh}
disabled={refreshDisabled}
>
Refresh Refresh
</button> </button>
<input <input
@@ -34,7 +29,7 @@ const Header: React.FC<HeaderProps> = ({
/> />
</div> </div>
</header> </header>
); )
}; }
export default Header; export default Header

View File

@@ -1,10 +1,9 @@
// src/components/ImagePreloader.tsx import React, { useEffect } from 'react'
import React, { useEffect } from 'react'; import { findBestGameImage } from '../services/ImageService'
import { findBestGameImage } from '../services/ImageService';
interface ImagePreloaderProps { interface ImagePreloaderProps {
gameIds: string[]; gameIds: string[]
onComplete?: () => void; onComplete?: () => void
} }
const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => { const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => {
@@ -12,37 +11,31 @@ const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete })
const preloadImages = async () => { const preloadImages = async () => {
try { try {
// Only preload the first batch for performance (10 images max) // 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 // Load images in parallel
await Promise.allSettled( await Promise.allSettled(batchToPreload.map((id) => findBestGameImage(id)))
batchToPreload.map(id => findBestGameImage(id))
);
if (onComplete) { if (onComplete) {
onComplete(); onComplete()
} }
} catch (error) { } catch (error) {
console.error("Error preloading images:", error); console.error('Error preloading images:', error)
// Continue even if there's an error // Continue even if there's an error
if (onComplete) { if (onComplete) {
onComplete(); onComplete()
}
} }
} }
};
if (gameIds.length > 0) { if (gameIds.length > 0) {
preloadImages(); preloadImages()
} else if (onComplete) { } else if (onComplete) {
onComplete(); onComplete()
} }
}, [gameIds, onComplete]); }, [gameIds, onComplete])
return ( return <div className="image-preloader">{/* Hidden element, just used for preloading */}</div>
<div className="image-preloader"> }
{/* Hidden element, just used for preloading */}
</div>
);
};
export default ImagePreloader; export default ImagePreloader

View File

@@ -1,14 +1,11 @@
import React from 'react'; import React from 'react'
interface InitialLoadingScreenProps { interface InitialLoadingScreenProps {
message: string; message: string
progress: number; progress: number
} }
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, progress }) => {
message,
progress
}) => {
return ( return (
<div className="initial-loading-screen"> <div className="initial-loading-screen">
<div className="loading-content"> <div className="loading-content">
@@ -22,15 +19,12 @@ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
</div> </div>
<p className="loading-message">{message}</p> <p className="loading-message">{message}</p>
<div className="progress-bar-container"> <div className="progress-bar-container">
<div <div className="progress-bar" style={{ width: `${progress}%` }} />
className="progress-bar"
style={{ width: `${progress}%` }}
/>
</div> </div>
<div className="progress-percentage">{Math.round(progress)}%</div> <div className="progress-percentage">{Math.round(progress)}%</div>
</div> </div>
</div> </div>
); )
}; }
export default InitialLoadingScreen; export default InitialLoadingScreen

View File

@@ -1,21 +1,20 @@
// src/components/ProgressDialog.tsx import React, { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react';
interface InstructionInfo { interface InstructionInfo {
type: string; type: string
command: string; command: string
game_title: string; game_title: string
dlc_count?: number; dlc_count?: number
} }
interface ProgressDialogProps { interface ProgressDialogProps {
title: string; title: string
message: string; message: string
progress: number; // 0-100 progress: number // 0-100
visible: boolean; visible: boolean
showInstructions?: boolean; showInstructions?: boolean
instructions?: InstructionInfo; instructions?: InstructionInfo
onClose?: () => void; onClose?: () => void
} }
const ProgressDialog: React.FC<ProgressDialogProps> = ({ const ProgressDialog: React.FC<ProgressDialogProps> = ({
@@ -25,76 +24,77 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
visible, visible,
showInstructions = false, showInstructions = false,
instructions, instructions,
onClose onClose,
}) => { }) => {
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false)
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false)
// Reset copy state when dialog visibility changes // Reset copy state when dialog visibility changes
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
setCopySuccess(false); setCopySuccess(false)
setShowContent(false); setShowContent(false)
} else { } else {
// Add a small delay to trigger the entrance animation // Add a small delay to trigger the entrance animation
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowContent(true); setShowContent(true)
}, 50); }, 50)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [visible]); }, [visible])
if (!visible) return null; if (!visible) return null
const handleCopyCommand = () => { const handleCopyCommand = () => {
if (instructions?.command) { if (instructions?.command) {
navigator.clipboard.writeText(instructions.command); navigator.clipboard.writeText(instructions.command)
setCopySuccess(true); setCopySuccess(true)
// Reset the success message after 2 seconds // Reset the success message after 2 seconds
setTimeout(() => { setTimeout(() => {
setCopySuccess(false); setCopySuccess(false)
}, 2000); }, 2000)
}
} }
};
const handleClose = () => { const handleClose = () => {
setShowContent(false); setShowContent(false)
// Delay closing to allow exit animation // Delay closing to allow exit animation
setTimeout(() => { setTimeout(() => {
if (onClose) { if (onClose) {
onClose(); onClose()
}
}, 300)
} }
}, 300);
};
// Modified to prevent closing when in progress // Prevent closing when in progress
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Always prevent propagation // Always prevent propagation
e.stopPropagation(); e.stopPropagation()
// Only allow clicking outside to close if we're done processing (100%) // Only allow clicking outside to close if we're done processing (100%)
// and showing instructions or if explicitly allowed via a prop // and showing instructions or if explicitly allowed via a prop
if (e.target === e.currentTarget && progress >= 100 && showInstructions) { if (e.target === e.currentTarget && progress >= 100 && showInstructions) {
handleClose(); handleClose()
} }
// Otherwise, do nothing - require using the close button // Otherwise, do nothing - require using the close button
}; }
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI) // Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
const showCopyButton = instructions?.type === 'cream_install' || const showCopyButton =
instructions?.type === 'cream_uninstall'; instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
// Format instruction message based on type // Format instruction message based on type
const getInstructionText = () => { const getInstructionText = () => {
if (!instructions) return null; if (!instructions) return null
switch (instructions.type) { switch (instructions.type) {
case 'cream_install': case 'cream_install':
return ( return (
<> <>
<p className="instruction-text"> <p className="instruction-text">
In Steam, set the following launch options for <strong>{instructions.game_title}</strong>: In Steam, set the following launch options for{' '}
<strong>{instructions.game_title}</strong>:
</p> </p>
{instructions.dlc_count !== undefined && ( {instructions.dlc_count !== undefined && (
<div className="dlc-count"> <div className="dlc-count">
@@ -102,13 +102,14 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
</div> </div>
)} )}
</> </>
); )
case 'cream_uninstall': case 'cream_uninstall':
return ( return (
<p className="instruction-text"> <p className="instruction-text">
For <strong>{instructions.game_title}</strong>, open Steam properties and remove the following launch option: For <strong>{instructions.game_title}</strong>, open Steam properties and remove the
following launch option:
</p> </p>
); )
case 'smoke_install': case 'smoke_install':
return ( return (
<> <>
@@ -121,44 +122,43 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
</div> </div>
)} )}
</> </>
); )
case 'smoke_uninstall': case 'smoke_uninstall':
return ( return (
<p className="instruction-text"> <p className="instruction-text">
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong> SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
</p> </p>
); )
default: default:
return ( return (
<p className="instruction-text"> <p className="instruction-text">
Done processing <strong>{instructions.game_title}</strong> Done processing <strong>{instructions.game_title}</strong>
</p> </p>
); )
}
} }
};
// Determine the CSS class for the command box based on instruction type // Determine the CSS class for the command box based on instruction type
const getCommandBoxClass = () => { 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 // Determine if close button should be enabled
const isCloseButtonEnabled = showInstructions || progress >= 100; const isCloseButtonEnabled = showInstructions || progress >= 100
return ( return (
<div <div
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`} className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<div className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}> <div
className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}
>
<h3>{title}</h3> <h3>{title}</h3>
<p>{message}</p> <p>{message}</p>
<div className="progress-bar-container"> <div className="progress-bar-container">
<div <div className="progress-bar" style={{ width: `${progress}%` }} />
className="progress-bar"
style={{ width: `${progress}%` }}
/>
</div> </div>
<div className="progress-percentage">{Math.round(progress)}%</div> <div className="progress-percentage">{Math.round(progress)}%</div>
@@ -177,10 +177,7 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
<div className="action-buttons"> <div className="action-buttons">
{showCopyButton && ( {showCopyButton && (
<button <button className="copy-button" onClick={handleCopyCommand}>
className="copy-button"
onClick={handleCopyCommand}
>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'} {copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</button> </button>
)} )}
@@ -199,17 +196,14 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
{/* Show close button even if no instructions */} {/* Show close button even if no instructions */}
{!showInstructions && progress >= 100 && ( {!showInstructions && progress >= 100 && (
<div className="action-buttons" style={{ marginTop: '1rem' }}> <div className="action-buttons" style={{ marginTop: '1rem' }}>
<button <button className="close-button" onClick={handleClose}>
className="close-button"
onClick={handleClose}
>
Close Close
</button> </button>
</div> </div>
)} )}
</div> </div>
</div> </div>
); )
}; }
export default ProgressDialog; export default ProgressDialog

View File

@@ -1,9 +1,8 @@
// src/components/Sidebar.tsx import React from 'react'
import React from 'react';
interface SidebarProps { interface SidebarProps {
setFilter: (filter: string) => void; setFilter: (filter: string) => void
currentFilter: string; currentFilter: string
} }
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => { const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
@@ -11,10 +10,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
<div className="sidebar"> <div className="sidebar">
<h2>Library</h2> <h2>Library</h2>
<ul className="filter-list"> <ul className="filter-list">
<li <li className={currentFilter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
className={currentFilter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
All Games All Games
</li> </li>
<li <li
@@ -31,7 +27,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
</li> </li>
</ul> </ul>
</div> </div>
); )
}; }
export default Sidebar; export default Sidebar

View File

@@ -5,5 +5,5 @@ import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>
) )

View File

@@ -1,5 +1,3 @@
// src/services/ImageService.ts
/** /**
* Game image sources from Steam's CDN * Game image sources from Steam's CDN
*/ */
@@ -9,12 +7,12 @@ export const SteamImageType = {
LOGO: 'logo', // Game logo with transparency LOGO: 'logo', // Game logo with transparency
LIBRARY_HERO: 'library_hero', // 1920x620 LIBRARY_HERO: 'library_hero', // 1920x620
LIBRARY_CAPSULE: 'library_600x900', // 600x900 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 // Cache for images to prevent flickering
const imageCache: Map<string, string> = new Map(); const imageCache: Map<string, string> = new Map()
/** /**
* Builds a Steam CDN URL for game images * Builds a Steam CDN URL for game images
@@ -22,9 +20,12 @@ const imageCache: Map<string, string> = new Map();
* @param type Image type from SteamImageType enum * @param type Image type from SteamImageType enum
* @returns URL string for the image * @returns URL string for the image
*/ */
export const getSteamImageUrl = (appId: string, type: typeof SteamImageType[SteamImageTypeKey]) => { export const getSteamImageUrl = (
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`; 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 * Checks if an image exists by performing a HEAD request
@@ -33,13 +34,13 @@ return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`;
*/ */
export const checkImageExists = async (url: string): Promise<boolean> => { export const checkImageExists = async (url: string): Promise<boolean> => {
try { try {
const response = await fetch(url, { method: 'HEAD' }); const response = await fetch(url, { method: 'HEAD' })
return response.ok; return response.ok
} catch (error) { } catch (error) {
console.error('Error checking image existence:', error); console.error('Error checking image existence:', error)
return false; return false
}
} }
};
/** /**
* Preloads an image for faster rendering * Preloads an image for faster rendering
@@ -48,12 +49,12 @@ try {
*/ */
const preloadImage = (url: string): Promise<string> => { const preloadImage = (url: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image()
img.onload = () => resolve(url); img.onload = () => resolve(url)
img.onerror = reject; img.onerror = reject
img.src = url; img.src = url
}); })
}; }
/** /**
* Attempts to find a valid image for a Steam game, trying different image types * Attempts to find a valid image for a Steam game, trying different image types
@@ -63,34 +64,30 @@ return new Promise((resolve, reject) => {
export const findBestGameImage = async (appId: string): Promise<string | null> => { export const findBestGameImage = async (appId: string): Promise<string | null> => {
// Check cache first // Check cache first
if (imageCache.has(appId)) { if (imageCache.has(appId)) {
return imageCache.get(appId) || null; return imageCache.get(appId) || null
} }
// Try these image types in order of preference // Try these image types in order of preference
const typesToTry = [ const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE]
SteamImageType.HEADER,
SteamImageType.CAPSULE,
SteamImageType.LIBRARY_CAPSULE
];
for (const type of typesToTry) { for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type); const url = getSteamImageUrl(appId, type)
const exists = await checkImageExists(url); const exists = await checkImageExists(url)
if (exists) { if (exists) {
try { try {
// Preload the image to prevent flickering // Preload the image to prevent flickering
const preloadedUrl = await preloadImage(url); const preloadedUrl = await preloadImage(url)
// Store in cache // Store in cache
imageCache.set(appId, preloadedUrl); imageCache.set(appId, preloadedUrl)
return preloadedUrl; return preloadedUrl
} catch { } catch {
// If preloading fails, just return the URL // If preloading fails, just return the URL
imageCache.set(appId, url); imageCache.set(appId, url)
return url; return url
} }
} }
} }
// If we've reached here, no valid image was found // If we've reached here, no valid image was found
return null; return null
}; }

View File

@@ -1,10 +1,10 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('../assets/fonts/Satoshi.ttf') format('ttf'), src:
url('../assets/fonts/Satoshi.ttf') format('ttf'),
url('../assets/fonts/Roboto.ttf') format('ttf'), url('../assets/fonts/Roboto.ttf') format('ttf'),
url('../assets/fonts/WorkSans.ttf') format('ttf'); url('../assets/fonts/WorkSans.ttf') format('ttf');
font-weight: 400; // adjust as needed font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }

View File

@@ -1,5 +1,3 @@
// src/styles/_layout.scss
@use './variables' as *; @use './variables' as *;
@use './mixins' as *; @use './mixins' as *;
@@ -57,7 +55,12 @@
left: 0; left: 0;
right: 0; right: 0;
height: 3px; 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; opacity: 0.7;
} }
@@ -88,7 +91,7 @@
z-index: var(--z-elevate); z-index: var(--z-elevate);
} }
/* Sidebar */ // Sidebar
.sidebar { .sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
min-width: var(--sidebar-width); min-width: var(--sidebar-width);
@@ -161,7 +164,8 @@
} }
// Loading and empty state // Loading and empty state
.loading-indicator, .no-games-message { .loading-indicator,
.no-games-message {
@include flex-center; @include flex-center;
height: 250px; height: 250px;
width: 100%; width: 100%;
@@ -185,12 +189,7 @@
left: -100%; left: -100%;
width: 50%; width: 50%;
height: 100%; height: 100%;
background: linear-gradient( background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
animation: loading-shimmer 2s infinite; animation: loading-shimmer 2s infinite;
} }
} }

View File

@@ -1,9 +1,5 @@
// src/styles/_mixins.scss
@use './variables' as *; @use './variables' as *;
// src/styles/_mixins.scss
// Basic flex helpers // Basic flex helpers
@mixin flex-center { @mixin flex-center {
display: flex; display: flex;
@@ -43,7 +39,7 @@
} }
@mixin shadow-hover { @mixin shadow-hover {
box-shadow: var(--shadow-hover);; box-shadow: var(--shadow-hover);
} }
@mixin text-shadow { @mixin text-shadow {
@@ -60,19 +56,27 @@
// Responsive mixins // Responsive mixins
@mixin media-sm { @mixin media-sm {
@media (min-width: 576px) { @content; } @media (min-width: 576px) {
@content;
}
} }
@mixin media-md { @mixin media-md {
@media (min-width: 768px) { @content; } @media (min-width: 768px) {
@content;
}
} }
@mixin media-lg { @mixin media-lg {
@media (min-width: 992px) { @content; } @media (min-width: 992px) {
@content;
}
} }
@mixin media-xl { @mixin media-xl {
@media (min-width: 1200px) { @content; } @media (min-width: 1200px) {
@content;
}
} }
// Card base styling // Card base styling

View File

@@ -1,9 +1,6 @@
// src/styles/_reset.scss
@use './variables' as *; @use './variables' as *;
@use './mixins' as *; @use './mixins' as *;
@use './fonts' as *; @use './fonts' as *;
// src/styles/_reset.scss
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -11,7 +8,8 @@
padding: 0; padding: 0;
} }
html, body { html,
body {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
@@ -23,7 +21,7 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--primary-bg); background-color: var(--primary-bg);
color: var(--text-primary); color: var(--text-primary);
/* Prevent text selection by default */ // Prevent text selection by default
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
@@ -51,15 +49,24 @@ a {
text-decoration: none; text-decoration: none;
} }
ul, ol { ul,
ol {
list-style: none; list-style: none;
} }
input, button, textarea, select { input,
button,
textarea,
select {
font: inherit; font: inherit;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: inherit; font-weight: inherit;
font-size: inherit; font-size: inherit;
} }

View File

@@ -1,5 +1,3 @@
// src/styles/_variables.scss
@use './fonts' as *; @use './fonts' as *;
// Color palette // Color palette
@@ -13,7 +11,7 @@
--secondary-bg: #151515; --secondary-bg: #151515;
--tertiary-bg: #121212; --tertiary-bg: #121212;
--elevated-bg: #1a1a1a; --elevated-bg: #1a1a1a;
--disabled: #5E5E5E; --disabled: #5e5e5e;
// Text // Text
--text-primary: #f0f0f0; --text-primary: #f0f0f0;
@@ -27,7 +25,7 @@
--border-soft: #282828; --border-soft: #282828;
--border: #323232; --border: #323232;
// Status colors - more vibrant // Status colors
--success: #8cc893; --success: #8cc893;
--warning: #ffc896; --warning: #ffc896;
--danger: #d96b6b; --danger: #d96b6b;
@@ -93,13 +91,6 @@
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5); --shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7); --shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
// Z-index levels
//--z-index-bg: 0;
//--z-index-content: 1;
//--z-index-header: 100;
//--z-index-modal: 1000;
//--z-index-tooltip: 1500;
// Z-index levels // Z-index levels
--z-bg: 0; --z-bg: 0;
--z-elevate: 1; --z-elevate: 1;

View File

@@ -1,5 +1,3 @@
// src/styles/components/_animated_checkbox.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;

View File

@@ -1,5 +1,3 @@
// src/styles/_components/_background.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@use 'sass:color'; @use 'sass:color';

View File

@@ -1,9 +1,7 @@
// src/styles/_components/_dialog.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
/* Progress Dialog */ // Progress Dialog
.progress-dialog-overlay { .progress-dialog-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -23,8 +21,14 @@
} }
@keyframes modal-appear { @keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); } 0% {
100% { opacity: 1; transform: scale(1); } opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
} }
@@ -32,7 +36,7 @@
background-color: var(--elevated-bg); background-color: var(--elevated-bg);
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; 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; width: 450px;
max-width: 90vw; max-width: 90vw;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
@@ -85,7 +89,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Instruction container in progress dialog */ // Instruction container in progress dialog
.instruction-container { .instruction-container {
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem; padding-top: 1rem;
@@ -164,7 +168,8 @@
justify-content: flex-end; justify-content: flex-end;
} }
.copy-button, .close-button { .copy-button,
.close-button {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 600; font-weight: 600;
@@ -180,7 +185,7 @@
&:hover { &:hover {
background-color: var(--primary-color); 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); box-shadow: 0 6px 14px var(--info-soft);
} }
} }
@@ -191,7 +196,7 @@
&:hover { &:hover {
background-color: var(--border); 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); box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
} }
} }
@@ -244,6 +249,10 @@
// Animation for progress bar // Animation for progress bar
@keyframes progress-shimmer { @keyframes progress-shimmer {
0% { transform: translateX(-100%); } 0% {
100% { transform: translateX(100%); } transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
} }

View File

@@ -1,5 +1,3 @@
// src/styles/components/_dlc_dialog.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@@ -39,7 +37,9 @@
&.dialog-visible { &.dialog-visible {
transform: scale(1); transform: scale(1);
opacity: 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;
} }
} }
@@ -189,17 +189,19 @@
.loading-pulse { .loading-pulse {
width: 70%; width: 70%;
height: 20px; height: 20px;
background: linear-gradient(90deg, background: linear-gradient(
90deg,
var(--border-soft) 0%, var(--border-soft) 0%,
var(--border) 50%, var(--border) 50%,
var(--border-soft) 100%); var(--border-soft) 100%
);
background-size: 200% 100%; background-size: 200% 100%;
border-radius: 4px; border-radius: 4px;
animation: loading-pulse 1.5s infinite; 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) { :global(.animated-checkbox) {
width: 100%; width: 100%;
@@ -261,7 +263,8 @@
gap: 1rem; gap: 1rem;
} }
.cancel-button, .confirm-button { .cancel-button,
.confirm-button {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 600; font-weight: 600;
@@ -299,16 +302,30 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
@keyframes modal-appear { @keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); } 0% {
100% { opacity: 1; transform: scale(1); } opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
@keyframes loading-pulse { @keyframes loading-pulse {
0% { background-position: 200% 50%; } 0% {
100% { background-position: 0% 50%; } background-position: 200% 50%;
}
100% {
background-position: 0% 50%;
}
} }

View File

@@ -1,5 +1,3 @@
// src/styles/components/_gamecard.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@@ -25,7 +23,7 @@
z-index: 5; z-index: 5;
.status-badge.native { .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 { .status-badge.proton {
@@ -43,11 +41,15 @@
// Special styling for cards with different statuses // Special styling for cards with different statuses
.game-item-card:has(.status-badge.cream) { .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) { .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 // Simple clean overlay
@@ -57,7 +59,8 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 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.5) 0%,
rgba(0, 0, 0, 0.6) 50%, rgba(0, 0, 0, 0.6) 50%,
rgba(0, 0, 0, 0.8) 100% rgba(0, 0, 0, 0.8) 100%
@@ -70,7 +73,7 @@
font-family: var(--family); font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased; -webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision; text-rendering: geometricPrecision;
color: var(--text-heavy);; color: var(--text-heavy);
z-index: 1; z-index: 1;
} }
@@ -92,7 +95,7 @@
font-family: var(--family); font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased; -webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision; text-rendering: geometricPrecision;
color: var(--text-heavy);; color: var(--text-heavy);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
@include transition-standard; @include transition-standard;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -129,7 +132,7 @@
margin: 0; margin: 0;
-webkit-font-smoothing: subpixel-antialiased; -webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision; text-rendering: geometricPrecision;
transform: translateZ(0); // or transform: translateZ(0);
will-change: opacity, transform; will-change: opacity, transform;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
overflow: hidden; overflow: hidden;
@@ -176,7 +179,7 @@
.action-button.uninstall:hover { .action-button.uninstall:hover {
background-color: var(--danger-light); background-color: var(--danger-light);
transform: translateY(-2px) scale(1.02); 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 { .action-button:active {
@@ -278,10 +281,16 @@
// Simple animations // Simple animations
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes button-loading { @keyframes button-loading {
to { left: 100%; } to {
left: 100%;
}
} }

View File

@@ -1,5 +1,3 @@
// src/styles/_components/_header.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;

View File

@@ -76,7 +76,12 @@
background-color: var(--primary-color); background-color: var(--primary-color);
border-radius: 4px; border-radius: 4px;
transition: width 0.5s ease; 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); box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
} }
@@ -91,10 +96,12 @@
// Animation for the bouncing circles // Animation for the bouncing circles
@keyframes bounce { @keyframes bounce {
0%, 80%, 100% { 0%,
80%,
100% {
transform: scale(0); transform: scale(0);
} }
40% { 40% {
transform: scale(1.0); transform: scale(1);
} }
} }

View File

@@ -1,5 +1,3 @@
// src/styles/_components/_sidebar.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
@@ -133,14 +131,16 @@
margin-left: -100px; margin-left: -100px;
opacity: 0; opacity: 0;
transform: translateY(10px); 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); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.8rem; font-size: 0.8rem;
pointer-events: none; pointer-events: none;
&::after { &::after {
content: ""; content: '';
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 50%; left: 50%;
@@ -197,7 +197,9 @@
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
outline: none; 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 { &::placeholder {

View File

@@ -1,5 +1,3 @@
// src/styles/main.scss
// Import variables and mixins first // Import variables and mixins first
@use './variables' as *; @use './variables' as *;
@use './mixins' as *; @use './mixins' as *;

View File

@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }

View File

@@ -1,12 +1,10 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react'
// Removed unused import: loadEnv
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// Vite options tailored for Tauri development
clearScreen: false, clearScreen: false,
server: { server: {
port: 1420, port: 1420,
@@ -14,11 +12,8 @@ export default defineConfig({
}, },
envPrefix: ['VITE_', 'TAURI_'], envPrefix: ['VITE_', 'TAURI_'],
build: { build: {
// Tauri supports es2021
target: ['es2021', 'chrome105', 'safari13'], target: ['es2021', 'chrome105', 'safari13'],
// Don't minify for debug builds
minify: 'esbuild', minify: 'esbuild',
// Produce sourcemaps for debug builds
sourcemap: true, sourcemap: true,
}, },
}); })