mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2025-12-06 03:55:37 -05:00
formatting
This commit is contained in:
17
.github/ISSUE_TEMPLATE/bug_report.md
vendored
17
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -7,37 +7,46 @@ assignees: ''
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Bug Description
|
## Bug Description
|
||||||
|
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
## Steps To Reproduce
|
## Steps To Reproduce
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
## Expected Behavior
|
## Expected Behavior
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
## System Information
|
## System Information
|
||||||
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
|
|
||||||
- Desktop Environment: [e.g. GNOME, KDE, etc.]
|
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
|
||||||
- CreamLinux Version: [e.g. 0.1.0]
|
- Desktop Environment: [e.g. GNOME, KDE, etc.]
|
||||||
- Steam Version: [e.g. latest]
|
- CreamLinux Version: [e.g. 0.1.0]
|
||||||
|
- Steam Version: [e.g. latest]
|
||||||
|
|
||||||
## Game Information
|
## Game Information
|
||||||
|
|
||||||
- Game name:
|
- Game name:
|
||||||
- Game ID (if known):
|
- Game ID (if known):
|
||||||
- Native Linux or Proton:
|
- Native Linux or Proton:
|
||||||
- Steam installation path:
|
- Steam installation path:
|
||||||
|
|
||||||
## Additional Context
|
## Additional Context
|
||||||
|
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
|
If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
|
||||||
|
|
||||||
```
|
```
|
||||||
Paste log content here
|
Paste log content here
|
||||||
```
|
```
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -7,17 +7,22 @@ assignees: ''
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Feature Description
|
## Feature Description
|
||||||
|
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
## Problem This Feature Solves
|
## Problem This Feature Solves
|
||||||
|
|
||||||
Is your feature request related to a problem? Please describe.
|
Is your feature request related to a problem? Please describe.
|
||||||
Ex. I'm always frustrated when [...]
|
Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
## Alternatives You've Considered
|
## Alternatives You've Considered
|
||||||
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
## Additional Context
|
## Additional Context
|
||||||
|
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|
||||||
## Implementation Ideas (Optional)
|
## Implementation Ideas (Optional)
|
||||||
|
|
||||||
If you have any ideas on how this feature could be implemented, please share them here.
|
If you have any ideas on how this feature could be implemented, please share them here.
|
||||||
|
|||||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -1,10 +1,10 @@
|
|||||||
name: "Build CreamLinux"
|
name: 'Build CreamLinux'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: ['main']
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
src-tauri/target
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": false,
|
"singleQuote": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"trailingComma": "es5"
|
"trailingComma": "es5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,23 +29,28 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
|
|||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Rust 1.77.2 or later
|
- Rust 1.77.2 or later
|
||||||
- Node.js 18 or later
|
- Node.js 18 or later
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
|
|
||||||
#### Steps
|
#### Steps
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/creamlinux.git
|
git clone https://github.com/yourusername/creamlinux.git
|
||||||
cd creamlinux
|
cd creamlinux
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install # or yarn
|
npm install # or yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Build the application:
|
3. Build the application:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NO_STRIP=true npm run tauri build
|
NO_STRIP=true npm run tauri build
|
||||||
```
|
```
|
||||||
@@ -57,11 +62,13 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
|
|||||||
If you're using the AppImage version, you can integrate it into your desktop environment:
|
If you're using the AppImage version, you can integrate it into your desktop environment:
|
||||||
|
|
||||||
1. Create a desktop entry file:
|
1. Create a desktop entry file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.local/share/applications
|
mkdir -p ~/.local/share/applications
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
|
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
|
||||||
|
|
||||||
```
|
```
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=Creamlinux
|
Name=Creamlinux
|
||||||
@@ -73,6 +80,7 @@ If you're using the AppImage version, you can integrate it into your desktop env
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Update your desktop database so creamlinux appears in your app launcher:
|
3. Update your desktop database so creamlinux appears in your app launcher:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
update-desktop-database ~/.local/share/applications
|
update-desktop-database ~/.local/share/applications
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist'] },
|
{ ignores: ['dist', 'node_modules', 'src-tauri/target'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
@@ -19,10 +19,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': [
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ repository = ""
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.2.0", features = [] }
|
tauri-build = { version = "2.2.0", features = [] }
|
||||||
|
|
||||||
@@ -37,6 +34,4 @@ num_cpus = "1.16.0"
|
|||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
|
||||||
# DO NOT REMOVE!!
|
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "enables the default permissions",
|
"description": "enables the default permissions",
|
||||||
"windows": [
|
"windows": ["main"],
|
||||||
"main"
|
"permissions": ["core:default"]
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
"core:default"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// src/cache.rs
|
use crate::dlc_manager::DlcInfoWithState;
|
||||||
|
use log::{info, warn};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::{PathBuf};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::time::{SystemTime};
|
use std::path::PathBuf;
|
||||||
use log::{info, warn};
|
use std::time::SystemTime;
|
||||||
use crate::dlc_manager::DlcInfoWithState;
|
|
||||||
|
|
||||||
// Cache entry with timestamp for expiration
|
// Cache entry with timestamp for expiration
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -52,8 +50,8 @@ where
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Serialize and write to file
|
// Serialize and write to file
|
||||||
let serialized = serde_json::to_string(&json_data)
|
let serialized =
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
serde_json::to_string(&json_data).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||||
|
|
||||||
fs::write(cache_file, serialized)?;
|
fs::write(cache_file, serialized)?;
|
||||||
info!("Saved cache for key: {}", key);
|
info!("Saved cache for key: {}", key);
|
||||||
@@ -125,7 +123,11 @@ where
|
|||||||
let data: T = match serde_json::from_value(json_value["data"].clone()) {
|
let data: T = match serde_json::from_value(json_value["data"].clone()) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to parse data in cache file {}: {}", cache_file.display(), e);
|
warn!(
|
||||||
|
"Failed to parse data in cache file {}: {}",
|
||||||
|
cache_file.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// src/dlc_manager.rs
|
use log::{error, info};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use log::{info, error};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
/// More detailed DLC information with enabled state
|
// More detailed DLC information with enabled state
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct DlcInfoWithState {
|
pub struct DlcInfoWithState {
|
||||||
pub appid: String,
|
pub appid: String,
|
||||||
@@ -14,21 +13,24 @@ pub struct DlcInfoWithState {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the cream_api.ini file to extract both enabled and disabled DLCs
|
// Parse the cream_api.ini file to extract both enabled and disabled DLCs
|
||||||
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
||||||
info!("Reading enabled DLCs from {}", game_path);
|
info!("Reading enabled DLCs from {}", game_path);
|
||||||
|
|
||||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||||
if !cream_api_path.exists() {
|
if !cream_api_path.exists() {
|
||||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
return Err(format!(
|
||||||
|
"cream_api.ini not found at {}",
|
||||||
|
cream_api_path.display()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let contents = match fs::read_to_string(&cream_api_path) {
|
let contents = match fs::read_to_string(&cream_api_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract DLCs - they are in the [dlc] section with format "appid = name"
|
// Extract DLCs
|
||||||
let mut in_dlc_section = false;
|
let mut in_dlc_section = false;
|
||||||
let mut enabled_dlcs = Vec::new();
|
let mut enabled_dlcs = Vec::new();
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're leaving the DLC section (another section begins)
|
// Check if we're leaving the DLC section
|
||||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
in_dlc_section = false;
|
in_dlc_section = false;
|
||||||
continue;
|
continue;
|
||||||
@@ -64,21 +66,24 @@ pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
|||||||
Ok(enabled_dlcs)
|
Ok(enabled_dlcs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all DLCs (both enabled and disabled) from cream_api.ini
|
// Get all DLCs (both enabled and disabled) from cream_api.ini
|
||||||
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
||||||
info!("Reading all DLCs from {}", game_path);
|
info!("Reading all DLCs from {}", game_path);
|
||||||
|
|
||||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||||
if !cream_api_path.exists() {
|
if !cream_api_path.exists() {
|
||||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
return Err(format!(
|
||||||
|
"cream_api.ini not found at {}",
|
||||||
|
cream_api_path.display()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let contents = match fs::read_to_string(&cream_api_path) {
|
let contents = match fs::read_to_string(&cream_api_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract DLCs - both enabled and disabled
|
// Extract DLCs
|
||||||
let mut in_dlc_section = false;
|
let mut in_dlc_section = false;
|
||||||
let mut all_dlcs = Vec::new();
|
let mut all_dlcs = Vec::new();
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're leaving the DLC section (another section begins)
|
// Check if we're leaving the DLC section
|
||||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
in_dlc_section = false;
|
in_dlc_section = false;
|
||||||
continue;
|
continue;
|
||||||
@@ -120,31 +125,40 @@ pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Found {} total DLCs ({} enabled, {} disabled)",
|
info!(
|
||||||
|
"Found {} total DLCs ({} enabled, {} disabled)",
|
||||||
all_dlcs.len(),
|
all_dlcs.len(),
|
||||||
all_dlcs.iter().filter(|d| d.enabled).count(),
|
all_dlcs.iter().filter(|d| d.enabled).count(),
|
||||||
all_dlcs.iter().filter(|d| !d.enabled).count());
|
all_dlcs.iter().filter(|d| !d.enabled).count()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(all_dlcs)
|
Ok(all_dlcs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the cream_api.ini file with the user's DLC selections
|
// Update the cream_api.ini file with the user's DLC selections
|
||||||
pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
|
pub fn update_dlc_configuration(
|
||||||
|
game_path: &str,
|
||||||
|
dlcs: Vec<DlcInfoWithState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
info!("Updating DLC configuration for {}", game_path);
|
info!("Updating DLC configuration for {}", game_path);
|
||||||
|
|
||||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||||
if !cream_api_path.exists() {
|
if !cream_api_path.exists() {
|
||||||
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
|
return Err(format!(
|
||||||
|
"cream_api.ini not found at {}",
|
||||||
|
cream_api_path.display()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the current file contents
|
// Read the current file contents
|
||||||
let current_contents = match fs::read_to_string(&cream_api_path) {
|
let current_contents = match fs::read_to_string(&cream_api_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
|
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a mapping of DLC appid to its state for easy lookup
|
// Create a mapping of DLC appid to its state for easy lookup
|
||||||
let dlc_states: HashMap<String, (bool, String)> = dlcs.iter()
|
let dlc_states: HashMap<String, (bool, String)> = dlcs
|
||||||
|
.iter()
|
||||||
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
|
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -165,7 +179,7 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're leaving the DLC section (another section begins)
|
// Check if we're leaving the DLC section
|
||||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
in_dlc_section = false;
|
in_dlc_section = false;
|
||||||
|
|
||||||
@@ -218,15 +232,15 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
|||||||
}
|
}
|
||||||
processed_dlcs.insert(appid.to_string());
|
processed_dlcs.insert(appid.to_string());
|
||||||
} else {
|
} else {
|
||||||
// Not in our list - keep the original line
|
// Not in our list keep the original line
|
||||||
new_contents.push(line.to_string());
|
new_contents.push(line.to_string());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Invalid format or not a DLC line - keep as is
|
// Invalid format or not a DLC line keep as is
|
||||||
new_contents.push(line.to_string());
|
new_contents.push(line.to_string());
|
||||||
}
|
}
|
||||||
} else if !in_dlc_section || trimmed.is_empty() {
|
} else if !in_dlc_section || trimmed.is_empty() {
|
||||||
// Not a DLC line or empty line - keep as is
|
// Not a DLC line or empty line keep as is
|
||||||
new_contents.push(line.to_string());
|
new_contents.push(line.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,9 +261,12 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
|||||||
// Write the updated file
|
// Write the updated file
|
||||||
match fs::write(&cream_api_path, new_contents.join("\n")) {
|
match fs::write(&cream_api_path, new_contents.join("\n")) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("Successfully updated DLC configuration at {}", cream_api_path.display());
|
info!(
|
||||||
|
"Successfully updated DLC configuration at {}",
|
||||||
|
cream_api_path.display()
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to write updated cream_api.ini: {}", e);
|
error!("Failed to write updated cream_api.ini: {}", e);
|
||||||
Err(format!("Failed to write updated cream_api.ini: {}", e))
|
Err(format!("Failed to write updated cream_api.ini: {}", e))
|
||||||
@@ -257,7 +274,7 @@ pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get app ID from game path by reading cream_api.ini
|
// Get app ID from game path by reading cream_api.ini
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn extract_app_id_from_config(game_path: &str) -> Option<String> {
|
fn extract_app_id_from_config(game_path: &str) -> Option<String> {
|
||||||
if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) {
|
if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) {
|
||||||
@@ -269,17 +286,20 @@ fn extract_app_id_from_config(game_path: &str) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a custom installation with selected DLCs
|
// Create a custom installation with selected DLCs
|
||||||
pub async fn install_cream_with_dlcs(
|
pub async fn install_cream_with_dlcs(
|
||||||
game_id: String,
|
game_id: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
selected_dlcs: Vec<DlcInfoWithState>
|
selected_dlcs: Vec<DlcInfoWithState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
// Count enabled DLCs for logging
|
// Count enabled DLCs for logging
|
||||||
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
|
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
|
||||||
info!("Starting installation of CreamLinux with {} selected DLCs", enabled_dlc_count);
|
info!(
|
||||||
|
"Starting installation of CreamLinux with {} selected DLCs",
|
||||||
|
enabled_dlc_count
|
||||||
|
);
|
||||||
|
|
||||||
// Get the game from state
|
// Get the game from state
|
||||||
let game = {
|
let game = {
|
||||||
@@ -287,17 +307,21 @@ pub async fn install_cream_with_dlcs(
|
|||||||
let games = state.games.lock();
|
let games = state.games.lock();
|
||||||
match games.get(&game_id) {
|
match games.get(&game_id) {
|
||||||
Some(g) => g.clone(),
|
Some(g) => g.clone(),
|
||||||
None => return Err(format!("Game with ID {} not found", game_id))
|
None => return Err(format!("Game with ID {} not found", game_id)),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Installing CreamLinux for game: {} ({})", game.title, game_id);
|
info!(
|
||||||
|
"Installing CreamLinux for game: {} ({})",
|
||||||
|
game.title, game_id
|
||||||
|
);
|
||||||
|
|
||||||
// Install CreamLinux first - but provide the DLCs directly instead of fetching them again
|
// Install CreamLinux first - but provide the DLCs directly instead of fetching them again
|
||||||
use crate::installer::install_creamlinux_with_dlcs;
|
use crate::installer::install_creamlinux_with_dlcs;
|
||||||
|
|
||||||
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
|
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
|
||||||
let enabled_dlcs = selected_dlcs.iter()
|
let enabled_dlcs = selected_dlcs
|
||||||
|
.iter()
|
||||||
.filter(|dlc| dlc.enabled)
|
.filter(|dlc| dlc.enabled)
|
||||||
.map(|dlc| crate::installer::DlcInfo {
|
.map(|dlc| crate::installer::DlcInfo {
|
||||||
appid: dlc.appid.clone(),
|
appid: dlc.appid.clone(),
|
||||||
@@ -323,14 +347,19 @@ pub async fn install_cream_with_dlcs(
|
|||||||
progress * 100.0, // Scale progress from 0 to 100%
|
progress * 100.0, // Scale progress from 0 to 100%
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
).await {
|
|
||||||
Ok(_) => {
|
|
||||||
info!("CreamLinux installation completed successfully for game: {}", game.title);
|
|
||||||
Ok(())
|
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
info!(
|
||||||
|
"CreamLinux installation completed successfully for game: {}",
|
||||||
|
game.title
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to install CreamLinux: {}", e);
|
error!("Failed to install CreamLinux: {}", e);
|
||||||
Err(format!("Failed to install CreamLinux: {}", e))
|
Err(format!("Failed to install CreamLinux: {}", e))
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
// src/installer.rs
|
use crate::AppState;
|
||||||
use serde::{Serialize, Deserialize};
|
use log::{error, info, warn};
|
||||||
|
use reqwest;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use log::{info, error, warn};
|
use std::sync::atomic::Ordering;
|
||||||
use reqwest;
|
use std::time::Duration;
|
||||||
|
use tauri::Manager;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use zip::ZipArchive;
|
use zip::ZipArchive;
|
||||||
use std::time::Duration;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
use crate::AppState;
|
|
||||||
use tauri::Manager;
|
|
||||||
|
|
||||||
// Constants for API endpoints and downloads
|
// Constants for API endpoints and downloads
|
||||||
const CREAMLINUX_RELEASE_URL: &str = "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip";
|
const CREAMLINUX_RELEASE_URL: &str =
|
||||||
|
"https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip";
|
||||||
const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI";
|
const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI";
|
||||||
|
|
||||||
// Type of installer
|
// Type of installer
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum InstallerType {
|
pub enum InstallerType {
|
||||||
Cream,
|
Cream,
|
||||||
Smoke
|
Smoke,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action to perform
|
// Action to perform
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum InstallerAction {
|
pub enum InstallerAction {
|
||||||
Install,
|
Install,
|
||||||
Uninstall
|
Uninstall,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error type combining all possible errors
|
// Error type combining all possible errors
|
||||||
@@ -72,14 +72,14 @@ impl std::fmt::Display for InstallerError {
|
|||||||
|
|
||||||
impl std::error::Error for InstallerError {}
|
impl std::error::Error for InstallerError {}
|
||||||
|
|
||||||
/// DLC Information structure
|
// DLC Information structure
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct DlcInfo {
|
pub struct DlcInfo {
|
||||||
pub appid: String,
|
pub appid: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Struct to hold installation instructions for the frontend
|
// Struct to hold installation instructions for the frontend
|
||||||
#[derive(Serialize, Debug, Clone)]
|
#[derive(Serialize, Debug, Clone)]
|
||||||
pub struct InstallationInstructions {
|
pub struct InstallationInstructions {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -89,7 +89,7 @@ pub struct InstallationInstructions {
|
|||||||
pub dlc_count: Option<usize>,
|
pub dlc_count: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Game information structure from searcher module
|
// Game information structure from searcher module
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct Game {
|
pub struct Game {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -102,7 +102,7 @@ pub struct Game {
|
|||||||
pub installing: bool,
|
pub installing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit a progress update to the frontend
|
// Emit a progress update to the frontend
|
||||||
pub fn emit_progress(
|
pub fn emit_progress(
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -110,7 +110,7 @@ pub fn emit_progress(
|
|||||||
progress: f32,
|
progress: f32,
|
||||||
complete: bool,
|
complete: bool,
|
||||||
show_instructions: bool,
|
show_instructions: bool,
|
||||||
instructions: Option<InstallationInstructions>
|
instructions: Option<InstallationInstructions>,
|
||||||
) {
|
) {
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -129,13 +129,13 @@ pub fn emit_progress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a single game action (install/uninstall Cream/Smoke)
|
// Process a single game action (install/uninstall Cream/Smoke)
|
||||||
pub async fn process_action(
|
pub async fn process_action(
|
||||||
_game_id: String,
|
_game_id: String,
|
||||||
installer_type: InstallerType,
|
installer_type: InstallerType,
|
||||||
action: InstallerAction,
|
action: InstallerAction,
|
||||||
game: Game,
|
game: Game,
|
||||||
app_handle: AppHandle
|
app_handle: AppHandle,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
match (installer_type, action) {
|
match (installer_type, action) {
|
||||||
(InstallerType::Cream, InstallerAction::Install) => {
|
(InstallerType::Cream, InstallerAction::Install) => {
|
||||||
@@ -154,7 +154,7 @@ pub async fn process_action(
|
|||||||
10.0,
|
10.0,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch DLC list
|
// Fetch DLC list
|
||||||
@@ -176,7 +176,7 @@ pub async fn process_action(
|
|||||||
30.0,
|
30.0,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Install CreamLinux
|
// Install CreamLinux
|
||||||
@@ -192,16 +192,18 @@ pub async fn process_action(
|
|||||||
30.0 + (progress * 60.0), // Scale progress from 30% to 90%
|
30.0 + (progress * 60.0), // Scale progress from 30% to 90%
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
}).await {
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Emit completion with instructions
|
// Emit completion with instructions
|
||||||
let instructions = InstallationInstructions {
|
let instructions = InstallationInstructions {
|
||||||
type_: "cream_install".to_string(),
|
type_: "cream_install".to_string(),
|
||||||
command: "sh ./cream.sh %command%".to_string(),
|
command: "sh ./cream.sh %command%".to_string(),
|
||||||
game_title: game_title.clone(),
|
game_title: game_title.clone(),
|
||||||
dlc_count: Some(dlc_count)
|
dlc_count: Some(dlc_count),
|
||||||
};
|
};
|
||||||
|
|
||||||
emit_progress(
|
emit_progress(
|
||||||
@@ -211,22 +213,24 @@ pub async fn process_action(
|
|||||||
100.0,
|
100.0,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Some(instructions)
|
Some(instructions),
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("CreamLinux installation completed for: {}", game_title);
|
info!("CreamLinux installation completed for: {}", game_title);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to install CreamLinux: {}", e);
|
error!("Failed to install CreamLinux: {}", e);
|
||||||
Err(format!("Failed to install CreamLinux: {}", e))
|
Err(format!("Failed to install CreamLinux: {}", e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||||
// Ensure this is a native game
|
// Ensure this is a native game
|
||||||
if !game.native {
|
if !game.native {
|
||||||
return Err("CreamLinux can only be uninstalled from native Linux games".to_string());
|
return Err(
|
||||||
|
"CreamLinux can only be uninstalled from native Linux games".to_string()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let game_title = game.title.clone();
|
let game_title = game.title.clone();
|
||||||
@@ -239,7 +243,7 @@ pub async fn process_action(
|
|||||||
30.0,
|
30.0,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Uninstall CreamLinux
|
// Uninstall CreamLinux
|
||||||
@@ -250,7 +254,7 @@ pub async fn process_action(
|
|||||||
type_: "cream_uninstall".to_string(),
|
type_: "cream_uninstall".to_string(),
|
||||||
command: "sh ./cream.sh %command%".to_string(),
|
command: "sh ./cream.sh %command%".to_string(),
|
||||||
game_title: game_title.clone(),
|
game_title: game_title.clone(),
|
||||||
dlc_count: None
|
dlc_count: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
emit_progress(
|
emit_progress(
|
||||||
@@ -260,18 +264,18 @@ pub async fn process_action(
|
|||||||
100.0,
|
100.0,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Some(instructions)
|
Some(instructions),
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("CreamLinux uninstallation completed for: {}", game_title);
|
info!("CreamLinux uninstallation completed for: {}", game_title);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to uninstall CreamLinux: {}", e);
|
error!("Failed to uninstall CreamLinux: {}", e);
|
||||||
Err(format!("Failed to uninstall CreamLinux: {}", e))
|
Err(format!("Failed to uninstall CreamLinux: {}", e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||||
// We only allow SmokeAPI for Proton/Windows games
|
// We only allow SmokeAPI for Proton/Windows games
|
||||||
if game.native {
|
if game.native {
|
||||||
@@ -280,7 +284,9 @@ pub async fn process_action(
|
|||||||
|
|
||||||
// Check if we have any Steam API DLLs to patch
|
// Check if we have any Steam API DLLs to patch
|
||||||
if game.api_files.is_empty() {
|
if game.api_files.is_empty() {
|
||||||
return Err("No Steam API DLLs found to patch. SmokeAPI cannot be installed.".to_string());
|
return Err(
|
||||||
|
"No Steam API DLLs found to patch. SmokeAPI cannot be installed.".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let game_title = game.title.clone();
|
let game_title = game.title.clone();
|
||||||
@@ -293,7 +299,7 @@ pub async fn process_action(
|
|||||||
10.0,
|
10.0,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create clones for the closure
|
// Create clones for the closure
|
||||||
@@ -311,16 +317,19 @@ pub async fn process_action(
|
|||||||
10.0 + (progress * 90.0), // Scale progress from 10% to 100%
|
10.0 + (progress * 90.0), // Scale progress from 10% to 100%
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
}).await {
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Emit completion with instructions
|
// Emit completion with instructions
|
||||||
let instructions = InstallationInstructions {
|
let instructions = InstallationInstructions {
|
||||||
type_: "smoke_install".to_string(),
|
type_: "smoke_install".to_string(),
|
||||||
command: "No additional steps needed. SmokeAPI will work automatically.".to_string(),
|
command: "No additional steps needed. SmokeAPI will work automatically."
|
||||||
|
.to_string(),
|
||||||
game_title: game_title.clone(),
|
game_title: game_title.clone(),
|
||||||
dlc_count: Some(game.api_files.len())
|
dlc_count: Some(game.api_files.len()),
|
||||||
};
|
};
|
||||||
|
|
||||||
emit_progress(
|
emit_progress(
|
||||||
@@ -330,22 +339,24 @@ pub async fn process_action(
|
|||||||
100.0,
|
100.0,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Some(instructions)
|
Some(instructions),
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("SmokeAPI installation completed for: {}", game_title);
|
info!("SmokeAPI installation completed for: {}", game_title);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to install SmokeAPI: {}", e);
|
error!("Failed to install SmokeAPI: {}", e);
|
||||||
Err(format!("Failed to install SmokeAPI: {}", e))
|
Err(format!("Failed to install SmokeAPI: {}", e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||||
// Ensure this is a non-native game
|
// Ensure this is a non-native game
|
||||||
if game.native {
|
if game.native {
|
||||||
return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string());
|
return Err(
|
||||||
|
"SmokeAPI can only be uninstalled from Proton/Windows games".to_string()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let game_title = game.title.clone();
|
let game_title = game.title.clone();
|
||||||
@@ -358,7 +369,7 @@ pub async fn process_action(
|
|||||||
30.0,
|
30.0,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
None
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Uninstall SmokeAPI
|
// Uninstall SmokeAPI
|
||||||
@@ -369,7 +380,7 @@ pub async fn process_action(
|
|||||||
type_: "smoke_uninstall".to_string(),
|
type_: "smoke_uninstall".to_string(),
|
||||||
command: "Original Steam API files have been restored.".to_string(),
|
command: "Original Steam API files have been restored.".to_string(),
|
||||||
game_title: game_title.clone(),
|
game_title: game_title.clone(),
|
||||||
dlc_count: None
|
dlc_count: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
emit_progress(
|
emit_progress(
|
||||||
@@ -379,12 +390,12 @@ pub async fn process_action(
|
|||||||
100.0,
|
100.0,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Some(instructions)
|
Some(instructions),
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("SmokeAPI uninstallation completed for: {}", game_title);
|
info!("SmokeAPI uninstallation completed for: {}", game_title);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to uninstall SmokeAPI: {}", e);
|
error!("Failed to uninstall SmokeAPI: {}", e);
|
||||||
Err(format!("Failed to uninstall SmokeAPI: {}", e))
|
Err(format!("Failed to uninstall SmokeAPI: {}", e))
|
||||||
@@ -394,19 +405,15 @@ pub async fn process_action(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Install CreamLinux for a game
|
||||||
// CreamLinux specific functions
|
|
||||||
//
|
|
||||||
|
|
||||||
/// Install CreamLinux for a game
|
|
||||||
async fn install_creamlinux<F>(
|
async fn install_creamlinux<F>(
|
||||||
game_path: &str,
|
game_path: &str,
|
||||||
app_id: &str,
|
app_id: &str,
|
||||||
dlcs: Vec<DlcInfo>,
|
dlcs: Vec<DlcInfo>,
|
||||||
progress_callback: F
|
progress_callback: F,
|
||||||
) -> Result<(), InstallerError>
|
) -> Result<(), InstallerError>
|
||||||
where
|
where
|
||||||
F: Fn(f32, &str) + Send + 'static
|
F: Fn(f32, &str) + Send + 'static,
|
||||||
{
|
{
|
||||||
// Progress update
|
// Progress update
|
||||||
progress_callback(0.1, "Preparing to download CreamLinux...");
|
progress_callback(0.1, "Preparing to download CreamLinux...");
|
||||||
@@ -415,15 +422,17 @@ where
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
progress_callback(0.2, "Downloading CreamLinux...");
|
progress_callback(0.2, "Downloading CreamLinux...");
|
||||||
|
|
||||||
let response = client.get(CREAMLINUX_RELEASE_URL)
|
let response = client
|
||||||
|
.get(CREAMLINUX_RELEASE_URL)
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(InstallerError::InstallationError(
|
return Err(InstallerError::InstallationError(format!(
|
||||||
format!("Failed to download CreamLinux: HTTP {}", response.status())
|
"Failed to download CreamLinux: HTTP {}",
|
||||||
));
|
response.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to temporary file
|
// Save to temporary file
|
||||||
@@ -488,16 +497,15 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install CreamLinux for a game with pre-fetched DLC list
|
// Install CreamLinux for a game with pre-fetched DLC list
|
||||||
/// This avoids the redundant network calls to Steam API
|
|
||||||
pub async fn install_creamlinux_with_dlcs<F>(
|
pub async fn install_creamlinux_with_dlcs<F>(
|
||||||
game_path: &str,
|
game_path: &str,
|
||||||
app_id: &str,
|
app_id: &str,
|
||||||
dlcs: Vec<DlcInfo>,
|
dlcs: Vec<DlcInfo>,
|
||||||
progress_callback: F
|
progress_callback: F,
|
||||||
) -> Result<(), InstallerError>
|
) -> Result<(), InstallerError>
|
||||||
where
|
where
|
||||||
F: Fn(f32, &str) + Send + 'static
|
F: Fn(f32, &str) + Send + 'static,
|
||||||
{
|
{
|
||||||
// Progress update
|
// Progress update
|
||||||
progress_callback(0.1, "Preparing to download CreamLinux...");
|
progress_callback(0.1, "Preparing to download CreamLinux...");
|
||||||
@@ -506,15 +514,17 @@ where
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
progress_callback(0.2, "Downloading CreamLinux...");
|
progress_callback(0.2, "Downloading CreamLinux...");
|
||||||
|
|
||||||
let response = client.get(CREAMLINUX_RELEASE_URL)
|
let response = client
|
||||||
|
.get(CREAMLINUX_RELEASE_URL)
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(InstallerError::InstallationError(
|
return Err(InstallerError::InstallationError(format!(
|
||||||
format!("Failed to download CreamLinux: HTTP {}", response.status())
|
"Failed to download CreamLinux: HTTP {}",
|
||||||
));
|
response.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to temporary file
|
// Save to temporary file
|
||||||
@@ -579,7 +589,7 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uninstall CreamLinux from a game
|
// Uninstall CreamLinux from a game
|
||||||
fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
|
fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
|
||||||
info!("Uninstalling CreamLinux from: {}", game_path);
|
info!("Uninstalling CreamLinux from: {}", game_path);
|
||||||
|
|
||||||
@@ -589,7 +599,7 @@ fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
|
|||||||
"cream_api.ini",
|
"cream_api.ini",
|
||||||
"cream_api.so",
|
"cream_api.so",
|
||||||
"lib32Creamlinux.so",
|
"lib32Creamlinux.so",
|
||||||
"lib64Creamlinux.so"
|
"lib64Creamlinux.so",
|
||||||
];
|
];
|
||||||
|
|
||||||
for file in &files_to_remove {
|
for file in &files_to_remove {
|
||||||
@@ -609,34 +619,39 @@ fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch DLC details from Steam API
|
// Fetch DLC details from Steam API
|
||||||
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerError> {
|
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerError> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let base_url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id);
|
let base_url = format!(
|
||||||
|
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||||
|
app_id
|
||||||
|
);
|
||||||
|
|
||||||
let response = client.get(&base_url)
|
let response = client
|
||||||
|
.get(&base_url)
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(InstallerError::InstallationError(
|
return Err(InstallerError::InstallationError(format!(
|
||||||
format!("Failed to fetch game details: HTTP {}", response.status())
|
"Failed to fetch game details: HTTP {}",
|
||||||
));
|
response.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: serde_json::Value = response.json().await?;
|
let data: serde_json::Value = response.json().await?;
|
||||||
let dlc_ids = match data.get(app_id)
|
let dlc_ids = match data
|
||||||
|
.get(app_id)
|
||||||
.and_then(|app| app.get("data"))
|
.and_then(|app| app.get("data"))
|
||||||
.and_then(|data| data.get("dlc"))
|
.and_then(|data| data.get("dlc"))
|
||||||
{
|
{
|
||||||
Some(dlc_array) => {
|
Some(dlc_array) => match dlc_array.as_array() {
|
||||||
match dlc_array.as_array() {
|
Some(array) => array
|
||||||
Some(array) => array.iter()
|
.iter()
|
||||||
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
||||||
.collect::<Vec<String>>(),
|
.collect::<Vec<String>>(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -646,12 +661,16 @@ pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerEr
|
|||||||
let mut dlc_details = Vec::new();
|
let mut dlc_details = Vec::new();
|
||||||
|
|
||||||
for dlc_id in dlc_ids {
|
for dlc_id in dlc_ids {
|
||||||
let dlc_url = format!("https://store.steampowered.com/api/appdetails?appids={}", dlc_id);
|
let dlc_url = format!(
|
||||||
|
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||||
|
dlc_id
|
||||||
|
);
|
||||||
|
|
||||||
// Add a small delay to avoid rate limiting
|
// Add a small delay to avoid rate limiting
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
let dlc_response = client.get(&dlc_url)
|
let dlc_response = client
|
||||||
|
.get(&dlc_url)
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -659,15 +678,14 @@ pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerEr
|
|||||||
if dlc_response.status().is_success() {
|
if dlc_response.status().is_success() {
|
||||||
let dlc_data: serde_json::Value = dlc_response.json().await?;
|
let dlc_data: serde_json::Value = dlc_response.json().await?;
|
||||||
|
|
||||||
let dlc_name = match dlc_data.get(&dlc_id)
|
let dlc_name = match dlc_data
|
||||||
|
.get(&dlc_id)
|
||||||
.and_then(|app| app.get("data"))
|
.and_then(|app| app.get("data"))
|
||||||
.and_then(|data| data.get("name"))
|
.and_then(|data| data.get("name"))
|
||||||
{
|
{
|
||||||
Some(name) => {
|
Some(name) => match name.as_str() {
|
||||||
match name.as_str() {
|
|
||||||
Some(s) => s.to_string(),
|
Some(s) => s.to_string(),
|
||||||
_ => "Unknown DLC".to_string(),
|
_ => "Unknown DLC".to_string(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
_ => "Unknown DLC".to_string(),
|
_ => "Unknown DLC".to_string(),
|
||||||
};
|
};
|
||||||
@@ -684,26 +702,39 @@ pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerEr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Successfully retrieved details for {} DLCs", dlc_details.len());
|
info!(
|
||||||
|
"Successfully retrieved details for {} DLCs",
|
||||||
|
dlc_details.len()
|
||||||
|
);
|
||||||
Ok(dlc_details)
|
Ok(dlc_details)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch DLC details from Steam API with progress updates
|
// Fetch DLC details from Steam API with progress updates
|
||||||
pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::AppHandle) -> Result<Vec<DlcInfo>, InstallerError> {
|
pub async fn fetch_dlc_details_with_progress(
|
||||||
info!("Starting DLC details fetch with progress for game ID: {}", app_id);
|
app_id: &str,
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
) -> Result<Vec<DlcInfo>, InstallerError> {
|
||||||
|
info!(
|
||||||
|
"Starting DLC details fetch with progress for game ID: {}",
|
||||||
|
app_id
|
||||||
|
);
|
||||||
|
|
||||||
// Get a reference to a cancellation flag from app state
|
// Get a reference to a cancellation flag from app state
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
let should_cancel = state.fetch_cancellation.clone();
|
let should_cancel = state.fetch_cancellation.clone();
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let base_url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id);
|
let base_url = format!(
|
||||||
|
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||||
|
app_id
|
||||||
|
);
|
||||||
|
|
||||||
// Emit initial progress
|
// Emit initial progress
|
||||||
emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
|
emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
|
||||||
info!("Emitted initial DLC progress: 5%");
|
info!("Emitted initial DLC progress: 5%");
|
||||||
|
|
||||||
let response = client.get(&base_url)
|
let response = client
|
||||||
|
.get(&base_url)
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -715,23 +746,28 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data: serde_json::Value = response.json().await?;
|
let data: serde_json::Value = response.json().await?;
|
||||||
let dlc_ids = match data.get(app_id)
|
let dlc_ids = match data
|
||||||
|
.get(app_id)
|
||||||
.and_then(|app| app.get("data"))
|
.and_then(|app| app.get("data"))
|
||||||
.and_then(|data| data.get("dlc"))
|
.and_then(|data| data.get("dlc"))
|
||||||
{
|
{
|
||||||
Some(dlc_array) => {
|
Some(dlc_array) => match dlc_array.as_array() {
|
||||||
match dlc_array.as_array() {
|
Some(array) => array
|
||||||
Some(array) => array.iter()
|
.iter()
|
||||||
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
||||||
.collect::<Vec<String>>(),
|
.collect::<Vec<String>>(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
|
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
|
||||||
emit_dlc_progress(app_handle, &format!("Found {} DLCs. Fetching details...", dlc_ids.len()), 10, None);
|
emit_dlc_progress(
|
||||||
|
app_handle,
|
||||||
|
&format!("Found {} DLCs. Fetching details...", dlc_ids.len()),
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
);
|
||||||
info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len());
|
info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len());
|
||||||
|
|
||||||
let mut dlc_details = Vec::new();
|
let mut dlc_details = Vec::new();
|
||||||
@@ -741,7 +777,9 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
|
|||||||
// Check if cancellation was requested
|
// Check if cancellation was requested
|
||||||
if should_cancel.load(Ordering::SeqCst) {
|
if should_cancel.load(Ordering::SeqCst) {
|
||||||
info!("DLC fetch cancelled for game {}", app_id);
|
info!("DLC fetch cancelled for game {}", app_id);
|
||||||
return Err(InstallerError::InstallationError("Operation cancelled by user".to_string()));
|
return Err(InstallerError::InstallationError(
|
||||||
|
"Operation cancelled by user".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0;
|
let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0;
|
||||||
let progress_rounded = progress_percent as u32;
|
let progress_rounded = progress_percent as u32;
|
||||||
@@ -759,20 +797,29 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
|
|||||||
"almost done".to_string()
|
"almost done".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Processing DLC {}/{} - Progress: {}%", index + 1, total_dlcs, progress_rounded);
|
info!(
|
||||||
|
"Processing DLC {}/{} - Progress: {}%",
|
||||||
|
index + 1,
|
||||||
|
total_dlcs,
|
||||||
|
progress_rounded
|
||||||
|
);
|
||||||
emit_dlc_progress(
|
emit_dlc_progress(
|
||||||
app_handle,
|
app_handle,
|
||||||
&format!("Processing DLC {}/{}", index + 1, total_dlcs),
|
&format!("Processing DLC {}/{}", index + 1, total_dlcs),
|
||||||
progress_rounded,
|
progress_rounded,
|
||||||
Some(&est_time_left)
|
Some(&est_time_left),
|
||||||
);
|
);
|
||||||
|
|
||||||
let dlc_url = format!("https://store.steampowered.com/api/appdetails?appids={}", dlc_id);
|
let dlc_url = format!(
|
||||||
|
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||||
|
dlc_id
|
||||||
|
);
|
||||||
|
|
||||||
// Add a small delay to avoid rate limiting
|
// Add a small delay to avoid rate limiting
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
let dlc_response = client.get(&dlc_url)
|
let dlc_response = client
|
||||||
|
.get(&dlc_url)
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -780,15 +827,14 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
|
|||||||
if dlc_response.status().is_success() {
|
if dlc_response.status().is_success() {
|
||||||
let dlc_data: serde_json::Value = dlc_response.json().await?;
|
let dlc_data: serde_json::Value = dlc_response.json().await?;
|
||||||
|
|
||||||
let dlc_name = match dlc_data.get(&dlc_id)
|
let dlc_name = match dlc_data
|
||||||
|
.get(&dlc_id)
|
||||||
.and_then(|app| app.get("data"))
|
.and_then(|app| app.get("data"))
|
||||||
.and_then(|data| data.get("name"))
|
.and_then(|data| data.get("name"))
|
||||||
{
|
{
|
||||||
Some(name) => {
|
Some(name) => match name.as_str() {
|
||||||
match name.as_str() {
|
|
||||||
Some(s) => s.to_string(),
|
Some(s) => s.to_string(),
|
||||||
_ => "Unknown DLC".to_string(),
|
_ => "Unknown DLC".to_string(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
_ => "Unknown DLC".to_string(),
|
_ => "Unknown DLC".to_string(),
|
||||||
};
|
};
|
||||||
@@ -812,25 +858,38 @@ pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::A
|
|||||||
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||||
// If rate limited, wait longer
|
// If rate limited, wait longer
|
||||||
error!("Rate limited by Steam API, waiting 10 seconds");
|
error!("Rate limited by Steam API, waiting 10 seconds");
|
||||||
emit_dlc_progress(app_handle, "Rate limited by Steam. Waiting...", progress_rounded, None);
|
emit_dlc_progress(
|
||||||
|
app_handle,
|
||||||
|
"Rate limited by Steam. Waiting...",
|
||||||
|
progress_rounded,
|
||||||
|
None,
|
||||||
|
);
|
||||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final progress update
|
// Final progress update
|
||||||
info!("Completed DLC fetch. Found {} DLCs in total", dlc_details.len());
|
info!(
|
||||||
emit_dlc_progress(app_handle, &format!("Completed! Found {} DLCs", dlc_details.len()), 100, None);
|
"Completed DLC fetch. Found {} DLCs in total",
|
||||||
|
dlc_details.len()
|
||||||
|
);
|
||||||
|
emit_dlc_progress(
|
||||||
|
app_handle,
|
||||||
|
&format!("Completed! Found {} DLCs", dlc_details.len()),
|
||||||
|
100,
|
||||||
|
None,
|
||||||
|
);
|
||||||
info!("Emitted final DLC progress: 100%");
|
info!("Emitted final DLC progress: 100%");
|
||||||
|
|
||||||
Ok(dlc_details)
|
Ok(dlc_details)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit DLC progress updates to the frontend
|
// Emit DLC progress updates to the frontend
|
||||||
fn emit_dlc_progress(
|
fn emit_dlc_progress(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
message: &str,
|
message: &str,
|
||||||
progress: u32,
|
progress: u32,
|
||||||
time_left: Option<&str>
|
time_left: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -846,34 +905,35 @@ fn emit_dlc_progress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Install SmokeAPI for a game
|
||||||
// SmokeAPI specific functions
|
|
||||||
//
|
|
||||||
|
|
||||||
/// Install SmokeAPI for a game
|
|
||||||
async fn install_smokeapi<F>(
|
async fn install_smokeapi<F>(
|
||||||
game_path: &str,
|
game_path: &str,
|
||||||
api_files: &[String],
|
api_files: &[String],
|
||||||
progress_callback: F
|
progress_callback: F,
|
||||||
) -> Result<(), InstallerError>
|
) -> Result<(), InstallerError>
|
||||||
where
|
where
|
||||||
F: Fn(f32, &str) + Send + 'static
|
F: Fn(f32, &str) + Send + 'static,
|
||||||
{
|
{
|
||||||
// 1. Get the latest SmokeAPI release
|
// Get the latest SmokeAPI release
|
||||||
progress_callback(0.1, "Fetching latest SmokeAPI release...");
|
progress_callback(0.1, "Fetching latest SmokeAPI release...");
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let releases_url = format!("https://api.github.com/repos/{}/releases/latest", SMOKEAPI_REPO);
|
let releases_url = format!(
|
||||||
|
"https://api.github.com/repos/{}/releases/latest",
|
||||||
|
SMOKEAPI_REPO
|
||||||
|
);
|
||||||
|
|
||||||
let response = client.get(&releases_url)
|
let response = client
|
||||||
|
.get(&releases_url)
|
||||||
.header("User-Agent", "CreamLinux")
|
.header("User-Agent", "CreamLinux")
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(InstallerError::InstallationError(
|
return Err(InstallerError::InstallationError(format!(
|
||||||
format!("Failed to fetch SmokeAPI releases: HTTP {}", response.status())
|
"Failed to fetch SmokeAPI releases: HTTP {}",
|
||||||
));
|
response.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let release_info: serde_json::Value = response.json().await?;
|
let release_info: serde_json::Value = response.json().await?;
|
||||||
@@ -884,33 +944,35 @@ where
|
|||||||
|
|
||||||
info!("Latest SmokeAPI version: {}", latest_version);
|
info!("Latest SmokeAPI version: {}", latest_version);
|
||||||
|
|
||||||
// 2. Construct download URL
|
// Construct download URL
|
||||||
let zip_url = format!(
|
let zip_url = format!(
|
||||||
"https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip",
|
"https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip",
|
||||||
SMOKEAPI_REPO, latest_version, latest_version
|
SMOKEAPI_REPO, latest_version, latest_version
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Download the zip
|
// Download the zip
|
||||||
progress_callback(0.3, "Downloading SmokeAPI...");
|
progress_callback(0.3, "Downloading SmokeAPI...");
|
||||||
let response = client.get(&zip_url)
|
let response = client
|
||||||
|
.get(&zip_url)
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(InstallerError::InstallationError(
|
return Err(InstallerError::InstallationError(format!(
|
||||||
format!("Failed to download SmokeAPI: HTTP {}", response.status())
|
"Failed to download SmokeAPI: HTTP {}",
|
||||||
));
|
response.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save to temporary file
|
// Save to temporary file
|
||||||
progress_callback(0.5, "Saving downloaded files...");
|
progress_callback(0.5, "Saving downloaded files...");
|
||||||
let temp_dir = tempdir()?;
|
let temp_dir = tempdir()?;
|
||||||
let zip_path = temp_dir.path().join("smokeapi.zip");
|
let zip_path = temp_dir.path().join("smokeapi.zip");
|
||||||
let content = response.bytes().await?;
|
let content = response.bytes().await?;
|
||||||
fs::write(&zip_path, &content)?;
|
fs::write(&zip_path, &content)?;
|
||||||
|
|
||||||
// 5. Extract and install for each API file
|
// Extract and install for each API file
|
||||||
progress_callback(0.6, "Extracting SmokeAPI files...");
|
progress_callback(0.6, "Extracting SmokeAPI files...");
|
||||||
let file = fs::File::open(&zip_path)?;
|
let file = fs::File::open(&zip_path)?;
|
||||||
let mut archive = ZipArchive::new(file)?;
|
let mut archive = ZipArchive::new(file)?;
|
||||||
@@ -919,7 +981,11 @@ where
|
|||||||
let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3;
|
let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3;
|
||||||
progress_callback(progress, &format!("Installing SmokeAPI for {}", api_file));
|
progress_callback(progress, &format!("Installing SmokeAPI for {}", api_file));
|
||||||
|
|
||||||
let api_dir = Path::new(game_path).join(Path::new(api_file).parent().unwrap_or_else(|| Path::new("")));
|
let api_dir = Path::new(game_path).join(
|
||||||
|
Path::new(api_file)
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("")),
|
||||||
|
);
|
||||||
let api_name = Path::new(api_file).file_name().unwrap_or_default();
|
let api_name = Path::new(api_file).file_name().unwrap_or_default();
|
||||||
|
|
||||||
// Backup original file
|
// Backup original file
|
||||||
@@ -941,9 +1007,10 @@ where
|
|||||||
io::copy(&mut file, &mut outfile)?;
|
io::copy(&mut file, &mut outfile)?;
|
||||||
info!("Installed SmokeAPI as: {}", original_path.display());
|
info!("Installed SmokeAPI as: {}", original_path.display());
|
||||||
} else {
|
} else {
|
||||||
return Err(InstallerError::InstallationError(
|
return Err(InstallerError::InstallationError(format!(
|
||||||
format!("Could not find {} in the SmokeAPI zip file", api_name.to_string_lossy())
|
"Could not find {} in the SmokeAPI zip file",
|
||||||
));
|
api_name.to_string_lossy()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,7 +1019,7 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uninstall SmokeAPI from a game
|
// Uninstall SmokeAPI from a game
|
||||||
fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> {
|
fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> {
|
||||||
info!("Uninstalling SmokeAPI from: {}", game_path);
|
info!("Uninstalling SmokeAPI from: {}", game_path);
|
||||||
|
|
||||||
@@ -972,7 +1039,11 @@ fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), Insta
|
|||||||
if original_path.exists() {
|
if original_path.exists() {
|
||||||
match fs::remove_file(&original_path) {
|
match fs::remove_file(&original_path) {
|
||||||
Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()),
|
Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()),
|
||||||
Err(e) => error!("Failed to remove SmokeAPI file: {}, error: {}", original_path.display(), e)
|
Err(e) => error!(
|
||||||
|
"Failed to remove SmokeAPI file: {}, error: {}",
|
||||||
|
original_path.display(),
|
||||||
|
e
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,9 +1051,15 @@ fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), Insta
|
|||||||
match fs::rename(&backup_path, &original_path) {
|
match fs::rename(&backup_path, &original_path) {
|
||||||
Ok(_) => info!("Restored original file: {}", original_path.display()),
|
Ok(_) => info!("Restored original file: {}", original_path.display()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to restore original file: {}, error: {}", original_path.display(), e);
|
error!(
|
||||||
|
"Failed to restore original file: {}, error: {}",
|
||||||
|
original_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
// Try to copy instead if rename fails
|
// Try to copy instead if rename fails
|
||||||
if let Err(copy_err) = fs::copy(&backup_path, &original_path).and_then(|_| fs::remove_file(&backup_path)) {
|
if let Err(copy_err) = fs::copy(&backup_path, &original_path)
|
||||||
|
.and_then(|_| fs::remove_file(&backup_path))
|
||||||
|
{
|
||||||
error!("Failed to copy backup file: {}", copy_err);
|
error!("Failed to copy backup file: {}", copy_err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
// src/main.rs
|
|
||||||
#![cfg_attr(
|
#![cfg_attr(
|
||||||
all(not(debug_assertions), target_os = "windows"),
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
mod searcher;
|
mod cache;
|
||||||
mod installer;
|
|
||||||
mod dlc_manager;
|
mod dlc_manager;
|
||||||
mod cache; // Keep the module for now, but we won't use its functionality
|
mod installer;
|
||||||
|
mod searcher; // Keep the module for now
|
||||||
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use parking_lot::Mutex;
|
|
||||||
use tokio::time::Instant;
|
|
||||||
use tokio::time::Duration;
|
|
||||||
use tauri::State;
|
|
||||||
use tauri::{Manager, Emitter};
|
|
||||||
use log::{info, warn, error, debug};
|
|
||||||
use installer::{InstallerType, InstallerAction, Game};
|
|
||||||
use dlc_manager::DlcInfoWithState;
|
use dlc_manager::DlcInfoWithState;
|
||||||
use std::sync::Arc;
|
use installer::{Game, InstallerAction, InstallerType};
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use tokio::time::Duration;
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct GameAction {
|
pub struct GameAction {
|
||||||
game_id: String,
|
game_id: String,
|
||||||
action: String,
|
action: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -37,9 +36,9 @@ struct DlcCache {
|
|||||||
|
|
||||||
// Structure to hold the state of installed games
|
// Structure to hold the state of installed games
|
||||||
struct AppState {
|
struct AppState {
|
||||||
games: Mutex<HashMap<String, Game>>,
|
games: Mutex<HashMap<String, Game>>,
|
||||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||||
fetch_cancellation: Arc<AtomicBool>,
|
fetch_cancellation: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -50,7 +49,10 @@ fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, Stri
|
|||||||
|
|
||||||
// Scan and get the list of Steam games
|
// Scan and get the list of Steam games
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<Vec<Game>, String> {
|
async fn scan_steam_games(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<Vec<Game>, String> {
|
||||||
info!("Starting Steam games scan");
|
info!("Starting Steam games scan");
|
||||||
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
|
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
|
||||||
|
|
||||||
@@ -67,24 +69,50 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
|
|||||||
unique_libraries.insert(lib.to_string_lossy().to_string());
|
unique_libraries.insert(lib.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Found {} Steam library directories:", unique_libraries.len());
|
info!(
|
||||||
|
"Found {} Steam library directories:",
|
||||||
|
unique_libraries.len()
|
||||||
|
);
|
||||||
for (i, lib) in unique_libraries.iter().enumerate() {
|
for (i, lib) in unique_libraries.iter().enumerate() {
|
||||||
info!(" Library {}: {}", i+1, lib);
|
info!(" Library {}: {}", i + 1, lib);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_scan_progress(&app_handle, &format!("Found {} Steam libraries. Starting game scan...", unique_libraries.len()), 20);
|
emit_scan_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!(
|
||||||
|
"Found {} Steam libraries. Starting game scan...",
|
||||||
|
unique_libraries.len()
|
||||||
|
),
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
|
||||||
// Find installed games
|
// Find installed games
|
||||||
let games_info = searcher::find_installed_games(&libraries).await;
|
let games_info = searcher::find_installed_games(&libraries).await;
|
||||||
|
|
||||||
emit_scan_progress(&app_handle, &format!("Found {} games. Processing...", games_info.len()), 90);
|
emit_scan_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!("Found {} games. Processing...", games_info.len()),
|
||||||
|
90,
|
||||||
|
);
|
||||||
|
|
||||||
// Log summary of games found
|
// Log summary of games found
|
||||||
info!("Games scan complete - Found {} games", games_info.len());
|
info!("Games scan complete - Found {} games", games_info.len());
|
||||||
info!("Native games: {}", games_info.iter().filter(|g| g.native).count());
|
info!(
|
||||||
info!("Proton games: {}", games_info.iter().filter(|g| !g.native).count());
|
"Native games: {}",
|
||||||
info!("Games with CreamLinux: {}", games_info.iter().filter(|g| g.cream_installed).count());
|
games_info.iter().filter(|g| g.native).count()
|
||||||
info!("Games with SmokeAPI: {}", games_info.iter().filter(|g| g.smoke_installed).count());
|
);
|
||||||
|
info!(
|
||||||
|
"Proton games: {}",
|
||||||
|
games_info.iter().filter(|g| !g.native).count()
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
"Games with CreamLinux: {}",
|
||||||
|
games_info.iter().filter(|g| g.cream_installed).count()
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
"Games with SmokeAPI: {}",
|
||||||
|
games_info.iter().filter(|g| g.smoke_installed).count()
|
||||||
|
);
|
||||||
|
|
||||||
// Convert to our Game struct
|
// Convert to our Game struct
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
@@ -92,8 +120,10 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
|
|||||||
info!("Processing games into application state...");
|
info!("Processing games into application state...");
|
||||||
for game_info in games_info {
|
for game_info in games_info {
|
||||||
// Only log detailed game info at Debug level to keep Info logs cleaner
|
// Only log detailed game info at Debug level to keep Info logs cleaner
|
||||||
debug!("Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
|
debug!(
|
||||||
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed);
|
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
|
||||||
|
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
|
||||||
|
);
|
||||||
|
|
||||||
let game = Game {
|
let game = Game {
|
||||||
id: game_info.id,
|
id: game_info.id,
|
||||||
@@ -112,7 +142,11 @@ async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHand
|
|||||||
state.games.lock().insert(game.id.clone(), game);
|
state.games.lock().insert(game.id.clone(), game);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_scan_progress(&app_handle, &format!("Scan complete. Found {} games.", result.len()), 100);
|
emit_scan_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!("Scan complete. Found {} games.", result.len()),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
info!("Game scan completed successfully");
|
info!("Game scan completed successfully");
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -137,7 +171,8 @@ fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u3
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
|
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
|
||||||
let games = state.games.lock();
|
let games = state.games.lock();
|
||||||
games.get(&game_id)
|
games
|
||||||
|
.get(&game_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| format!("Game with ID {} not found", game_id))
|
.ok_or_else(|| format!("Game with ID {} not found", game_id))
|
||||||
}
|
}
|
||||||
@@ -145,53 +180,59 @@ fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String
|
|||||||
// Unified action handler for installation and uninstallation
|
// Unified action handler for installation and uninstallation
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn process_game_action(
|
async fn process_game_action(
|
||||||
game_action: GameAction,
|
game_action: GameAction,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app_handle: tauri::AppHandle
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Game, String> {
|
) -> Result<Game, String> {
|
||||||
// Clone the information we need from state to avoid lifetime issues
|
// Clone the information we need from state to avoid lifetime issues
|
||||||
let game = {
|
let game = {
|
||||||
let games = state.games.lock();
|
let games = state.games.lock();
|
||||||
games.get(&game_action.game_id)
|
games
|
||||||
|
.get(&game_action.game_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
|
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse the action string to determine type and operation
|
// Parse the action string to determine type and operation
|
||||||
let (installer_type, action) = match game_action.action.as_str() {
|
let (installer_type, action) = match game_action.action.as_str() {
|
||||||
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
|
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
|
||||||
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
|
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
|
||||||
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
||||||
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
||||||
_ => return Err(format!("Invalid action: {}", game_action.action))
|
_ => return Err(format!("Invalid action: {}", game_action.action)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute the action
|
// Execute the action
|
||||||
installer::process_action(
|
installer::process_action(
|
||||||
game_action.game_id.clone(),
|
game_action.game_id.clone(),
|
||||||
installer_type,
|
installer_type,
|
||||||
action,
|
action,
|
||||||
game.clone(),
|
game.clone(),
|
||||||
app_handle.clone()
|
app_handle.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Update game status in state based on the action
|
// Update game status in state based on the action
|
||||||
let updated_game = {
|
let updated_game = {
|
||||||
let mut games_map = state.games.lock();
|
let mut games_map = state.games.lock();
|
||||||
let game = games_map.get_mut(&game_action.game_id)
|
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
|
||||||
.ok_or_else(|| format!("Game with ID {} not found after action", game_action.game_id))?;
|
format!(
|
||||||
|
"Game with ID {} not found after action",
|
||||||
|
game_action.game_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Update installation status
|
// Update installation status
|
||||||
match (installer_type, action) {
|
match (installer_type, action) {
|
||||||
(InstallerType::Cream, InstallerAction::Install) => {
|
(InstallerType::Cream, InstallerAction::Install) => {
|
||||||
game.cream_installed = true;
|
game.cream_installed = true;
|
||||||
},
|
}
|
||||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||||
game.cream_installed = false;
|
game.cream_installed = false;
|
||||||
},
|
}
|
||||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||||
game.smoke_installed = true;
|
game.smoke_installed = true;
|
||||||
},
|
}
|
||||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||||
game.smoke_installed = false;
|
game.smoke_installed = false;
|
||||||
}
|
}
|
||||||
@@ -202,30 +243,30 @@ let updated_game = {
|
|||||||
|
|
||||||
// Return updated game info
|
// Return updated game info
|
||||||
game.clone()
|
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) {
|
||||||
// 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);
|
warn!("Failed to emit game-updated event: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(updated_game)
|
Ok(updated_game)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch DLC list for a game
|
// Fetch DLC list for a game
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<Vec<DlcInfoWithState>, String> {
|
async fn fetch_game_dlcs(
|
||||||
|
game_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<Vec<DlcInfoWithState>, String> {
|
||||||
info!("Fetching DLCs for game ID: {}", game_id);
|
info!("Fetching DLCs for game ID: {}", game_id);
|
||||||
|
|
||||||
// Removed cache checking
|
// Fetch DLC data
|
||||||
|
|
||||||
// Always fetch fresh DLC data instead of using cache
|
|
||||||
match installer::fetch_dlc_details(&game_id).await {
|
match installer::fetch_dlc_details(&game_id).await {
|
||||||
Ok(dlcs) => {
|
Ok(dlcs) => {
|
||||||
// Convert to DlcInfoWithState (all enabled by default)
|
// Convert to DlcInfoWithState
|
||||||
let dlcs_with_state = dlcs.into_iter()
|
let dlcs_with_state = dlcs
|
||||||
|
.into_iter()
|
||||||
.map(|dlc| DlcInfoWithState {
|
.map(|dlc| DlcInfoWithState {
|
||||||
appid: dlc.appid,
|
appid: dlc.appid,
|
||||||
name: dlc.name,
|
name: dlc.name,
|
||||||
@@ -236,14 +277,17 @@ async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resul
|
|||||||
// Cache in memory for this session (but not on disk)
|
// Cache in memory for this session (but not on disk)
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
let mut cache = state.dlc_cache.lock();
|
let mut cache = state.dlc_cache.lock();
|
||||||
cache.insert(game_id.clone(), DlcCache {
|
cache.insert(
|
||||||
|
game_id.clone(),
|
||||||
|
DlcCache {
|
||||||
data: dlcs_with_state.clone(),
|
data: dlcs_with_state.clone(),
|
||||||
timestamp: Instant::now(),
|
timestamp: Instant::now(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(dlcs_with_state)
|
Ok(dlcs_with_state)
|
||||||
},
|
}
|
||||||
Err(e) => Err(format!("Failed to fetch DLC details: {}", e))
|
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +313,18 @@ fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(),
|
|||||||
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
info!("Streaming DLCs for game ID: {}", game_id);
|
info!("Streaming DLCs for game ID: {}", game_id);
|
||||||
|
|
||||||
// Removed cached DLC check - always fetch fresh data
|
// Fetch DLC data from API
|
||||||
|
|
||||||
// Always fetch fresh DLC data from API
|
|
||||||
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
||||||
Ok(dlcs) => {
|
Ok(dlcs) => {
|
||||||
info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id);
|
info!(
|
||||||
|
"Successfully streamed {} DLCs for game {}",
|
||||||
|
dlcs.len(),
|
||||||
|
game_id
|
||||||
|
);
|
||||||
|
|
||||||
// Convert to DLCInfoWithState for in-memory caching only
|
// Convert to DLCInfoWithState for in-memory caching only
|
||||||
let dlcs_with_state = dlcs.into_iter()
|
let dlcs_with_state = dlcs
|
||||||
|
.into_iter()
|
||||||
.map(|dlc| DlcInfoWithState {
|
.map(|dlc| DlcInfoWithState {
|
||||||
appid: dlc.appid,
|
appid: dlc.appid,
|
||||||
name: dlc.name,
|
name: dlc.name,
|
||||||
@@ -288,13 +335,16 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu
|
|||||||
// Update in-memory cache without storing to disk
|
// Update in-memory cache without storing to disk
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
let mut dlc_cache = state.dlc_cache.lock();
|
let mut dlc_cache = state.dlc_cache.lock();
|
||||||
dlc_cache.insert(game_id.clone(), DlcCache {
|
dlc_cache.insert(
|
||||||
|
game_id.clone(),
|
||||||
|
DlcCache {
|
||||||
data: dlcs_with_state,
|
data: dlcs_with_state,
|
||||||
timestamp: tokio::time::Instant::now(),
|
timestamp: tokio::time::Instant::now(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to stream DLC details: {}", e);
|
error!("Failed to stream DLC details: {}", e);
|
||||||
// Emit error event
|
// Emit error event
|
||||||
@@ -327,7 +377,10 @@ fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
|
|||||||
|
|
||||||
// Update the DLC configuration for a game
|
// Update the DLC configuration for a game
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_dlc_configuration_command(game_path: String, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
|
fn update_dlc_configuration_command(
|
||||||
|
game_path: String,
|
||||||
|
dlcs: Vec<DlcInfoWithState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
info!("Updating DLC configuration for: {}", game_path);
|
info!("Updating DLC configuration for: {}", game_path);
|
||||||
dlc_manager::update_dlc_configuration(&game_path, dlcs)
|
dlc_manager::update_dlc_configuration(&game_path, dlcs)
|
||||||
}
|
}
|
||||||
@@ -337,15 +390,20 @@ fn update_dlc_configuration_command(game_path: String, dlcs: Vec<DlcInfoWithStat
|
|||||||
async fn install_cream_with_dlcs_command(
|
async fn install_cream_with_dlcs_command(
|
||||||
game_id: String,
|
game_id: String,
|
||||||
selected_dlcs: Vec<DlcInfoWithState>,
|
selected_dlcs: Vec<DlcInfoWithState>,
|
||||||
app_handle: tauri::AppHandle
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Game, String> {
|
) -> Result<Game, String> {
|
||||||
info!("Installing CreamLinux with selected DLCs for game: {}", game_id);
|
info!(
|
||||||
|
"Installing CreamLinux with selected DLCs for game: {}",
|
||||||
|
game_id
|
||||||
|
);
|
||||||
|
|
||||||
// Clone selected_dlcs for later use
|
// Clone selected_dlcs for later use
|
||||||
let selected_dlcs_clone = selected_dlcs.clone();
|
let selected_dlcs_clone = selected_dlcs.clone();
|
||||||
|
|
||||||
// Install CreamLinux with the selected DLCs
|
// Install CreamLinux with the selected DLCs
|
||||||
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs).await {
|
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Return updated game info
|
// Return updated game info
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
@@ -353,8 +411,9 @@ async fn install_cream_with_dlcs_command(
|
|||||||
// Get a mutable reference and update the game
|
// Get a mutable reference and update the game
|
||||||
let game = {
|
let game = {
|
||||||
let mut games_map = state.games.lock();
|
let mut games_map = state.games.lock();
|
||||||
let game = games_map.get_mut(&game_id)
|
let game = games_map.get_mut(&game_id).ok_or_else(|| {
|
||||||
.ok_or_else(|| format!("Game with ID {} not found after installation", game_id))?;
|
format!("Game with ID {} not found after installation", game_id)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Update installation status
|
// Update installation status
|
||||||
game.cream_installed = true;
|
game.cream_installed = true;
|
||||||
@@ -362,9 +421,7 @@ async fn install_cream_with_dlcs_command(
|
|||||||
|
|
||||||
// Clone the game for returning later
|
// Clone the game for returning later
|
||||||
game.clone()
|
game.clone()
|
||||||
}; // mutable borrow ends here
|
};
|
||||||
|
|
||||||
// Removed game caching
|
|
||||||
|
|
||||||
// Emit an event to update the UI
|
// Emit an event to update the UI
|
||||||
if let Err(e) = app_handle.emit("game-updated", &game) {
|
if let Err(e) = app_handle.emit("game-updated", &game) {
|
||||||
@@ -376,7 +433,7 @@ async fn install_cream_with_dlcs_command(
|
|||||||
type_: "cream_install".to_string(),
|
type_: "cream_install".to_string(),
|
||||||
command: "sh ./cream.sh %command%".to_string(),
|
command: "sh ./cream.sh %command%".to_string(),
|
||||||
game_title: game.title.clone(),
|
game_title: game.title.clone(),
|
||||||
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count())
|
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
|
||||||
};
|
};
|
||||||
|
|
||||||
installer::emit_progress(
|
installer::emit_progress(
|
||||||
@@ -386,14 +443,17 @@ async fn install_cream_with_dlcs_command(
|
|||||||
100.0,
|
100.0,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
Some(instructions)
|
Some(instructions),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(game)
|
Ok(game)
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
||||||
Err(format!("Failed to install CreamLinux with selected DLCs: {}", e))
|
Err(format!(
|
||||||
|
"Failed to install CreamLinux with selected DLCs: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,10 +477,10 @@ fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a file appender with improved log format
|
// Create a file appender
|
||||||
let file = FileAppender::builder()
|
let file = FileAppender::builder()
|
||||||
.encoder(Box::new(PatternEncoder::new(
|
.encoder(Box::new(PatternEncoder::new(
|
||||||
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n"
|
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
|
||||||
)))
|
)))
|
||||||
.build(log_path)?;
|
.build(log_path)?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
// src/searcher.rs
|
use log::{debug, error, info, warn};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::collections::HashSet;
|
|
||||||
use log::{info, debug, warn, error};
|
|
||||||
use regex::Regex;
|
|
||||||
use walkdir::WalkDir;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
/// Game information structure
|
// Game information structure
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GameInfo {
|
pub struct GameInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -21,7 +20,7 @@ pub struct GameInfo {
|
|||||||
pub smoke_installed: bool,
|
pub smoke_installed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find potential Steam installation directories
|
// Find potential Steam installation directories
|
||||||
pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||||
let mut paths = Vec::new();
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
@@ -48,11 +47,8 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Steam Deck paths if they exist (these don't rely on HOME)
|
// Add Steam Deck paths if they exist
|
||||||
let deck_paths = [
|
let deck_paths = ["/home/deck/.steam/steam", "/home/deck/.local/share/Steam"];
|
||||||
"/home/deck/.steam/steam",
|
|
||||||
"/home/deck/.local/share/Steam",
|
|
||||||
];
|
|
||||||
|
|
||||||
for path in &deck_paths {
|
for path in &deck_paths {
|
||||||
let p = PathBuf::from(path);
|
let p = PathBuf::from(path);
|
||||||
@@ -76,7 +72,7 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
|||||||
paths
|
paths
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to read the Steam registry file to find installation paths
|
// Try to read the Steam registry file to find installation paths
|
||||||
fn read_steam_registry() -> Option<Vec<PathBuf>> {
|
fn read_steam_registry() -> Option<Vec<PathBuf>> {
|
||||||
let home = match std::env::var("HOME") {
|
let home = match std::env::var("HOME") {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
@@ -123,7 +119,7 @@ fn read_steam_registry() -> Option<Vec<PathBuf>> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find all Steam library folders from base Steam installation paths
|
// Find all Steam library folders from base Steam installation paths
|
||||||
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
|
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
let mut libraries = HashSet::new();
|
let mut libraries = HashSet::new();
|
||||||
|
|
||||||
@@ -160,12 +156,12 @@ pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
|
|||||||
let result: Vec<PathBuf> = libraries.into_iter().collect();
|
let result: Vec<PathBuf> = libraries.into_iter().collect();
|
||||||
info!("Found {} Steam library directories", result.len());
|
info!("Found {} Steam library directories", result.len());
|
||||||
for (i, lib) in result.iter().enumerate() {
|
for (i, lib) in result.iter().enumerate() {
|
||||||
info!(" Library {}: {}", i+1, lib.display());
|
info!(" Library {}: {}", i + 1, lib.display());
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse libraryfolders.vdf to extract additional library paths
|
// Parse libraryfolders.vdf to extract additional library paths
|
||||||
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
|
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
|
||||||
// Check both possible locations of the VDF file
|
// Check both possible locations of the VDF file
|
||||||
let vdf_paths = [
|
let vdf_paths = [
|
||||||
@@ -199,7 +195,7 @@ fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<Path
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an appmanifest ACF file to extract game information
|
// Parse an appmanifest ACF file to extract game information
|
||||||
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
||||||
match fs::read_to_string(path) {
|
match fs::read_to_string(path) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
@@ -211,7 +207,7 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
|||||||
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
|
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
|
||||||
re_appid.captures(&content),
|
re_appid.captures(&content),
|
||||||
re_name.captures(&content),
|
re_name.captures(&content),
|
||||||
re_installdir.captures(&content)
|
re_installdir.captures(&content),
|
||||||
) {
|
) {
|
||||||
let app_id = app_id_cap[1].to_string();
|
let app_id = app_id_cap[1].to_string();
|
||||||
let name = name_cap[1].to_string();
|
let name = name_cap[1].to_string();
|
||||||
@@ -228,26 +224,25 @@ fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a file is a Linux ELF binary
|
// Check if a file is a Linux ELF binary
|
||||||
fn is_elf_binary(path: &Path) -> bool {
|
fn is_elf_binary(path: &Path) -> bool {
|
||||||
if let Ok(mut file) = fs::File::open(path) {
|
if let Ok(mut file) = fs::File::open(path) {
|
||||||
let mut buffer = [0; 4];
|
let mut buffer = [0; 4];
|
||||||
if file.read_exact(&mut buffer).is_ok() {
|
if file.read_exact(&mut buffer).is_ok() {
|
||||||
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
||||||
return buffer[0] == 0x7F && buffer[1] == b'E' && buffer[2] == b'L' && buffer[3] == b'F';
|
return buffer[0] == 0x7F
|
||||||
|
&& buffer[1] == b'E'
|
||||||
|
&& buffer[2] == b'L'
|
||||||
|
&& buffer[3] == b'F';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a game has CreamLinux installed
|
// Check if a game has CreamLinux installed
|
||||||
fn check_creamlinux_installed(game_path: &Path) -> bool {
|
fn check_creamlinux_installed(game_path: &Path) -> bool {
|
||||||
let cream_files = [
|
let cream_files = ["cream.sh", "cream_api.ini", "cream_api.so"];
|
||||||
"cream.sh",
|
|
||||||
"cream_api.ini",
|
|
||||||
"cream_api.so",
|
|
||||||
];
|
|
||||||
|
|
||||||
for file in &cream_files {
|
for file in &cream_files {
|
||||||
if game_path.join(file).exists() {
|
if game_path.join(file).exists() {
|
||||||
@@ -259,7 +254,7 @@ fn check_creamlinux_installed(game_path: &Path) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a game has SmokeAPI installed
|
// Check if a game has SmokeAPI installed
|
||||||
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
||||||
if api_files.is_empty() {
|
if api_files.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
@@ -284,8 +279,8 @@ fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan a game directory to determine if it's native or needs Proton
|
// Scan a game directory to determine if it's native or needs Proton
|
||||||
/// Also collect any Steam API DLLs for potential SmokeAPI installation
|
// Also collect any Steam API DLLs for potential SmokeAPI installation
|
||||||
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||||
let mut found_exe = false;
|
let mut found_exe = false;
|
||||||
let mut found_linux_binary = false;
|
let mut found_linux_binary = false;
|
||||||
@@ -293,11 +288,21 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
|||||||
|
|
||||||
// Directories to skip for better performance
|
// Directories to skip for better performance
|
||||||
let skip_dirs = [
|
let skip_dirs = [
|
||||||
"videos", "video", "movies", "movie",
|
"videos",
|
||||||
"sound", "sounds", "audio",
|
"video",
|
||||||
"textures", "music", "localization",
|
"movies",
|
||||||
"shaders", "logs", "assets/audio",
|
"movie",
|
||||||
"assets/video", "assets/textures"
|
"sound",
|
||||||
|
"sounds",
|
||||||
|
"audio",
|
||||||
|
"textures",
|
||||||
|
"music",
|
||||||
|
"localization",
|
||||||
|
"shaders",
|
||||||
|
"logs",
|
||||||
|
"assets/audio",
|
||||||
|
"assets/video",
|
||||||
|
"assets/textures",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Only scan to a reasonable depth (avoid extreme recursion)
|
// Only scan to a reasonable depth (avoid extreme recursion)
|
||||||
@@ -307,7 +312,7 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
|||||||
let exe_extensions = ["exe", "bat", "cmd", "msi"];
|
let exe_extensions = ["exe", "bat", "cmd", "msi"];
|
||||||
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
|
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
|
||||||
|
|
||||||
// Recursively walk through the game directory with optimized settings
|
// Recursively walk through the game directory
|
||||||
for entry in WalkDir::new(game_path)
|
for entry in WalkDir::new(game_path)
|
||||||
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
|
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
|
||||||
.follow_links(false) // Don't follow symlinks to prevent cycles
|
.follow_links(false) // Don't follow symlinks to prevent cycles
|
||||||
@@ -323,8 +328,8 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
})
|
})
|
||||||
.filter_map(Result::ok) {
|
.filter_map(Result::ok)
|
||||||
|
{
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
@@ -341,7 +346,11 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
|||||||
|
|
||||||
// Check for Steam API DLLs
|
// Check for Steam API DLLs
|
||||||
if ext_str == "dll" {
|
if ext_str == "dll" {
|
||||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_lowercase();
|
||||||
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
|
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
|
||||||
if let Ok(rel_path) = path.strip_prefix(game_path) {
|
if let Ok(rel_path) = path.strip_prefix(game_path) {
|
||||||
let rel_path_str = rel_path.to_string_lossy().to_string();
|
let rel_path_str = rel_path.to_string_lossy().to_string();
|
||||||
@@ -378,7 +387,6 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
|
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
|
||||||
// This early break greatly improves performance for large game directories
|
|
||||||
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
|
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
|
||||||
debug!("Found sufficient evidence, breaking scan early");
|
debug!("Found sufficient evidence, breaking scan early");
|
||||||
break;
|
break;
|
||||||
@@ -388,25 +396,34 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
|||||||
// A game is considered native if it has Linux binaries but no Windows executables
|
// A game is considered native if it has Linux binaries but no Windows executables
|
||||||
let is_native = found_linux_binary && !found_exe;
|
let is_native = found_linux_binary && !found_exe;
|
||||||
|
|
||||||
debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len());
|
debug!(
|
||||||
|
"Game scan results: native={}, exe={}, api_dlls={}",
|
||||||
|
is_native,
|
||||||
|
found_exe,
|
||||||
|
steam_api_files.len()
|
||||||
|
);
|
||||||
(is_native, steam_api_files)
|
(is_native, steam_api_files)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find all installed Steam games from library folders
|
// Find all installed Steam games from library folders
|
||||||
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
|
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
|
||||||
|
|
||||||
let mut games = Vec::new();
|
let mut games = Vec::new();
|
||||||
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
||||||
|
|
||||||
// IDs to skip (tools, redistributables, etc.)
|
// IDs to skip (tools, redistributables, etc.)
|
||||||
let skip_ids = Arc::new([
|
let skip_ids = Arc::new(
|
||||||
|
[
|
||||||
"228980", // Steamworks Common Redistributables
|
"228980", // Steamworks Common Redistributables
|
||||||
"1070560", // Steam Linux Runtime
|
"1070560", // Steam Linux Runtime
|
||||||
"1391110", // Steam Linux Runtime - Soldier
|
"1391110", // Steam Linux Runtime - Soldier
|
||||||
"1628350", // Steam Linux Runtime - Sniper
|
"1628350", // Steam Linux Runtime - Sniper
|
||||||
"1493710", // Proton Experimental
|
"1493710", // Proton Experimental
|
||||||
"2180100", // Steam Linux Runtime - Scout
|
"2180100", // Steam Linux Runtime - Scout
|
||||||
].iter().copied().collect::<HashSet<&str>>());
|
]
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.collect::<HashSet<&str>>(),
|
||||||
|
);
|
||||||
|
|
||||||
// Name patterns to skip (case insensitive)
|
// Name patterns to skip (case insensitive)
|
||||||
let skip_patterns = Arc::new(
|
let skip_patterns = Arc::new(
|
||||||
@@ -420,7 +437,7 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
|
|||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|pat| Regex::new(pat).unwrap())
|
.map(|pat| Regex::new(pat).unwrap())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("Scanning for installed games in parallel...");
|
info!("Scanning for installed games in parallel...");
|
||||||
@@ -446,7 +463,7 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
|
|||||||
|
|
||||||
info!("Found {} appmanifest files to process", app_manifests.len());
|
info!("Found {} appmanifest files to process", app_manifests.len());
|
||||||
|
|
||||||
// Process each appmanifest file in parallel with a maximum concurrency
|
// Process appmanifest files
|
||||||
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
|
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
|
||||||
info!("Using {} concurrent scanners", max_concurrent);
|
info!("Using {} concurrent scanners", max_concurrent);
|
||||||
|
|
||||||
@@ -555,8 +572,10 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
|
|||||||
while let Some(game) = rx.recv().await {
|
while let Some(game) = rx.recv().await {
|
||||||
info!("Found game: {} ({})", game.title, game.id);
|
info!("Found game: {} ({})", game.title, game.id);
|
||||||
info!(" Path: {}", game.path.display());
|
info!(" Path: {}", game.path.display());
|
||||||
info!(" Status: Native={}, Cream={}, Smoke={}",
|
info!(
|
||||||
game.native, game.cream_installed, game.smoke_installed);
|
" Status: Native={}, Cream={}, Smoke={}",
|
||||||
|
game.native, game.cream_installed, game.smoke_installed
|
||||||
|
);
|
||||||
|
|
||||||
// Log Steam API DLLs if any
|
// Log Steam API DLLs if any
|
||||||
if !game.api_files.is_empty() {
|
if !game.api_files.is_empty() {
|
||||||
@@ -571,9 +590,9 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
|
|||||||
results
|
results
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all scan tasks to complete - but don't wait for the results yet
|
// Wait for all scan tasks to complete but don't wait for the results yet
|
||||||
for handle in handles {
|
for handle in handles {
|
||||||
// Ignore errors - the receiver task will just get fewer results
|
// Ignore errors the receiver task will just get fewer results
|
||||||
let _ = handle.await;
|
let _ = handle.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,7 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"icon": [
|
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.png"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"productName": "Creamlinux",
|
"productName": "Creamlinux",
|
||||||
"mainBinaryName": "creamlinux",
|
"mainBinaryName": "creamlinux",
|
||||||
|
|||||||
732
src/App.tsx
732
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,13 @@
|
|||||||
// src/components/ActionButton.tsx
|
import React from 'react'
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke';
|
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
action: ActionType;
|
action: ActionType
|
||||||
isInstalled: boolean;
|
isInstalled: boolean
|
||||||
isWorking: boolean;
|
isWorking: boolean
|
||||||
onClick: () => void;
|
onClick: () => void
|
||||||
disabled?: boolean;
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionButton: React.FC<ActionButtonProps> = ({
|
const ActionButton: React.FC<ActionButtonProps> = ({
|
||||||
@@ -16,31 +15,27 @@ const ActionButton: React.FC<ActionButtonProps> = ({
|
|||||||
isInstalled,
|
isInstalled,
|
||||||
isWorking,
|
isWorking,
|
||||||
onClick,
|
onClick,
|
||||||
disabled = false
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const getButtonText = () => {
|
const getButtonText = () => {
|
||||||
if (isWorking) return "Working...";
|
if (isWorking) return 'Working...'
|
||||||
|
|
||||||
const isCream = action.includes('cream');
|
const isCream = action.includes('cream')
|
||||||
const product = isCream ? "CreamLinux" : "SmokeAPI";
|
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
||||||
|
|
||||||
return isInstalled ? `Uninstall ${product}` : `Install ${product}`;
|
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
|
||||||
};
|
}
|
||||||
|
|
||||||
const getButtonClass = () => {
|
const getButtonClass = () => {
|
||||||
const baseClass = "action-button";
|
const baseClass = 'action-button'
|
||||||
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`;
|
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button className={getButtonClass()} onClick={onClick} disabled={disabled || isWorking}>
|
||||||
className={getButtonClass()}
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled || isWorking}
|
|
||||||
>
|
|
||||||
{getButtonText()}
|
{getButtonText()}
|
||||||
</button>
|
</button>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ActionButton;
|
export default ActionButton
|
||||||
|
|||||||
@@ -1,37 +1,36 @@
|
|||||||
// src/components/AnimatedBackground.tsx
|
import React, { useEffect, useRef } from 'react'
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
const AnimatedBackground: React.FC = () => {
|
const AnimatedBackground: React.FC = () => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return;
|
if (!canvas) return
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return;
|
if (!ctx) return
|
||||||
|
|
||||||
// Set canvas size to match window
|
// Set canvas size to match window
|
||||||
const setCanvasSize = () => {
|
const setCanvasSize = () => {
|
||||||
canvas.width = window.innerWidth;
|
canvas.width = window.innerWidth
|
||||||
canvas.height = window.innerHeight;
|
canvas.height = window.innerHeight
|
||||||
};
|
}
|
||||||
|
|
||||||
setCanvasSize();
|
setCanvasSize()
|
||||||
window.addEventListener('resize', setCanvasSize);
|
window.addEventListener('resize', setCanvasSize)
|
||||||
|
|
||||||
// Create particles
|
// Create particles
|
||||||
const particles: Particle[] = [];
|
const particles: Particle[] = []
|
||||||
const particleCount = 30;
|
const particleCount = 30
|
||||||
|
|
||||||
interface Particle {
|
interface Particle {
|
||||||
x: number;
|
x: number
|
||||||
y: number;
|
y: number
|
||||||
size: number;
|
size: number
|
||||||
speedX: number;
|
speedX: number
|
||||||
speedY: number;
|
speedY: number
|
||||||
opacity: number;
|
opacity: number
|
||||||
color: string;
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color palette
|
// Color palette
|
||||||
@@ -39,7 +38,7 @@ const AnimatedBackground: React.FC = () => {
|
|||||||
'rgba(74, 118, 196, 0.5)', // primary blue
|
'rgba(74, 118, 196, 0.5)', // primary blue
|
||||||
'rgba(155, 125, 255, 0.5)', // purple
|
'rgba(155, 125, 255, 0.5)', // purple
|
||||||
'rgba(251, 177, 60, 0.5)', // gold
|
'rgba(251, 177, 60, 0.5)', // gold
|
||||||
];
|
]
|
||||||
|
|
||||||
// Create initial particles
|
// Create initial particles
|
||||||
for (let i = 0; i < particleCount; i++) {
|
for (let i = 0; i < particleCount; i++) {
|
||||||
@@ -50,61 +49,61 @@ const AnimatedBackground: React.FC = () => {
|
|||||||
speedX: Math.random() * 0.2 - 0.1,
|
speedX: Math.random() * 0.2 - 0.1,
|
||||||
speedY: Math.random() * 0.2 - 0.1,
|
speedY: Math.random() * 0.2 - 0.1,
|
||||||
opacity: Math.random() * 0.07 + 0.03,
|
opacity: Math.random() * 0.07 + 0.03,
|
||||||
color: colors[Math.floor(Math.random() * colors.length)]
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animation loop
|
// Animation loop
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
// Clear canvas with transparent black to create fade effect
|
// Clear canvas with transparent black to create fade effect
|
||||||
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)';
|
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
// Update and draw particles
|
// Update and draw particles
|
||||||
particles.forEach(particle => {
|
particles.forEach((particle) => {
|
||||||
// Update position
|
// Update position
|
||||||
particle.x += particle.speedX;
|
particle.x += particle.speedX
|
||||||
particle.y += particle.speedY;
|
particle.y += particle.speedY
|
||||||
|
|
||||||
// Wrap around edges
|
// Wrap around edges
|
||||||
if (particle.x < 0) particle.x = canvas.width;
|
if (particle.x < 0) particle.x = canvas.width
|
||||||
if (particle.x > canvas.width) particle.x = 0;
|
if (particle.x > canvas.width) particle.x = 0
|
||||||
if (particle.y < 0) particle.y = canvas.height;
|
if (particle.y < 0) particle.y = canvas.height
|
||||||
if (particle.y > canvas.height) particle.y = 0;
|
if (particle.y > canvas.height) particle.y = 0
|
||||||
|
|
||||||
// Draw particle
|
// Draw particle
|
||||||
ctx.beginPath();
|
ctx.beginPath()
|
||||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)
|
||||||
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`);
|
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
|
||||||
ctx.fill();
|
ctx.fill()
|
||||||
|
|
||||||
// Connect particles
|
// Connect particles
|
||||||
particles.forEach(otherParticle => {
|
particles.forEach((otherParticle) => {
|
||||||
const dx = particle.x - otherParticle.x;
|
const dx = particle.x - otherParticle.x
|
||||||
const dy = particle.y - otherParticle.y;
|
const dy = particle.y - otherParticle.y
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
if (distance < 100) {
|
if (distance < 100) {
|
||||||
ctx.beginPath();
|
ctx.beginPath()
|
||||||
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`);
|
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`)
|
||||||
ctx.lineWidth = 0.2;
|
ctx.lineWidth = 0.2
|
||||||
ctx.moveTo(particle.x, particle.y);
|
ctx.moveTo(particle.x, particle.y)
|
||||||
ctx.lineTo(otherParticle.x, otherParticle.y);
|
ctx.lineTo(otherParticle.x, otherParticle.y)
|
||||||
ctx.stroke();
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate)
|
||||||
};
|
}
|
||||||
|
|
||||||
// Start animation
|
// Start animation
|
||||||
animate();
|
animate()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', setCanvasSize);
|
window.removeEventListener('resize', setCanvasSize)
|
||||||
};
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
@@ -118,10 +117,10 @@ const AnimatedBackground: React.FC = () => {
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
opacity: 0.4
|
opacity: 0.4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AnimatedBackground;
|
export default AnimatedBackground
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// src/components/AnimatedCheckbox.tsx
|
import React from 'react'
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface AnimatedCheckboxProps {
|
interface AnimatedCheckboxProps {
|
||||||
checked: boolean;
|
checked: boolean
|
||||||
onChange: () => void;
|
onChange: () => void
|
||||||
label?: string;
|
label?: string
|
||||||
sublabel?: string;
|
sublabel?: string
|
||||||
className?: string;
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
||||||
@@ -14,16 +13,11 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
sublabel,
|
sublabel,
|
||||||
className = ''
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<label className={`animated-checkbox ${className}`}>
|
<label className={`animated-checkbox ${className}`}>
|
||||||
<input
|
<input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={onChange}
|
|
||||||
className="checkbox-original"
|
|
||||||
/>
|
|
||||||
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
|
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
|
||||||
<svg viewBox="0 0 24 24" className="checkmark-icon">
|
<svg viewBox="0 0 24 24" className="checkmark-icon">
|
||||||
<path
|
<path
|
||||||
@@ -44,7 +38,7 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AnimatedCheckbox;
|
export default AnimatedCheckbox
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
// src/components/DlcSelectionDialog.tsx
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import AnimatedCheckbox from './AnimatedCheckbox'
|
||||||
import AnimatedCheckbox from './AnimatedCheckbox';
|
|
||||||
|
|
||||||
interface DlcInfo {
|
interface DlcInfo {
|
||||||
appid: string;
|
appid: string
|
||||||
name: string;
|
name: string
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DlcSelectionDialogProps {
|
interface DlcSelectionDialogProps {
|
||||||
visible: boolean;
|
visible: boolean
|
||||||
gameTitle: string;
|
gameTitle: string
|
||||||
dlcs: DlcInfo[];
|
dlcs: DlcInfo[]
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
onConfirm: (selectedDlcs: DlcInfo[]) => void;
|
onConfirm: (selectedDlcs: DlcInfo[]) => void
|
||||||
isLoading: boolean;
|
isLoading: boolean
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean
|
||||||
loadingProgress?: number;
|
loadingProgress?: number
|
||||||
estimatedTimeLeft?: string;
|
estimatedTimeLeft?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
||||||
@@ -29,118 +28,121 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
loadingProgress = 0,
|
loadingProgress = 0,
|
||||||
estimatedTimeLeft = ''
|
estimatedTimeLeft = '',
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([]);
|
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
|
||||||
const [showContent, setShowContent] = useState(false);
|
const [showContent, setShowContent] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectAll, setSelectAll] = useState(true);
|
const [selectAll, setSelectAll] = useState(true)
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
|
||||||
// Initialize selected DLCs when DLC list changes
|
// Initialize selected DLCs when DLC list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && dlcs.length > 0 && !initialized) {
|
if (visible && dlcs.length > 0 && !initialized) {
|
||||||
setSelectedDlcs(dlcs);
|
setSelectedDlcs(dlcs)
|
||||||
|
|
||||||
// Determine initial selectAll state based on if all DLCs are enabled
|
// Determine initial selectAll state based on if all DLCs are enabled
|
||||||
const allSelected = dlcs.every(dlc => dlc.enabled);
|
const allSelected = dlcs.every((dlc) => dlc.enabled)
|
||||||
setSelectAll(allSelected);
|
setSelectAll(allSelected)
|
||||||
|
|
||||||
// Mark as initialized so we don't reset selections on subsequent DLC additions
|
// Mark as initialized so we don't reset selections on subsequent DLC additions
|
||||||
setInitialized(true);
|
setInitialized(true)
|
||||||
}
|
}
|
||||||
}, [visible, dlcs, initialized]);
|
}, [visible, dlcs, initialized])
|
||||||
|
|
||||||
// Handle visibility changes
|
// Handle visibility changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
// Show content immediately for better UX
|
// Show content immediately for better UX
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setShowContent(true);
|
setShowContent(true)
|
||||||
}, 50);
|
}, 50)
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer)
|
||||||
} else {
|
} else {
|
||||||
setShowContent(false);
|
setShowContent(false)
|
||||||
setInitialized(false); // Reset initialized state when dialog closes
|
setInitialized(false) // Reset initialized state when dialog closes
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible])
|
||||||
|
|
||||||
// Memoize filtered DLCs to avoid unnecessary recalculations
|
// Memoize filtered DLCs to avoid unnecessary recalculations
|
||||||
const filteredDlcs = useMemo(() => {
|
const filteredDlcs = useMemo(() => {
|
||||||
return searchQuery.trim() === ''
|
return searchQuery.trim() === ''
|
||||||
? selectedDlcs
|
? selectedDlcs
|
||||||
: selectedDlcs.filter(dlc =>
|
: selectedDlcs.filter(
|
||||||
|
(dlc) =>
|
||||||
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
dlc.appid.includes(searchQuery)
|
dlc.appid.includes(searchQuery)
|
||||||
);
|
)
|
||||||
}, [selectedDlcs, searchQuery]);
|
}, [selectedDlcs, searchQuery])
|
||||||
|
|
||||||
// Update DLC selection status
|
// Update DLC selection status
|
||||||
const handleToggleDlc = (appid: string) => {
|
const handleToggleDlc = (appid: string) => {
|
||||||
setSelectedDlcs(prev => prev.map(dlc =>
|
setSelectedDlcs((prev) =>
|
||||||
dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc
|
prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
|
||||||
));
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
// Update selectAll state when individual DLC selections change
|
// Update selectAll state when individual DLC selections change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const allSelected = selectedDlcs.every(dlc => dlc.enabled);
|
const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
|
||||||
setSelectAll(allSelected);
|
setSelectAll(allSelected)
|
||||||
}, [selectedDlcs]);
|
}, [selectedDlcs])
|
||||||
|
|
||||||
// Handle new DLCs being added while dialog is already open
|
// Handle new DLCs being added while dialog is already open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized && dlcs.length > selectedDlcs.length) {
|
if (initialized && dlcs.length > selectedDlcs.length) {
|
||||||
// Find new DLCs that aren't in our current selection
|
// Find new DLCs that aren't in our current selection
|
||||||
const currentAppIds = new Set(selectedDlcs.map(dlc => dlc.appid));
|
const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
|
||||||
const newDlcs = dlcs.filter(dlc => !currentAppIds.has(dlc.appid));
|
const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
|
||||||
|
|
||||||
// Add new DLCs to our selection, maintaining their enabled state
|
// Add new DLCs to our selection, maintaining their enabled state
|
||||||
if (newDlcs.length > 0) {
|
if (newDlcs.length > 0) {
|
||||||
setSelectedDlcs(prev => [...prev, ...newDlcs]);
|
setSelectedDlcs((prev) => [...prev, ...newDlcs])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dlcs, selectedDlcs, initialized]);
|
}, [dlcs, selectedDlcs, initialized])
|
||||||
|
|
||||||
const handleToggleSelectAll = () => {
|
const handleToggleSelectAll = () => {
|
||||||
const newSelectAllState = !selectAll;
|
const newSelectAllState = !selectAll
|
||||||
setSelectAll(newSelectAllState);
|
setSelectAll(newSelectAllState)
|
||||||
|
|
||||||
setSelectedDlcs(prev => prev.map(dlc => ({
|
setSelectedDlcs((prev) =>
|
||||||
|
prev.map((dlc) => ({
|
||||||
...dlc,
|
...dlc,
|
||||||
enabled: newSelectAllState
|
enabled: newSelectAllState,
|
||||||
})));
|
}))
|
||||||
};
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
onConfirm(selectedDlcs);
|
onConfirm(selectedDlcs)
|
||||||
};
|
}
|
||||||
|
|
||||||
// Modified to prevent closing when loading
|
// Modified to prevent closing when loading
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// Prevent clicks from propagating through the overlay
|
// Prevent clicks from propagating through the overlay
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
|
|
||||||
// Only allow closing via overlay click if not loading
|
// Only allow closing via overlay click if not loading
|
||||||
if (e.target === e.currentTarget && !isLoading) {
|
if (e.target === e.currentTarget && !isLoading) {
|
||||||
onClose();
|
onClose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Count selected DLCs
|
// Count selected DLCs
|
||||||
const selectedCount = selectedDlcs.filter(dlc => dlc.enabled).length;
|
const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
|
||||||
|
|
||||||
// Format loading message to show total number of DLCs found
|
// Format loading message to show total number of DLCs found
|
||||||
const getLoadingInfoText = () => {
|
const getLoadingInfoText = () => {
|
||||||
if (isLoading && loadingProgress < 100) {
|
if (isLoading && loadingProgress < 100) {
|
||||||
return ` (Loading more DLCs...)`;
|
return ` (Loading more DLCs...)`
|
||||||
} else if (dlcs.length > 0) {
|
} else if (dlcs.length > 0) {
|
||||||
return ` (Total DLCs: ${dlcs.length})`;
|
return ` (Total DLCs: ${dlcs.length})`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -179,14 +181,13 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="dlc-loading-progress">
|
<div className="dlc-loading-progress">
|
||||||
<div className="progress-bar-container">
|
<div className="progress-bar-container">
|
||||||
<div
|
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
||||||
className="progress-bar"
|
|
||||||
style={{ width: `${loadingProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="loading-details">
|
<div className="loading-details">
|
||||||
<span>Loading DLCs: {loadingProgress}%</span>
|
<span>Loading DLCs: {loadingProgress}%</span>
|
||||||
{estimatedTimeLeft && <span className="time-left">Est. time left: {estimatedTimeLeft}</span>}
|
{estimatedTimeLeft && (
|
||||||
|
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -194,7 +195,7 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
|||||||
<div className="dlc-list-container">
|
<div className="dlc-list-container">
|
||||||
{selectedDlcs.length > 0 ? (
|
{selectedDlcs.length > 0 ? (
|
||||||
<ul className="dlc-list">
|
<ul className="dlc-list">
|
||||||
{filteredDlcs.map(dlc => (
|
{filteredDlcs.map((dlc) => (
|
||||||
<li key={dlc.appid} className="dlc-item">
|
<li key={dlc.appid} className="dlc-item">
|
||||||
<AnimatedCheckbox
|
<AnimatedCheckbox
|
||||||
checked={dlc.enabled}
|
checked={dlc.enabled}
|
||||||
@@ -226,17 +227,13 @@ const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="confirm-button" onClick={handleConfirm} disabled={isLoading}>
|
||||||
className="confirm-button"
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
|
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DlcSelectionDialog;
|
export default DlcSelectionDialog
|
||||||
|
|||||||
@@ -1,93 +1,95 @@
|
|||||||
// src/components/GameItem.tsx
|
import React, { useState, useEffect } from 'react'
|
||||||
import React, { useState, useEffect } from 'react';
|
import { findBestGameImage } from '../services/ImageService'
|
||||||
import { findBestGameImage } from '../services/ImageService';
|
import { ActionType } from './ActionButton'
|
||||||
import { ActionType } from './ActionButton';
|
|
||||||
|
|
||||||
interface Game {
|
interface Game {
|
||||||
id: string;
|
id: string
|
||||||
title: string;
|
title: string
|
||||||
path: string;
|
path: string
|
||||||
platform?: string;
|
platform?: string
|
||||||
native: boolean;
|
native: boolean
|
||||||
api_files: string[];
|
api_files: string[]
|
||||||
cream_installed?: boolean;
|
cream_installed?: boolean
|
||||||
smoke_installed?: boolean;
|
smoke_installed?: boolean
|
||||||
installing?: boolean;
|
installing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameItemProps {
|
interface GameItemProps {
|
||||||
game: Game;
|
game: Game
|
||||||
onAction: (gameId: string, action: ActionType) => Promise<void>;
|
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||||
onEdit?: (gameId: string) => void;
|
onEdit?: (gameId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Function to fetch the game cover/image
|
// Function to fetch the game cover/image
|
||||||
const fetchGameImage = async () => {
|
const fetchGameImage = async () => {
|
||||||
// First check if we already have it (to prevent flickering on re-renders)
|
// First check if we already have it (to prevent flickering on re-renders)
|
||||||
if (imageUrl) return;
|
if (imageUrl) return
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
// Try to find the best available image for this game
|
// Try to find the best available image for this game
|
||||||
const bestImageUrl = await findBestGameImage(game.id);
|
const bestImageUrl = await findBestGameImage(game.id)
|
||||||
|
|
||||||
if (bestImageUrl) {
|
if (bestImageUrl) {
|
||||||
setImageUrl(bestImageUrl);
|
setImageUrl(bestImageUrl)
|
||||||
setHasError(false);
|
setHasError(false)
|
||||||
} else {
|
} else {
|
||||||
setHasError(true);
|
setHasError(true)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching game image:', error);
|
console.error('Error fetching game image:', error)
|
||||||
setHasError(true);
|
setHasError(true)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (game.id) {
|
if (game.id) {
|
||||||
fetchGameImage();
|
fetchGameImage()
|
||||||
}
|
}
|
||||||
}, [game.id, imageUrl]);
|
}, [game.id, imageUrl])
|
||||||
|
|
||||||
// Determine if we should show CreamLinux buttons (only for native games)
|
// Determine if we should show CreamLinux buttons (only for native games)
|
||||||
const shouldShowCream = game.native === true;
|
const shouldShowCream = game.native === true
|
||||||
|
|
||||||
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
||||||
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0;
|
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
|
||||||
|
|
||||||
// Check if this is a Proton game without API files
|
// Check if this is a Proton game without API files
|
||||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0);
|
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
|
||||||
|
|
||||||
const handleCreamAction = () => {
|
const handleCreamAction = () => {
|
||||||
if (game.installing) return;
|
if (game.installing) return
|
||||||
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream';
|
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
|
||||||
onAction(game.id, action);
|
onAction(game.id, action)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSmokeAction = () => {
|
const handleSmokeAction = () => {
|
||||||
if (game.installing) return;
|
if (game.installing) return
|
||||||
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke';
|
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
|
||||||
onAction(game.id, action);
|
onAction(game.id, action)
|
||||||
};
|
}
|
||||||
|
|
||||||
// Handle edit button click
|
// Handle edit button click
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
if (onEdit && game.cream_installed) {
|
if (onEdit && game.cream_installed) {
|
||||||
onEdit(game.id);
|
onEdit(game.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Determine background image
|
// Determine background image
|
||||||
const backgroundImage = !isLoading && imageUrl ?
|
const backgroundImage =
|
||||||
`url(${imageUrl})` :
|
!isLoading && imageUrl
|
||||||
hasError ? 'linear-gradient(135deg, #232323, #1A1A1A)' : 'linear-gradient(135deg, #232323, #1A1A1A)';
|
? `url(${imageUrl})`
|
||||||
|
: hasError
|
||||||
|
? 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||||
|
: 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -103,12 +105,8 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
|||||||
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
|
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
|
||||||
{game.native ? 'Native' : 'Proton'}
|
{game.native ? 'Native' : 'Proton'}
|
||||||
</span>
|
</span>
|
||||||
{game.cream_installed && (
|
{game.cream_installed && <span className="status-badge cream">CreamLinux</span>}
|
||||||
<span className="status-badge cream">CreamLinux</span>
|
{game.smoke_installed && <span className="status-badge smoke">SmokeAPI</span>}
|
||||||
)}
|
|
||||||
{game.smoke_installed && (
|
|
||||||
<span className="status-badge smoke">SmokeAPI</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="game-title">
|
<div className="game-title">
|
||||||
@@ -123,7 +121,11 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
|||||||
onClick={handleCreamAction}
|
onClick={handleCreamAction}
|
||||||
disabled={!!game.installing}
|
disabled={!!game.installing}
|
||||||
>
|
>
|
||||||
{game.installing ? "Working..." : (game.cream_installed ? "Uninstall CreamLinux" : "Install CreamLinux")}
|
{game.installing
|
||||||
|
? 'Working...'
|
||||||
|
: game.cream_installed
|
||||||
|
? 'Uninstall CreamLinux'
|
||||||
|
: 'Install CreamLinux'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -134,7 +136,11 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
|||||||
onClick={handleSmokeAction}
|
onClick={handleSmokeAction}
|
||||||
disabled={!!game.installing}
|
disabled={!!game.installing}
|
||||||
>
|
>
|
||||||
{game.installing ? "Working..." : (game.smoke_installed ? "Uninstall SmokeAPI" : "Install SmokeAPI")}
|
{game.installing
|
||||||
|
? 'Working...'
|
||||||
|
: game.smoke_installed
|
||||||
|
? 'Uninstall SmokeAPI'
|
||||||
|
: 'Install SmokeAPI'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -166,7 +172,7 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default GameItem;
|
export default GameItem
|
||||||
|
|||||||
@@ -1,64 +1,58 @@
|
|||||||
// src/components/GameList.tsx
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import GameItem from './GameItem'
|
||||||
import GameItem from './GameItem';
|
import ImagePreloader from './ImagePreloader'
|
||||||
import ImagePreloader from './ImagePreloader';
|
import { ActionType } from './ActionButton'
|
||||||
import { ActionType } from './ActionButton';
|
|
||||||
|
|
||||||
interface Game {
|
interface Game {
|
||||||
id: string;
|
id: string
|
||||||
title: string;
|
title: string
|
||||||
path: string;
|
path: string
|
||||||
platform?: string;
|
platform?: string
|
||||||
native: boolean;
|
native: boolean
|
||||||
api_files: string[];
|
api_files: string[]
|
||||||
cream_installed?: boolean;
|
cream_installed?: boolean
|
||||||
smoke_installed?: boolean;
|
smoke_installed?: boolean
|
||||||
installing?: boolean;
|
installing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameListProps {
|
interface GameListProps {
|
||||||
games: Game[];
|
games: Game[]
|
||||||
isLoading: boolean;
|
isLoading: boolean
|
||||||
onAction: (gameId: string, action: ActionType) => Promise<void>;
|
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||||
onEdit?: (gameId: string) => void;
|
onEdit?: (gameId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameList: React.FC<GameListProps> = ({
|
const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit }) => {
|
||||||
games,
|
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||||
isLoading,
|
|
||||||
onAction,
|
|
||||||
onEdit
|
|
||||||
}) => {
|
|
||||||
const [imagesPreloaded, setImagesPreloaded] = useState(false);
|
|
||||||
|
|
||||||
// Sort games alphabetically by title - using useMemo to avoid re-sorting on each render
|
// Sort games alphabetically by title using useMemo to avoid re-sorting on each render
|
||||||
const sortedGames = useMemo(() => {
|
const sortedGames = useMemo(() => {
|
||||||
return [...games].sort((a, b) => a.title.localeCompare(b.title));
|
return [...games].sort((a, b) => a.title.localeCompare(b.title))
|
||||||
}, [games]);
|
}, [games])
|
||||||
|
|
||||||
// Reset preloaded state when games change
|
// Reset preloaded state when games change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImagesPreloaded(false);
|
setImagesPreloaded(false)
|
||||||
}, [games]);
|
}, [games])
|
||||||
|
|
||||||
// Debug log to help diagnose game states
|
// Debug log to help diagnose game states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (games.length > 0) {
|
if (games.length > 0) {
|
||||||
console.log("Games state in GameList:", games.length, "games");
|
console.log('Games state in GameList:', games.length, 'games')
|
||||||
}
|
}
|
||||||
}, [games]);
|
}, [games])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="game-list">
|
<div className="game-list">
|
||||||
<div className="loading-indicator">Scanning for games...</div>
|
<div className="loading-indicator">Scanning for games...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreloadComplete = () => {
|
const handlePreloadComplete = () => {
|
||||||
setImagesPreloaded(true);
|
setImagesPreloaded(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-list">
|
<div className="game-list">
|
||||||
@@ -66,7 +60,7 @@ const GameList: React.FC<GameListProps> = ({
|
|||||||
|
|
||||||
{!imagesPreloaded && games.length > 0 && (
|
{!imagesPreloaded && games.length > 0 && (
|
||||||
<ImagePreloader
|
<ImagePreloader
|
||||||
gameIds={sortedGames.map(game => game.id)}
|
gameIds={sortedGames.map((game) => game.id)}
|
||||||
onComplete={handlePreloadComplete}
|
onComplete={handlePreloadComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -75,18 +69,13 @@ const GameList: React.FC<GameListProps> = ({
|
|||||||
<div className="no-games-message">No games found</div>
|
<div className="no-games-message">No games found</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="game-grid">
|
<div className="game-grid">
|
||||||
{sortedGames.map(game => (
|
{sortedGames.map((game) => (
|
||||||
<GameItem
|
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} />
|
||||||
key={game.id}
|
|
||||||
game={game}
|
|
||||||
onAction={onAction}
|
|
||||||
onEdit={onEdit}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default GameList;
|
export default GameList
|
||||||
|
|||||||
@@ -1,28 +1,23 @@
|
|||||||
// src/components/Header.tsx
|
import React from 'react'
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onRefresh: () => void;
|
onRefresh: () => void
|
||||||
refreshDisabled?: boolean;
|
refreshDisabled?: boolean
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void
|
||||||
searchQuery: string;
|
searchQuery: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({
|
const Header: React.FC<HeaderProps> = ({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshDisabled = false,
|
refreshDisabled = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
searchQuery
|
searchQuery,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>CreamLinux</h1>
|
<h1>CreamLinux</h1>
|
||||||
<div className="header-controls">
|
<div className="header-controls">
|
||||||
<button
|
<button className="refresh-button" onClick={onRefresh} disabled={refreshDisabled}>
|
||||||
className="refresh-button"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={refreshDisabled}
|
|
||||||
>
|
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@@ -34,7 +29,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Header;
|
export default Header
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// src/components/ImagePreloader.tsx
|
import React, { useEffect } from 'react'
|
||||||
import React, { useEffect } from 'react';
|
import { findBestGameImage } from '../services/ImageService'
|
||||||
import { findBestGameImage } from '../services/ImageService';
|
|
||||||
|
|
||||||
interface ImagePreloaderProps {
|
interface ImagePreloaderProps {
|
||||||
gameIds: string[];
|
gameIds: string[]
|
||||||
onComplete?: () => void;
|
onComplete?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => {
|
const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => {
|
||||||
@@ -12,37 +11,31 @@ const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete })
|
|||||||
const preloadImages = async () => {
|
const preloadImages = async () => {
|
||||||
try {
|
try {
|
||||||
// Only preload the first batch for performance (10 images max)
|
// Only preload the first batch for performance (10 images max)
|
||||||
const batchToPreload = gameIds.slice(0, 10);
|
const batchToPreload = gameIds.slice(0, 10)
|
||||||
|
|
||||||
// Load images in parallel
|
// Load images in parallel
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(batchToPreload.map((id) => findBestGameImage(id)))
|
||||||
batchToPreload.map(id => findBestGameImage(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete();
|
onComplete()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error preloading images:", error);
|
console.error('Error preloading images:', error)
|
||||||
// Continue even if there's an error
|
// Continue even if there's an error
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete();
|
onComplete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (gameIds.length > 0) {
|
if (gameIds.length > 0) {
|
||||||
preloadImages();
|
preloadImages()
|
||||||
} else if (onComplete) {
|
} else if (onComplete) {
|
||||||
onComplete();
|
onComplete()
|
||||||
}
|
}
|
||||||
}, [gameIds, onComplete]);
|
}, [gameIds, onComplete])
|
||||||
|
|
||||||
return (
|
return <div className="image-preloader">{/* Hidden element, just used for preloading */}</div>
|
||||||
<div className="image-preloader">
|
}
|
||||||
{/* Hidden element, just used for preloading */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImagePreloader;
|
export default ImagePreloader
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
|
|
||||||
interface InitialLoadingScreenProps {
|
interface InitialLoadingScreenProps {
|
||||||
message: string;
|
message: string
|
||||||
progress: number;
|
progress: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
|
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, progress }) => {
|
||||||
message,
|
|
||||||
progress
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="initial-loading-screen">
|
<div className="initial-loading-screen">
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
@@ -22,15 +19,12 @@ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<p className="loading-message">{message}</p>
|
<p className="loading-message">{message}</p>
|
||||||
<div className="progress-bar-container">
|
<div className="progress-bar-container">
|
||||||
<div
|
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||||
className="progress-bar"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default InitialLoadingScreen;
|
export default InitialLoadingScreen
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
// src/components/ProgressDialog.tsx
|
import React, { useState, useEffect } from 'react'
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface InstructionInfo {
|
interface InstructionInfo {
|
||||||
type: string;
|
type: string
|
||||||
command: string;
|
command: string
|
||||||
game_title: string;
|
game_title: string
|
||||||
dlc_count?: number;
|
dlc_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProgressDialogProps {
|
interface ProgressDialogProps {
|
||||||
title: string;
|
title: string
|
||||||
message: string;
|
message: string
|
||||||
progress: number; // 0-100
|
progress: number // 0-100
|
||||||
visible: boolean;
|
visible: boolean
|
||||||
showInstructions?: boolean;
|
showInstructions?: boolean
|
||||||
instructions?: InstructionInfo;
|
instructions?: InstructionInfo
|
||||||
onClose?: () => void;
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||||
@@ -25,76 +24,77 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
|||||||
visible,
|
visible,
|
||||||
showInstructions = false,
|
showInstructions = false,
|
||||||
instructions,
|
instructions,
|
||||||
onClose
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
const [copySuccess, setCopySuccess] = useState(false)
|
||||||
const [showContent, setShowContent] = useState(false);
|
const [showContent, setShowContent] = useState(false)
|
||||||
|
|
||||||
// Reset copy state when dialog visibility changes
|
// Reset copy state when dialog visibility changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setCopySuccess(false);
|
setCopySuccess(false)
|
||||||
setShowContent(false);
|
setShowContent(false)
|
||||||
} else {
|
} else {
|
||||||
// Add a small delay to trigger the entrance animation
|
// Add a small delay to trigger the entrance animation
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setShowContent(true);
|
setShowContent(true)
|
||||||
}, 50);
|
}, 50)
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible])
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null
|
||||||
|
|
||||||
const handleCopyCommand = () => {
|
const handleCopyCommand = () => {
|
||||||
if (instructions?.command) {
|
if (instructions?.command) {
|
||||||
navigator.clipboard.writeText(instructions.command);
|
navigator.clipboard.writeText(instructions.command)
|
||||||
setCopySuccess(true);
|
setCopySuccess(true)
|
||||||
|
|
||||||
// Reset the success message after 2 seconds
|
// Reset the success message after 2 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopySuccess(false);
|
setCopySuccess(false)
|
||||||
}, 2000);
|
}, 2000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setShowContent(false);
|
setShowContent(false)
|
||||||
// Delay closing to allow exit animation
|
// Delay closing to allow exit animation
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose()
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
}
|
}
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modified to prevent closing when in progress
|
// Prevent closing when in progress
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// Always prevent propagation
|
// Always prevent propagation
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
|
|
||||||
// Only allow clicking outside to close if we're done processing (100%)
|
// Only allow clicking outside to close if we're done processing (100%)
|
||||||
// and showing instructions or if explicitly allowed via a prop
|
// and showing instructions or if explicitly allowed via a prop
|
||||||
if (e.target === e.currentTarget && progress >= 100 && showInstructions) {
|
if (e.target === e.currentTarget && progress >= 100 && showInstructions) {
|
||||||
handleClose();
|
handleClose()
|
||||||
}
|
}
|
||||||
// Otherwise, do nothing - require using the close button
|
// Otherwise, do nothing - require using the close button
|
||||||
};
|
}
|
||||||
|
|
||||||
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
|
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
|
||||||
const showCopyButton = instructions?.type === 'cream_install' ||
|
const showCopyButton =
|
||||||
instructions?.type === 'cream_uninstall';
|
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
|
||||||
|
|
||||||
// Format instruction message based on type
|
// Format instruction message based on type
|
||||||
const getInstructionText = () => {
|
const getInstructionText = () => {
|
||||||
if (!instructions) return null;
|
if (!instructions) return null
|
||||||
|
|
||||||
switch (instructions.type) {
|
switch (instructions.type) {
|
||||||
case 'cream_install':
|
case 'cream_install':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="instruction-text">
|
<p className="instruction-text">
|
||||||
In Steam, set the following launch options for <strong>{instructions.game_title}</strong>:
|
In Steam, set the following launch options for{' '}
|
||||||
|
<strong>{instructions.game_title}</strong>:
|
||||||
</p>
|
</p>
|
||||||
{instructions.dlc_count !== undefined && (
|
{instructions.dlc_count !== undefined && (
|
||||||
<div className="dlc-count">
|
<div className="dlc-count">
|
||||||
@@ -102,13 +102,14 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
case 'cream_uninstall':
|
case 'cream_uninstall':
|
||||||
return (
|
return (
|
||||||
<p className="instruction-text">
|
<p className="instruction-text">
|
||||||
For <strong>{instructions.game_title}</strong>, open Steam properties and remove the following launch option:
|
For <strong>{instructions.game_title}</strong>, open Steam properties and remove the
|
||||||
|
following launch option:
|
||||||
</p>
|
</p>
|
||||||
);
|
)
|
||||||
case 'smoke_install':
|
case 'smoke_install':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -121,44 +122,43 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
case 'smoke_uninstall':
|
case 'smoke_uninstall':
|
||||||
return (
|
return (
|
||||||
<p className="instruction-text">
|
<p className="instruction-text">
|
||||||
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
|
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
|
||||||
</p>
|
</p>
|
||||||
);
|
)
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<p className="instruction-text">
|
<p className="instruction-text">
|
||||||
Done processing <strong>{instructions.game_title}</strong>
|
Done processing <strong>{instructions.game_title}</strong>
|
||||||
</p>
|
</p>
|
||||||
);
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the CSS class for the command box based on instruction type
|
// Determine the CSS class for the command box based on instruction type
|
||||||
const getCommandBoxClass = () => {
|
const getCommandBoxClass = () => {
|
||||||
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box';
|
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'
|
||||||
};
|
}
|
||||||
|
|
||||||
// Determine if close button should be enabled
|
// Determine if close button should be enabled
|
||||||
const isCloseButtonEnabled = showInstructions || progress >= 100;
|
const isCloseButtonEnabled = showInstructions || progress >= 100
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
|
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
>
|
>
|
||||||
<div className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}>
|
<div
|
||||||
|
className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}
|
||||||
|
>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
|
|
||||||
<div className="progress-bar-container">
|
<div className="progress-bar-container">
|
||||||
<div
|
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||||
className="progress-bar"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||||
|
|
||||||
@@ -177,10 +177,7 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
|||||||
|
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
{showCopyButton && (
|
{showCopyButton && (
|
||||||
<button
|
<button className="copy-button" onClick={handleCopyCommand}>
|
||||||
className="copy-button"
|
|
||||||
onClick={handleCopyCommand}
|
|
||||||
>
|
|
||||||
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -199,17 +196,14 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
|||||||
{/* Show close button even if no instructions */}
|
{/* Show close button even if no instructions */}
|
||||||
{!showInstructions && progress >= 100 && (
|
{!showInstructions && progress >= 100 && (
|
||||||
<div className="action-buttons" style={{ marginTop: '1rem' }}>
|
<div className="action-buttons" style={{ marginTop: '1rem' }}>
|
||||||
<button
|
<button className="close-button" onClick={handleClose}>
|
||||||
className="close-button"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ProgressDialog;
|
export default ProgressDialog
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
// src/components/Sidebar.tsx
|
import React from 'react'
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
setFilter: (filter: string) => void;
|
setFilter: (filter: string) => void
|
||||||
currentFilter: string;
|
currentFilter: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
|
||||||
@@ -11,10 +10,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
|
|||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<h2>Library</h2>
|
<h2>Library</h2>
|
||||||
<ul className="filter-list">
|
<ul className="filter-list">
|
||||||
<li
|
<li className={currentFilter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
|
||||||
className={currentFilter === 'all' ? 'active' : ''}
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
>
|
|
||||||
All Games
|
All Games
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
@@ -31,7 +27,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Sidebar;
|
export default Sidebar
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ import App from './App.tsx'
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/services/ImageService.ts
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Game image sources from Steam's CDN
|
* Game image sources from Steam's CDN
|
||||||
*/
|
*/
|
||||||
@@ -9,88 +7,87 @@ export const SteamImageType = {
|
|||||||
LOGO: 'logo', // Game logo with transparency
|
LOGO: 'logo', // Game logo with transparency
|
||||||
LIBRARY_HERO: 'library_hero', // 1920x620
|
LIBRARY_HERO: 'library_hero', // 1920x620
|
||||||
LIBRARY_CAPSULE: 'library_600x900', // 600x900
|
LIBRARY_CAPSULE: 'library_600x900', // 600x900
|
||||||
} as const;
|
} as const
|
||||||
|
|
||||||
export type SteamImageTypeKey = keyof typeof SteamImageType;
|
export type SteamImageTypeKey = keyof typeof SteamImageType
|
||||||
|
|
||||||
// Cache for images to prevent flickering
|
// Cache for images to prevent flickering
|
||||||
const imageCache: Map<string, string> = new Map();
|
const imageCache: Map<string, string> = new Map()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Steam CDN URL for game images
|
* Builds a Steam CDN URL for game images
|
||||||
* @param appId Steam application ID
|
* @param appId Steam application ID
|
||||||
* @param type Image type from SteamImageType enum
|
* @param type Image type from SteamImageType enum
|
||||||
* @returns URL string for the image
|
* @returns URL string for the image
|
||||||
*/
|
*/
|
||||||
export const getSteamImageUrl = (appId: string, type: typeof SteamImageType[SteamImageTypeKey]) => {
|
export const getSteamImageUrl = (
|
||||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`;
|
appId: string,
|
||||||
};
|
type: (typeof SteamImageType)[SteamImageTypeKey]
|
||||||
|
) => {
|
||||||
|
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if an image exists by performing a HEAD request
|
* Checks if an image exists by performing a HEAD request
|
||||||
* @param url Image URL to check
|
* @param url Image URL to check
|
||||||
* @returns Promise resolving to a boolean indicating if the image exists
|
* @returns Promise resolving to a boolean indicating if the image exists
|
||||||
*/
|
*/
|
||||||
export const checkImageExists = async (url: string): Promise<boolean> => {
|
export const checkImageExists = async (url: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
const response = await fetch(url, { method: 'HEAD' })
|
||||||
return response.ok;
|
return response.ok
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking image existence:', error);
|
console.error('Error checking image existence:', error)
|
||||||
return false;
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preloads an image for faster rendering
|
* Preloads an image for faster rendering
|
||||||
* @param url URL of image to preload
|
* @param url URL of image to preload
|
||||||
* @returns Promise that resolves when image is loaded
|
* @returns Promise that resolves when image is loaded
|
||||||
*/
|
*/
|
||||||
const preloadImage = (url: string): Promise<string> => {
|
const preloadImage = (url: string): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image()
|
||||||
img.onload = () => resolve(url);
|
img.onload = () => resolve(url)
|
||||||
img.onerror = reject;
|
img.onerror = reject
|
||||||
img.src = url;
|
img.src = url
|
||||||
});
|
})
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to find a valid image for a Steam game, trying different image types
|
|
||||||
* @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 = [
|
* Attempts to find a valid image for a Steam game, trying different image types
|
||||||
SteamImageType.HEADER,
|
* @param appId Steam application ID
|
||||||
SteamImageType.CAPSULE,
|
* @returns Promise resolving to a valid image URL or null if none found
|
||||||
SteamImageType.LIBRARY_CAPSULE
|
*/
|
||||||
];
|
export const findBestGameImage = async (appId: string): Promise<string | null> => {
|
||||||
|
// Check cache first
|
||||||
|
if (imageCache.has(appId)) {
|
||||||
|
return imageCache.get(appId) || null
|
||||||
|
}
|
||||||
|
|
||||||
for (const type of typesToTry) {
|
// Try these image types in order of preference
|
||||||
const url = getSteamImageUrl(appId, type);
|
const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE]
|
||||||
const exists = await checkImageExists(url);
|
|
||||||
|
for (const type of typesToTry) {
|
||||||
|
const url = getSteamImageUrl(appId, type)
|
||||||
|
const exists = await checkImageExists(url)
|
||||||
if (exists) {
|
if (exists) {
|
||||||
try {
|
try {
|
||||||
// Preload the image to prevent flickering
|
// Preload the image to prevent flickering
|
||||||
const preloadedUrl = await preloadImage(url);
|
const preloadedUrl = await preloadImage(url)
|
||||||
// Store in cache
|
// Store in cache
|
||||||
imageCache.set(appId, preloadedUrl);
|
imageCache.set(appId, preloadedUrl)
|
||||||
return preloadedUrl;
|
return preloadedUrl
|
||||||
} catch {
|
} catch {
|
||||||
// If preloading fails, just return the URL
|
// If preloading fails, just return the URL
|
||||||
imageCache.set(appId, url);
|
imageCache.set(appId, url)
|
||||||
return url;
|
return url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If we've reached here, no valid image was found
|
// If we've reached here, no valid image was found
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Satoshi';
|
font-family: 'Satoshi';
|
||||||
src: url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
src:
|
||||||
|
url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
||||||
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
||||||
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
||||||
font-weight: 400; // adjust as needed
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/_layout.scss
|
|
||||||
|
|
||||||
@use './variables' as *;
|
@use './variables' as *;
|
||||||
@use './mixins' as *;
|
@use './mixins' as *;
|
||||||
|
|
||||||
@@ -57,7 +55,12 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: linear-gradient(90deg, var(--cream-color), var(--primary-color), var(--smoke-color));
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--cream-color),
|
||||||
|
var(--primary-color),
|
||||||
|
var(--smoke-color)
|
||||||
|
);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
z-index: var(--z-elevate);
|
z-index: var(--z-elevate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar */
|
// Sidebar
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
min-width: var(--sidebar-width);
|
min-width: var(--sidebar-width);
|
||||||
@@ -161,7 +164,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Loading and empty state
|
// Loading and empty state
|
||||||
.loading-indicator, .no-games-message {
|
.loading-indicator,
|
||||||
|
.no-games-message {
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
height: 250px;
|
height: 250px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -185,12 +189,7 @@
|
|||||||
left: -100%;
|
left: -100%;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.05),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
animation: loading-shimmer 2s infinite;
|
animation: loading-shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
// src/styles/_mixins.scss
|
|
||||||
|
|
||||||
@use './variables' as *;
|
@use './variables' as *;
|
||||||
|
|
||||||
// src/styles/_mixins.scss
|
|
||||||
|
|
||||||
// Basic flex helpers
|
// Basic flex helpers
|
||||||
@mixin flex-center {
|
@mixin flex-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -43,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mixin shadow-hover {
|
@mixin shadow-hover {
|
||||||
box-shadow: var(--shadow-hover);;
|
box-shadow: var(--shadow-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin text-shadow {
|
@mixin text-shadow {
|
||||||
@@ -60,19 +56,27 @@
|
|||||||
|
|
||||||
// Responsive mixins
|
// Responsive mixins
|
||||||
@mixin media-sm {
|
@mixin media-sm {
|
||||||
@media (min-width: 576px) { @content; }
|
@media (min-width: 576px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin media-md {
|
@mixin media-md {
|
||||||
@media (min-width: 768px) { @content; }
|
@media (min-width: 768px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin media-lg {
|
@mixin media-lg {
|
||||||
@media (min-width: 992px) { @content; }
|
@media (min-width: 992px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin media-xl {
|
@mixin media-xl {
|
||||||
@media (min-width: 1200px) { @content; }
|
@media (min-width: 1200px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card base styling
|
// Card base styling
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
// src/styles/_reset.scss
|
|
||||||
|
|
||||||
@use './variables' as *;
|
@use './variables' as *;
|
||||||
@use './mixins' as *;
|
@use './mixins' as *;
|
||||||
@use './fonts' as *;
|
@use './fonts' as *;
|
||||||
// src/styles/_reset.scss
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -11,7 +8,8 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -23,7 +21,7 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: var(--primary-bg);
|
background-color: var(--primary-bg);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
/* Prevent text selection by default */
|
// Prevent text selection by default
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
@@ -51,15 +49,24 @@ a {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul, ol {
|
ul,
|
||||||
|
ol {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, button, textarea, select {
|
input,
|
||||||
|
button,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/_variables.scss
|
|
||||||
|
|
||||||
@use './fonts' as *;
|
@use './fonts' as *;
|
||||||
|
|
||||||
// Color palette
|
// Color palette
|
||||||
@@ -13,7 +11,7 @@
|
|||||||
--secondary-bg: #151515;
|
--secondary-bg: #151515;
|
||||||
--tertiary-bg: #121212;
|
--tertiary-bg: #121212;
|
||||||
--elevated-bg: #1a1a1a;
|
--elevated-bg: #1a1a1a;
|
||||||
--disabled: #5E5E5E;
|
--disabled: #5e5e5e;
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
--text-primary: #f0f0f0;
|
--text-primary: #f0f0f0;
|
||||||
@@ -27,7 +25,7 @@
|
|||||||
--border-soft: #282828;
|
--border-soft: #282828;
|
||||||
--border: #323232;
|
--border: #323232;
|
||||||
|
|
||||||
// Status colors - more vibrant
|
// Status colors
|
||||||
--success: #8cc893;
|
--success: #8cc893;
|
||||||
--warning: #ffc896;
|
--warning: #ffc896;
|
||||||
--danger: #d96b6b;
|
--danger: #d96b6b;
|
||||||
@@ -93,13 +91,6 @@
|
|||||||
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
|
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
|
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
// Z-index levels
|
|
||||||
//--z-index-bg: 0;
|
|
||||||
//--z-index-content: 1;
|
|
||||||
//--z-index-header: 100;
|
|
||||||
//--z-index-modal: 1000;
|
|
||||||
//--z-index-tooltip: 1500;
|
|
||||||
|
|
||||||
// Z-index levels
|
// Z-index levels
|
||||||
--z-bg: 0;
|
--z-bg: 0;
|
||||||
--z-elevate: 1;
|
--z-elevate: 1;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/components/_animated_checkbox.scss
|
|
||||||
|
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/_components/_background.scss
|
|
||||||
|
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
@use 'sass:color';
|
@use 'sass:color';
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// src/styles/_components/_dialog.scss
|
|
||||||
|
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
|
|
||||||
/* Progress Dialog */
|
// Progress Dialog
|
||||||
.progress-dialog-overlay {
|
.progress-dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -23,8 +21,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes modal-appear {
|
@keyframes modal-appear {
|
||||||
0% { opacity: 0; transform: scale(0.95); }
|
0% {
|
||||||
100% { opacity: 1; transform: scale(1); }
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
background-color: var(--elevated-bg);
|
background-color: var(--elevated-bg);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); /* shadow-glow */
|
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); // shadow-glow
|
||||||
width: 450px;
|
width: 450px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
@@ -85,7 +89,7 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Instruction container in progress dialog */
|
// Instruction container in progress dialog
|
||||||
.instruction-container {
|
.instruction-container {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
@@ -164,7 +168,8 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-button, .close-button {
|
.copy-button,
|
||||||
|
.close-button {
|
||||||
padding: 0.6rem 1.2rem;
|
padding: 0.6rem 1.2rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -180,7 +185,7 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
transform: translateY(-2px) scale(1.02); /* hover-lift */
|
transform: translateY(-2px) scale(1.02); // hover-lift
|
||||||
box-shadow: 0 6px 14px var(--info-soft);
|
box-shadow: 0 6px 14px var(--info-soft);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +196,7 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--border);
|
background-color: var(--border);
|
||||||
transform: translateY(-2px) scale(1.02); /* hover-lift */
|
transform: translateY(-2px) scale(1.02); // hover-lift
|
||||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,6 +249,10 @@
|
|||||||
|
|
||||||
// Animation for progress bar
|
// Animation for progress bar
|
||||||
@keyframes progress-shimmer {
|
@keyframes progress-shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% {
|
||||||
100% { transform: translateX(100%); }
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/components/_dlc_dialog.scss
|
|
||||||
|
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
|
|
||||||
@@ -39,7 +37,9 @@
|
|||||||
&.dialog-visible {
|
&.dialog-visible {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: transform 0.2s var(--easing-bounce), opacity 0.2s ease-out;
|
transition:
|
||||||
|
transform 0.2s var(--easing-bounce),
|
||||||
|
opacity 0.2s ease-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,17 +189,19 @@
|
|||||||
.loading-pulse {
|
.loading-pulse {
|
||||||
width: 70%;
|
width: 70%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: linear-gradient(90deg,
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
var(--border-soft) 0%,
|
var(--border-soft) 0%,
|
||||||
var(--border) 50%,
|
var(--border) 50%,
|
||||||
var(--border-soft) 100%);
|
var(--border-soft) 100%
|
||||||
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
animation: loading-pulse 1.5s infinite;
|
animation: loading-pulse 1.5s infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced styling for the checkbox component inside dlc-item
|
// Styling for the checkbox component inside dlc-item
|
||||||
:global(.animated-checkbox) {
|
:global(.animated-checkbox) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@@ -261,7 +263,8 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-button, .confirm-button {
|
.cancel-button,
|
||||||
|
.confirm-button {
|
||||||
padding: 0.6rem 1.2rem;
|
padding: 0.6rem 1.2rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -299,16 +302,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% {
|
||||||
100% { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes modal-appear {
|
@keyframes modal-appear {
|
||||||
0% { opacity: 0; transform: scale(0.95); }
|
0% {
|
||||||
100% { opacity: 1; transform: scale(1); }
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes loading-pulse {
|
@keyframes loading-pulse {
|
||||||
0% { background-position: 200% 50%; }
|
0% {
|
||||||
100% { background-position: 0% 50%; }
|
background-position: 200% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/components/_gamecard.scss
|
|
||||||
|
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
|
|
||||||
@@ -25,7 +23,7 @@
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
|
||||||
.status-badge.native {
|
.status-badge.native {
|
||||||
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5)
|
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.proton {
|
.status-badge.proton {
|
||||||
@@ -43,11 +41,15 @@
|
|||||||
|
|
||||||
// Special styling for cards with different statuses
|
// Special styling for cards with different statuses
|
||||||
.game-item-card:has(.status-badge.cream) {
|
.game-item-card:has(.status-badge.cream) {
|
||||||
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15);
|
box-shadow:
|
||||||
|
var(--shadow-standard),
|
||||||
|
0 0 15px rgba(128, 181, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-item-card:has(.status-badge.smoke) {
|
.game-item-card:has(.status-badge.smoke) {
|
||||||
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15);
|
box-shadow:
|
||||||
|
var(--shadow-standard),
|
||||||
|
0 0 15px rgba(255, 239, 150, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple clean overlay
|
// Simple clean overlay
|
||||||
@@ -57,7 +59,8 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(to bottom,
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
rgba(0, 0, 0, 0.5) 0%,
|
rgba(0, 0, 0, 0.5) 0%,
|
||||||
rgba(0, 0, 0, 0.6) 50%,
|
rgba(0, 0, 0, 0.6) 50%,
|
||||||
rgba(0, 0, 0, 0.8) 100%
|
rgba(0, 0, 0, 0.8) 100%
|
||||||
@@ -70,7 +73,7 @@
|
|||||||
font-family: var(--family);
|
font-family: var(--family);
|
||||||
-webkit-font-smoothing: subpixel-antialiased;
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
text-rendering: geometricPrecision;
|
text-rendering: geometricPrecision;
|
||||||
color: var(--text-heavy);;
|
color: var(--text-heavy);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@
|
|||||||
font-family: var(--family);
|
font-family: var(--family);
|
||||||
-webkit-font-smoothing: subpixel-antialiased;
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
text-rendering: geometricPrecision;
|
text-rendering: geometricPrecision;
|
||||||
color: var(--text-heavy);;
|
color: var(--text-heavy);
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||||
@include transition-standard;
|
@include transition-standard;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
@@ -129,7 +132,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-font-smoothing: subpixel-antialiased;
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
text-rendering: geometricPrecision;
|
text-rendering: geometricPrecision;
|
||||||
transform: translateZ(0); // or
|
transform: translateZ(0);
|
||||||
will-change: opacity, transform;
|
will-change: opacity, transform;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -176,7 +179,7 @@
|
|||||||
.action-button.uninstall:hover {
|
.action-button.uninstall:hover {
|
||||||
background-color: var(--danger-light);
|
background-color: var(--danger-light);
|
||||||
transform: translateY(-2px) scale(1.02);
|
transform: translateY(-2px) scale(1.02);
|
||||||
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3)
|
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button:active {
|
.action-button:active {
|
||||||
@@ -278,10 +281,16 @@
|
|||||||
|
|
||||||
// Simple animations
|
// Simple animations
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes button-loading {
|
@keyframes button-loading {
|
||||||
to { left: 100%; }
|
to {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/_components/_header.scss
|
|
||||||
|
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
|
|
||||||
@@ -15,7 +13,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--primary-bg);
|
background-color: var(--primary-bg);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
.app-header {
|
.app-header {
|
||||||
@@ -35,15 +33,15 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@include text-shadow;
|
@include text-shadow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-button {
|
.refresh-button {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -53,29 +51,29 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-button:hover {
|
.refresh-button:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
|
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-button:active {
|
.refresh-button:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
background-color: var(--border-dark);
|
background-color: var(--border-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||||
|
|||||||
@@ -76,7 +76,12 @@
|
|||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: width 0.5s ease;
|
transition: width 0.5s ease;
|
||||||
background: linear-gradient(to right, var(--cream-color), var(--primary-color), var(--smoke-color));
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--cream-color),
|
||||||
|
var(--primary-color),
|
||||||
|
var(--smoke-color)
|
||||||
|
);
|
||||||
box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
|
box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +96,12 @@
|
|||||||
|
|
||||||
// Animation for the bouncing circles
|
// Animation for the bouncing circles
|
||||||
@keyframes bounce {
|
@keyframes bounce {
|
||||||
0%, 80%, 100% {
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
}
|
}
|
||||||
40% {
|
40% {
|
||||||
transform: scale(1.0);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/_components/_sidebar.scss
|
|
||||||
|
|
||||||
@use '../variables' as *;
|
@use '../variables' as *;
|
||||||
@use '../mixins' as *;
|
@use '../mixins' as *;
|
||||||
|
|
||||||
@@ -133,14 +131,16 @@
|
|||||||
margin-left: -100px;
|
margin-left: -100px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
transition: opacity 0.3s, transform 0.3s;
|
transition:
|
||||||
|
opacity 0.3s,
|
||||||
|
transform 0.3s;
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -197,7 +197,9 @@
|
|||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(var(--primary-color), 0.3),
|
||||||
|
inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/styles/main.scss
|
|
||||||
|
|
||||||
// Import variables and mixins first
|
// Import variables and mixins first
|
||||||
@use './variables' as *;
|
@use './variables' as *;
|
||||||
@use './mixins' as *;
|
@use './mixins' as *;
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react'
|
||||||
// Removed unused import: loadEnv
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|
||||||
// Vite options tailored for Tauri development
|
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
@@ -14,11 +12,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
envPrefix: ['VITE_', 'TAURI_'],
|
envPrefix: ['VITE_', 'TAURI_'],
|
||||||
build: {
|
build: {
|
||||||
// Tauri supports es2021
|
|
||||||
target: ['es2021', 'chrome105', 'safari13'],
|
target: ['es2021', 'chrome105', 'safari13'],
|
||||||
// Don't minify for debug builds
|
|
||||||
minify: 'esbuild',
|
minify: 'esbuild',
|
||||||
// Produce sourcemaps for debug builds
|
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user