mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2025-12-05 19:45:36 -05:00
formatting
This commit is contained in:
19
.github/ISSUE_TEMPLATE/bug_report.md
vendored
19
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -7,37 +7,46 @@ assignees: ''
|
||||
---
|
||||
|
||||
## Bug Description
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## Steps To Reproduce
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## System Information
|
||||
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
|
||||
- Desktop Environment: [e.g. GNOME, KDE, etc.]
|
||||
- CreamLinux Version: [e.g. 0.1.0]
|
||||
- Steam Version: [e.g. latest]
|
||||
|
||||
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
|
||||
- Desktop Environment: [e.g. GNOME, KDE, etc.]
|
||||
- CreamLinux Version: [e.g. 0.1.0]
|
||||
- Steam Version: [e.g. latest]
|
||||
|
||||
## Game Information
|
||||
|
||||
- Game name:
|
||||
- Game ID (if known):
|
||||
- Native Linux or Proton:
|
||||
- Steam installation path:
|
||||
- Steam installation path:
|
||||
|
||||
## Additional Context
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
## Logs
|
||||
|
||||
If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
|
||||
|
||||
```
|
||||
Paste log content here
|
||||
```
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -7,17 +7,22 @@ assignees: ''
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Problem This Feature Solves
|
||||
|
||||
Is your feature request related to a problem? Please describe.
|
||||
Ex. I'm always frustrated when [...]
|
||||
|
||||
## Alternatives You've Considered
|
||||
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
## Implementation Ideas (Optional)
|
||||
|
||||
If you have any ideas on how this feature could be implemented, please share them here.
|
||||
|
||||
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -1,10 +1,10 @@
|
||||
name: "Build CreamLinux"
|
||||
name: 'Build CreamLinux'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: ['main']
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -19,32 +19,32 @@ jobs:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 19
|
||||
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
profile: minimal
|
||||
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
|
||||
- name: Build the app
|
||||
run: npm run tauri build
|
||||
|
||||
|
||||
- name: Upload binary artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
src-tauri/target
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
||||
10
README.md
10
README.md
@@ -29,23 +29,28 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
|
||||
### Building from Source
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Rust 1.77.2 or later
|
||||
- Node.js 18 or later
|
||||
- npm or yarn
|
||||
|
||||
#### Steps
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/creamlinux.git
|
||||
cd creamlinux
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install # or yarn
|
||||
```
|
||||
|
||||
3. Build the application:
|
||||
|
||||
```bash
|
||||
NO_STRIP=true npm run tauri build
|
||||
```
|
||||
@@ -57,11 +62,13 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
|
||||
If you're using the AppImage version, you can integrate it into your desktop environment:
|
||||
|
||||
1. Create a desktop entry file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/share/applications
|
||||
```
|
||||
|
||||
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
|
||||
|
||||
```
|
||||
[Desktop Entry]
|
||||
Name=Creamlinux
|
||||
@@ -73,6 +80,7 @@ If you're using the AppImage version, you can integrate it into your desktop env
|
||||
```
|
||||
|
||||
3. Update your desktop database so creamlinux appears in your app launcher:
|
||||
|
||||
```bash
|
||||
update-desktop-database ~/.local/share/applications
|
||||
```
|
||||
@@ -114,4 +122,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support
|
||||
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
|
||||
- [Tauri](https://tauri.app/) - Framework for building the desktop application
|
||||
- [React](https://reactjs.org/) - UI library
|
||||
- [React](https://reactjs.org/) - UI library
|
||||
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{ ignores: ['dist', 'node_modules', 'src-tauri/target'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
@@ -19,10 +19,7 @@ export default tseslint.config(
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,9 +8,6 @@ repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.2.0", features = [] }
|
||||
|
||||
@@ -37,6 +34,4 @@ num_cpus = "1.16.0"
|
||||
futures = "0.3.31"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
# DO NOT REMOVE!!
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// src/cache.rs
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::dlc_manager::DlcInfoWithState;
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::path::{PathBuf};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::time::{SystemTime};
|
||||
use log::{info, warn};
|
||||
use crate::dlc_manager::DlcInfoWithState;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
// Cache entry with timestamp for expiration
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -20,14 +18,14 @@ struct CacheEntry<T> {
|
||||
fn get_cache_dir() -> io::Result<PathBuf> {
|
||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
|
||||
let cache_dir = xdg_dirs.get_cache_home();
|
||||
|
||||
|
||||
// Make sure the cache directory exists
|
||||
if !cache_dir.exists() {
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
}
|
||||
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
@@ -38,26 +36,26 @@ where
|
||||
{
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let cache_file = cache_dir.join(format!("{}.cache", key));
|
||||
|
||||
|
||||
// Get current timestamp
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
|
||||
// Create a JSON object with timestamp and data directly
|
||||
let json_data = json!({
|
||||
"timestamp": now,
|
||||
"data": data // No clone needed here
|
||||
});
|
||||
|
||||
|
||||
// Serialize and write to file
|
||||
let serialized = serde_json::to_string(&json_data)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
let serialized =
|
||||
serde_json::to_string(&json_data).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
fs::write(cache_file, serialized)?;
|
||||
info!("Saved cache for key: {}", key);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -73,14 +71,14 @@ where
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let cache_file = cache_dir.join(format!("{}.cache", key));
|
||||
|
||||
|
||||
// Check if cache file exists
|
||||
if !cache_file.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Read and deserialize
|
||||
let cached_data = match fs::read_to_string(&cache_file) {
|
||||
Ok(data) => data,
|
||||
@@ -89,54 +87,58 @@ where
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Parse the JSON
|
||||
let json_value: serde_json::Value = match serde_json::from_str(&cached_data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cache file {}: {}", cache_file.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract timestamp
|
||||
let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
warn!("Invalid timestamp in cache file {}", cache_file.display());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Check expiration
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let age_hours = (now - timestamp) / 3600;
|
||||
|
||||
if age_hours > ttl_hours {
|
||||
info!("Cache for key {} is expired ({} hours old)", key, age_hours);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract data
|
||||
let data: T = match serde_json::from_value(json_value["data"].clone()) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse data in cache file {}: {}", cache_file.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
info!("Using cache for key {} ({} hours old)", key, age_hours);
|
||||
Some(data)
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cache file {}: {}", cache_file.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract timestamp
|
||||
let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
warn!("Invalid timestamp in cache file {}", cache_file.display());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Check expiration
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let age_hours = (now - timestamp) / 3600;
|
||||
|
||||
if age_hours > ttl_hours {
|
||||
info!("Cache for key {} is expired ({} hours old)", key, age_hours);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract data
|
||||
let data: T = match serde_json::from_value(json_value["data"].clone()) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to parse data in cache file {}: {}",
|
||||
cache_file.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
info!("Using cache for key {} ({} hours old)", key, age_hours);
|
||||
Some(data)
|
||||
}
|
||||
|
||||
// Cache game scanning results
|
||||
pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> {
|
||||
save_to_cache("games", games, 24) // Cache games for 24 hours
|
||||
save_to_cache("games", games, 24) // Cache games for 24 hours
|
||||
}
|
||||
|
||||
// Load cached game scanning results
|
||||
@@ -146,7 +148,7 @@ pub fn load_cached_games() -> Option<Vec<crate::installer::Game>> {
|
||||
|
||||
// Cache DLC list for a game
|
||||
pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> {
|
||||
save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours)
|
||||
save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours)
|
||||
}
|
||||
|
||||
// Load cached DLC list
|
||||
@@ -157,11 +159,11 @@ pub fn load_cached_dlcs(game_id: &str) -> Option<Vec<DlcInfoWithState>> {
|
||||
// Clear all caches
|
||||
pub fn clear_all_caches() -> io::Result<()> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
|
||||
|
||||
for entry in fs::read_dir(cache_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
|
||||
if path.is_file() && path.extension().map_or(false, |ext| ext == "cache") {
|
||||
if let Err(e) = fs::remove_file(&path) {
|
||||
warn!("Failed to remove cache file {}: {}", path.display(), e);
|
||||
@@ -170,7 +172,7 @@ pub fn clear_all_caches() -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
info!("All caches cleared");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// src/dlc_manager.rs
|
||||
use serde::{Serialize, Deserialize};
|
||||
use log::{error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use log::{info, error};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tauri::Manager;
|
||||
|
||||
/// More detailed DLC information with enabled state
|
||||
// More detailed DLC information with enabled state
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DlcInfoWithState {
|
||||
pub appid: String,
|
||||
@@ -14,39 +13,42 @@ pub struct DlcInfoWithState {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Parse the cream_api.ini file to extract both enabled and disabled DLCs
|
||||
// Parse the cream_api.ini file to extract both enabled and disabled DLCs
|
||||
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
||||
info!("Reading enabled DLCs from {}", game_path);
|
||||
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
||||
return Err(format!(
|
||||
"cream_api.ini not found at {}",
|
||||
cream_api_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
let contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||
};
|
||||
|
||||
// Extract DLCs - they are in the [dlc] section with format "appid = name"
|
||||
|
||||
// Extract DLCs
|
||||
let mut in_dlc_section = false;
|
||||
let mut enabled_dlcs = Vec::new();
|
||||
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
|
||||
// Check if we're in the DLC section
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section (another section begins)
|
||||
|
||||
// Check if we're leaving the DLC section
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Skip empty lines and non-DLC comments
|
||||
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
|
||||
// Extract the DLC app ID
|
||||
@@ -59,44 +61,47 @@ pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
info!("Found {} enabled DLCs", enabled_dlcs.len());
|
||||
Ok(enabled_dlcs)
|
||||
}
|
||||
|
||||
/// Get all DLCs (both enabled and disabled) from cream_api.ini
|
||||
// Get all DLCs (both enabled and disabled) from cream_api.ini
|
||||
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Reading all DLCs from {}", game_path);
|
||||
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
||||
return Err(format!(
|
||||
"cream_api.ini not found at {}",
|
||||
cream_api_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
let contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||
};
|
||||
|
||||
// Extract DLCs - both enabled and disabled
|
||||
|
||||
// Extract DLCs
|
||||
let mut in_dlc_section = false;
|
||||
let mut all_dlcs = Vec::new();
|
||||
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
|
||||
// Check if we're in the DLC section
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section (another section begins)
|
||||
|
||||
// Check if we're leaving the DLC section
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Process DLC entries (both enabled and commented/disabled)
|
||||
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
|
||||
let is_commented = trimmed.starts_with("#");
|
||||
@@ -105,12 +110,12 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
|
||||
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let appid = parts[0].trim();
|
||||
let name = parts[1].trim();
|
||||
|
||||
|
||||
all_dlcs.push(DlcInfoWithState {
|
||||
appid: appid.to_string(),
|
||||
name: name.to_string().trim_matches('"').to_string(),
|
||||
@@ -119,56 +124,65 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} total DLCs ({} enabled, {} disabled)",
|
||||
all_dlcs.len(),
|
||||
all_dlcs.iter().filter(|d| d.enabled).count(),
|
||||
all_dlcs.iter().filter(|d| !d.enabled).count());
|
||||
|
||||
|
||||
info!(
|
||||
"Found {} total DLCs ({} enabled, {} disabled)",
|
||||
all_dlcs.len(),
|
||||
all_dlcs.iter().filter(|d| d.enabled).count(),
|
||||
all_dlcs.iter().filter(|d| !d.enabled).count()
|
||||
);
|
||||
|
||||
Ok(all_dlcs)
|
||||
}
|
||||
|
||||
/// Update the cream_api.ini file with the user's DLC selections
|
||||
pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
|
||||
// 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> {
|
||||
info!("Updating DLC configuration for {}", game_path);
|
||||
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
||||
return Err(format!(
|
||||
"cream_api.ini not found at {}",
|
||||
cream_api_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Read the current file contents
|
||||
let current_contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||
};
|
||||
|
||||
|
||||
// Create a mapping of DLC appid to its state for easy lookup
|
||||
let dlc_states: HashMap<String, (bool, String)> = dlcs.iter()
|
||||
let dlc_states: HashMap<String, (bool, String)> = dlcs
|
||||
.iter()
|
||||
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
|
||||
.collect();
|
||||
|
||||
|
||||
// Keep track of processed DLCs to avoid duplicates
|
||||
let mut processed_dlcs = HashSet::new();
|
||||
|
||||
|
||||
// Process the file line by line to retain most of the original structure
|
||||
let mut new_contents = Vec::new();
|
||||
let mut in_dlc_section = false;
|
||||
|
||||
|
||||
for line in current_contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
|
||||
// Add section markers directly
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section (another section begins)
|
||||
|
||||
// Check if we're leaving the DLC section
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
|
||||
|
||||
// Before leaving the DLC section, add any DLCs that weren't processed yet
|
||||
for (appid, (enabled, name)) in &dlc_states {
|
||||
if !processed_dlcs.contains(appid) {
|
||||
@@ -179,21 +193,21 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Now add the section marker
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if in_dlc_section && !trimmed.is_empty() {
|
||||
let is_comment_line = trimmed.starts_with(';');
|
||||
|
||||
|
||||
// If it's a regular comment line (not a DLC), keep it as is
|
||||
if is_comment_line {
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Check if it's a commented-out DLC line or a regular DLC line
|
||||
let is_commented = trimmed.starts_with("#");
|
||||
let actual_line = if is_commented {
|
||||
@@ -201,13 +215,13 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
|
||||
// Extract appid and name
|
||||
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let appid = parts[0].trim();
|
||||
let name = parts[1].trim();
|
||||
|
||||
|
||||
// Check if this DLC exists in our updated list
|
||||
if let Some((enabled, _)) = dlc_states.get(appid) {
|
||||
// Add the DLC with its updated state
|
||||
@@ -218,19 +232,19 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
||||
}
|
||||
processed_dlcs.insert(appid.to_string());
|
||||
} else {
|
||||
// Not in our list - keep the original line
|
||||
// Not in our list keep the original line
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
} else {
|
||||
// Invalid format or not a DLC line - keep as is
|
||||
// Invalid format or not a DLC line keep as is
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
} else if !in_dlc_section || trimmed.is_empty() {
|
||||
// Not a DLC line or empty line - keep as is
|
||||
// Not a DLC line or empty line keep as is
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If we never left the DLC section, make sure we add any unprocessed DLCs
|
||||
if in_dlc_section {
|
||||
for (appid, (enabled, name)) in &dlc_states {
|
||||
@@ -243,13 +257,16 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Write the updated file
|
||||
match fs::write(&cream_api_path, new_contents.join("\n")) {
|
||||
Ok(_) => {
|
||||
info!("Successfully updated DLC configuration at {}", cream_api_path.display());
|
||||
info!(
|
||||
"Successfully updated DLC configuration at {}",
|
||||
cream_api_path.display()
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to write updated cream_api.ini: {}", e);
|
||||
Err(format!("Failed to write updated cream_api.ini: {}", e))
|
||||
@@ -257,7 +274,7 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<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)]
|
||||
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")) {
|
||||
@@ -269,71 +286,83 @@ fn extract_app_id_from_config(game_path: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a custom installation with selected DLCs
|
||||
// Create a custom installation with selected DLCs
|
||||
pub async fn install_cream_with_dlcs(
|
||||
game_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
selected_dlcs: Vec<DlcInfoWithState>
|
||||
game_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
selected_dlcs: Vec<DlcInfoWithState>,
|
||||
) -> Result<(), String> {
|
||||
use crate::AppState;
|
||||
|
||||
// Count enabled DLCs for logging
|
||||
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
|
||||
info!("Starting installation of CreamLinux with {} selected DLCs", enabled_dlc_count);
|
||||
|
||||
// Get the game from state
|
||||
let game = {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let games = state.games.lock();
|
||||
match games.get(&game_id) {
|
||||
Some(g) => g.clone(),
|
||||
None => return Err(format!("Game with ID {} not found", game_id))
|
||||
}
|
||||
};
|
||||
|
||||
info!("Installing CreamLinux for game: {} ({})", game.title, game_id);
|
||||
|
||||
// Install CreamLinux first - but provide the DLCs directly instead of fetching them again
|
||||
use crate::installer::install_creamlinux_with_dlcs;
|
||||
|
||||
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
|
||||
let enabled_dlcs = selected_dlcs.iter()
|
||||
.filter(|dlc| dlc.enabled)
|
||||
.map(|dlc| crate::installer::DlcInfo {
|
||||
appid: dlc.appid.clone(),
|
||||
name: dlc.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let game_title = game.title.clone();
|
||||
|
||||
// Use direct installation with provided DLCs instead of re-fetching
|
||||
match install_creamlinux_with_dlcs(
|
||||
&game.path,
|
||||
&game_id,
|
||||
enabled_dlcs,
|
||||
move |progress, message| {
|
||||
// Emit progress updates during installation
|
||||
use crate::installer::emit_progress;
|
||||
emit_progress(
|
||||
&app_handle_clone,
|
||||
&format!("Installing CreamLinux for {}", game_title),
|
||||
message,
|
||||
progress * 100.0, // Scale progress from 0 to 100%
|
||||
false,
|
||||
false,
|
||||
None
|
||||
);
|
||||
}
|
||||
).await {
|
||||
Ok(_) => {
|
||||
info!("CreamLinux installation completed successfully for game: {}", game.title);
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to install CreamLinux: {}", e);
|
||||
Err(format!("Failed to install CreamLinux: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::AppState;
|
||||
|
||||
// Count enabled DLCs for logging
|
||||
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
|
||||
info!(
|
||||
"Starting installation of CreamLinux with {} selected DLCs",
|
||||
enabled_dlc_count
|
||||
);
|
||||
|
||||
// Get the game from state
|
||||
let game = {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let games = state.games.lock();
|
||||
match games.get(&game_id) {
|
||||
Some(g) => g.clone(),
|
||||
None => return Err(format!("Game with ID {} not found", game_id)),
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Installing CreamLinux for game: {} ({})",
|
||||
game.title, game_id
|
||||
);
|
||||
|
||||
// Install CreamLinux first - but provide the DLCs directly instead of fetching them again
|
||||
use crate::installer::install_creamlinux_with_dlcs;
|
||||
|
||||
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
|
||||
let enabled_dlcs = selected_dlcs
|
||||
.iter()
|
||||
.filter(|dlc| dlc.enabled)
|
||||
.map(|dlc| crate::installer::DlcInfo {
|
||||
appid: dlc.appid.clone(),
|
||||
name: dlc.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let game_title = game.title.clone();
|
||||
|
||||
// Use direct installation with provided DLCs instead of re-fetching
|
||||
match install_creamlinux_with_dlcs(
|
||||
&game.path,
|
||||
&game_id,
|
||||
enabled_dlcs,
|
||||
move |progress, message| {
|
||||
// Emit progress updates during installation
|
||||
use crate::installer::emit_progress;
|
||||
emit_progress(
|
||||
&app_handle_clone,
|
||||
&format!("Installing CreamLinux for {}", game_title),
|
||||
message,
|
||||
progress * 100.0, // Scale progress from 0 to 100%
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"CreamLinux installation completed successfully for game: {}",
|
||||
game.title
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to install CreamLinux: {}", e);
|
||||
Err(format!("Failed to install CreamLinux: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,130 @@
|
||||
// src/main.rs
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
mod searcher;
|
||||
mod installer;
|
||||
mod cache;
|
||||
mod dlc_manager;
|
||||
mod cache; // Keep the module for now, but we won't use its functionality
|
||||
mod installer;
|
||||
mod searcher; // Keep the module for now
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::Duration;
|
||||
use tauri::State;
|
||||
use tauri::{Manager, Emitter};
|
||||
use log::{info, warn, error, debug};
|
||||
use installer::{InstallerType, InstallerAction, Game};
|
||||
use dlc_manager::DlcInfoWithState;
|
||||
use std::sync::Arc;
|
||||
use installer::{Game, InstallerAction, InstallerType};
|
||||
use log::{debug, error, info, warn};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GameAction {
|
||||
game_id: String,
|
||||
action: String,
|
||||
game_id: String,
|
||||
action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DlcCache {
|
||||
data: Vec<DlcInfoWithState>,
|
||||
timestamp: Instant,
|
||||
data: Vec<DlcInfoWithState>,
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
// Structure to hold the state of installed games
|
||||
struct AppState {
|
||||
games: Mutex<HashMap<String, Game>>,
|
||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||
fetch_cancellation: Arc<AtomicBool>,
|
||||
games: Mutex<HashMap<String, Game>>,
|
||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||
fetch_cancellation: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
|
||||
dlc_manager::get_all_dlcs(&game_path)
|
||||
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
|
||||
dlc_manager::get_all_dlcs(&game_path)
|
||||
}
|
||||
|
||||
// Scan and get the list of Steam games
|
||||
#[tauri::command]
|
||||
async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<Vec<Game>, String> {
|
||||
async fn scan_steam_games(
|
||||
state: State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<Game>, String> {
|
||||
info!("Starting Steam games scan");
|
||||
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
|
||||
|
||||
|
||||
// Get default Steam paths
|
||||
let paths = searcher::get_default_steam_paths();
|
||||
|
||||
|
||||
// Find Steam libraries
|
||||
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
|
||||
let libraries = searcher::find_steam_libraries(&paths);
|
||||
|
||||
|
||||
// Group libraries by path to avoid duplicates in logs
|
||||
let mut unique_libraries = std::collections::HashSet::new();
|
||||
for lib in &libraries {
|
||||
unique_libraries.insert(lib.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
info!("Found {} Steam library directories:", unique_libraries.len());
|
||||
|
||||
info!(
|
||||
"Found {} Steam library directories:",
|
||||
unique_libraries.len()
|
||||
);
|
||||
for (i, lib) in unique_libraries.iter().enumerate() {
|
||||
info!(" Library {}: {}", i+1, lib);
|
||||
info!(" Library {}: {}", i + 1, lib);
|
||||
}
|
||||
|
||||
emit_scan_progress(&app_handle, &format!("Found {} Steam libraries. Starting game scan...", unique_libraries.len()), 20);
|
||||
|
||||
|
||||
emit_scan_progress(
|
||||
&app_handle,
|
||||
&format!(
|
||||
"Found {} Steam libraries. Starting game scan...",
|
||||
unique_libraries.len()
|
||||
),
|
||||
20,
|
||||
);
|
||||
|
||||
// Find installed games
|
||||
let games_info = searcher::find_installed_games(&libraries).await;
|
||||
|
||||
emit_scan_progress(&app_handle, &format!("Found {} games. Processing...", games_info.len()), 90);
|
||||
|
||||
|
||||
emit_scan_progress(
|
||||
&app_handle,
|
||||
&format!("Found {} games. Processing...", games_info.len()),
|
||||
90,
|
||||
);
|
||||
|
||||
// Log summary of games found
|
||||
info!("Games scan complete - Found {} games", games_info.len());
|
||||
info!("Native games: {}", games_info.iter().filter(|g| g.native).count());
|
||||
info!("Proton games: {}", games_info.iter().filter(|g| !g.native).count());
|
||||
info!("Games with CreamLinux: {}", games_info.iter().filter(|g| g.cream_installed).count());
|
||||
info!("Games with SmokeAPI: {}", games_info.iter().filter(|g| g.smoke_installed).count());
|
||||
|
||||
info!(
|
||||
"Native games: {}",
|
||||
games_info.iter().filter(|g| g.native).count()
|
||||
);
|
||||
info!(
|
||||
"Proton games: {}",
|
||||
games_info.iter().filter(|g| !g.native).count()
|
||||
);
|
||||
info!(
|
||||
"Games with CreamLinux: {}",
|
||||
games_info.iter().filter(|g| g.cream_installed).count()
|
||||
);
|
||||
info!(
|
||||
"Games with SmokeAPI: {}",
|
||||
games_info.iter().filter(|g| g.smoke_installed).count()
|
||||
);
|
||||
|
||||
// Convert to our Game struct
|
||||
let mut result = Vec::new();
|
||||
|
||||
|
||||
info!("Processing games into application state...");
|
||||
for game_info in games_info {
|
||||
// Only log detailed game info at Debug level to keep Info logs cleaner
|
||||
debug!("Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
|
||||
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed);
|
||||
|
||||
debug!(
|
||||
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
|
||||
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
|
||||
);
|
||||
|
||||
let game = Game {
|
||||
id: game_info.id,
|
||||
title: game_info.title,
|
||||
@@ -105,383 +135,413 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
|
||||
smoke_installed: game_info.smoke_installed,
|
||||
installing: false,
|
||||
};
|
||||
|
||||
|
||||
result.push(game.clone());
|
||||
|
||||
|
||||
// Store in state for later use
|
||||
state.games.lock().insert(game.id.clone(), game);
|
||||
}
|
||||
|
||||
emit_scan_progress(&app_handle, &format!("Scan complete. Found {} games.", result.len()), 100);
|
||||
|
||||
|
||||
emit_scan_progress(
|
||||
&app_handle,
|
||||
&format!("Scan complete. Found {} games.", result.len()),
|
||||
100,
|
||||
);
|
||||
|
||||
info!("Game scan completed successfully");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Helper function to emit scan progress events
|
||||
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
|
||||
// Log first, then emit the event
|
||||
info!("Scan progress: {}% - {}", progress, message);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"message": message,
|
||||
"progress": progress
|
||||
});
|
||||
|
||||
if let Err(e) = app_handle.emit("scan-progress", payload) {
|
||||
warn!("Failed to emit scan-progress event: {}", e);
|
||||
}
|
||||
// Log first, then emit the event
|
||||
info!("Scan progress: {}% - {}", progress, message);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"message": message,
|
||||
"progress": progress
|
||||
});
|
||||
|
||||
if let Err(e) = app_handle.emit("scan-progress", payload) {
|
||||
warn!("Failed to emit scan-progress event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch game info by ID - useful for single game updates
|
||||
#[tauri::command]
|
||||
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
|
||||
let games = state.games.lock();
|
||||
games.get(&game_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Game with ID {} not found", game_id))
|
||||
let games = state.games.lock();
|
||||
games
|
||||
.get(&game_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Game with ID {} not found", game_id))
|
||||
}
|
||||
|
||||
// Unified action handler for installation and uninstallation
|
||||
#[tauri::command]
|
||||
async fn process_game_action(
|
||||
game_action: GameAction,
|
||||
state: State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle
|
||||
game_action: GameAction,
|
||||
state: State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Game, String> {
|
||||
// Clone the information we need from state to avoid lifetime issues
|
||||
let game = {
|
||||
let games = state.games.lock();
|
||||
games.get(&game_action.game_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
|
||||
};
|
||||
// Clone the information we need from state to avoid lifetime issues
|
||||
let game = {
|
||||
let games = state.games.lock();
|
||||
games
|
||||
.get(&game_action.game_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
|
||||
};
|
||||
|
||||
// Parse the action string to determine type and operation
|
||||
let (installer_type, action) = match game_action.action.as_str() {
|
||||
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
|
||||
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
|
||||
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
||||
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
||||
_ => return Err(format!("Invalid action: {}", game_action.action))
|
||||
};
|
||||
// Parse the action string to determine type and operation
|
||||
let (installer_type, action) = match game_action.action.as_str() {
|
||||
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
|
||||
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
|
||||
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
||||
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
||||
_ => return Err(format!("Invalid action: {}", game_action.action)),
|
||||
};
|
||||
|
||||
// Execute the action
|
||||
installer::process_action(
|
||||
game_action.game_id.clone(),
|
||||
installer_type,
|
||||
action,
|
||||
game.clone(),
|
||||
app_handle.clone()
|
||||
).await?;
|
||||
// Execute the action
|
||||
installer::process_action(
|
||||
game_action.game_id.clone(),
|
||||
installer_type,
|
||||
action,
|
||||
game.clone(),
|
||||
app_handle.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update game status in state based on the action
|
||||
let updated_game = {
|
||||
let mut games_map = state.games.lock();
|
||||
let game = games_map.get_mut(&game_action.game_id)
|
||||
.ok_or_else(|| format!("Game with ID {} not found after action", game_action.game_id))?;
|
||||
|
||||
// Update installation status
|
||||
match (installer_type, action) {
|
||||
(InstallerType::Cream, InstallerAction::Install) => {
|
||||
game.cream_installed = true;
|
||||
},
|
||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||
game.cream_installed = false;
|
||||
},
|
||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||
game.smoke_installed = true;
|
||||
},
|
||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||
game.smoke_installed = false;
|
||||
// Update game status in state based on the action
|
||||
let updated_game = {
|
||||
let mut games_map = state.games.lock();
|
||||
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
|
||||
format!(
|
||||
"Game with ID {} not found after action",
|
||||
game_action.game_id
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update installation status
|
||||
match (installer_type, action) {
|
||||
(InstallerType::Cream, InstallerAction::Install) => {
|
||||
game.cream_installed = true;
|
||||
}
|
||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||
game.cream_installed = false;
|
||||
}
|
||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||
game.smoke_installed = true;
|
||||
}
|
||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||
game.smoke_installed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset installing flag
|
||||
game.installing = false;
|
||||
|
||||
// Return updated game info
|
||||
game.clone()
|
||||
};
|
||||
|
||||
// Emit an event to update the UI for this specific game
|
||||
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
// Reset installing flag
|
||||
game.installing = false;
|
||||
|
||||
// Return updated game info
|
||||
game.clone()
|
||||
};
|
||||
|
||||
// Removed cache update
|
||||
|
||||
// Emit an event to update the UI for this specific game
|
||||
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
Ok(updated_game)
|
||||
Ok(updated_game)
|
||||
}
|
||||
|
||||
// Fetch DLC list for a game
|
||||
#[tauri::command]
|
||||
async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Fetching DLCs for game ID: {}", game_id);
|
||||
|
||||
// Removed cache checking
|
||||
|
||||
// Always fetch fresh DLC data instead of using cache
|
||||
match installer::fetch_dlc_details(&game_id).await {
|
||||
Ok(dlcs) => {
|
||||
// Convert to DlcInfoWithState (all enabled by default)
|
||||
let dlcs_with_state = dlcs.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Cache in memory for this session (but not on disk)
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut cache = state.dlc_cache.lock();
|
||||
cache.insert(game_id.clone(), DlcCache {
|
||||
data: dlcs_with_state.clone(),
|
||||
timestamp: Instant::now(),
|
||||
});
|
||||
|
||||
Ok(dlcs_with_state)
|
||||
},
|
||||
Err(e) => Err(format!("Failed to fetch DLC details: {}", e))
|
||||
}
|
||||
async fn fetch_game_dlcs(
|
||||
game_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Fetching DLCs for game ID: {}", game_id);
|
||||
|
||||
// Fetch DLC data
|
||||
match installer::fetch_dlc_details(&game_id).await {
|
||||
Ok(dlcs) => {
|
||||
// Convert to DlcInfoWithState
|
||||
let dlcs_with_state = dlcs
|
||||
.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Cache in memory for this session (but not on disk)
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut cache = state.dlc_cache.lock();
|
||||
cache.insert(
|
||||
game_id.clone(),
|
||||
DlcCache {
|
||||
data: dlcs_with_state.clone(),
|
||||
timestamp: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(dlcs_with_state)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
info!("Request to abort DLC fetch for game ID: {}", game_id);
|
||||
|
||||
let state = app_handle.state::<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));
|
||||
info!("Request to abort DLC fetch for game ID: {}", game_id);
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
state.fetch_cancellation.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
state.fetch_cancellation.store(true, Ordering::SeqCst);
|
||||
|
||||
// Reset after a short delay
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let state = app_handle.state::<AppState>();
|
||||
state.fetch_cancellation.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Fetch DLC list with progress updates (streaming)
|
||||
#[tauri::command]
|
||||
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
info!("Streaming DLCs for game ID: {}", game_id);
|
||||
|
||||
// Removed cached DLC check - always fetch fresh data
|
||||
|
||||
// Always fetch fresh DLC data from API
|
||||
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
||||
Ok(dlcs) => {
|
||||
info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id);
|
||||
|
||||
// Convert to DLCInfoWithState for in-memory caching only
|
||||
let dlcs_with_state = dlcs.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Update in-memory cache without storing to disk
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut dlc_cache = state.dlc_cache.lock();
|
||||
dlc_cache.insert(game_id.clone(), DlcCache {
|
||||
data: dlcs_with_state,
|
||||
timestamp: tokio::time::Instant::now(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to stream DLC details: {}", e);
|
||||
// Emit error event
|
||||
let error_payload = serde_json::json!({
|
||||
"error": format!("Failed to fetch DLC details: {}", e)
|
||||
});
|
||||
|
||||
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
|
||||
warn!("Failed to emit dlc-error event: {}", emit_err);
|
||||
}
|
||||
|
||||
Err(format!("Failed to fetch DLC details: {}", e))
|
||||
}
|
||||
}
|
||||
info!("Streaming DLCs for game ID: {}", game_id);
|
||||
|
||||
// Fetch DLC data from API
|
||||
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
||||
Ok(dlcs) => {
|
||||
info!(
|
||||
"Successfully streamed {} DLCs for game {}",
|
||||
dlcs.len(),
|
||||
game_id
|
||||
);
|
||||
|
||||
// Convert to DLCInfoWithState for in-memory caching only
|
||||
let dlcs_with_state = dlcs
|
||||
.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Update in-memory cache without storing to disk
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut dlc_cache = state.dlc_cache.lock();
|
||||
dlc_cache.insert(
|
||||
game_id.clone(),
|
||||
DlcCache {
|
||||
data: dlcs_with_state,
|
||||
timestamp: tokio::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to stream DLC details: {}", e);
|
||||
// Emit error event
|
||||
let error_payload = serde_json::json!({
|
||||
"error": format!("Failed to fetch DLC details: {}", e)
|
||||
});
|
||||
|
||||
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
|
||||
warn!("Failed to emit dlc-error event: {}", emit_err);
|
||||
}
|
||||
|
||||
Err(format!("Failed to fetch DLC details: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear caches command renamed to flush_data for clarity
|
||||
#[tauri::command]
|
||||
fn clear_caches() -> Result<(), String> {
|
||||
info!("Data flush requested - cleaning in-memory state only");
|
||||
Ok(())
|
||||
info!("Data flush requested - cleaning in-memory state only");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get the list of enabled DLCs for a game
|
||||
#[tauri::command]
|
||||
fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
|
||||
info!("Getting enabled DLCs for: {}", game_path);
|
||||
dlc_manager::get_enabled_dlcs(&game_path)
|
||||
info!("Getting enabled DLCs for: {}", game_path);
|
||||
dlc_manager::get_enabled_dlcs(&game_path)
|
||||
}
|
||||
|
||||
// Update the DLC configuration for a game
|
||||
#[tauri::command]
|
||||
fn update_dlc_configuration_command(game_path: String, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
|
||||
info!("Updating DLC configuration for: {}", game_path);
|
||||
dlc_manager::update_dlc_configuration(&game_path, dlcs)
|
||||
fn update_dlc_configuration_command(
|
||||
game_path: String,
|
||||
dlcs: Vec<DlcInfoWithState>,
|
||||
) -> Result<(), String> {
|
||||
info!("Updating DLC configuration for: {}", game_path);
|
||||
dlc_manager::update_dlc_configuration(&game_path, dlcs)
|
||||
}
|
||||
|
||||
// Install CreamLinux with selected DLCs
|
||||
#[tauri::command]
|
||||
async fn install_cream_with_dlcs_command(
|
||||
game_id: String,
|
||||
selected_dlcs: Vec<DlcInfoWithState>,
|
||||
app_handle: tauri::AppHandle
|
||||
game_id: String,
|
||||
selected_dlcs: Vec<DlcInfoWithState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Game, String> {
|
||||
info!("Installing CreamLinux with selected DLCs for game: {}", game_id);
|
||||
|
||||
// Clone selected_dlcs for later use
|
||||
let selected_dlcs_clone = selected_dlcs.clone();
|
||||
|
||||
// Install CreamLinux with the selected DLCs
|
||||
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs).await {
|
||||
Ok(_) => {
|
||||
// Return updated game info
|
||||
let state = app_handle.state::<AppState>();
|
||||
|
||||
// Get a mutable reference and update the game
|
||||
let game = {
|
||||
let mut games_map = state.games.lock();
|
||||
let game = games_map.get_mut(&game_id)
|
||||
.ok_or_else(|| format!("Game with ID {} not found after installation", game_id))?;
|
||||
|
||||
// Update installation status
|
||||
game.cream_installed = true;
|
||||
game.installing = false;
|
||||
|
||||
// Clone the game for returning later
|
||||
game.clone()
|
||||
}; // mutable borrow ends here
|
||||
|
||||
// Removed game caching
|
||||
|
||||
// Emit an event to update the UI
|
||||
if let Err(e) = app_handle.emit("game-updated", &game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
// Show installation complete dialog with instructions
|
||||
let instructions = installer::InstallationInstructions {
|
||||
type_: "cream_install".to_string(),
|
||||
command: "sh ./cream.sh %command%".to_string(),
|
||||
game_title: game.title.clone(),
|
||||
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count())
|
||||
};
|
||||
|
||||
installer::emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game.title),
|
||||
"CreamLinux has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions)
|
||||
);
|
||||
|
||||
Ok(game)
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
||||
Err(format!("Failed to install CreamLinux with selected DLCs: {}", e))
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"Installing CreamLinux with selected DLCs for game: {}",
|
||||
game_id
|
||||
);
|
||||
|
||||
// Clone selected_dlcs for later use
|
||||
let selected_dlcs_clone = selected_dlcs.clone();
|
||||
|
||||
// Install CreamLinux with the selected DLCs
|
||||
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// Return updated game info
|
||||
let state = app_handle.state::<AppState>();
|
||||
|
||||
// Get a mutable reference and update the game
|
||||
let game = {
|
||||
let mut games_map = state.games.lock();
|
||||
let game = games_map.get_mut(&game_id).ok_or_else(|| {
|
||||
format!("Game with ID {} not found after installation", game_id)
|
||||
})?;
|
||||
|
||||
// Update installation status
|
||||
game.cream_installed = true;
|
||||
game.installing = false;
|
||||
|
||||
// Clone the game for returning later
|
||||
game.clone()
|
||||
};
|
||||
|
||||
// Emit an event to update the UI
|
||||
if let Err(e) = app_handle.emit("game-updated", &game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
// Show installation complete dialog with instructions
|
||||
let instructions = installer::InstallationInstructions {
|
||||
type_: "cream_install".to_string(),
|
||||
command: "sh ./cream.sh %command%".to_string(),
|
||||
game_title: game.title.clone(),
|
||||
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
|
||||
};
|
||||
|
||||
installer::emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game.title),
|
||||
"CreamLinux has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions),
|
||||
);
|
||||
|
||||
Ok(game)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
||||
Err(format!(
|
||||
"Failed to install CreamLinux with selected DLCs: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup logging
|
||||
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use log::LevelFilter;
|
||||
use log4rs::append::file::FileAppender;
|
||||
use log4rs::config::{Appender, Config, Root};
|
||||
use log4rs::encode::pattern::PatternEncoder;
|
||||
use std::fs;
|
||||
|
||||
// Get XDG cache directory
|
||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
|
||||
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
|
||||
|
||||
// Clear the log file on startup
|
||||
if log_path.exists() {
|
||||
if let Err(e) = fs::write(&log_path, "") {
|
||||
eprintln!("Warning: Failed to clear log file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a file appender with improved log format
|
||||
let file = FileAppender::builder()
|
||||
.encoder(Box::new(PatternEncoder::new(
|
||||
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n"
|
||||
)))
|
||||
.build(log_path)?;
|
||||
|
||||
// Build the config
|
||||
let config = Config::builder()
|
||||
.appender(Appender::builder().build("file", Box::new(file)))
|
||||
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
|
||||
|
||||
// Initialize log4rs with this config
|
||||
log4rs::init_config(config)?;
|
||||
|
||||
info!("CreamLinux started with a clean log file");
|
||||
Ok(())
|
||||
use log::LevelFilter;
|
||||
use log4rs::append::file::FileAppender;
|
||||
use log4rs::config::{Appender, Config, Root};
|
||||
use log4rs::encode::pattern::PatternEncoder;
|
||||
use std::fs;
|
||||
|
||||
// Get XDG cache directory
|
||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
|
||||
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
|
||||
|
||||
// Clear the log file on startup
|
||||
if log_path.exists() {
|
||||
if let Err(e) = fs::write(&log_path, "") {
|
||||
eprintln!("Warning: Failed to clear log file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a file appender
|
||||
let file = FileAppender::builder()
|
||||
.encoder(Box::new(PatternEncoder::new(
|
||||
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
|
||||
)))
|
||||
.build(log_path)?;
|
||||
|
||||
// Build the config
|
||||
let config = Config::builder()
|
||||
.appender(Appender::builder().build("file", Box::new(file)))
|
||||
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
|
||||
|
||||
// Initialize log4rs with this config
|
||||
log4rs::init_config(config)?;
|
||||
|
||||
info!("CreamLinux started with a clean log file");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Set up logging first
|
||||
if let Err(e) = setup_logging() {
|
||||
eprintln!("Warning: Failed to initialize logging: {}", e);
|
||||
}
|
||||
|
||||
info!("Initializing CreamLinux application");
|
||||
|
||||
let app_state = AppState {
|
||||
games: Mutex::new(HashMap::new()),
|
||||
dlc_cache: Mutex::new(HashMap::new()),
|
||||
fetch_cancellation: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
// Set up logging first
|
||||
if let Err(e) = setup_logging() {
|
||||
eprintln!("Warning: Failed to initialize logging: {}", e);
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
scan_steam_games,
|
||||
get_game_info,
|
||||
process_game_action,
|
||||
fetch_game_dlcs,
|
||||
stream_game_dlcs,
|
||||
get_enabled_dlcs_command,
|
||||
update_dlc_configuration_command,
|
||||
install_cream_with_dlcs_command,
|
||||
get_all_dlcs_command,
|
||||
clear_caches,
|
||||
abort_dlc_fetch,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Add a setup handler to do any initialization work
|
||||
info!("Tauri application setup");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
info!("Initializing CreamLinux application");
|
||||
|
||||
let app_state = AppState {
|
||||
games: Mutex::new(HashMap::new()),
|
||||
dlc_cache: Mutex::new(HashMap::new()),
|
||||
fetch_cancellation: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
scan_steam_games,
|
||||
get_game_info,
|
||||
process_game_action,
|
||||
fetch_game_dlcs,
|
||||
stream_game_dlcs,
|
||||
get_enabled_dlcs_command,
|
||||
update_dlc_configuration_command,
|
||||
install_cream_with_dlcs_command,
|
||||
get_all_dlcs_command,
|
||||
clear_caches,
|
||||
abort_dlc_fetch,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Add a setup handler to do any initialization work
|
||||
info!("Tauri application setup");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// src/searcher.rs
|
||||
use log::{debug, error, info, warn};
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashSet;
|
||||
use log::{info, debug, warn, error};
|
||||
use regex::Regex;
|
||||
use walkdir::WalkDir;
|
||||
use tokio::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Game information structure
|
||||
// Game information structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameInfo {
|
||||
pub id: String,
|
||||
@@ -21,24 +20,24 @@ pub struct GameInfo {
|
||||
pub smoke_installed: bool,
|
||||
}
|
||||
|
||||
/// Find potential Steam installation directories
|
||||
// Find potential Steam installation directories
|
||||
pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
|
||||
// Get user's home directory
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
info!("Searching for Steam in home directory: {}", home);
|
||||
|
||||
|
||||
// Common Steam installation locations on Linux
|
||||
let common_paths = [
|
||||
".steam/steam", // Steam symlink directory
|
||||
".steam/root", // Alternative symlink
|
||||
".local/share/Steam", // Flatpak Steam installation
|
||||
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
|
||||
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
|
||||
"/run/media/mmcblk0p1", // Removable Storage path
|
||||
".steam/steam", // Steam symlink directory
|
||||
".steam/root", // Alternative symlink
|
||||
".local/share/Steam", // Flatpak Steam installation
|
||||
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
|
||||
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
|
||||
"/run/media/mmcblk0p1", // Removable Storage path
|
||||
];
|
||||
|
||||
|
||||
for path in &common_paths {
|
||||
let full_path = PathBuf::from(&home).join(path);
|
||||
if full_path.exists() {
|
||||
@@ -47,13 +46,10 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Steam Deck paths if they exist (these don't rely on HOME)
|
||||
let deck_paths = [
|
||||
"/home/deck/.steam/steam",
|
||||
"/home/deck/.local/share/Steam",
|
||||
];
|
||||
|
||||
|
||||
// Add Steam Deck paths if they exist
|
||||
let deck_paths = ["/home/deck/.steam/steam", "/home/deck/.local/share/Steam"];
|
||||
|
||||
for path in &deck_paths {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() && !paths.contains(&p) {
|
||||
@@ -61,7 +57,7 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Try to extract paths from Steam registry file
|
||||
if let Some(registry_paths) = read_steam_registry() {
|
||||
for path in registry_paths {
|
||||
@@ -71,39 +67,39 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
info!("Found {} potential Steam directories", paths.len());
|
||||
paths
|
||||
}
|
||||
|
||||
/// Try to read the Steam registry file to find installation paths
|
||||
// Try to read the Steam registry file to find installation paths
|
||||
fn read_steam_registry() -> Option<Vec<PathBuf>> {
|
||||
let home = match std::env::var("HOME") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
|
||||
let registry_paths = [
|
||||
format!("{}/.steam/registry.vdf", home),
|
||||
format!("{}/.steam/steam/registry.vdf", home),
|
||||
format!("{}/.local/share/Steam/registry.vdf", home),
|
||||
];
|
||||
|
||||
|
||||
for registry_path in registry_paths {
|
||||
let path = Path::new(®istry_path);
|
||||
if path.exists() {
|
||||
debug!("Found Steam registry at: {}", path.display());
|
||||
|
||||
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
|
||||
// Extract Steam installation paths
|
||||
let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap();
|
||||
if let Some(cap) = re_steam_path.captures(&content) {
|
||||
let steam_path = PathBuf::from(&cap[1]);
|
||||
paths.push(steam_path);
|
||||
}
|
||||
|
||||
|
||||
// Look for install path
|
||||
let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap();
|
||||
if let Some(cap) = re_install_path.captures(&content) {
|
||||
@@ -112,84 +108,84 @@ fn read_steam_registry() -> Option<Vec<PathBuf>> {
|
||||
paths.push(install_path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !paths.is_empty() {
|
||||
return Some(paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find all Steam library folders from base Steam installation paths
|
||||
// Find all Steam library folders from base Steam installation paths
|
||||
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut libraries = HashSet::new();
|
||||
|
||||
|
||||
for base_path in base_paths {
|
||||
debug!("Looking for Steam libraries in: {}", base_path.display());
|
||||
|
||||
|
||||
// Check if this path contains a steamapps directory
|
||||
let steamapps_path = base_path.join("steamapps");
|
||||
if steamapps_path.exists() && steamapps_path.is_dir() {
|
||||
debug!("Found steamapps directory: {}", steamapps_path.display());
|
||||
libraries.insert(steamapps_path.clone());
|
||||
|
||||
|
||||
// Check for additional libraries in libraryfolders.vdf
|
||||
parse_library_folders_vdf(&steamapps_path, &mut libraries);
|
||||
}
|
||||
|
||||
|
||||
// Also check for steamapps in common locations relative to this path
|
||||
let possible_steamapps = [
|
||||
base_path.join("steam/steamapps"),
|
||||
base_path.join("Steam/steamapps"),
|
||||
];
|
||||
|
||||
|
||||
for path in &possible_steamapps {
|
||||
if path.exists() && path.is_dir() && !libraries.contains(path) {
|
||||
debug!("Found steamapps directory: {}", path.display());
|
||||
libraries.insert(path.clone());
|
||||
|
||||
|
||||
// Check for additional libraries in libraryfolders.vdf
|
||||
parse_library_folders_vdf(path, &mut libraries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let result: Vec<PathBuf> = libraries.into_iter().collect();
|
||||
info!("Found {} Steam library directories", result.len());
|
||||
for (i, lib) in result.iter().enumerate() {
|
||||
info!(" Library {}: {}", i+1, lib.display());
|
||||
info!(" Library {}: {}", i + 1, lib.display());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse libraryfolders.vdf to extract additional library paths
|
||||
// Parse libraryfolders.vdf to extract additional library paths
|
||||
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
|
||||
// Check both possible locations of the VDF file
|
||||
let vdf_paths = [
|
||||
steamapps_path.join("libraryfolders.vdf"),
|
||||
steamapps_path.join("config/libraryfolders.vdf"),
|
||||
];
|
||||
|
||||
|
||||
for vdf_path in &vdf_paths {
|
||||
if vdf_path.exists() {
|
||||
debug!("Found library folders VDF: {}", vdf_path.display());
|
||||
|
||||
|
||||
if let Ok(content) = fs::read_to_string(vdf_path) {
|
||||
// Extract library paths using regex for both new and old format VDFs
|
||||
let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap();
|
||||
for cap in re_path.captures_iter(&content) {
|
||||
let path_str = &cap[1];
|
||||
let lib_path = PathBuf::from(path_str).join("steamapps");
|
||||
|
||||
|
||||
if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) {
|
||||
debug!("Found library from VDF: {}", lib_path.display());
|
||||
// Clone lib_path before inserting to avoid ownership issues
|
||||
let lib_path_clone = lib_path.clone();
|
||||
libraries.insert(lib_path_clone);
|
||||
|
||||
|
||||
// Recursively check this library for more libraries
|
||||
parse_library_folders_vdf(&lib_path, libraries);
|
||||
}
|
||||
@@ -199,7 +195,7 @@ fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<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)> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
@@ -207,16 +203,16 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
||||
let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap();
|
||||
let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap();
|
||||
let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap();
|
||||
|
||||
|
||||
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
|
||||
re_appid.captures(&content),
|
||||
re_name.captures(&content),
|
||||
re_installdir.captures(&content)
|
||||
re_installdir.captures(&content),
|
||||
) {
|
||||
let app_id = app_id_cap[1].to_string();
|
||||
let name = name_cap[1].to_string();
|
||||
let install_dir = dir_cap[1].to_string();
|
||||
|
||||
|
||||
return Some((app_id, name, install_dir));
|
||||
}
|
||||
}
|
||||
@@ -224,364 +220,387 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
||||
error!("Failed to read ACF file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a file is a Linux ELF binary
|
||||
// Check if a file is a Linux ELF binary
|
||||
fn is_elf_binary(path: &Path) -> bool {
|
||||
if let Ok(mut file) = fs::File::open(path) {
|
||||
let mut buffer = [0; 4];
|
||||
if file.read_exact(&mut buffer).is_ok() {
|
||||
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
||||
return buffer[0] == 0x7F && buffer[1] == b'E' && buffer[2] == b'L' && buffer[3] == b'F';
|
||||
return buffer[0] == 0x7F
|
||||
&& buffer[1] == b'E'
|
||||
&& buffer[2] == b'L'
|
||||
&& buffer[3] == b'F';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a game has CreamLinux installed
|
||||
// Check if a game has CreamLinux installed
|
||||
fn check_creamlinux_installed(game_path: &Path) -> bool {
|
||||
let cream_files = [
|
||||
"cream.sh",
|
||||
"cream_api.ini",
|
||||
"cream_api.so",
|
||||
];
|
||||
|
||||
let cream_files = ["cream.sh", "cream_api.ini", "cream_api.so"];
|
||||
|
||||
for file in &cream_files {
|
||||
if game_path.join(file).exists() {
|
||||
debug!("CreamLinux installation detected: {}", file);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a game has SmokeAPI installed
|
||||
// Check if a game has SmokeAPI installed
|
||||
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
||||
if api_files.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// SmokeAPI creates backups with _o.dll suffix
|
||||
for api_file in api_files {
|
||||
let api_path = game_path.join(api_file);
|
||||
let api_dir = api_path.parent().unwrap_or(game_path);
|
||||
let api_filename = api_path.file_name().unwrap_or_default();
|
||||
|
||||
|
||||
// Check for backup file (original file renamed with _o.dll suffix)
|
||||
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
|
||||
let backup_path = api_dir.join(backup_name);
|
||||
|
||||
|
||||
if backup_path.exists() {
|
||||
debug!("SmokeAPI backup file found: {}", backup_path.display());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan a game directory to determine if it's native or needs Proton
|
||||
/// Also collect any Steam API DLLs for potential SmokeAPI installation
|
||||
// Scan a game directory to determine if it's native or needs Proton
|
||||
// Also collect any Steam API DLLs for potential SmokeAPI installation
|
||||
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
let mut found_exe = false;
|
||||
let mut found_linux_binary = false;
|
||||
let mut steam_api_files = Vec::new();
|
||||
|
||||
// Directories to skip for better performance
|
||||
let skip_dirs = [
|
||||
"videos", "video", "movies", "movie",
|
||||
"sound", "sounds", "audio",
|
||||
"textures", "music", "localization",
|
||||
"shaders", "logs", "assets/audio",
|
||||
"assets/video", "assets/textures"
|
||||
];
|
||||
|
||||
// Only scan to a reasonable depth (avoid extreme recursion)
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
// File extensions to check for (executable and Steam API files)
|
||||
let exe_extensions = ["exe", "bat", "cmd", "msi"];
|
||||
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
|
||||
|
||||
// Recursively walk through the game directory with optimized settings
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
|
||||
.follow_links(false) // Don't follow symlinks to prevent cycles
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
// Skip certain directories for performance
|
||||
if e.file_type().is_dir() {
|
||||
let file_name = e.file_name().to_string_lossy().to_lowercase();
|
||||
if skip_dirs.iter().any(|&dir| file_name == dir) {
|
||||
debug!("Skipping directory: {}", e.path().display());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.filter_map(Result::ok) {
|
||||
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
|
||||
// Check for Windows executables
|
||||
if exe_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_exe = true;
|
||||
}
|
||||
|
||||
// Check for Steam API DLLs
|
||||
if ext_str == "dll" {
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
|
||||
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
|
||||
if let Ok(rel_path) = path.strip_prefix(game_path) {
|
||||
let rel_path_str = rel_path.to_string_lossy().to_string();
|
||||
debug!("Found Steam API DLL: {}", rel_path_str);
|
||||
steam_api_files.push(rel_path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux binary files
|
||||
if binary_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_linux_binary = true;
|
||||
|
||||
// Check if it's actually an ELF binary for more certainty
|
||||
if ext_str == "so" && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux executables (no extension)
|
||||
#[cfg(unix)]
|
||||
if !path.extension().is_some() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
|
||||
// Check executable permission and ELF format
|
||||
if is_executable && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
|
||||
// This early break greatly improves performance for large game directories
|
||||
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
|
||||
debug!("Found sufficient evidence, breaking scan early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// A game is considered native if it has Linux binaries but no Windows executables
|
||||
let is_native = found_linux_binary && !found_exe;
|
||||
|
||||
debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len());
|
||||
(is_native, steam_api_files)
|
||||
let mut found_exe = false;
|
||||
let mut found_linux_binary = false;
|
||||
let mut steam_api_files = Vec::new();
|
||||
|
||||
// Directories to skip for better performance
|
||||
let skip_dirs = [
|
||||
"videos",
|
||||
"video",
|
||||
"movies",
|
||||
"movie",
|
||||
"sound",
|
||||
"sounds",
|
||||
"audio",
|
||||
"textures",
|
||||
"music",
|
||||
"localization",
|
||||
"shaders",
|
||||
"logs",
|
||||
"assets/audio",
|
||||
"assets/video",
|
||||
"assets/textures",
|
||||
];
|
||||
|
||||
// Only scan to a reasonable depth (avoid extreme recursion)
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
// File extensions to check for (executable and Steam API files)
|
||||
let exe_extensions = ["exe", "bat", "cmd", "msi"];
|
||||
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
|
||||
|
||||
// Recursively walk through the game directory
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
|
||||
.follow_links(false) // Don't follow symlinks to prevent cycles
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
// Skip certain directories for performance
|
||||
if e.file_type().is_dir() {
|
||||
let file_name = e.file_name().to_string_lossy().to_lowercase();
|
||||
if skip_dirs.iter().any(|&dir| file_name == dir) {
|
||||
debug!("Skipping directory: {}", e.path().display());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
|
||||
// Check for Windows executables
|
||||
if exe_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_exe = true;
|
||||
}
|
||||
|
||||
// Check for Steam API DLLs
|
||||
if ext_str == "dll" {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
|
||||
if let Ok(rel_path) = path.strip_prefix(game_path) {
|
||||
let rel_path_str = rel_path.to_string_lossy().to_string();
|
||||
debug!("Found Steam API DLL: {}", rel_path_str);
|
||||
steam_api_files.push(rel_path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux binary files
|
||||
if binary_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_linux_binary = true;
|
||||
|
||||
// Check if it's actually an ELF binary for more certainty
|
||||
if ext_str == "so" && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux executables (no extension)
|
||||
#[cfg(unix)]
|
||||
if !path.extension().is_some() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
|
||||
// Check executable permission and ELF format
|
||||
if is_executable && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
|
||||
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
|
||||
debug!("Found sufficient evidence, breaking scan early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// A game is considered native if it has Linux binaries but no Windows executables
|
||||
let is_native = found_linux_binary && !found_exe;
|
||||
|
||||
debug!(
|
||||
"Game scan results: native={}, exe={}, api_dlls={}",
|
||||
is_native,
|
||||
found_exe,
|
||||
steam_api_files.len()
|
||||
);
|
||||
(is_native, steam_api_files)
|
||||
}
|
||||
|
||||
/// Find all installed Steam games from library folders
|
||||
// Find all installed Steam games from library folders
|
||||
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
|
||||
|
||||
let mut games = Vec::new();
|
||||
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
||||
|
||||
// IDs to skip (tools, redistributables, etc.)
|
||||
let skip_ids = Arc::new([
|
||||
"228980", // Steamworks Common Redistributables
|
||||
"1070560", // Steam Linux Runtime
|
||||
"1391110", // Steam Linux Runtime - Soldier
|
||||
"1628350", // Steam Linux Runtime - Sniper
|
||||
"1493710", // Proton Experimental
|
||||
"2180100", // Steam Linux Runtime - Scout
|
||||
].iter().copied().collect::<HashSet<&str>>());
|
||||
|
||||
// Name patterns to skip (case insensitive)
|
||||
let skip_patterns = Arc::new(
|
||||
[
|
||||
r"(?i)steam linux runtime",
|
||||
r"(?i)proton",
|
||||
r"(?i)steamworks common",
|
||||
r"(?i)redistributable",
|
||||
r"(?i)dotnet",
|
||||
r"(?i)vc redist",
|
||||
]
|
||||
.iter()
|
||||
.map(|pat| Regex::new(pat).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
info!("Scanning for installed games in parallel...");
|
||||
|
||||
// Create a channel to collect results
|
||||
let (tx, mut rx) = mpsc::channel(32);
|
||||
|
||||
// First collect all appmanifest files to process
|
||||
let mut app_manifests = Vec::new();
|
||||
for steamapps_dir in steamapps_paths {
|
||||
if let Ok(entries) = fs::read_dir(steamapps_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Check for appmanifest files
|
||||
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
|
||||
app_manifests.push((path, steamapps_dir.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} appmanifest files to process", app_manifests.len());
|
||||
|
||||
// Process each appmanifest file in parallel with a maximum concurrency
|
||||
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
|
||||
info!("Using {} concurrent scanners", max_concurrent);
|
||||
|
||||
// Use a semaphore to limit concurrency
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
|
||||
|
||||
// Create a Vec to store all our task handles
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Process each manifest file
|
||||
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
|
||||
// Clone what we need for the task
|
||||
let path = path.clone();
|
||||
let steamapps_dir = steamapps_dir.clone();
|
||||
let skip_patterns = Arc::clone(&skip_patterns);
|
||||
let tx = tx.clone();
|
||||
let seen_ids = Arc::clone(&seen_ids);
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let skip_ids = Arc::clone(&skip_ids);
|
||||
|
||||
// Create a new task
|
||||
let handle = tokio::spawn(async move {
|
||||
// Acquire a permit from the semaphore
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
// Parse the appmanifest file
|
||||
if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
|
||||
// Skip if in exclusion list
|
||||
if skip_ids.contains(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a guard against duplicates
|
||||
{
|
||||
let mut seen = seen_ids.lock().await;
|
||||
if seen.contains(&id) {
|
||||
return;
|
||||
}
|
||||
seen.insert(id.clone());
|
||||
}
|
||||
|
||||
// Skip if the name matches any exclusion patterns
|
||||
if skip_patterns.iter().any(|re| re.is_match(&name)) {
|
||||
debug!("Skipping runtime/tool: {} ({})", name, id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Full path to the game directory
|
||||
let game_path = steamapps_dir.join("common").join(&install_dir);
|
||||
|
||||
// Skip if game directory doesn't exist
|
||||
if !game_path.exists() {
|
||||
warn!("Game directory not found: {}", game_path.display());
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan the game directory to determine platform and find Steam API DLLs
|
||||
info!("Scanning game: {} at {}", name, game_path.display());
|
||||
|
||||
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly
|
||||
let (is_native, api_files) = scan_game_directory(&game_path);
|
||||
|
||||
// Check for CreamLinux installation
|
||||
let cream_installed = check_creamlinux_installed(&game_path);
|
||||
|
||||
// Check for SmokeAPI installation (only for non-native games with Steam API DLLs)
|
||||
let smoke_installed = if !is_native && !api_files.is_empty() {
|
||||
check_smokeapi_installed(&game_path, &api_files)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Create the game info
|
||||
let game_info = GameInfo {
|
||||
id,
|
||||
title: name,
|
||||
path: game_path,
|
||||
native: is_native,
|
||||
api_files,
|
||||
cream_installed,
|
||||
smoke_installed,
|
||||
};
|
||||
|
||||
// Send the game info through the channel
|
||||
if tx.send(game_info).await.is_err() {
|
||||
error!("Failed to send game info through channel");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
|
||||
// Every 10 files, yield to allow progress updates
|
||||
if manifest_idx % 10 == 0 {
|
||||
// We would update progress here in a full implementation
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the original sender so the receiver knows when we're done
|
||||
drop(tx);
|
||||
|
||||
// Spawn a task to collect all the results
|
||||
let receiver_task = tokio::spawn(async move {
|
||||
let mut results = Vec::new();
|
||||
while let Some(game) = rx.recv().await {
|
||||
info!("Found game: {} ({})", game.title, game.id);
|
||||
info!(" Path: {}", game.path.display());
|
||||
info!(" Status: Native={}, Cream={}, Smoke={}",
|
||||
game.native, game.cream_installed, game.smoke_installed);
|
||||
|
||||
// Log Steam API DLLs if any
|
||||
if !game.api_files.is_empty() {
|
||||
info!(" Steam API files:");
|
||||
for api_file in &game.api_files {
|
||||
info!(" - {}", api_file);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(game);
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
// Wait for all scan tasks to complete - but don't wait for the results yet
|
||||
for handle in handles {
|
||||
// Ignore errors - the receiver task will just get fewer results
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Now wait for all results to be collected
|
||||
if let Ok(results) = receiver_task.await {
|
||||
games = results;
|
||||
}
|
||||
|
||||
info!("Found {} installed games", games.len());
|
||||
games
|
||||
}
|
||||
let mut games = Vec::new();
|
||||
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
||||
|
||||
// IDs to skip (tools, redistributables, etc.)
|
||||
let skip_ids = Arc::new(
|
||||
[
|
||||
"228980", // Steamworks Common Redistributables
|
||||
"1070560", // Steam Linux Runtime
|
||||
"1391110", // Steam Linux Runtime - Soldier
|
||||
"1628350", // Steam Linux Runtime - Sniper
|
||||
"1493710", // Proton Experimental
|
||||
"2180100", // Steam Linux Runtime - Scout
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<HashSet<&str>>(),
|
||||
);
|
||||
|
||||
// Name patterns to skip (case insensitive)
|
||||
let skip_patterns = Arc::new(
|
||||
[
|
||||
r"(?i)steam linux runtime",
|
||||
r"(?i)proton",
|
||||
r"(?i)steamworks common",
|
||||
r"(?i)redistributable",
|
||||
r"(?i)dotnet",
|
||||
r"(?i)vc redist",
|
||||
]
|
||||
.iter()
|
||||
.map(|pat| Regex::new(pat).unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
info!("Scanning for installed games in parallel...");
|
||||
|
||||
// Create a channel to collect results
|
||||
let (tx, mut rx) = mpsc::channel(32);
|
||||
|
||||
// First collect all appmanifest files to process
|
||||
let mut app_manifests = Vec::new();
|
||||
for steamapps_dir in steamapps_paths {
|
||||
if let Ok(entries) = fs::read_dir(steamapps_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Check for appmanifest files
|
||||
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
|
||||
app_manifests.push((path, steamapps_dir.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} appmanifest files to process", app_manifests.len());
|
||||
|
||||
// Process appmanifest files
|
||||
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
|
||||
info!("Using {} concurrent scanners", max_concurrent);
|
||||
|
||||
// Use a semaphore to limit concurrency
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
|
||||
|
||||
// Create a Vec to store all our task handles
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Process each manifest file
|
||||
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
|
||||
// Clone what we need for the task
|
||||
let path = path.clone();
|
||||
let steamapps_dir = steamapps_dir.clone();
|
||||
let skip_patterns = Arc::clone(&skip_patterns);
|
||||
let tx = tx.clone();
|
||||
let seen_ids = Arc::clone(&seen_ids);
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let skip_ids = Arc::clone(&skip_ids);
|
||||
|
||||
// Create a new task
|
||||
let handle = tokio::spawn(async move {
|
||||
// Acquire a permit from the semaphore
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
// Parse the appmanifest file
|
||||
if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
|
||||
// Skip if in exclusion list
|
||||
if skip_ids.contains(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a guard against duplicates
|
||||
{
|
||||
let mut seen = seen_ids.lock().await;
|
||||
if seen.contains(&id) {
|
||||
return;
|
||||
}
|
||||
seen.insert(id.clone());
|
||||
}
|
||||
|
||||
// Skip if the name matches any exclusion patterns
|
||||
if skip_patterns.iter().any(|re| re.is_match(&name)) {
|
||||
debug!("Skipping runtime/tool: {} ({})", name, id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Full path to the game directory
|
||||
let game_path = steamapps_dir.join("common").join(&install_dir);
|
||||
|
||||
// Skip if game directory doesn't exist
|
||||
if !game_path.exists() {
|
||||
warn!("Game directory not found: {}", game_path.display());
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan the game directory to determine platform and find Steam API DLLs
|
||||
info!("Scanning game: {} at {}", name, game_path.display());
|
||||
|
||||
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly
|
||||
let (is_native, api_files) = scan_game_directory(&game_path);
|
||||
|
||||
// Check for CreamLinux installation
|
||||
let cream_installed = check_creamlinux_installed(&game_path);
|
||||
|
||||
// Check for SmokeAPI installation (only for non-native games with Steam API DLLs)
|
||||
let smoke_installed = if !is_native && !api_files.is_empty() {
|
||||
check_smokeapi_installed(&game_path, &api_files)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Create the game info
|
||||
let game_info = GameInfo {
|
||||
id,
|
||||
title: name,
|
||||
path: game_path,
|
||||
native: is_native,
|
||||
api_files,
|
||||
cream_installed,
|
||||
smoke_installed,
|
||||
};
|
||||
|
||||
// Send the game info through the channel
|
||||
if tx.send(game_info).await.is_err() {
|
||||
error!("Failed to send game info through channel");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
|
||||
// Every 10 files, yield to allow progress updates
|
||||
if manifest_idx % 10 == 0 {
|
||||
// We would update progress here in a full implementation
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the original sender so the receiver knows when we're done
|
||||
drop(tx);
|
||||
|
||||
// Spawn a task to collect all the results
|
||||
let receiver_task = tokio::spawn(async move {
|
||||
let mut results = Vec::new();
|
||||
while let Some(game) = rx.recv().await {
|
||||
info!("Found game: {} ({})", game.title, game.id);
|
||||
info!(" Path: {}", game.path.display());
|
||||
info!(
|
||||
" Status: Native={}, Cream={}, Smoke={}",
|
||||
game.native, game.cream_installed, game.smoke_installed
|
||||
);
|
||||
|
||||
// Log Steam API DLLs if any
|
||||
if !game.api_files.is_empty() {
|
||||
info!(" Steam API files:");
|
||||
for api_file in &game.api_files {
|
||||
info!(" - {}", api_file);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(game);
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
// Wait for all scan tasks to complete but don't wait for the results yet
|
||||
for handle in handles {
|
||||
// Ignore errors the receiver task will just get fewer results
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Now wait for all results to be collected
|
||||
if let Ok(results) = receiver_task.await {
|
||||
games = results;
|
||||
}
|
||||
|
||||
info!("Found {} installed games", games.len());
|
||||
games
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Utility",
|
||||
"icon": [
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png"
|
||||
]
|
||||
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
|
||||
946
src/App.tsx
946
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,41 @@
|
||||
// src/components/ActionButton.tsx
|
||||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke';
|
||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: ActionType;
|
||||
isInstalled: boolean;
|
||||
isWorking: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
action: ActionType
|
||||
isInstalled: boolean
|
||||
isWorking: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ActionButton: React.FC<ActionButtonProps> = ({
|
||||
action,
|
||||
isInstalled,
|
||||
isWorking,
|
||||
onClick,
|
||||
disabled = false
|
||||
const ActionButton: React.FC<ActionButtonProps> = ({
|
||||
action,
|
||||
isInstalled,
|
||||
isWorking,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const getButtonText = () => {
|
||||
if (isWorking) return "Working...";
|
||||
|
||||
const isCream = action.includes('cream');
|
||||
const product = isCream ? "CreamLinux" : "SmokeAPI";
|
||||
|
||||
return isInstalled ? `Uninstall ${product}` : `Install ${product}`;
|
||||
};
|
||||
if (isWorking) return 'Working...'
|
||||
|
||||
const isCream = action.includes('cream')
|
||||
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
||||
|
||||
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
|
||||
}
|
||||
|
||||
const getButtonClass = () => {
|
||||
const baseClass = "action-button";
|
||||
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`;
|
||||
};
|
||||
const baseClass = 'action-button'
|
||||
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={getButtonClass()}
|
||||
onClick={onClick}
|
||||
disabled={disabled || isWorking}
|
||||
>
|
||||
<button className={getButtonClass()} onClick={onClick} disabled={disabled || isWorking}>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionButton;
|
||||
export default ActionButton
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
// src/components/AnimatedBackground.tsx
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
const AnimatedBackground: React.FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas size to match window
|
||||
const setCanvasSize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
|
||||
setCanvasSize();
|
||||
window.addEventListener('resize', setCanvasSize);
|
||||
|
||||
// Create particles
|
||||
const particles: Particle[] = [];
|
||||
const particleCount = 30;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
speedX: number;
|
||||
speedY: number;
|
||||
opacity: number;
|
||||
color: string;
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
}
|
||||
|
||||
|
||||
setCanvasSize()
|
||||
window.addEventListener('resize', setCanvasSize)
|
||||
|
||||
// Create particles
|
||||
const particles: Particle[] = []
|
||||
const particleCount = 30
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
speedX: number
|
||||
speedY: number
|
||||
opacity: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// Color palette
|
||||
const colors = [
|
||||
'rgba(74, 118, 196, 0.5)', // primary blue
|
||||
'rgba(74, 118, 196, 0.5)', // primary blue
|
||||
'rgba(155, 125, 255, 0.5)', // purple
|
||||
'rgba(251, 177, 60, 0.5)', // gold
|
||||
];
|
||||
|
||||
'rgba(251, 177, 60, 0.5)', // gold
|
||||
]
|
||||
|
||||
// Create initial particles
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
particles.push({
|
||||
@@ -50,65 +49,65 @@ const AnimatedBackground: React.FC = () => {
|
||||
speedX: Math.random() * 0.2 - 0.1,
|
||||
speedY: Math.random() * 0.2 - 0.1,
|
||||
opacity: Math.random() * 0.07 + 0.03,
|
||||
color: colors[Math.floor(Math.random() * colors.length)]
|
||||
});
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
// Clear canvas with transparent black to create fade effect
|
||||
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Update and draw particles
|
||||
particles.forEach(particle => {
|
||||
particles.forEach((particle) => {
|
||||
// Update position
|
||||
particle.x += particle.speedX;
|
||||
particle.y += particle.speedY;
|
||||
|
||||
particle.x += particle.speedX
|
||||
particle.y += particle.speedY
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < 0) particle.x = canvas.width;
|
||||
if (particle.x > canvas.width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = canvas.height;
|
||||
if (particle.y > canvas.height) particle.y = 0;
|
||||
|
||||
if (particle.x < 0) particle.x = canvas.width
|
||||
if (particle.x > canvas.width) particle.x = 0
|
||||
if (particle.y < 0) particle.y = canvas.height
|
||||
if (particle.y > canvas.height) particle.y = 0
|
||||
|
||||
// Draw particle
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`);
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
|
||||
ctx.fill()
|
||||
|
||||
// Connect particles
|
||||
particles.forEach(otherParticle => {
|
||||
const dx = particle.x - otherParticle.x;
|
||||
const dy = particle.y - otherParticle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
particles.forEach((otherParticle) => {
|
||||
const dx = particle.x - otherParticle.x
|
||||
const dy = particle.y - otherParticle.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance < 100) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`);
|
||||
ctx.lineWidth = 0.2;
|
||||
ctx.moveTo(particle.x, particle.y);
|
||||
ctx.lineTo(otherParticle.x, otherParticle.y);
|
||||
ctx.stroke();
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`)
|
||||
ctx.lineWidth = 0.2
|
||||
ctx.moveTo(particle.x, particle.y)
|
||||
ctx.lineTo(otherParticle.x, otherParticle.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// Start animation
|
||||
animate();
|
||||
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', setCanvasSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
window.removeEventListener('resize', setCanvasSize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="animated-background"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -118,10 +117,10 @@ const AnimatedBackground: React.FC = () => {
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.4
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedBackground;
|
||||
export default AnimatedBackground
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// src/components/AnimatedCheckbox.tsx
|
||||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
interface AnimatedCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
className?: string;
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
label?: string
|
||||
sublabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
||||
@@ -14,25 +13,20 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
||||
onChange,
|
||||
label,
|
||||
sublabel,
|
||||
className = ''
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<label className={`animated-checkbox ${className}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="checkbox-original"
|
||||
/>
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
|
||||
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
|
||||
<svg viewBox="0 0 24 24" className="checkmark-icon">
|
||||
<path
|
||||
<path
|
||||
className={`checkmark ${checked ? 'checked' : ''}`}
|
||||
d="M5 12l5 5L20 7"
|
||||
stroke="#fff"
|
||||
d="M5 12l5 5L20 7"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -44,7 +38,7 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedCheckbox;
|
||||
export default AnimatedCheckbox
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
// src/components/DlcSelectionDialog.tsx
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import AnimatedCheckbox from './AnimatedCheckbox';
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import AnimatedCheckbox from './AnimatedCheckbox'
|
||||
|
||||
interface DlcInfo {
|
||||
appid: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
appid: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface DlcSelectionDialogProps {
|
||||
visible: boolean;
|
||||
gameTitle: string;
|
||||
dlcs: DlcInfo[];
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedDlcs: DlcInfo[]) => void;
|
||||
isLoading: boolean;
|
||||
isEditMode?: boolean;
|
||||
loadingProgress?: number;
|
||||
estimatedTimeLeft?: string;
|
||||
visible: boolean
|
||||
gameTitle: string
|
||||
dlcs: DlcInfo[]
|
||||
onClose: () => void
|
||||
onConfirm: (selectedDlcs: DlcInfo[]) => void
|
||||
isLoading: boolean
|
||||
isEditMode?: boolean
|
||||
loadingProgress?: number
|
||||
estimatedTimeLeft?: string
|
||||
}
|
||||
|
||||
const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
||||
@@ -29,122 +28,125 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
||||
isLoading,
|
||||
isEditMode = false,
|
||||
loadingProgress = 0,
|
||||
estimatedTimeLeft = ''
|
||||
estimatedTimeLeft = '',
|
||||
}) => {
|
||||
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([]);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectAll, setSelectAll] = useState(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
// Initialize selected DLCs when DLC list changes
|
||||
useEffect(() => {
|
||||
if (visible && dlcs.length > 0 && !initialized) {
|
||||
setSelectedDlcs(dlcs);
|
||||
|
||||
setSelectedDlcs(dlcs)
|
||||
|
||||
// Determine initial selectAll state based on if all DLCs are enabled
|
||||
const allSelected = dlcs.every(dlc => dlc.enabled);
|
||||
setSelectAll(allSelected);
|
||||
|
||||
const allSelected = dlcs.every((dlc) => dlc.enabled)
|
||||
setSelectAll(allSelected)
|
||||
|
||||
// Mark as initialized so we don't reset selections on subsequent DLC additions
|
||||
setInitialized(true);
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [visible, dlcs, initialized]);
|
||||
}, [visible, dlcs, initialized])
|
||||
|
||||
// Handle visibility changes
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Show content immediately for better UX
|
||||
const timer = setTimeout(() => {
|
||||
setShowContent(true);
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
setShowContent(true)
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
setShowContent(false);
|
||||
setInitialized(false); // Reset initialized state when dialog closes
|
||||
setShowContent(false)
|
||||
setInitialized(false) // Reset initialized state when dialog closes
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
// Memoize filtered DLCs to avoid unnecessary recalculations
|
||||
const filteredDlcs = useMemo(() => {
|
||||
return searchQuery.trim() === ''
|
||||
? selectedDlcs
|
||||
: selectedDlcs.filter(dlc =>
|
||||
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
dlc.appid.includes(searchQuery)
|
||||
);
|
||||
}, [selectedDlcs, searchQuery]);
|
||||
return searchQuery.trim() === ''
|
||||
? selectedDlcs
|
||||
: selectedDlcs.filter(
|
||||
(dlc) =>
|
||||
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
dlc.appid.includes(searchQuery)
|
||||
)
|
||||
}, [selectedDlcs, searchQuery])
|
||||
|
||||
// Update DLC selection status
|
||||
const handleToggleDlc = (appid: string) => {
|
||||
setSelectedDlcs(prev => prev.map(dlc =>
|
||||
dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc
|
||||
));
|
||||
};
|
||||
setSelectedDlcs((prev) =>
|
||||
prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
|
||||
)
|
||||
}
|
||||
|
||||
// Update selectAll state when individual DLC selections change
|
||||
useEffect(() => {
|
||||
const allSelected = selectedDlcs.every(dlc => dlc.enabled);
|
||||
setSelectAll(allSelected);
|
||||
}, [selectedDlcs]);
|
||||
const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
|
||||
setSelectAll(allSelected)
|
||||
}, [selectedDlcs])
|
||||
|
||||
// Handle new DLCs being added while dialog is already open
|
||||
useEffect(() => {
|
||||
if (initialized && dlcs.length > selectedDlcs.length) {
|
||||
// Find new DLCs that aren't in our current selection
|
||||
const currentAppIds = new Set(selectedDlcs.map(dlc => dlc.appid));
|
||||
const newDlcs = dlcs.filter(dlc => !currentAppIds.has(dlc.appid));
|
||||
|
||||
const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
|
||||
const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
|
||||
|
||||
// Add new DLCs to our selection, maintaining their enabled state
|
||||
if (newDlcs.length > 0) {
|
||||
setSelectedDlcs(prev => [...prev, ...newDlcs]);
|
||||
setSelectedDlcs((prev) => [...prev, ...newDlcs])
|
||||
}
|
||||
}
|
||||
}, [dlcs, selectedDlcs, initialized]);
|
||||
}, [dlcs, selectedDlcs, initialized])
|
||||
|
||||
const handleToggleSelectAll = () => {
|
||||
const newSelectAllState = !selectAll;
|
||||
setSelectAll(newSelectAllState);
|
||||
|
||||
setSelectedDlcs(prev => prev.map(dlc => ({
|
||||
...dlc,
|
||||
enabled: newSelectAllState
|
||||
})));
|
||||
};
|
||||
const newSelectAllState = !selectAll
|
||||
setSelectAll(newSelectAllState)
|
||||
|
||||
setSelectedDlcs((prev) =>
|
||||
prev.map((dlc) => ({
|
||||
...dlc,
|
||||
enabled: newSelectAllState,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedDlcs);
|
||||
};
|
||||
onConfirm(selectedDlcs)
|
||||
}
|
||||
|
||||
// Modified to prevent closing when loading
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Prevent clicks from propagating through the overlay
|
||||
e.stopPropagation();
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
// Only allow closing via overlay click if not loading
|
||||
if (e.target === e.currentTarget && !isLoading) {
|
||||
onClose();
|
||||
onClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Count selected DLCs
|
||||
const selectedCount = selectedDlcs.filter(dlc => dlc.enabled).length;
|
||||
|
||||
const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
|
||||
|
||||
// Format loading message to show total number of DLCs found
|
||||
const getLoadingInfoText = () => {
|
||||
if (isLoading && loadingProgress < 100) {
|
||||
return ` (Loading more DLCs...)`;
|
||||
return ` (Loading more DLCs...)`
|
||||
} else if (dlcs.length > 0) {
|
||||
return ` (Total DLCs: ${dlcs.length})`;
|
||||
return ` (Total DLCs: ${dlcs.length})`
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dlc-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
<div
|
||||
className={`dlc-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className={`dlc-selection-dialog ${showContent ? 'dialog-visible' : ''}`}>
|
||||
@@ -160,9 +162,9 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="dlc-dialog-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dlc-search-input"
|
||||
@@ -179,14 +181,13 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
||||
{isLoading && (
|
||||
<div className="dlc-loading-progress">
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${loadingProgress}%` }}
|
||||
/>
|
||||
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
||||
</div>
|
||||
<div className="loading-details">
|
||||
<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>
|
||||
)}
|
||||
@@ -194,7 +195,7 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
||||
<div className="dlc-list-container">
|
||||
{selectedDlcs.length > 0 ? (
|
||||
<ul className="dlc-list">
|
||||
{filteredDlcs.map(dlc => (
|
||||
{filteredDlcs.map((dlc) => (
|
||||
<li key={dlc.appid} className="dlc-item">
|
||||
<AnimatedCheckbox
|
||||
checked={dlc.enabled}
|
||||
@@ -219,24 +220,20 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="dlc-dialog-actions">
|
||||
<button
|
||||
className="cancel-button"
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="confirm-button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<button className="confirm-button" onClick={handleConfirm} disabled={isLoading}>
|
||||
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DlcSelectionDialog;
|
||||
export default DlcSelectionDialog
|
||||
|
||||
@@ -1,98 +1,100 @@
|
||||
// src/components/GameItem.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { findBestGameImage } from '../services/ImageService';
|
||||
import { ActionType } from './ActionButton';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { findBestGameImage } from '../services/ImageService'
|
||||
import { ActionType } from './ActionButton'
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
platform?: string;
|
||||
native: boolean;
|
||||
api_files: string[];
|
||||
cream_installed?: boolean;
|
||||
smoke_installed?: boolean;
|
||||
installing?: boolean;
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
platform?: string
|
||||
native: boolean
|
||||
api_files: string[]
|
||||
cream_installed?: boolean
|
||||
smoke_installed?: boolean
|
||||
installing?: boolean
|
||||
}
|
||||
|
||||
interface GameItemProps {
|
||||
game: Game;
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>;
|
||||
onEdit?: (gameId: string) => void;
|
||||
game: Game
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
}
|
||||
|
||||
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Function to fetch the game cover/image
|
||||
const fetchGameImage = async () => {
|
||||
// First check if we already have it (to prevent flickering on re-renders)
|
||||
if (imageUrl) return;
|
||||
|
||||
setIsLoading(true);
|
||||
if (imageUrl) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Try to find the best available image for this game
|
||||
const bestImageUrl = await findBestGameImage(game.id);
|
||||
|
||||
const bestImageUrl = await findBestGameImage(game.id)
|
||||
|
||||
if (bestImageUrl) {
|
||||
setImageUrl(bestImageUrl);
|
||||
setHasError(false);
|
||||
setImageUrl(bestImageUrl)
|
||||
setHasError(false)
|
||||
} else {
|
||||
setHasError(true);
|
||||
setHasError(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching game image:', error);
|
||||
setHasError(true);
|
||||
console.error('Error fetching game image:', error)
|
||||
setHasError(true)
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (game.id) {
|
||||
fetchGameImage();
|
||||
fetchGameImage()
|
||||
}
|
||||
}, [game.id, imageUrl]);
|
||||
}, [game.id, imageUrl])
|
||||
|
||||
// Determine if we should show CreamLinux buttons (only for native games)
|
||||
const shouldShowCream = game.native === true;
|
||||
|
||||
const shouldShowCream = game.native === true
|
||||
|
||||
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
||||
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0;
|
||||
|
||||
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
|
||||
|
||||
// Check if this is a Proton game without API files
|
||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0);
|
||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
|
||||
|
||||
const handleCreamAction = () => {
|
||||
if (game.installing) return;
|
||||
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream';
|
||||
onAction(game.id, action);
|
||||
};
|
||||
if (game.installing) return
|
||||
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
const handleSmokeAction = () => {
|
||||
if (game.installing) return;
|
||||
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke';
|
||||
onAction(game.id, action);
|
||||
};
|
||||
|
||||
if (game.installing) return
|
||||
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
// Handle edit button click
|
||||
const handleEdit = () => {
|
||||
if (onEdit && game.cream_installed) {
|
||||
onEdit(game.id);
|
||||
onEdit(game.id)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Determine background image
|
||||
const backgroundImage = !isLoading && imageUrl ?
|
||||
`url(${imageUrl})` :
|
||||
hasError ? 'linear-gradient(135deg, #232323, #1A1A1A)' : 'linear-gradient(135deg, #232323, #1A1A1A)';
|
||||
const backgroundImage =
|
||||
!isLoading && imageUrl
|
||||
? `url(${imageUrl})`
|
||||
: hasError
|
||||
? 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
: 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-item-card"
|
||||
style={{
|
||||
<div
|
||||
className="game-item-card"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
@@ -103,14 +105,10 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
|
||||
{game.native ? 'Native' : 'Proton'}
|
||||
</span>
|
||||
{game.cream_installed && (
|
||||
<span className="status-badge cream">CreamLinux</span>
|
||||
)}
|
||||
{game.smoke_installed && (
|
||||
<span className="status-badge smoke">SmokeAPI</span>
|
||||
)}
|
||||
{game.cream_installed && <span className="status-badge cream">CreamLinux</span>}
|
||||
{game.smoke_installed && <span className="status-badge smoke">SmokeAPI</span>}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="game-title">
|
||||
<h3>{game.title}</h3>
|
||||
</div>
|
||||
@@ -118,31 +116,39 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
<div className="game-actions">
|
||||
{/* Show CreamLinux button only for native games */}
|
||||
{shouldShowCream && (
|
||||
<button
|
||||
<button
|
||||
className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`}
|
||||
onClick={handleCreamAction}
|
||||
disabled={!!game.installing}
|
||||
>
|
||||
{game.installing ? "Working..." : (game.cream_installed ? "Uninstall CreamLinux" : "Install CreamLinux")}
|
||||
{game.installing
|
||||
? 'Working...'
|
||||
: game.cream_installed
|
||||
? 'Uninstall CreamLinux'
|
||||
: 'Install CreamLinux'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
|
||||
{shouldShowSmoke && (
|
||||
<button
|
||||
<button
|
||||
className={`action-button ${game.smoke_installed ? 'uninstall' : 'install'}`}
|
||||
onClick={handleSmokeAction}
|
||||
disabled={!!game.installing}
|
||||
>
|
||||
{game.installing ? "Working..." : (game.smoke_installed ? "Uninstall SmokeAPI" : "Install SmokeAPI")}
|
||||
{game.installing
|
||||
? 'Working...'
|
||||
: game.smoke_installed
|
||||
? 'Uninstall SmokeAPI'
|
||||
: 'Install SmokeAPI'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Show message for Proton games without API files */}
|
||||
{isProtonNoApi && (
|
||||
<div className="api-not-found-message">
|
||||
<span>Steam API DLL not found</span>
|
||||
<button
|
||||
<button
|
||||
className="rescan-button"
|
||||
onClick={() => onAction(game.id, 'install_smoke')}
|
||||
title="Attempt to scan again"
|
||||
@@ -151,10 +157,10 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||
{game.cream_installed && (
|
||||
<button
|
||||
<button
|
||||
className="edit-button"
|
||||
onClick={handleEdit}
|
||||
disabled={!game.cream_installed || !!game.installing}
|
||||
@@ -166,7 +172,7 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default GameItem;
|
||||
export default GameItem
|
||||
|
||||
@@ -1,92 +1,81 @@
|
||||
// src/components/GameList.tsx
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import GameItem from './GameItem';
|
||||
import ImagePreloader from './ImagePreloader';
|
||||
import { ActionType } from './ActionButton';
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import GameItem from './GameItem'
|
||||
import ImagePreloader from './ImagePreloader'
|
||||
import { ActionType } from './ActionButton'
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
platform?: string;
|
||||
native: boolean;
|
||||
api_files: string[];
|
||||
cream_installed?: boolean;
|
||||
smoke_installed?: boolean;
|
||||
installing?: boolean;
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
platform?: string
|
||||
native: boolean
|
||||
api_files: string[]
|
||||
cream_installed?: boolean
|
||||
smoke_installed?: boolean
|
||||
installing?: boolean
|
||||
}
|
||||
|
||||
interface GameListProps {
|
||||
games: Game[];
|
||||
isLoading: boolean;
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>;
|
||||
onEdit?: (gameId: string) => void;
|
||||
games: Game[]
|
||||
isLoading: boolean
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
}
|
||||
|
||||
const GameList: React.FC<GameListProps> = ({
|
||||
games,
|
||||
isLoading,
|
||||
onAction,
|
||||
onEdit
|
||||
}) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false);
|
||||
|
||||
// Sort games alphabetically by title - using useMemo to avoid re-sorting on each render
|
||||
const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit }) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title using useMemo to avoid re-sorting on each render
|
||||
const sortedGames = useMemo(() => {
|
||||
return [...games].sort((a, b) => a.title.localeCompare(b.title));
|
||||
}, [games]);
|
||||
|
||||
return [...games].sort((a, b) => a.title.localeCompare(b.title))
|
||||
}, [games])
|
||||
|
||||
// Reset preloaded state when games change
|
||||
useEffect(() => {
|
||||
setImagesPreloaded(false);
|
||||
}, [games]);
|
||||
setImagesPreloaded(false)
|
||||
}, [games])
|
||||
|
||||
// Debug log to help diagnose game states
|
||||
useEffect(() => {
|
||||
if (games.length > 0) {
|
||||
console.log("Games state in GameList:", games.length, "games");
|
||||
console.log('Games state in GameList:', games.length, 'games')
|
||||
}
|
||||
}, [games]);
|
||||
|
||||
}, [games])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="game-list">
|
||||
<div className="loading-indicator">Scanning for games...</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const handlePreloadComplete = () => {
|
||||
setImagesPreloaded(true);
|
||||
};
|
||||
setImagesPreloaded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<h2>Games ({games.length})</h2>
|
||||
|
||||
|
||||
{!imagesPreloaded && games.length > 0 && (
|
||||
<ImagePreloader
|
||||
gameIds={sortedGames.map(game => game.id)}
|
||||
<ImagePreloader
|
||||
gameIds={sortedGames.map((game) => game.id)}
|
||||
onComplete={handlePreloadComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{games.length === 0 ? (
|
||||
<div className="no-games-message">No games found</div>
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map(game => (
|
||||
<GameItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
onAction={onAction}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
{sortedGames.map((game) => (
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default GameList;
|
||||
export default GameList
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
// src/components/Header.tsx
|
||||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
interface HeaderProps {
|
||||
onRefresh: () => void;
|
||||
refreshDisabled?: boolean;
|
||||
onSearch: (query: string) => void;
|
||||
searchQuery: string;
|
||||
onRefresh: () => void
|
||||
refreshDisabled?: boolean
|
||||
onSearch: (query: string) => void
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
onRefresh,
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
onRefresh,
|
||||
refreshDisabled = false,
|
||||
onSearch,
|
||||
searchQuery
|
||||
searchQuery,
|
||||
}) => {
|
||||
return (
|
||||
<header className="app-header">
|
||||
<h1>CreamLinux</h1>
|
||||
<div className="header-controls">
|
||||
<button
|
||||
className="refresh-button"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshDisabled}
|
||||
>
|
||||
<button className="refresh-button" onClick={onRefresh} disabled={refreshDisabled}>
|
||||
Refresh
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search games..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search games..."
|
||||
className="search-input"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Header;
|
||||
export default Header
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// src/components/ImagePreloader.tsx
|
||||
import React, { useEffect } from 'react';
|
||||
import { findBestGameImage } from '../services/ImageService';
|
||||
import React, { useEffect } from 'react'
|
||||
import { findBestGameImage } from '../services/ImageService'
|
||||
|
||||
interface ImagePreloaderProps {
|
||||
gameIds: string[];
|
||||
onComplete?: () => void;
|
||||
gameIds: string[]
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => {
|
||||
@@ -12,37 +11,31 @@ const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete })
|
||||
const preloadImages = async () => {
|
||||
try {
|
||||
// Only preload the first batch for performance (10 images max)
|
||||
const batchToPreload = gameIds.slice(0, 10);
|
||||
|
||||
const batchToPreload = gameIds.slice(0, 10)
|
||||
|
||||
// Load images in parallel
|
||||
await Promise.allSettled(
|
||||
batchToPreload.map(id => findBestGameImage(id))
|
||||
);
|
||||
|
||||
await Promise.allSettled(batchToPreload.map((id) => findBestGameImage(id)))
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
onComplete()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error preloading images:", error);
|
||||
console.error('Error preloading images:', error)
|
||||
// Continue even if there's an error
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
preloadImages();
|
||||
} else if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}, [gameIds, onComplete]);
|
||||
|
||||
return (
|
||||
<div className="image-preloader">
|
||||
{/* Hidden element, just used for preloading */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePreloader;
|
||||
if (gameIds.length > 0) {
|
||||
preloadImages()
|
||||
} else if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
}, [gameIds, onComplete])
|
||||
|
||||
return <div className="image-preloader">{/* Hidden element, just used for preloading */}</div>
|
||||
}
|
||||
|
||||
export default ImagePreloader
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
interface InitialLoadingScreenProps {
|
||||
message: string;
|
||||
progress: number;
|
||||
message: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
|
||||
message,
|
||||
progress
|
||||
}) => {
|
||||
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, progress }) => {
|
||||
return (
|
||||
<div className="initial-loading-screen">
|
||||
<div className="loading-content">
|
||||
@@ -22,15 +19,12 @@ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
|
||||
</div>
|
||||
<p className="loading-message">{message}</p>
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default InitialLoadingScreen;
|
||||
export default InitialLoadingScreen
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
// src/components/ProgressDialog.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface InstructionInfo {
|
||||
type: string;
|
||||
command: string;
|
||||
game_title: string;
|
||||
dlc_count?: number;
|
||||
type: string
|
||||
command: string
|
||||
game_title: string
|
||||
dlc_count?: number
|
||||
}
|
||||
|
||||
interface ProgressDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
progress: number; // 0-100
|
||||
visible: boolean;
|
||||
showInstructions?: boolean;
|
||||
instructions?: InstructionInfo;
|
||||
onClose?: () => void;
|
||||
title: string
|
||||
message: string
|
||||
progress: number // 0-100
|
||||
visible: boolean
|
||||
showInstructions?: boolean
|
||||
instructions?: InstructionInfo
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
title,
|
||||
message,
|
||||
progress,
|
||||
const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
title,
|
||||
message,
|
||||
progress,
|
||||
visible,
|
||||
showInstructions = false,
|
||||
instructions,
|
||||
onClose
|
||||
onClose,
|
||||
}) => {
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
|
||||
// Reset copy state when dialog visibility changes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setCopySuccess(false);
|
||||
setShowContent(false);
|
||||
setCopySuccess(false)
|
||||
setShowContent(false)
|
||||
} else {
|
||||
// Add a small delay to trigger the entrance animation
|
||||
const timer = setTimeout(() => {
|
||||
setShowContent(true);
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
setShowContent(true)
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
const handleCopyCommand = () => {
|
||||
if (instructions?.command) {
|
||||
navigator.clipboard.writeText(instructions.command);
|
||||
setCopySuccess(true);
|
||||
|
||||
navigator.clipboard.writeText(instructions.command)
|
||||
setCopySuccess(true)
|
||||
|
||||
// Reset the success message after 2 seconds
|
||||
setTimeout(() => {
|
||||
setCopySuccess(false);
|
||||
}, 2000);
|
||||
setCopySuccess(false)
|
||||
}, 2000)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setShowContent(false);
|
||||
setShowContent(false)
|
||||
// Delay closing to allow exit animation
|
||||
setTimeout(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
onClose()
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Modified to prevent closing when in progress
|
||||
// Prevent closing when in progress
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Always prevent propagation
|
||||
e.stopPropagation();
|
||||
|
||||
// Only allow clicking outside to close if we're done processing (100%)
|
||||
e.stopPropagation()
|
||||
|
||||
// Only allow clicking outside to close if we're done processing (100%)
|
||||
// and showing instructions or if explicitly allowed via a prop
|
||||
if (e.target === e.currentTarget && progress >= 100 && showInstructions) {
|
||||
handleClose();
|
||||
handleClose()
|
||||
}
|
||||
// Otherwise, do nothing - require using the close button
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
|
||||
const showCopyButton = instructions?.type === 'cream_install' ||
|
||||
instructions?.type === 'cream_uninstall';
|
||||
const showCopyButton =
|
||||
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
|
||||
|
||||
// Format instruction message based on type
|
||||
const getInstructionText = () => {
|
||||
if (!instructions) return null;
|
||||
|
||||
if (!instructions) return null
|
||||
|
||||
switch (instructions.type) {
|
||||
case 'cream_install':
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{instructions.dlc_count !== undefined && (
|
||||
<div className="dlc-count">
|
||||
@@ -102,13 +102,14 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
case 'cream_uninstall':
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
)
|
||||
case 'smoke_install':
|
||||
return (
|
||||
<>
|
||||
@@ -121,71 +122,67 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
case 'smoke_uninstall':
|
||||
return (
|
||||
<p className="instruction-text">
|
||||
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
|
||||
</p>
|
||||
);
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<p className="instruction-text">
|
||||
Done processing <strong>{instructions.game_title}</strong>
|
||||
</p>
|
||||
);
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Determine the CSS class for the command box based on instruction type
|
||||
const getCommandBoxClass = () => {
|
||||
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box';
|
||||
};
|
||||
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'
|
||||
}
|
||||
|
||||
// Determine if close button should be enabled
|
||||
const isCloseButtonEnabled = showInstructions || progress >= 100;
|
||||
const isCloseButtonEnabled = showInstructions || progress >= 100
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
<div
|
||||
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
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>
|
||||
<p>{message}</p>
|
||||
|
||||
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||
|
||||
|
||||
{showInstructions && instructions && (
|
||||
<div className="instruction-container">
|
||||
<h4>
|
||||
{instructions.type.includes('uninstall')
|
||||
? 'Uninstallation Instructions'
|
||||
{instructions.type.includes('uninstall')
|
||||
? 'Uninstallation Instructions'
|
||||
: 'Installation Instructions'}
|
||||
</h4>
|
||||
{getInstructionText()}
|
||||
|
||||
|
||||
<div className={getCommandBoxClass()}>
|
||||
<pre className="selectable-text">{instructions.command}</pre>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="action-buttons">
|
||||
{showCopyButton && (
|
||||
<button
|
||||
className="copy-button"
|
||||
onClick={handleCopyCommand}
|
||||
>
|
||||
<button className="copy-button" onClick={handleCopyCommand}>
|
||||
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={handleClose}
|
||||
disabled={!isCloseButtonEnabled}
|
||||
@@ -195,21 +192,18 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Show close button even if no instructions */}
|
||||
{!showInstructions && progress >= 100 && (
|
||||
<div className="action-buttons" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<button className="close-button" onClick={handleClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressDialog;
|
||||
export default ProgressDialog
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/components/Sidebar.tsx
|
||||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
interface SidebarProps {
|
||||
setFilter: (filter: string) => void;
|
||||
currentFilter: string;
|
||||
setFilter: (filter: string) => void
|
||||
currentFilter: string
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
|
||||
@@ -11,27 +10,24 @@ const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
|
||||
<div className="sidebar">
|
||||
<h2>Library</h2>
|
||||
<ul className="filter-list">
|
||||
<li
|
||||
className={currentFilter === 'all' ? 'active' : ''}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
<li className={currentFilter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
|
||||
All Games
|
||||
</li>
|
||||
<li
|
||||
className={currentFilter === 'native' ? 'active' : ''}
|
||||
<li
|
||||
className={currentFilter === 'native' ? 'active' : ''}
|
||||
onClick={() => setFilter('native')}
|
||||
>
|
||||
Native
|
||||
</li>
|
||||
<li
|
||||
className={currentFilter === 'proton' ? 'active' : ''}
|
||||
<li
|
||||
className={currentFilter === 'proton' ? 'active' : ''}
|
||||
onClick={() => setFilter('proton')}
|
||||
>
|
||||
Proton Required
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
export default Sidebar
|
||||
|
||||
@@ -5,5 +5,5 @@ import App from './App.tsx'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/services/ImageService.ts
|
||||
|
||||
/**
|
||||
* Game image sources from Steam's CDN
|
||||
*/
|
||||
@@ -9,88 +7,87 @@ export const SteamImageType = {
|
||||
LOGO: 'logo', // Game logo with transparency
|
||||
LIBRARY_HERO: 'library_hero', // 1920x620
|
||||
LIBRARY_CAPSULE: 'library_600x900', // 600x900
|
||||
} as const;
|
||||
} as const
|
||||
|
||||
export type SteamImageTypeKey = keyof typeof SteamImageType;
|
||||
export type SteamImageTypeKey = keyof typeof SteamImageType
|
||||
|
||||
// Cache for images to prevent flickering
|
||||
const imageCache: Map<string, string> = new Map();
|
||||
const imageCache: Map<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* Builds a Steam CDN URL for game images
|
||||
* @param appId Steam application ID
|
||||
* @param type Image type from SteamImageType enum
|
||||
* @returns URL string for the image
|
||||
*/
|
||||
export const getSteamImageUrl = (appId: string, type: typeof SteamImageType[SteamImageTypeKey]) => {
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`;
|
||||
};
|
||||
* Builds a Steam CDN URL for game images
|
||||
* @param appId Steam application ID
|
||||
* @param type Image type from SteamImageType enum
|
||||
* @returns URL string for the image
|
||||
*/
|
||||
export const getSteamImageUrl = (
|
||||
appId: string,
|
||||
type: (typeof SteamImageType)[SteamImageTypeKey]
|
||||
) => {
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an image exists by performing a HEAD request
|
||||
* @param url Image URL to check
|
||||
* @returns Promise resolving to a boolean indicating if the image exists
|
||||
*/
|
||||
* Checks if an image exists by performing a HEAD request
|
||||
* @param url Image URL to check
|
||||
* @returns Promise resolving to a boolean indicating if the image exists
|
||||
*/
|
||||
export const checkImageExists = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error checking image existence:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Preloads an image for faster rendering
|
||||
* @param url URL of image to preload
|
||||
* @returns Promise that resolves when image is loaded
|
||||
*/
|
||||
const preloadImage = (url: string): Promise<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;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error('Error checking image existence:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// If we've reached here, no valid image was found
|
||||
return null;
|
||||
};
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
||||
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
||||
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
||||
font-weight: 400; // adjust as needed
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
font-family: 'Satoshi';
|
||||
src:
|
||||
url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
||||
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
||||
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/styles/_layout.scss
|
||||
|
||||
@use './variables' as *;
|
||||
@use './mixins' as *;
|
||||
|
||||
@@ -23,7 +21,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
|
||||
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
@@ -41,7 +39,7 @@
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
@@ -57,7 +55,12 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--cream-color), var(--primary-color), var(--smoke-color));
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cream-color),
|
||||
var(--primary-color),
|
||||
var(--smoke-color)
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -88,7 +91,7 @@
|
||||
z-index: var(--z-elevate);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
// Sidebar
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
@@ -161,7 +164,8 @@
|
||||
}
|
||||
|
||||
// Loading and empty state
|
||||
.loading-indicator, .no-games-message {
|
||||
.loading-indicator,
|
||||
.no-games-message {
|
||||
@include flex-center;
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
@@ -185,12 +189,7 @@
|
||||
left: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05),
|
||||
transparent
|
||||
);
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||
animation: loading-shimmer 2s infinite;
|
||||
}
|
||||
}
|
||||
@@ -259,4 +258,4 @@
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// src/styles/_mixins.scss
|
||||
|
||||
@use './variables' as *;
|
||||
|
||||
// src/styles/_mixins.scss
|
||||
|
||||
// Basic flex helpers
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
@@ -43,7 +39,7 @@
|
||||
}
|
||||
|
||||
@mixin shadow-hover {
|
||||
box-shadow: var(--shadow-hover);;
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
@mixin text-shadow {
|
||||
@@ -60,19 +56,27 @@
|
||||
|
||||
// Responsive mixins
|
||||
@mixin media-sm {
|
||||
@media (min-width: 576px) { @content; }
|
||||
@media (min-width: 576px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin media-md {
|
||||
@media (min-width: 768px) { @content; }
|
||||
@media (min-width: 768px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin media-lg {
|
||||
@media (min-width: 992px) { @content; }
|
||||
@media (min-width: 992px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin media-xl {
|
||||
@media (min-width: 1200px) { @content; }
|
||||
@media (min-width: 1200px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
// Card base styling
|
||||
@@ -104,4 +108,4 @@
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, white 10%, var(--primary-color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// src/styles/_reset.scss
|
||||
|
||||
@use './variables' as *;
|
||||
@use './mixins' as *;
|
||||
@use './fonts' as *;
|
||||
// src/styles/_reset.scss
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -11,7 +8,8 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -23,7 +21,7 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--text-primary);
|
||||
/* Prevent text selection by default */
|
||||
// Prevent text selection by default
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
@@ -51,15 +49,24 @@ a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,102 @@
|
||||
// src/styles/_variables.scss
|
||||
|
||||
@use './fonts' as *;
|
||||
|
||||
// Color palette
|
||||
:root {
|
||||
// Primary colors
|
||||
--primary-color: #ffc896;
|
||||
--secondary-color: #ffb278;
|
||||
// Primary colors
|
||||
--primary-color: #ffc896;
|
||||
--secondary-color: #ffb278;
|
||||
|
||||
// Background
|
||||
--primary-bg: #0f0f0f;
|
||||
--secondary-bg: #151515;
|
||||
--tertiary-bg: #121212;
|
||||
--elevated-bg: #1a1a1a;
|
||||
--disabled: #5E5E5E;
|
||||
// Background
|
||||
--primary-bg: #0f0f0f;
|
||||
--secondary-bg: #151515;
|
||||
--tertiary-bg: #121212;
|
||||
--elevated-bg: #1a1a1a;
|
||||
--disabled: #5e5e5e;
|
||||
|
||||
// Text
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #c8c8c8;
|
||||
--text-soft: #afafaf;
|
||||
--text-heavy: #1a1a1a;
|
||||
--text-muted: #4b4b4b;
|
||||
// Text
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #c8c8c8;
|
||||
--text-soft: #afafaf;
|
||||
--text-heavy: #1a1a1a;
|
||||
--text-muted: #4b4b4b;
|
||||
|
||||
// Borders
|
||||
--border-dark: #1a1a1a;
|
||||
--border-soft: #282828;
|
||||
--border: #323232;
|
||||
|
||||
// Status colors - more vibrant
|
||||
--success: #8cc893;
|
||||
--warning: #ffc896;
|
||||
--danger: #d96b6b;
|
||||
--info: #80b4ff;
|
||||
// Borders
|
||||
--border-dark: #1a1a1a;
|
||||
--border-soft: #282828;
|
||||
--border: #323232;
|
||||
|
||||
--success-light: #b0e0a9;
|
||||
--warning-light: #ffdcb9;
|
||||
--danger-light: #e69691;
|
||||
--info-light: #a8d2ff;
|
||||
// Status colors
|
||||
--success: #8cc893;
|
||||
--warning: #ffc896;
|
||||
--danger: #d96b6b;
|
||||
--info: #80b4ff;
|
||||
|
||||
--success-soft: rgba(176, 224, 169, 0.15);
|
||||
--warning-soft: rgba(247, 200, 111, 0.15);
|
||||
--danger-soft: rgba(230, 150, 145, 0.15);
|
||||
--info-soft: rgba(168, 210, 255, 0.15);
|
||||
--success-light: #b0e0a9;
|
||||
--warning-light: #ffdcb9;
|
||||
--danger-light: #e69691;
|
||||
--info-light: #a8d2ff;
|
||||
|
||||
// Feature colors
|
||||
--native: #8cc893;
|
||||
--proton: #ffc896;
|
||||
--cream: #80b4ff;
|
||||
--smoke: #fff096;
|
||||
|
||||
--modal-backdrop: rgba(30, 30, 30, 0.95);
|
||||
|
||||
// Animation durations
|
||||
--duration-fast: 100ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
|
||||
// Animation easings
|
||||
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--success-soft: rgba(176, 224, 169, 0.15);
|
||||
--warning-soft: rgba(247, 200, 111, 0.15);
|
||||
--danger-soft: rgba(230, 150, 145, 0.15);
|
||||
--info-soft: rgba(168, 210, 255, 0.15);
|
||||
|
||||
// Layout values
|
||||
--header-height: 64px;
|
||||
--sidebar-width: 250px;
|
||||
--card-height: 200px;
|
||||
// Feature colors
|
||||
--native: #8cc893;
|
||||
--proton: #ffc896;
|
||||
--cream: #80b4ff;
|
||||
--smoke: #fff096;
|
||||
|
||||
// Border radius
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--modal-backdrop: rgba(30, 30, 30, 0.95);
|
||||
|
||||
// Font weights
|
||||
--thin: 100;
|
||||
--extralight: 200;
|
||||
--light: 300;
|
||||
--normal: 400;
|
||||
--medium: 500;
|
||||
--semibold: 600;
|
||||
--bold: 700;
|
||||
--extrabold: 800;
|
||||
// Animation durations
|
||||
--duration-fast: 100ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
|
||||
--family: 'Satoshi';
|
||||
// Animation easings
|
||||
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
// Shadows
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
|
||||
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
|
||||
// Layout values
|
||||
--header-height: 64px;
|
||||
--sidebar-width: 250px;
|
||||
--card-height: 200px;
|
||||
|
||||
// Z-index levels
|
||||
//--z-index-bg: 0;
|
||||
//--z-index-content: 1;
|
||||
//--z-index-header: 100;
|
||||
//--z-index-modal: 1000;
|
||||
//--z-index-tooltip: 1500;
|
||||
// Border radius
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
// Z-index levels
|
||||
--z-bg: 0;
|
||||
--z-elevate: 1;
|
||||
--z-header: 100;
|
||||
--z-modal: 1000;
|
||||
--z-tooltip: 1500;
|
||||
// Font weights
|
||||
--thin: 100;
|
||||
--extralight: 200;
|
||||
--light: 300;
|
||||
--normal: 400;
|
||||
--medium: 500;
|
||||
--semibold: 600;
|
||||
--bold: 700;
|
||||
--extrabold: 800;
|
||||
|
||||
--family: 'Satoshi';
|
||||
|
||||
// Shadows
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
|
||||
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
|
||||
|
||||
// Z-index levels
|
||||
--z-bg: 0;
|
||||
--z-elevate: 1;
|
||||
--z-header: 100;
|
||||
--z-modal: 1000;
|
||||
--z-tooltip: 1500;
|
||||
}
|
||||
|
||||
$success-color: #55e07a;
|
||||
@@ -113,4 +104,4 @@ $danger-color: #ff5252;
|
||||
$primary-color: #4a76c4;
|
||||
$cream-color: #9b7dff;
|
||||
$smoke-color: #fbb13c;
|
||||
$warning-color: #fbb13c;
|
||||
$warning-color: #fbb13c;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/styles/components/_animated_checkbox.scss
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
@@ -9,7 +7,7 @@
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
|
||||
&:hover .checkbox-custom {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
@@ -35,7 +33,7 @@
|
||||
margin-right: 15px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
|
||||
&.checked {
|
||||
background-color: var(--primary-color, #ffc896);
|
||||
border-color: var(--primary-color, #ffc896);
|
||||
@@ -53,7 +51,7 @@
|
||||
stroke-dashoffset: 30;
|
||||
opacity: 0;
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
|
||||
|
||||
&.checked {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
@@ -95,4 +93,4 @@
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/styles/_components/_background.scss
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
@use 'sass:color';
|
||||
@@ -13,4 +11,4 @@
|
||||
pointer-events: none;
|
||||
z-index: var(--z-bg);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// src/styles/_components/_dialog.scss
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
/* Progress Dialog */
|
||||
// Progress Dialog
|
||||
.progress-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -17,22 +15,28 @@
|
||||
opacity: 0;
|
||||
animation: modal-appear 0.2s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
0% { opacity: 0; transform: scale(0.95); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.progress-dialog {
|
||||
background-color: var(--elevated-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); /* shadow-glow */
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); // shadow-glow
|
||||
width: 450px;
|
||||
max-width: 90vw;
|
||||
border: 1px solid var(--border-soft);
|
||||
@@ -43,17 +47,17 @@
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
&.with-instructions {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
|
||||
h3 {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -85,7 +89,7 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Instruction container in progress dialog */
|
||||
// Instruction container in progress dialog
|
||||
.instruction-container {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
@@ -112,7 +116,7 @@
|
||||
color: var(--info);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
@@ -143,7 +147,7 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.selectable-text {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
@@ -164,7 +168,8 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.copy-button, .close-button {
|
||||
.copy-button,
|
||||
.close-button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
@@ -180,7 +185,7 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color);
|
||||
transform: translateY(-2px) scale(1.02); /* hover-lift */
|
||||
transform: translateY(-2px) scale(1.02); // hover-lift
|
||||
box-shadow: 0 6px 14px var(--info-soft);
|
||||
}
|
||||
}
|
||||
@@ -191,7 +196,7 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--border);
|
||||
transform: translateY(-2px) scale(1.02); /* hover-lift */
|
||||
transform: translateY(-2px) scale(1.02); // hover-lift
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
@@ -210,20 +215,20 @@
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
text-align: center;
|
||||
|
||||
|
||||
h3 {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
@@ -234,7 +239,7 @@
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
@include transition-standard;
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||
@@ -244,6 +249,10 @@
|
||||
|
||||
// Animation for progress bar
|
||||
@keyframes progress-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/styles/components/_dlc_dialog.scss
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
@@ -15,7 +13,7 @@
|
||||
z-index: var(--z-modal);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
animation: modal-appear 0.2s ease-out;
|
||||
@@ -35,18 +33,20 @@
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
|
||||
|
||||
&.dialog-visible {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
transition: transform 0.2s var(--easing-bounce), opacity 0.2s ease-out;
|
||||
transition:
|
||||
transform 0.2s var(--easing-bounce),
|
||||
opacity 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.dlc-dialog-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
@@ -60,12 +60,12 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
|
||||
.game-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.dlc-count {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
@@ -94,13 +94,13 @@
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
@include transition-standard;
|
||||
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -110,12 +110,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
|
||||
|
||||
// Custom styling for the select all checkbox
|
||||
:global(.animated-checkbox) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
:global(.checkbox-label) {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -126,7 +126,7 @@
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
|
||||
|
||||
.progress-bar-container {
|
||||
height: 6px;
|
||||
background-color: var(--border-soft);
|
||||
@@ -134,7 +134,7 @@
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
@@ -143,13 +143,13 @@
|
||||
background: var(--primary-color);
|
||||
box-shadow: 0px 0px 6px rgba(128, 181, 255, 0.3);
|
||||
}
|
||||
|
||||
|
||||
.loading-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
|
||||
.time-left {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -171,54 +171,56 @@
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
@include transition-standard;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
||||
&.dlc-item-loading {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
|
||||
.loading-pulse {
|
||||
width: 70%;
|
||||
height: 20px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--border-soft) 0%,
|
||||
var(--border) 50%,
|
||||
var(--border-soft) 100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--border-soft) 0%,
|
||||
var(--border) 50%,
|
||||
var(--border-soft) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 4px;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced styling for the checkbox component inside dlc-item
|
||||
|
||||
// Styling for the checkbox component inside dlc-item
|
||||
:global(.animated-checkbox) {
|
||||
width: 100%;
|
||||
|
||||
|
||||
.checkbox-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.checkbox-sublabel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
// Optional hover effect
|
||||
&:hover {
|
||||
.checkbox-label {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
.checkbox-custom {
|
||||
border-color: var(--primary-color, #ffc896);
|
||||
transform: scale(1.05);
|
||||
@@ -232,7 +234,7 @@
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -241,7 +243,7 @@
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -261,7 +263,8 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cancel-button, .confirm-button {
|
||||
.cancel-button,
|
||||
.confirm-button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
@@ -274,7 +277,7 @@
|
||||
.cancel-button {
|
||||
background-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: var(--border);
|
||||
transform: translateY(-2px);
|
||||
@@ -285,12 +288,12 @@
|
||||
.confirm-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px var(--info-soft);
|
||||
}
|
||||
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
@@ -299,16 +302,30 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
0% { opacity: 0; transform: scale(0.95); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0% { background-position: 200% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
0% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/styles/components/_gamecard.scss
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
@@ -12,7 +10,7 @@
|
||||
@include shadow-standard;
|
||||
@include transition-standard;
|
||||
transform-origin: center;
|
||||
|
||||
|
||||
// Simple image loading animation
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.5s forwards;
|
||||
@@ -23,19 +21,19 @@
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
@include shadow-hover;
|
||||
z-index: 5;
|
||||
|
||||
|
||||
.status-badge.native {
|
||||
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5)
|
||||
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5);
|
||||
}
|
||||
|
||||
|
||||
.status-badge.proton {
|
||||
box-shadow: 0 0 10px rgba(255, 201, 150, 0.5);
|
||||
}
|
||||
|
||||
|
||||
.status-badge.cream {
|
||||
box-shadow: 0 0 10px rgba(128, 181, 255, 0.5);
|
||||
}
|
||||
|
||||
|
||||
.status-badge.smoke {
|
||||
box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
|
||||
}
|
||||
@@ -43,11 +41,15 @@
|
||||
|
||||
// Special styling for cards with different statuses
|
||||
.game-item-card:has(.status-badge.cream) {
|
||||
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15);
|
||||
box-shadow:
|
||||
var(--shadow-standard),
|
||||
0 0 15px rgba(128, 181, 255, 0.15);
|
||||
}
|
||||
|
||||
.game-item-card:has(.status-badge.smoke) {
|
||||
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15);
|
||||
box-shadow:
|
||||
var(--shadow-standard),
|
||||
0 0 15px rgba(255, 239, 150, 0.15);
|
||||
}
|
||||
|
||||
// Simple clean overlay
|
||||
@@ -57,7 +59,8 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom,
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
rgba(0, 0, 0, 0.6) 50%,
|
||||
rgba(0, 0, 0, 0.8) 100%
|
||||
@@ -70,7 +73,7 @@
|
||||
font-family: var(--family);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-heavy);;
|
||||
color: var(--text-heavy);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -92,7 +95,7 @@
|
||||
font-family: var(--family);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-heavy);;
|
||||
color: var(--text-heavy);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
@include transition-standard;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -129,7 +132,7 @@
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
transform: translateZ(0); // or
|
||||
transform: translateZ(0);
|
||||
will-change: opacity, transform;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
overflow: hidden;
|
||||
@@ -176,7 +179,7 @@
|
||||
.action-button.uninstall:hover {
|
||||
background-color: var(--danger-light);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3)
|
||||
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.action-button:active {
|
||||
@@ -241,11 +244,11 @@
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.rescan-button {
|
||||
background-color: var(--warning);
|
||||
color: var(--text-heavy);
|
||||
@@ -257,12 +260,12 @@
|
||||
margin-left: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: var(--warning-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -271,17 +274,23 @@
|
||||
|
||||
// Apply staggered delay to cards
|
||||
@for $i from 1 through 12 {
|
||||
.game-grid .game-item-card:nth-child(#{$i}) {
|
||||
animation-delay: #{$i * 0.05}s;
|
||||
.game-grid .game-item-card:nth-child(#{$i}) {
|
||||
animation-delay: #{$i * 0.05}s;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple animations
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes button-loading {
|
||||
to { left: 100%; }
|
||||
}
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,80 @@
|
||||
// src/styles/_components/_header.scss
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--primary-bg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--primary-bg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Header
|
||||
.app-header {
|
||||
@include flex-between;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--tertiary-bg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
@include text-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
background-color: var(--primary-color);
|
||||
@include flex-between;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--tertiary-bg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-weight: var(--bold);
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
@include text-shadow;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.refresh-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
background-color: var(--border-dark);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-weight: var(--bold);
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.refresh-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
background-color: var(--border-dark);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal) + 1;
|
||||
|
||||
|
||||
.loading-content {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
@@ -23,46 +23,46 @@
|
||||
color: var(--primary-color);
|
||||
text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
|
||||
|
||||
.loading-animation {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
|
||||
.loading-circles {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
|
||||
.circle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
|
||||
|
||||
&.circle-1 {
|
||||
background-color: var(--primary-color);
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
|
||||
&.circle-2 {
|
||||
background-color: var(--cream-color);
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
|
||||
&.circle-3 {
|
||||
background-color: var(--smoke-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.loading-message {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
background-color: var(--border-soft);
|
||||
@@ -70,16 +70,21 @@
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
background: linear-gradient(to right, var(--cream-color), var(--primary-color), var(--smoke-color));
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--cream-color),
|
||||
var(--primary-color),
|
||||
var(--smoke-color)
|
||||
);
|
||||
box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
|
||||
}
|
||||
|
||||
|
||||
.progress-percentage {
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
@@ -91,10 +96,12 @@
|
||||
|
||||
// Animation for the bouncing circles
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.0);
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// src/styles/_components/_sidebar.scss
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
.filter-list {
|
||||
list-style: none;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
|
||||
li {
|
||||
@include transition-standard;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -14,11 +12,11 @@
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
|
||||
&.active {
|
||||
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color)));
|
||||
box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
|
||||
@@ -30,7 +28,7 @@
|
||||
.custom-select {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
|
||||
.select-selected {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -45,18 +43,18 @@
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 150px;
|
||||
|
||||
|
||||
&:after {
|
||||
content: '⯆';
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.select-items {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
@@ -71,20 +69,20 @@
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
|
||||
|
||||
&.show {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
|
||||
.select-item {
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
@include transition-standard;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
|
||||
&.selected {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
@@ -98,7 +96,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -111,13 +109,13 @@
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
|
||||
&:hover .tooltip-content {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
.tooltip-content {
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
@@ -133,14 +131,16 @@
|
||||
margin-left: -100px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
@@ -192,15 +192,17 @@
|
||||
@include transition-standard;
|
||||
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
min-width: 200px;
|
||||
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(var(--primary-color), 0.3),
|
||||
inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/styles/main.scss
|
||||
|
||||
// Import variables and mixins first
|
||||
@use './variables' as *;
|
||||
@use './mixins' as *;
|
||||
@@ -18,4 +16,4 @@
|
||||
@use './components/sidebar';
|
||||
@use './components/dlc_dialog';
|
||||
@use './components/loading_screen';
|
||||
@use './components/animated_checkbox';
|
||||
@use './components/animated_checkbox';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
// Removed unused import: loadEnv
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
// Vite options tailored for Tauri development
|
||||
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
@@ -14,11 +12,8 @@ export default defineConfig({
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
// Tauri supports es2021
|
||||
target: ['es2021', 'chrome105', 'safari13'],
|
||||
// Don't minify for debug builds
|
||||
minify: 'esbuild',
|
||||
// Produce sourcemaps for debug builds
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user