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.]
- Desktop Environment: [e.g. GNOME, KDE, etc.] - OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
- CreamLinux Version: [e.g. 0.1.0] - Desktop Environment: [e.g. GNOME, KDE, etc.]
- Steam Version: [e.g. latest] - CreamLinux Version: [e.g. 0.1.0]
- 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

@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }

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);
@@ -92,51 +90,55 @@ where
// Parse the JSON // Parse the JSON
let json_value: serde_json::Value = match serde_json::from_str(&cached_data) { let json_value: serde_json::Value = match serde_json::from_str(&cached_data) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
warn!("Failed to parse cache file {}: {}", cache_file.display(), e); warn!("Failed to parse cache file {}: {}", cache_file.display(), e);
return None; return None;
} }
}; };
// Extract timestamp // Extract timestamp
let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) { let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) {
Some(ts) => ts, Some(ts) => ts,
None => { None => {
warn!("Invalid timestamp in cache file {}", cache_file.display()); warn!("Invalid timestamp in cache file {}", cache_file.display());
return None; return None;
} }
}; };
// Check expiration // Check expiration
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let age_hours = (now - timestamp) / 3600; let age_hours = (now - timestamp) / 3600;
if age_hours > ttl_hours { if age_hours > ttl_hours {
info!("Cache for key {} is expired ({} hours old)", key, age_hours); info!("Cache for key {} is expired ({} hours old)", key, age_hours);
return None; return None;
} }
// Extract data // Extract data
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!(
return None; "Failed to parse data in cache file {}: {}",
} cache_file.display(),
}; e
);
return None;
}
};
info!("Using cache for key {} ({} hours old)", key, age_hours); info!("Using cache for key {} ({} hours old)", key, age_hours);
Some(data) Some(data)
} }
// Cache game scanning results // Cache game scanning results
pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> { pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> {
save_to_cache("games", games, 24) // Cache games for 24 hours save_to_cache("games", games, 24) // Cache games for 24 hours
} }
// Load cached game scanning results // Load cached game scanning results
@@ -146,7 +148,7 @@ pub fn load_cached_games() -> Option<Vec<crate::installer::Game>> {
// Cache DLC list for a game // Cache DLC list for a game
pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> { pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> {
save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours) save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours)
} }
// Load cached DLC list // Load cached DLC list

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!(
all_dlcs.len(), "Found {} total DLCs ({} enabled, {} disabled)",
all_dlcs.iter().filter(|d| d.enabled).count(), 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()
);
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,71 +286,83 @@ 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 = {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
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
.filter(|dlc| dlc.enabled) .iter()
.map(|dlc| crate::installer::DlcInfo { .filter(|dlc| dlc.enabled)
appid: dlc.appid.clone(), .map(|dlc| crate::installer::DlcInfo {
name: dlc.name.clone(), appid: dlc.appid.clone(),
}) name: dlc.name.clone(),
.collect::<Vec<_>>(); })
.collect::<Vec<_>>();
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
let game_title = game.title.clone(); let game_title = game.title.clone();
// Use direct installation with provided DLCs instead of re-fetching // Use direct installation with provided DLCs instead of re-fetching
match install_creamlinux_with_dlcs( match install_creamlinux_with_dlcs(
&game.path, &game.path,
&game_id, &game_id,
enabled_dlcs, enabled_dlcs,
move |progress, message| { move |progress, message| {
// Emit progress updates during installation // Emit progress updates during installation
use crate::installer::emit_progress; use crate::installer::emit_progress;
emit_progress( emit_progress(
&app_handle_clone, &app_handle_clone,
&format!("Installing CreamLinux for {}", game_title), &format!("Installing CreamLinux for {}", game_title),
message, message,
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(_) => { .await
info!("CreamLinux installation completed successfully for game: {}", game.title); {
Ok(()) Ok(_) => {
}, info!(
Err(e) => { "CreamLinux installation completed successfully for game: {}",
error!("Failed to install CreamLinux: {}", e); game.title
Err(format!("Failed to install CreamLinux: {}", e)) );
} Ok(())
} }
Err(e) => {
error!("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,98 +497,99 @@ 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...");
// Download CreamLinux zip // Download CreamLinux zip
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
.timeout(Duration::from_secs(30)) .get(CREAMLINUX_RELEASE_URL)
.send() .timeout(Duration::from_secs(30))
.await?; .send()
.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
progress_callback(0.4, "Saving downloaded files..."); progress_callback(0.4, "Saving downloaded files...");
let temp_dir = tempdir()?; let temp_dir = tempdir()?;
let zip_path = temp_dir.path().join("creamlinux.zip"); let zip_path = temp_dir.path().join("creamlinux.zip");
let content = response.bytes().await?; let content = response.bytes().await?;
fs::write(&zip_path, &content)?; fs::write(&zip_path, &content)?;
// Extract the zip // Extract the zip
progress_callback(0.5, "Extracting CreamLinux files..."); progress_callback(0.5, "Extracting CreamLinux 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)?;
for i in 0..archive.len() { for i in 0..archive.len() {
let mut file = archive.by_index(i)?; let mut file = archive.by_index(i)?;
let outpath = Path::new(game_path).join(file.name()); let outpath = Path::new(game_path).join(file.name());
if file.name().ends_with('/') { if file.name().ends_with('/') {
fs::create_dir_all(&outpath)?; fs::create_dir_all(&outpath)?;
} else { } else {
if let Some(p) = outpath.parent() { if let Some(p) = outpath.parent() {
if !p.exists() { if !p.exists() {
fs::create_dir_all(p)?; fs::create_dir_all(p)?;
} }
} }
let mut outfile = fs::File::create(&outpath)?; let mut outfile = fs::File::create(&outpath)?;
io::copy(&mut file, &mut outfile)?; io::copy(&mut file, &mut outfile)?;
} }
// Set executable permissions for cream.sh // Set executable permissions for cream.sh
if file.name() == "cream.sh" { if file.name() == "cream.sh" {
progress_callback(0.6, "Setting executable permissions..."); progress_callback(0.6, "Setting executable permissions...");
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&outpath)?.permissions(); let mut perms = fs::metadata(&outpath)?.permissions();
perms.set_mode(0o755); perms.set_mode(0o755);
fs::set_permissions(&outpath, perms)?; fs::set_permissions(&outpath, perms)?;
} }
} }
} }
// Create cream_api.ini with DLC info - using the provided DLCs directly // Create cream_api.ini with DLC info - using the provided DLCs directly
progress_callback(0.8, "Creating configuration file..."); progress_callback(0.8, "Creating configuration file...");
let cream_api_path = Path::new(game_path).join("cream_api.ini"); let cream_api_path = Path::new(game_path).join("cream_api.ini");
let mut config = String::new(); let mut config = String::new();
config.push_str(&format!("APPID = {}\n[config]\n", app_id)); config.push_str(&format!("APPID = {}\n[config]\n", app_id));
config.push_str("issubscribedapp_on_false_use_real = true\n"); config.push_str("issubscribedapp_on_false_use_real = true\n");
config.push_str("[methods]\n"); config.push_str("[methods]\n");
config.push_str("disable_steamapps_issubscribedapp = false\n"); config.push_str("disable_steamapps_issubscribedapp = false\n");
config.push_str("[dlc]\n"); config.push_str("[dlc]\n");
for dlc in dlcs { for dlc in dlcs {
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name)); config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
} }
fs::write(cream_api_path, config)?; fs::write(cream_api_path, config)?;
progress_callback(1.0, "Installation completed successfully!"); progress_callback(1.0, "Installation completed successfully!");
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,34 +746,41 @@ 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();
let total_dlcs = dlc_ids.len(); let total_dlcs = dlc_ids.len();
for (index, dlc_id) in dlc_ids.iter().enumerate() { for (index, dlc_id) in dlc_ids.iter().enumerate() {
// 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;
let remaining_dlcs = total_dlcs - index; let remaining_dlcs = total_dlcs - index;
@@ -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,56 +1,58 @@
// 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 {
game_id: String, game_id: String,
action: String, action: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct DlcCache { struct DlcCache {
data: Vec<DlcInfoWithState>, data: Vec<DlcInfoWithState>,
timestamp: Instant, timestamp: Instant,
} }
// Structure to hold the state of installed games // Structure to hold the state of installed games
struct AppState { struct AppState {
games: Mutex<HashMap<String, Game>>, games: Mutex<HashMap<String, Game>>,
dlc_cache: Mutex<HashMap<String, DlcCache>>, dlc_cache: Mutex<HashMap<String, DlcCache>>,
fetch_cancellation: Arc<AtomicBool>, fetch_cancellation: Arc<AtomicBool>,
} }
#[tauri::command] #[tauri::command]
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> { fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
info!("Getting all DLCs (enabled and disabled) for: {}", game_path); info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
dlc_manager::get_all_dlcs(&game_path) dlc_manager::get_all_dlcs(&game_path)
} }
// 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)
@@ -120,368 +154,394 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
// Helper function to emit scan progress events // Helper function to emit scan progress events
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) { fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
// Log first, then emit the event // Log first, then emit the event
info!("Scan progress: {}% - {}", progress, message); info!("Scan progress: {}% - {}", progress, message);
let payload = serde_json::json!({ let payload = serde_json::json!({
"message": message, "message": message,
"progress": progress "progress": progress
}); });
if let Err(e) = app_handle.emit("scan-progress", payload) { if let Err(e) = app_handle.emit("scan-progress", payload) {
warn!("Failed to emit scan-progress event: {}", e); warn!("Failed to emit scan-progress event: {}", e);
} }
} }
// Fetch game info by ID - useful for single game updates // Fetch game info by ID - useful for single game updates
#[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
.cloned() .get(&game_id)
.ok_or_else(|| format!("Game with ID {} not found", game_id)) .cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id))
} }
// Unified action handler for installation and uninstallation // Unified action handler for installation and uninstallation
#[tauri::command] #[tauri::command]
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
.cloned() .get(&game_action.game_id)
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))? .cloned()
}; .ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
};
// Parse the action string to determine type and operation // Parse the action string to determine type and operation
let (installer_type, action) = match game_action.action.as_str() { let (installer_type, action) = match game_action.action.as_str() {
"install_cream" => (InstallerType::Cream, InstallerAction::Install), "install_cream" => (InstallerType::Cream, InstallerAction::Install),
"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
installer::process_action( installer::process_action(
game_action.game_id.clone(), game_action.game_id.clone(),
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;
}
} }
// Reset installing flag
game.installing = false;
// Return updated game info
game.clone()
};
// Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
} }
// Reset installing flag Ok(updated_game)
game.installing = false;
// Return updated game info
game.clone()
};
// Removed cache update
// Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
Ok(updated_game)
} }
// 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(
info!("Fetching DLCs for game ID: {}", game_id); game_id: String,
app_handle: tauri::AppHandle,
) -> Result<Vec<DlcInfoWithState>, String> {
info!("Fetching DLCs for game ID: {}", game_id);
// Removed cache checking // Fetch DLC data
match installer::fetch_dlc_details(&game_id).await {
Ok(dlcs) => {
// Convert to DlcInfoWithState
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Always fetch fresh DLC data instead of using cache // Cache in memory for this session (but not on disk)
match installer::fetch_dlc_details(&game_id).await { let state = app_handle.state::<AppState>();
Ok(dlcs) => { let mut cache = state.dlc_cache.lock();
// Convert to DlcInfoWithState (all enabled by default) cache.insert(
let dlcs_with_state = dlcs.into_iter() game_id.clone(),
.map(|dlc| DlcInfoWithState { DlcCache {
appid: dlc.appid, data: dlcs_with_state.clone(),
name: dlc.name, timestamp: Instant::now(),
enabled: true, },
}) );
.collect::<Vec<_>>();
// Cache in memory for this session (but not on disk) Ok(dlcs_with_state)
let state = app_handle.state::<AppState>(); }
let mut cache = state.dlc_cache.lock(); Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
cache.insert(game_id.clone(), DlcCache { }
data: dlcs_with_state.clone(),
timestamp: Instant::now(),
});
Ok(dlcs_with_state)
},
Err(e) => Err(format!("Failed to fetch DLC details: {}", e))
}
} }
#[tauri::command] #[tauri::command]
fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> { fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Request to abort DLC fetch for game ID: {}", game_id); info!("Request to abort DLC fetch for game ID: {}", game_id);
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(true, Ordering::SeqCst);
// Reset after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst); state.fetch_cancellation.store(true, Ordering::SeqCst);
});
Ok(()) // Reset after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst);
});
Ok(())
} }
// Fetch DLC list with progress updates (streaming) // Fetch DLC list with progress updates (streaming)
#[tauri::command] #[tauri::command]
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
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
Ok(dlcs) => {
info!(
"Successfully streamed {} DLCs for game {}",
dlcs.len(),
game_id
);
// Always fetch fresh DLC data from API // Convert to DLCInfoWithState for in-memory caching only
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await { let dlcs_with_state = dlcs
Ok(dlcs) => { .into_iter()
info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id); .map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Convert to DLCInfoWithState for in-memory caching only // Update in-memory cache without storing to disk
let dlcs_with_state = dlcs.into_iter() let state = app_handle.state::<AppState>();
.map(|dlc| DlcInfoWithState { let mut dlc_cache = state.dlc_cache.lock();
appid: dlc.appid, dlc_cache.insert(
name: dlc.name, game_id.clone(),
enabled: true, DlcCache {
}) data: dlcs_with_state,
.collect::<Vec<_>>(); timestamp: tokio::time::Instant::now(),
},
);
// Update in-memory cache without storing to disk Ok(())
let state = app_handle.state::<AppState>(); }
let mut dlc_cache = state.dlc_cache.lock(); Err(e) => {
dlc_cache.insert(game_id.clone(), DlcCache { error!("Failed to stream DLC details: {}", e);
data: dlcs_with_state, // Emit error event
timestamp: tokio::time::Instant::now(), let error_payload = serde_json::json!({
}); "error": format!("Failed to fetch DLC details: {}", e)
});
Ok(()) if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
}, warn!("Failed to emit dlc-error event: {}", emit_err);
Err(e) => { }
error!("Failed to stream DLC details: {}", e);
// Emit error event
let error_payload = serde_json::json!({
"error": format!("Failed to fetch DLC details: {}", e)
});
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) { Err(format!("Failed to fetch DLC details: {}", e))
warn!("Failed to emit dlc-error event: {}", emit_err); }
} }
Err(format!("Failed to fetch DLC details: {}", e))
}
}
} }
// Clear caches command renamed to flush_data for clarity // Clear caches command renamed to flush_data for clarity
#[tauri::command] #[tauri::command]
fn clear_caches() -> Result<(), String> { fn clear_caches() -> Result<(), String> {
info!("Data flush requested - cleaning in-memory state only"); info!("Data flush requested - cleaning in-memory state only");
Ok(()) Ok(())
} }
// Get the list of enabled DLCs for a game // Get the list of enabled DLCs for a game
#[tauri::command] #[tauri::command]
fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> { fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
info!("Getting enabled DLCs for: {}", game_path); info!("Getting enabled DLCs for: {}", game_path);
dlc_manager::get_enabled_dlcs(&game_path) dlc_manager::get_enabled_dlcs(&game_path)
} }
// 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(
info!("Updating DLC configuration for: {}", game_path); game_path: String,
dlc_manager::update_dlc_configuration(&game_path, dlcs) dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for: {}", game_path);
dlc_manager::update_dlc_configuration(&game_path, dlcs)
} }
// Install CreamLinux with selected DLCs // Install CreamLinux with selected DLCs
#[tauri::command] #[tauri::command]
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)
Ok(_) => { .await
// Return updated game info {
let state = app_handle.state::<AppState>(); Ok(_) => {
// Return updated game info
let state = app_handle.state::<AppState>();
// 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;
game.installing = false; game.installing = false;
// 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
if let Err(e) = app_handle.emit("game-updated", &game) {
warn!("Failed to emit game-updated event: {}", e);
}
// Emit an event to update the UI // Show installation complete dialog with instructions
if let Err(e) = app_handle.emit("game-updated", &game) { let instructions = installer::InstallationInstructions {
warn!("Failed to emit game-updated event: {}", e); type_: "cream_install".to_string(),
} command: "sh ./cream.sh %command%".to_string(),
game_title: game.title.clone(),
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
};
// Show installation complete dialog with instructions installer::emit_progress(
let instructions = installer::InstallationInstructions { &app_handle,
type_: "cream_install".to_string(), &format!("Installation Completed: {}", game.title),
command: "sh ./cream.sh %command%".to_string(), "CreamLinux has been installed successfully!",
game_title: game.title.clone(), 100.0,
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()) true,
}; true,
Some(instructions),
);
installer::emit_progress( Ok(game)
&app_handle, }
&format!("Installation Completed: {}", game.title), Err(e) => {
"CreamLinux has been installed successfully!", error!("Failed to install CreamLinux with selected DLCs: {}", e);
100.0, Err(format!(
true, "Failed to install CreamLinux with selected DLCs: {}",
true, e
Some(instructions) ))
); }
}
Ok(game)
},
Err(e) => {
error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!("Failed to install CreamLinux with selected DLCs: {}", e))
}
}
} }
// Setup logging // Setup logging
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> { fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
use log::LevelFilter; use log::LevelFilter;
use log4rs::append::file::FileAppender; use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Config, Root}; use log4rs::config::{Appender, Config, Root};
use log4rs::encode::pattern::PatternEncoder; use log4rs::encode::pattern::PatternEncoder;
use std::fs; use std::fs;
// Get XDG cache directory // Get XDG cache directory
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?; let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?; let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
// Clear the log file on startup // Clear the log file on startup
if log_path.exists() { if log_path.exists() {
if let Err(e) = fs::write(&log_path, "") { if let Err(e) = fs::write(&log_path, "") {
eprintln!("Warning: Failed to clear log file: {}", e); eprintln!("Warning: Failed to clear log file: {}", e);
} }
} }
// 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)?;
// Build the config // Build the config
let config = Config::builder() let config = Config::builder()
.appender(Appender::builder().build("file", Box::new(file))) .appender(Appender::builder().build("file", Box::new(file)))
.build(Root::builder().appender("file").build(LevelFilter::Info))?; .build(Root::builder().appender("file").build(LevelFilter::Info))?;
// Initialize log4rs with this config // Initialize log4rs with this config
log4rs::init_config(config)?; log4rs::init_config(config)?;
info!("CreamLinux started with a clean log file"); info!("CreamLinux started with a clean log file");
Ok(()) Ok(())
} }
fn main() { fn main() {
// Set up logging first // Set up logging first
if let Err(e) = setup_logging() { if let Err(e) = setup_logging() {
eprintln!("Warning: Failed to initialize logging: {}", e); eprintln!("Warning: Failed to initialize logging: {}", e);
} }
info!("Initializing CreamLinux application"); info!("Initializing CreamLinux application");
let app_state = AppState { let app_state = AppState {
games: Mutex::new(HashMap::new()), games: Mutex::new(HashMap::new()),
dlc_cache: Mutex::new(HashMap::new()), dlc_cache: Mutex::new(HashMap::new()),
fetch_cancellation: Arc::new(AtomicBool::new(false)), fetch_cancellation: Arc::new(AtomicBool::new(false)),
}; };
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.manage(app_state) .manage(app_state)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
scan_steam_games, scan_steam_games,
get_game_info, get_game_info,
process_game_action, process_game_action,
fetch_game_dlcs, fetch_game_dlcs,
stream_game_dlcs, stream_game_dlcs,
get_enabled_dlcs_command, get_enabled_dlcs_command,
update_dlc_configuration_command, update_dlc_configuration_command,
install_cream_with_dlcs_command, install_cream_with_dlcs_command,
get_all_dlcs_command, get_all_dlcs_command,
clear_caches, clear_caches,
abort_dlc_fetch, abort_dlc_fetch,
]) ])
.setup(|app| { .setup(|app| {
// Add a setup handler to do any initialization work // Add a setup handler to do any initialization work
info!("Tauri application setup"); info!("Tauri application setup");
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") { if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
window.open_devtools(); window.open_devtools();
} }
} }
} }
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

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();
@@ -31,12 +30,12 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
// Common Steam installation locations on Linux // Common Steam installation locations on Linux
let common_paths = [ let common_paths = [
".steam/steam", // Steam symlink directory ".steam/steam", // Steam symlink directory
".steam/root", // Alternative symlink ".steam/root", // Alternative symlink
".local/share/Steam", // Flatpak Steam installation ".local/share/Steam", // Flatpak Steam installation
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path ".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path ".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
"/run/media/mmcblk0p1", // Removable Storage path "/run/media/mmcblk0p1", // Removable Storage path
]; ];
for path in &common_paths { for path in &common_paths {
@@ -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();
@@ -160,12 +156,12 @@ pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
let result: Vec<PathBuf> = libraries.into_iter().collect(); let result: Vec<PathBuf> = libraries.into_iter().collect();
info!("Found {} Steam library directories", result.len()); info!("Found {} Steam library directories", result.len());
for (i, lib) in result.iter().enumerate() { for (i, lib) in result.iter().enumerate() {
info!(" Library {}: {}", i+1, lib.display()); info!(" Library {}: {}", i + 1, lib.display());
} }
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,304 +279,328 @@ 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;
let mut steam_api_files = Vec::new(); let mut steam_api_files = Vec::new();
// 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)
const MAX_DEPTH: usize = 8; const MAX_DEPTH: usize = 8;
// File extensions to check for (executable and Steam API files) // File extensions to check for (executable and Steam API files)
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
.into_iter() .into_iter()
.filter_entry(|e| { .filter_entry(|e| {
// Skip certain directories for performance // Skip certain directories for performance
if e.file_type().is_dir() { if e.file_type().is_dir() {
let file_name = e.file_name().to_string_lossy().to_lowercase(); let file_name = e.file_name().to_string_lossy().to_lowercase();
if skip_dirs.iter().any(|&dir| file_name == dir) { if skip_dirs.iter().any(|&dir| file_name == dir) {
debug!("Skipping directory: {}", e.path().display()); debug!("Skipping directory: {}", e.path().display());
return false; return false;
} }
} }
true true
}) })
.filter_map(Result::ok) { .filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let path = entry.path(); // Check file extension
if !path.is_file() { if let Some(ext) = path.extension() {
continue; let ext_str = ext.to_string_lossy().to_lowercase();
}
// Check file extension // Check for Windows executables
if let Some(ext) = path.extension() { if exe_extensions.iter().any(|&e| ext_str == e) {
let ext_str = ext.to_string_lossy().to_lowercase(); found_exe = true;
}
// Check for Windows executables // Check for Steam API DLLs
if exe_extensions.iter().any(|&e| ext_str == e) { if ext_str == "dll" {
found_exe = true; let filename = path
} .file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
if let Ok(rel_path) = path.strip_prefix(game_path) {
let rel_path_str = rel_path.to_string_lossy().to_string();
debug!("Found Steam API DLL: {}", rel_path_str);
steam_api_files.push(rel_path_str);
}
}
}
// Check for Steam API DLLs // Check for Linux binary files
if ext_str == "dll" { if binary_extensions.iter().any(|&e| ext_str == e) {
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); found_linux_binary = true;
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
if let Ok(rel_path) = path.strip_prefix(game_path) {
let rel_path_str = rel_path.to_string_lossy().to_string();
debug!("Found Steam API DLL: {}", rel_path_str);
steam_api_files.push(rel_path_str);
}
}
}
// Check for Linux binary files // Check if it's actually an ELF binary for more certainty
if binary_extensions.iter().any(|&e| ext_str == e) { if ext_str == "so" && is_elf_binary(path) {
found_linux_binary = true; found_linux_binary = true;
}
}
}
// Check if it's actually an ELF binary for more certainty // Check for Linux executables (no extension)
if ext_str == "so" && is_elf_binary(path) { #[cfg(unix)]
found_linux_binary = true; if !path.extension().is_some() {
} use std::os::unix::fs::PermissionsExt;
}
}
// Check for Linux executables (no extension) if let Ok(metadata) = path.metadata() {
#[cfg(unix)] let is_executable = metadata.permissions().mode() & 0o111 != 0;
if !path.extension().is_some() {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() { // Check executable permission and ELF format
let is_executable = metadata.permissions().mode() & 0o111 != 0; if is_executable && is_elf_binary(path) {
found_linux_binary = true;
}
}
}
// Check executable permission and ELF format // If we've found enough evidence for both platforms and Steam API DLLs, we can stop
if is_executable && is_elf_binary(path) { if found_exe && found_linux_binary && !steam_api_files.is_empty() {
found_linux_binary = true; debug!("Found sufficient evidence, breaking scan early");
} break;
} }
} }
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop // A game is considered native if it has Linux binaries but no Windows executables
// This early break greatly improves performance for large game directories let is_native = found_linux_binary && !found_exe;
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
debug!("Found sufficient evidence, breaking scan early");
break;
}
}
// A game is considered native if it has Linux binaries but no Windows executables debug!(
let is_native = found_linux_binary && !found_exe; "Game scan results: native={}, exe={}, api_dlls={}",
is_native,
debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len()); found_exe,
(is_native, steam_api_files) steam_api_files.len()
);
(is_native, steam_api_files)
} }
/// Find all installed Steam games from library folders // Find all installed Steam games from library folders
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> { pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
let mut games = Vec::new();
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
let mut games = Vec::new(); // IDs to skip (tools, redistributables, etc.)
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new())); let skip_ids = Arc::new(
[
"228980", // Steamworks Common Redistributables
"1070560", // Steam Linux Runtime
"1391110", // Steam Linux Runtime - Soldier
"1628350", // Steam Linux Runtime - Sniper
"1493710", // Proton Experimental
"2180100", // Steam Linux Runtime - Scout
]
.iter()
.copied()
.collect::<HashSet<&str>>(),
);
// IDs to skip (tools, redistributables, etc.) // Name patterns to skip (case insensitive)
let skip_ids = Arc::new([ let skip_patterns = Arc::new(
"228980", // Steamworks Common Redistributables [
"1070560", // Steam Linux Runtime r"(?i)steam linux runtime",
"1391110", // Steam Linux Runtime - Soldier r"(?i)proton",
"1628350", // Steam Linux Runtime - Sniper r"(?i)steamworks common",
"1493710", // Proton Experimental r"(?i)redistributable",
"2180100", // Steam Linux Runtime - Scout r"(?i)dotnet",
].iter().copied().collect::<HashSet<&str>>()); r"(?i)vc redist",
]
.iter()
.map(|pat| Regex::new(pat).unwrap())
.collect::<Vec<_>>(),
);
// Name patterns to skip (case insensitive) info!("Scanning for installed games in parallel...");
let skip_patterns = Arc::new(
[
r"(?i)steam linux runtime",
r"(?i)proton",
r"(?i)steamworks common",
r"(?i)redistributable",
r"(?i)dotnet",
r"(?i)vc redist",
]
.iter()
.map(|pat| Regex::new(pat).unwrap())
.collect::<Vec<_>>()
);
info!("Scanning for installed games in parallel..."); // Create a channel to collect results
let (tx, mut rx) = mpsc::channel(32);
// Create a channel to collect results // First collect all appmanifest files to process
let (tx, mut rx) = mpsc::channel(32); let mut app_manifests = Vec::new();
for steamapps_dir in steamapps_paths {
if let Ok(entries) = fs::read_dir(steamapps_dir) {
for entry in entries.flatten() {
let path = entry.path();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// First collect all appmanifest files to process // Check for appmanifest files
let mut app_manifests = Vec::new(); if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
for steamapps_dir in steamapps_paths { app_manifests.push((path, steamapps_dir.clone()));
if let Ok(entries) = fs::read_dir(steamapps_dir) { }
for entry in entries.flatten() { }
let path = entry.path(); }
let filename = path.file_name().unwrap_or_default().to_string_lossy(); }
// Check for appmanifest files info!("Found {} appmanifest files to process", app_manifests.len());
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
app_manifests.push((path, steamapps_dir.clone()));
}
}
}
}
info!("Found {} appmanifest files to process", app_manifests.len()); // Process appmanifest files
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
info!("Using {} concurrent scanners", max_concurrent);
// Process each appmanifest file in parallel with a maximum concurrency // Use a semaphore to limit concurrency
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
info!("Using {} concurrent scanners", max_concurrent);
// Use a semaphore to limit concurrency // Create a Vec to store all our task handles
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent)); let mut handles = Vec::new();
// Create a Vec to store all our task handles // Process each manifest file
let mut handles = Vec::new(); for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
// Clone what we need for the task
let path = path.clone();
let steamapps_dir = steamapps_dir.clone();
let skip_patterns = Arc::clone(&skip_patterns);
let tx = tx.clone();
let seen_ids = Arc::clone(&seen_ids);
let semaphore = Arc::clone(&semaphore);
let skip_ids = Arc::clone(&skip_ids);
// Process each manifest file // Create a new task
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() { let handle = tokio::spawn(async move {
// Clone what we need for the task // Acquire a permit from the semaphore
let path = path.clone(); let _permit = semaphore.acquire().await.unwrap();
let steamapps_dir = steamapps_dir.clone();
let skip_patterns = Arc::clone(&skip_patterns);
let tx = tx.clone();
let seen_ids = Arc::clone(&seen_ids);
let semaphore = Arc::clone(&semaphore);
let skip_ids = Arc::clone(&skip_ids);
// Create a new task // Parse the appmanifest file
let handle = tokio::spawn(async move { if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
// Acquire a permit from the semaphore // Skip if in exclusion list
let _permit = semaphore.acquire().await.unwrap(); if skip_ids.contains(id.as_str()) {
return;
}
// Parse the appmanifest file // Add a guard against duplicates
if let Some((id, name, install_dir)) = parse_appmanifest(&path) { {
// Skip if in exclusion list let mut seen = seen_ids.lock().await;
if skip_ids.contains(id.as_str()) { if seen.contains(&id) {
return; return;
} }
seen.insert(id.clone());
}
// Add a guard against duplicates // Skip if the name matches any exclusion patterns
{ if skip_patterns.iter().any(|re| re.is_match(&name)) {
let mut seen = seen_ids.lock().await; debug!("Skipping runtime/tool: {} ({})", name, id);
if seen.contains(&id) { return;
return; }
}
seen.insert(id.clone());
}
// Skip if the name matches any exclusion patterns // Full path to the game directory
if skip_patterns.iter().any(|re| re.is_match(&name)) { let game_path = steamapps_dir.join("common").join(&install_dir);
debug!("Skipping runtime/tool: {} ({})", name, id);
return;
}
// Full path to the game directory // Skip if game directory doesn't exist
let game_path = steamapps_dir.join("common").join(&install_dir); if !game_path.exists() {
warn!("Game directory not found: {}", game_path.display());
return;
}
// Skip if game directory doesn't exist // Scan the game directory to determine platform and find Steam API DLLs
if !game_path.exists() { info!("Scanning game: {} at {}", name, game_path.display());
warn!("Game directory not found: {}", game_path.display());
return;
}
// Scan the game directory to determine platform and find Steam API DLLs // Scanning is I/O heavy but not CPU heavy, so we can just do it directly
info!("Scanning game: {} at {}", name, game_path.display()); let (is_native, api_files) = scan_game_directory(&game_path);
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly // Check for CreamLinux installation
let (is_native, api_files) = scan_game_directory(&game_path); let cream_installed = check_creamlinux_installed(&game_path);
// Check for CreamLinux installation // Check for SmokeAPI installation (only for non-native games with Steam API DLLs)
let cream_installed = check_creamlinux_installed(&game_path); let smoke_installed = if !is_native && !api_files.is_empty() {
check_smokeapi_installed(&game_path, &api_files)
} else {
false
};
// Check for SmokeAPI installation (only for non-native games with Steam API DLLs) // Create the game info
let smoke_installed = if !is_native && !api_files.is_empty() { let game_info = GameInfo {
check_smokeapi_installed(&game_path, &api_files) id,
} else { title: name,
false path: game_path,
}; native: is_native,
api_files,
cream_installed,
smoke_installed,
};
// Create the game info // Send the game info through the channel
let game_info = GameInfo { if tx.send(game_info).await.is_err() {
id, error!("Failed to send game info through channel");
title: name, }
path: game_path, }
native: is_native, });
api_files,
cream_installed,
smoke_installed,
};
// Send the game info through the channel handles.push(handle);
if tx.send(game_info).await.is_err() {
error!("Failed to send game info through channel");
}
}
});
handles.push(handle); // Every 10 files, yield to allow progress updates
if manifest_idx % 10 == 0 {
// We would update progress here in a full implementation
tokio::task::yield_now().await;
}
}
// Every 10 files, yield to allow progress updates // Drop the original sender so the receiver knows when we're done
if manifest_idx % 10 == 0 { drop(tx);
// We would update progress here in a full implementation
tokio::task::yield_now().await;
}
}
// Drop the original sender so the receiver knows when we're done // Spawn a task to collect all the results
drop(tx); let receiver_task = tokio::spawn(async move {
let mut results = Vec::new();
while let Some(game) = rx.recv().await {
info!("Found game: {} ({})", game.title, game.id);
info!(" Path: {}", game.path.display());
info!(
" Status: Native={}, Cream={}, Smoke={}",
game.native, game.cream_installed, game.smoke_installed
);
// Spawn a task to collect all the results // Log Steam API DLLs if any
let receiver_task = tokio::spawn(async move { if !game.api_files.is_empty() {
let mut results = Vec::new(); info!(" Steam API files:");
while let Some(game) = rx.recv().await { for api_file in &game.api_files {
info!("Found game: {} ({})", game.title, game.id); info!(" - {}", api_file);
info!(" Path: {}", game.path.display()); }
info!(" Status: Native={}, Cream={}, Smoke={}", }
game.native, game.cream_installed, game.smoke_installed);
// Log Steam API DLLs if any results.push(game);
if !game.api_files.is_empty() { }
info!(" Steam API files:"); results
for api_file in &game.api_files { });
info!(" - {}", api_file);
}
}
results.push(game); // Wait for all scan tasks to complete but don't wait for the results yet
} for handle in handles {
results // Ignore errors the receiver task will just get fewer results
}); let _ = handle.await;
}
// Wait for all scan tasks to complete - but don't wait for the results yet // Now wait for all results to be collected
for handle in handles { if let Ok(results) = receiver_task.await {
// Ignore errors - the receiver task will just get fewer results games = results;
let _ = handle.await; }
}
// Now wait for all results to be collected info!("Found {} installed games", games.len());
if let Ok(results) = receiver_task.await { games
games = results;
}
info!("Found {} installed games", games.len());
games
} }

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,45 +1,44 @@
// 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
const colors = [ const colors = [
'rgba(74, 118, 196, 0.5)', // primary blue 'rgba(74, 118, 196, 0.5)', // primary blue
'rgba(155, 125, 255, 0.5)', // purple 'rgba(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.name.toLowerCase().includes(searchQuery.toLowerCase()) || (dlc) =>
dlc.appid.includes(searchQuery) dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
); 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) =>
...dlc, prev.map((dlc) => ({
enabled: newSelectAllState ...dlc,
}))); 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,88 +7,87 @@ 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
* @param appId Steam application ID * @param appId Steam application ID
* @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
* @param url Image URL to check * @param url Image URL to check
* @returns Promise resolving to a boolean indicating if the image exists * @returns Promise resolving to a boolean indicating if the image exists
*/ */
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
* @param url URL of image to preload
* @returns Promise that resolves when image is loaded
*/
const preloadImage = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(url);
img.onerror = reject;
img.src = url;
});
};
/**
* Attempts to find a valid image for a Steam game, trying different image types
* @param appId Steam application ID
* @returns Promise resolving to a valid image URL or null if none found
*/
export const findBestGameImage = async (appId: string): Promise<string | null> => {
// Check cache first
if (imageCache.has(appId)) {
return imageCache.get(appId) || null;
}
// Try these image types in order of preference
const typesToTry = [
SteamImageType.HEADER,
SteamImageType.CAPSULE,
SteamImageType.LIBRARY_CAPSULE
];
for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type);
const exists = await checkImageExists(url);
if (exists) {
try {
// Preload the image to prevent flickering
const preloadedUrl = await preloadImage(url);
// Store in cache
imageCache.set(appId, preloadedUrl);
return preloadedUrl;
} catch {
// If preloading fails, just return the URL
imageCache.set(appId, url);
return url;
}
} }
} }
// If we've reached here, no valid image was found /**
return null; * Preloads an image for faster rendering
}; * @param url URL of image to preload
* @returns Promise that resolves when image is loaded
*/
const preloadImage = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(url)
img.onerror = reject
img.src = url
})
}
/**
* Attempts to find a valid image for a Steam game, trying different image types
* @param appId Steam application ID
* @returns Promise resolving to a valid image URL or null if none found
*/
export const findBestGameImage = async (appId: string): Promise<string | null> => {
// Check cache first
if (imageCache.has(appId)) {
return imageCache.get(appId) || null
}
// Try these image types in order of preference
const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE]
for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type)
const exists = await checkImageExists(url)
if (exists) {
try {
// Preload the image to prevent flickering
const preloadedUrl = await preloadImage(url)
// Store in cache
imageCache.set(appId, preloadedUrl)
return preloadedUrl
} catch {
// If preloading fails, just return the URL
imageCache.set(appId, url)
return url
}
}
}
// If we've reached here, no valid image was found
return null
}

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/Roboto.ttf') format('ttf'), url('../assets/fonts/Satoshi.ttf') format('ttf'),
url('../assets/fonts/WorkSans.ttf') format('ttf'); url('../assets/fonts/Roboto.ttf') format('ttf'),
font-weight: 400; // adjust as needed url('../assets/fonts/WorkSans.ttf') format('ttf');
font-style: normal; font-weight: 400;
font-display: swap; font-style: normal;
} 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,111 +1,102 @@
// src/styles/_variables.scss
@use './fonts' as *; @use './fonts' as *;
// Color palette // Color palette
:root { :root {
// Primary colors // Primary colors
--primary-color: #ffc896; --primary-color: #ffc896;
--secondary-color: #ffb278; --secondary-color: #ffb278;
// Background // Background
--primary-bg: #0f0f0f; --primary-bg: #0f0f0f;
--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;
--text-secondary: #c8c8c8; --text-secondary: #c8c8c8;
--text-soft: #afafaf; --text-soft: #afafaf;
--text-heavy: #1a1a1a; --text-heavy: #1a1a1a;
--text-muted: #4b4b4b; --text-muted: #4b4b4b;
// Borders // Borders
--border-dark: #1a1a1a; --border-dark: #1a1a1a;
--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;
--info: #80b4ff; --info: #80b4ff;
--success-light: #b0e0a9; --success-light: #b0e0a9;
--warning-light: #ffdcb9; --warning-light: #ffdcb9;
--danger-light: #e69691; --danger-light: #e69691;
--info-light: #a8d2ff; --info-light: #a8d2ff;
--success-soft: rgba(176, 224, 169, 0.15); --success-soft: rgba(176, 224, 169, 0.15);
--warning-soft: rgba(247, 200, 111, 0.15); --warning-soft: rgba(247, 200, 111, 0.15);
--danger-soft: rgba(230, 150, 145, 0.15); --danger-soft: rgba(230, 150, 145, 0.15);
--info-soft: rgba(168, 210, 255, 0.15); --info-soft: rgba(168, 210, 255, 0.15);
// Feature colors // Feature colors
--native: #8cc893; --native: #8cc893;
--proton: #ffc896; --proton: #ffc896;
--cream: #80b4ff; --cream: #80b4ff;
--smoke: #fff096; --smoke: #fff096;
--modal-backdrop: rgba(30, 30, 30, 0.95); --modal-backdrop: rgba(30, 30, 30, 0.95);
// Animation durations // Animation durations
--duration-fast: 100ms; --duration-fast: 100ms;
--duration-normal: 200ms; --duration-normal: 200ms;
--duration-slow: 300ms; --duration-slow: 300ms;
// Animation easings // Animation easings
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1); --easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1); --easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); --easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
// Layout values // Layout values
--header-height: 64px; --header-height: 64px;
--sidebar-width: 250px; --sidebar-width: 250px;
--card-height: 200px; --card-height: 200px;
// Border radius // Border radius
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 12px; --radius-lg: 12px;
// Font weights // Font weights
--thin: 100; --thin: 100;
--extralight: 200; --extralight: 200;
--light: 300; --light: 300;
--normal: 400; --normal: 400;
--medium: 500; --medium: 500;
--semibold: 600; --semibold: 600;
--bold: 700; --bold: 700;
--extrabold: 800; --extrabold: 800;
--family: 'Satoshi'; --family: 'Satoshi';
// Shadows // Shadows
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3); --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
--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 levels
//--z-index-bg: 0; --z-bg: 0;
//--z-index-content: 1; --z-elevate: 1;
//--z-index-header: 100; --z-header: 100;
//--z-index-modal: 1000; --z-modal: 1000;
//--z-index-tooltip: 1500; --z-tooltip: 1500;
// Z-index levels
--z-bg: 0;
--z-elevate: 1;
--z-header: 100;
--z-modal: 1000;
--z-tooltip: 1500;
} }
$success-color: #55e07a; $success-color: #55e07a;

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,82 +1,80 @@
// src/styles/_components/_header.scss
@use '../variables' as *; @use '../variables' as *;
@use '../mixins' as *; @use '../mixins' as *;
.app-container { .app-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: var(--primary-bg); background-color: var(--primary-bg);
position: relative; position: relative;
} }
// Header // Header
.app-header { .app-header {
@include flex-between; @include flex-between;
padding: 1rem 2rem; padding: 1rem 2rem;
background-color: var(--tertiary-bg); background-color: var(--tertiary-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.07); border-bottom: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
position: relative; position: relative;
z-index: var(--z-header); z-index: var(--z-header);
height: var(--header-height); height: var(--header-height);
h1 { h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.5px;
@include text-shadow;
}
}
.header-controls {
display: flex;
gap: 1rem;
align-items: center;
}
.refresh-button {
background-color: var(--primary-color);
color: var(--text-primary); color: var(--text-primary);
border: none;
border-radius: 4px;
padding: 0.6rem 1.2rem;
font-weight: var(--bold);
letter-spacing: 0.5px; letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); @include text-shadow;
transition: all 0.2s ease;
} }
}
.refresh-button:hover {
transform: translateY(-2px); .header-controls {
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3); display: flex;
background-color: var(--primary-color); gap: 1rem;
} align-items: center;
}
.refresh-button:active {
transform: translateY(0); .refresh-button {
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); background-color: var(--primary-color);
} color: var(--text-primary);
border: none;
.search-input { border-radius: 4px;
padding: 0.5rem 1rem; padding: 0.6rem 1.2rem;
border: 1px solid var(--border-soft); font-weight: var(--bold);
border-radius: 4px; letter-spacing: 0.5px;
min-width: 200px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
background-color: var(--border-dark); transition: all 0.2s ease;
color: var(--text-primary); }
}
.refresh-button:hover {
.search-input:focus { transform: translateY(-2px);
border-color: var(--primary-color); box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
outline: none; background-color: var(--primary-color);
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2); }
.refresh-button:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.search-input {
padding: 0.5rem 1rem;
border: 1px solid var(--border-soft);
border-radius: 4px;
min-width: 200px;
background-color: var(--border-dark);
color: var(--text-primary);
}
.search-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
} }

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,
}, },
}); })