Initial commit

This commit is contained in:
Tickbase
2025-05-17 21:08:01 +02:00
commit 329e058e1b
63 changed files with 17326 additions and 0 deletions

43
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

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

View File

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

55
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: "Build CreamLinux"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build-tauri:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 19
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: 1.77.2
override: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: npm install
- name: Run ESLint
run: npm run lint
- name: Build the app
run: npm run tauri build
- name: Upload binary artifacts
uses: actions/upload-artifact@v3
with:
name: creamlinux-${{ runner.os }}
path: |
src-tauri/target/release/creamlinux
src-tauri/target/release/bundle/deb/*.deb
src-tauri/target/release/bundle/appimage/*.AppImage

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 [Your Name]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

117
README.md Normal file
View File

@@ -0,0 +1,117 @@
# CreamLinux
CreamLinux is a GUI application for Linux that simplifies the management of DLC in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
![Screenshot](./src/assets/screenshot.png)
## Features
- **Auto-discovery**: Automatically finds Steam games installed on your system
- **Native support**: Installs CreamLinux for native Linux games
- **Proton support**: Installs SmokeAPI for Windows games running through Proton
- **DLC management**: Easily select which DLCs to enable
- **Modern UI**: Clean, responsive interface that's easy to use
## Installation
### AppImage (Recommended)
1. Download the latest `CreamLinux.AppImage` from the [Releases](https://github.com/yourusername/creamlinux/releases) page
2. Make it executable:
```bash
chmod +x CreamLinux.AppImage
```
3. Run it:
```bash
./CreamLinux.AppImage
```
### Building from Source
#### Prerequisites
- Rust 1.77.2 or later
- Node.js 18 or later
- npm or yarn
#### Steps
1. Clone the repository:
```bash
git clone https://github.com/yourusername/creamlinux.git
cd creamlinux
```
2. Install dependencies:
```bash
npm install # or yarn
```
3. Build the application:
```bash
NO_STRIP=true npm run tauri build
```
4. The compiled binary will be available in `src-tauri/target/release/creamlinux`
### Desktop Integration
If you're using the AppImage version, you can integrate it into your desktop environment:
1. Create a desktop entry file:
```bash
mkdir -p ~/.local/share/applications
```
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
```
[Desktop Entry]
Name=Creamlinux
Exec=/absolute/path/to/CreamLinux.AppImage
Icon=/absolute/path/to/creamlinux-icon.png
Type=Application
Categories=Game;Utility;
Comment=DLC Manager for Steam games on Linux
```
3. Update your desktop database so creamlinux appears in your app launcher:
```bash
update-desktop-database ~/.local/share/applications
```
## Troubleshooting
### Common Issues
- **Game doesn't load**: Make sure the launch options are correctly set in Steam
- **DLCs not showing up**: Try refreshing the game list and reinstalling
- **Cannot find Steam**: Ensure Steam is installed and you've launched it at least once
### Debug Logs
Logs are stored at: `~/.cache/creamlinux/creamlinux.log`
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
### Development Setup
1. Clone this repository
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run tauri dev
```
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Credits
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
- [Tauri](https://tauri.app/) - Framework for building the desktop application
- [React](https://reactjs.org/) - UI library

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Creamlinux</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4044
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "creamlinux",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@tauri-apps/cli": "^2.5.0",
"@types/node": "^20.10.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"sass-embedded": "^1.86.3",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

4
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

6144
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[package]
name = "app"
version = "0.1.0"
description = "DLC Manager for Steam games on Linux"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.2.0", features = [] }
[dependencies]
serde_json = { version = "1.0", features = ["raw_value"] }
serde = { version = "1.0", features = ["derive"] }
bincode = "1.3"
regex = "1"
xdg = "2"
log = "0.4"
log4rs = "1.2"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
zip = "0.6"
tempfile = "3.8"
walkdir = "2.3"
parking_lot = "0.12"
tauri = { version = "2.5.0", features = [] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-dialog = "2.0.0-rc"
tauri-plugin-fs = "2.0.0-rc"
num_cpus = "1.16.0"
futures = "0.3.31"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

3
src-tauri/build.rs Normal file
View File

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

View File

@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

176
src-tauri/src/cache.rs Normal file
View File

@@ -0,0 +1,176 @@
// src/cache.rs
use serde::{Serialize, Deserialize};
use serde_json::json;
use std::path::{PathBuf};
use std::fs;
use std::io;
use std::time::{SystemTime};
use log::{info, warn};
use crate::dlc_manager::DlcInfoWithState;
// Cache entry with timestamp for expiration
#[derive(Serialize, Deserialize)]
struct CacheEntry<T> {
data: T,
timestamp: u64, // Unix timestamp in seconds
}
// Get the cache directory
fn get_cache_dir() -> io::Result<PathBuf> {
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let cache_dir = xdg_dirs.get_cache_home();
// Make sure the cache directory exists
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir)?;
}
Ok(cache_dir)
}
// Save data to cache file
pub fn save_to_cache<T>(key: &str, data: &T, _ttl_hours: u64) -> io::Result<()>
where
T: Serialize + ?Sized,
{
let cache_dir = get_cache_dir()?;
let cache_file = cache_dir.join(format!("{}.cache", key));
// Get current timestamp
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Create a JSON object with timestamp and data directly
let json_data = json!({
"timestamp": now,
"data": data // No clone needed here
});
// Serialize and write to file
let serialized = serde_json::to_string(&json_data)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(cache_file, serialized)?;
info!("Saved cache for key: {}", key);
Ok(())
}
// Load data from cache file if it exists and is not expired
pub fn load_from_cache<T>(key: &str, ttl_hours: u64) -> Option<T>
where
T: for<'de> Deserialize<'de>,
{
let cache_dir = match get_cache_dir() {
Ok(dir) => dir,
Err(e) => {
warn!("Failed to get cache directory: {}", e);
return None;
}
};
let cache_file = cache_dir.join(format!("{}.cache", key));
// Check if cache file exists
if !cache_file.exists() {
return None;
}
// Read and deserialize
let cached_data = match fs::read_to_string(&cache_file) {
Ok(data) => data,
Err(e) => {
warn!("Failed to read cache file {}: {}", cache_file.display(), e);
return None;
}
};
// Parse the JSON
let json_value: serde_json::Value = match serde_json::from_str(&cached_data) {
Ok(v) => v,
Err(e) => {
warn!("Failed to parse cache file {}: {}", cache_file.display(), e);
return None;
}
};
// Extract timestamp
let timestamp = match json_value.get("timestamp").and_then(|v| v.as_u64()) {
Some(ts) => ts,
None => {
warn!("Invalid timestamp in cache file {}", cache_file.display());
return None;
}
};
// Check expiration
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_hours = (now - timestamp) / 3600;
if age_hours > ttl_hours {
info!("Cache for key {} is expired ({} hours old)", key, age_hours);
return None;
}
// Extract data
let data: T = match serde_json::from_value(json_value["data"].clone()) {
Ok(d) => d,
Err(e) => {
warn!("Failed to parse data in cache file {}: {}", cache_file.display(), e);
return None;
}
};
info!("Using cache for key {} ({} hours old)", key, age_hours);
Some(data)
}
// Cache game scanning results
pub fn cache_games(games: &[crate::installer::Game]) -> io::Result<()> {
save_to_cache("games", games, 24) // Cache games for 24 hours
}
// Load cached game scanning results
pub fn load_cached_games() -> Option<Vec<crate::installer::Game>> {
load_from_cache("games", 24)
}
// Cache DLC list for a game
pub fn cache_dlcs(game_id: &str, dlcs: &[DlcInfoWithState]) -> io::Result<()> {
save_to_cache(&format!("dlc_{}", game_id), dlcs, 168) // Cache DLCs for 7 days (168 hours)
}
// Load cached DLC list
pub fn load_cached_dlcs(game_id: &str) -> Option<Vec<DlcInfoWithState>> {
load_from_cache(&format!("dlc_{}", game_id), 168)
}
// Clear all caches
pub fn clear_all_caches() -> io::Result<()> {
let cache_dir = get_cache_dir()?;
for entry in fs::read_dir(cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "cache") {
if let Err(e) = fs::remove_file(&path) {
warn!("Failed to remove cache file {}: {}", path.display(), e);
} else {
info!("Removed cache file: {}", path.display());
}
}
}
info!("All caches cleared");
Ok(())
}

View File

@@ -0,0 +1,339 @@
// src/dlc_manager.rs
use serde::{Serialize, Deserialize};
use std::fs;
use std::path::Path;
use log::{info, error};
use std::collections::{HashMap, HashSet};
use tauri::Manager;
/// More detailed DLC information with enabled state
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DlcInfoWithState {
pub appid: String,
pub name: String,
pub enabled: bool,
}
/// Parse the cream_api.ini file to extract both enabled and disabled DLCs
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
info!("Reading enabled DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() {
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
}
let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
};
// Extract DLCs - they are in the [dlc] section with format "appid = name"
let mut in_dlc_section = false;
let mut enabled_dlcs = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
// Check if we're in the DLC section
if trimmed == "[dlc]" {
in_dlc_section = true;
continue;
}
// Check if we're leaving the DLC section (another section begins)
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false;
continue;
}
// Skip empty lines and non-DLC comments
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
// Extract the DLC app ID
if let Some(appid) = trimmed.split('=').next() {
let appid_clean = appid.trim();
// Check if the line is commented out (indicating a disabled DLC)
if !appid_clean.starts_with("#") {
enabled_dlcs.push(appid_clean.to_string());
}
}
}
}
info!("Found {} enabled DLCs", enabled_dlcs.len());
Ok(enabled_dlcs)
}
/// Get all DLCs (both enabled and disabled) from cream_api.ini
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
info!("Reading all DLCs from {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() {
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
}
let contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
};
// Extract DLCs - both enabled and disabled
let mut in_dlc_section = false;
let mut all_dlcs = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
// Check if we're in the DLC section
if trimmed == "[dlc]" {
in_dlc_section = true;
continue;
}
// Check if we're leaving the DLC section (another section begins)
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false;
continue;
}
// Process DLC entries (both enabled and commented/disabled)
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
let is_commented = trimmed.starts_with("#");
let actual_line = if is_commented {
trimmed.trim_start_matches('#').trim()
} else {
trimmed
};
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
if parts.len() == 2 {
let appid = parts[0].trim();
let name = parts[1].trim();
all_dlcs.push(DlcInfoWithState {
appid: appid.to_string(),
name: name.to_string().trim_matches('"').to_string(),
enabled: !is_commented,
});
}
}
}
info!("Found {} total DLCs ({} enabled, {} disabled)",
all_dlcs.len(),
all_dlcs.iter().filter(|d| d.enabled).count(),
all_dlcs.iter().filter(|d| !d.enabled).count());
Ok(all_dlcs)
}
/// Update the cream_api.ini file with the user's DLC selections
pub fn update_dlc_configuration(game_path: &str, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
info!("Updating DLC configuration for {}", game_path);
let cream_api_path = Path::new(game_path).join("cream_api.ini");
if !cream_api_path.exists() {
return Err(format!("cream_api.ini not found at {}", cream_api_path.display()));
}
// Read the current file contents
let current_contents = match fs::read_to_string(&cream_api_path) {
Ok(c) => c,
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e))
};
// Create a mapping of DLC appid to its state for easy lookup
let dlc_states: HashMap<String, (bool, String)> = dlcs.iter()
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
.collect();
// Keep track of processed DLCs to avoid duplicates
let mut processed_dlcs = HashSet::new();
// Process the file line by line to retain most of the original structure
let mut new_contents = Vec::new();
let mut in_dlc_section = false;
for line in current_contents.lines() {
let trimmed = line.trim();
// Add section markers directly
if trimmed == "[dlc]" {
in_dlc_section = true;
new_contents.push(line.to_string());
continue;
}
// Check if we're leaving the DLC section (another section begins)
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
in_dlc_section = false;
// Before leaving the DLC section, add any DLCs that weren't processed yet
for (appid, (enabled, name)) in &dlc_states {
if !processed_dlcs.contains(appid) {
if *enabled {
new_contents.push(format!("{} = {}", appid, name));
} else {
new_contents.push(format!("# {} = {}", appid, name));
}
}
}
// Now add the section marker
new_contents.push(line.to_string());
continue;
}
if in_dlc_section && !trimmed.is_empty() {
let is_comment_line = trimmed.starts_with(';');
// If it's a regular comment line (not a DLC), keep it as is
if is_comment_line {
new_contents.push(line.to_string());
continue;
}
// Check if it's a commented-out DLC line or a regular DLC line
let is_commented = trimmed.starts_with("#");
let actual_line = if is_commented {
trimmed.trim_start_matches('#').trim()
} else {
trimmed
};
// Extract appid and name
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
if parts.len() == 2 {
let appid = parts[0].trim();
let name = parts[1].trim();
// Check if this DLC exists in our updated list
if let Some((enabled, _)) = dlc_states.get(appid) {
// Add the DLC with its updated state
if *enabled {
new_contents.push(format!("{} = {}", appid, name));
} else {
new_contents.push(format!("# {} = {}", appid, name));
}
processed_dlcs.insert(appid.to_string());
} else {
// Not in our list - keep the original line
new_contents.push(line.to_string());
}
} else {
// Invalid format or not a DLC line - keep as is
new_contents.push(line.to_string());
}
} else if !in_dlc_section || trimmed.is_empty() {
// Not a DLC line or empty line - keep as is
new_contents.push(line.to_string());
}
}
// If we never left the DLC section, make sure we add any unprocessed DLCs
if in_dlc_section {
for (appid, (enabled, name)) in &dlc_states {
if !processed_dlcs.contains(appid) {
if *enabled {
new_contents.push(format!("{} = {}", appid, name));
} else {
new_contents.push(format!("# {} = {}", appid, name));
}
}
}
}
// Write the updated file
match fs::write(&cream_api_path, new_contents.join("\n")) {
Ok(_) => {
info!("Successfully updated DLC configuration at {}", cream_api_path.display());
Ok(())
},
Err(e) => {
error!("Failed to write updated cream_api.ini: {}", e);
Err(format!("Failed to write updated cream_api.ini: {}", e))
}
}
}
/// Get app ID from game path by reading cream_api.ini
#[allow(dead_code)]
fn extract_app_id_from_config(game_path: &str) -> Option<String> {
if let Ok(contents) = fs::read_to_string(Path::new(game_path).join("cream_api.ini")) {
let re = regex::Regex::new(r"APPID\s*=\s*(\d+)").unwrap();
if let Some(cap) = re.captures(&contents) {
return Some(cap[1].to_string());
}
}
None
}
/// Create a custom installation with selected DLCs
pub async fn install_cream_with_dlcs(
game_id: String,
app_handle: tauri::AppHandle,
selected_dlcs: Vec<DlcInfoWithState>
) -> Result<(), String> {
use crate::AppState;
// Count enabled DLCs for logging
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
info!("Starting installation of CreamLinux with {} selected DLCs", enabled_dlc_count);
// Get the game from state
let game = {
let state = app_handle.state::<AppState>();
let games = state.games.lock();
match games.get(&game_id) {
Some(g) => g.clone(),
None => return Err(format!("Game with ID {} not found", game_id))
}
};
info!("Installing CreamLinux for game: {} ({})", game.title, game_id);
// Install CreamLinux first - but provide the DLCs directly instead of fetching them again
use crate::installer::install_creamlinux_with_dlcs;
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
let enabled_dlcs = selected_dlcs.iter()
.filter(|dlc| dlc.enabled)
.map(|dlc| crate::installer::DlcInfo {
appid: dlc.appid.clone(),
name: dlc.name.clone(),
})
.collect::<Vec<_>>();
let app_handle_clone = app_handle.clone();
let game_title = game.title.clone();
// Use direct installation with provided DLCs instead of re-fetching
match install_creamlinux_with_dlcs(
&game.path,
&game_id,
enabled_dlcs,
move |progress, message| {
// Emit progress updates during installation
use crate::installer::emit_progress;
emit_progress(
&app_handle_clone,
&format!("Installing CreamLinux for {}", game_title),
message,
progress * 100.0, // Scale progress from 0 to 100%
false,
false,
None
);
}
).await {
Ok(_) => {
info!("CreamLinux installation completed successfully for game: {}", game.title);
Ok(())
},
Err(e) => {
error!("Failed to install CreamLinux: {}", e);
Err(format!("Failed to install CreamLinux: {}", e))
}
}
}

997
src-tauri/src/installer.rs Normal file
View File

@@ -0,0 +1,997 @@
// src/installer.rs
use serde::{Serialize, Deserialize};
use std::fs;
use std::io;
use std::path::Path;
use log::{info, error, warn};
use reqwest;
use tauri::{AppHandle, Emitter};
use tempfile::tempdir;
use zip::ZipArchive;
use std::time::Duration;
use serde_json::json;
use std::sync::atomic::Ordering;
use crate::AppState;
use tauri::Manager;
// Constants for API endpoints and downloads
const CREAMLINUX_RELEASE_URL: &str = "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip";
const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI";
// Type of installer
#[derive(Debug, Clone, Copy)]
pub enum InstallerType {
Cream,
Smoke
}
// Action to perform
#[derive(Debug, Clone, Copy)]
pub enum InstallerAction {
Install,
Uninstall
}
// Error type combining all possible errors
#[derive(Debug)]
pub enum InstallerError {
IoError(io::Error),
ReqwestError(reqwest::Error),
ZipError(zip::result::ZipError),
InstallationError(String),
}
impl From<io::Error> for InstallerError {
fn from(err: io::Error) -> Self {
InstallerError::IoError(err)
}
}
impl From<reqwest::Error> for InstallerError {
fn from(err: reqwest::Error) -> Self {
InstallerError::ReqwestError(err)
}
}
impl From<zip::result::ZipError> for InstallerError {
fn from(err: zip::result::ZipError) -> Self {
InstallerError::ZipError(err)
}
}
impl std::fmt::Display for InstallerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InstallerError::IoError(e) => write!(f, "IO error: {}", e),
InstallerError::ReqwestError(e) => write!(f, "Network error: {}", e),
InstallerError::ZipError(e) => write!(f, "Zip extraction error: {}", e),
InstallerError::InstallationError(e) => write!(f, "Installation error: {}", e),
}
}
}
impl std::error::Error for InstallerError {}
/// DLC Information structure
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DlcInfo {
pub appid: String,
pub name: String,
}
/// Struct to hold installation instructions for the frontend
#[derive(Serialize, Debug, Clone)]
pub struct InstallationInstructions {
#[serde(rename = "type")]
pub type_: String,
pub command: String,
pub game_title: String,
pub dlc_count: Option<usize>,
}
/// Game information structure from searcher module
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Game {
pub id: String,
pub title: String,
pub path: String,
pub native: bool,
pub api_files: Vec<String>,
pub cream_installed: bool,
pub smoke_installed: bool,
pub installing: bool,
}
/// Emit a progress update to the frontend
pub fn emit_progress(
app_handle: &AppHandle,
title: &str,
message: &str,
progress: f32,
complete: bool,
show_instructions: bool,
instructions: Option<InstallationInstructions>
) {
let mut payload = json!({
"title": title,
"message": message,
"progress": progress,
"complete": complete,
"show_instructions": show_instructions
});
if let Some(inst) = instructions {
payload["instructions"] = serde_json::to_value(inst).unwrap_or_default();
}
if let Err(e) = app_handle.emit("installation-progress", payload) {
warn!("Failed to emit progress event: {}", e);
}
}
/// Process a single game action (install/uninstall Cream/Smoke)
pub async fn process_action(
_game_id: String,
installer_type: InstallerType,
action: InstallerAction,
game: Game,
app_handle: AppHandle
) -> Result<(), String> {
match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => {
// We only allow CreamLinux for native games
if !game.native {
return Err("CreamLinux can only be installed on native Linux games".to_string());
}
info!("Installing CreamLinux for game: {}", game.title);
let game_title = game.title.clone();
emit_progress(
&app_handle,
&format!("Installing CreamLinux for {}", game_title),
"Fetching DLC list...",
10.0,
false,
false,
None
);
// Fetch DLC list
let dlcs = match fetch_dlc_details(&game.id).await {
Ok(dlcs) => dlcs,
Err(e) => {
error!("Failed to fetch DLC details: {}", e);
return Err(format!("Failed to fetch DLC details: {}", e));
}
};
let dlc_count = dlcs.len();
info!("Found {} DLCs for {}", dlc_count, game_title);
emit_progress(
&app_handle,
&format!("Installing CreamLinux for {}", game_title),
"Downloading CreamLinux...",
30.0,
false,
false,
None
);
// Install CreamLinux
let app_handle_clone = app_handle.clone();
let game_title_clone = game_title.clone();
match install_creamlinux(&game.path, &game.id, dlcs, move |progress, message| {
// Emit progress updates during installation
emit_progress(
&app_handle_clone,
&format!("Installing CreamLinux for {}", game_title_clone),
message,
30.0 + (progress * 60.0), // Scale progress from 30% to 90%
false,
false,
None
);
}).await {
Ok(_) => {
// Emit completion with instructions
let instructions = InstallationInstructions {
type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(),
game_title: game_title.clone(),
dlc_count: Some(dlc_count)
};
emit_progress(
&app_handle,
&format!("Installation Completed: {}", game_title),
"CreamLinux has been installed successfully!",
100.0,
true,
true,
Some(instructions)
);
info!("CreamLinux installation completed for: {}", game_title);
Ok(())
},
Err(e) => {
error!("Failed to install CreamLinux: {}", e);
Err(format!("Failed to install CreamLinux: {}", e))
}
}
},
(InstallerType::Cream, InstallerAction::Uninstall) => {
// Ensure this is a native game
if !game.native {
return Err("CreamLinux can only be uninstalled from native Linux games".to_string());
}
let game_title = game.title.clone();
info!("Uninstalling CreamLinux from game: {}", game_title);
emit_progress(
&app_handle,
&format!("Uninstalling CreamLinux from {}", game_title),
"Removing CreamLinux files...",
30.0,
false,
false,
None
);
// Uninstall CreamLinux
match uninstall_creamlinux(&game.path) {
Ok(_) => {
// Emit completion with instructions
let instructions = InstallationInstructions {
type_: "cream_uninstall".to_string(),
command: "sh ./cream.sh %command%".to_string(),
game_title: game_title.clone(),
dlc_count: None
};
emit_progress(
&app_handle,
&format!("Uninstallation Completed: {}", game_title),
"CreamLinux has been uninstalled successfully!",
100.0,
true,
true,
Some(instructions)
);
info!("CreamLinux uninstallation completed for: {}", game_title);
Ok(())
},
Err(e) => {
error!("Failed to uninstall CreamLinux: {}", e);
Err(format!("Failed to uninstall CreamLinux: {}", e))
}
}
},
(InstallerType::Smoke, InstallerAction::Install) => {
// We only allow SmokeAPI for Proton/Windows games
if game.native {
return Err("SmokeAPI can only be installed on Proton/Windows games".to_string());
}
// Check if we have any Steam API DLLs to patch
if game.api_files.is_empty() {
return Err("No Steam API DLLs found to patch. SmokeAPI cannot be installed.".to_string());
}
let game_title = game.title.clone();
info!("Installing SmokeAPI for game: {}", game_title);
emit_progress(
&app_handle,
&format!("Installing SmokeAPI for {}", game_title),
"Fetching SmokeAPI release information...",
10.0,
false,
false,
None
);
// Create clones for the closure
let app_handle_clone = app_handle.clone();
let game_title_clone = game_title.clone();
let api_files = game.api_files.clone();
// Call the SmokeAPI installation with progress updates
match install_smokeapi(&game.path, &api_files, move |progress, message| {
// Emit progress updates during installation
emit_progress(
&app_handle_clone,
&format!("Installing SmokeAPI for {}", game_title_clone),
message,
10.0 + (progress * 90.0), // Scale progress from 10% to 100%
false,
false,
None
);
}).await {
Ok(_) => {
// Emit completion with instructions
let instructions = InstallationInstructions {
type_: "smoke_install".to_string(),
command: "No additional steps needed. SmokeAPI will work automatically.".to_string(),
game_title: game_title.clone(),
dlc_count: Some(game.api_files.len())
};
emit_progress(
&app_handle,
&format!("Installation Completed: {}", game_title),
"SmokeAPI has been installed successfully!",
100.0,
true,
true,
Some(instructions)
);
info!("SmokeAPI installation completed for: {}", game_title);
Ok(())
},
Err(e) => {
error!("Failed to install SmokeAPI: {}", e);
Err(format!("Failed to install SmokeAPI: {}", e))
}
}
},
(InstallerType::Smoke, InstallerAction::Uninstall) => {
// Ensure this is a non-native game
if game.native {
return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string());
}
let game_title = game.title.clone();
info!("Uninstalling SmokeAPI from game: {}", game_title);
emit_progress(
&app_handle,
&format!("Uninstalling SmokeAPI from {}", game_title),
"Restoring original files...",
30.0,
false,
false,
None
);
// Uninstall SmokeAPI
match uninstall_smokeapi(&game.path, &game.api_files) {
Ok(_) => {
// Emit completion with instructions
let instructions = InstallationInstructions {
type_: "smoke_uninstall".to_string(),
command: "Original Steam API files have been restored.".to_string(),
game_title: game_title.clone(),
dlc_count: None
};
emit_progress(
&app_handle,
&format!("Uninstallation Completed: {}", game_title),
"SmokeAPI has been uninstalled successfully!",
100.0,
true,
true,
Some(instructions)
);
info!("SmokeAPI uninstallation completed for: {}", game_title);
Ok(())
},
Err(e) => {
error!("Failed to uninstall SmokeAPI: {}", e);
Err(format!("Failed to uninstall SmokeAPI: {}", e))
}
}
}
}
}
//
// CreamLinux specific functions
//
/// Install CreamLinux for a game
async fn install_creamlinux<F>(
game_path: &str,
app_id: &str,
dlcs: Vec<DlcInfo>,
progress_callback: F
) -> Result<(), InstallerError>
where
F: Fn(f32, &str) + Send + 'static
{
// Progress update
progress_callback(0.1, "Preparing to download CreamLinux...");
// Download CreamLinux zip
let client = reqwest::Client::new();
progress_callback(0.2, "Downloading CreamLinux...");
let response = client.get(CREAMLINUX_RELEASE_URL)
.timeout(Duration::from_secs(30))
.send()
.await?;
if !response.status().is_success() {
return Err(InstallerError::InstallationError(
format!("Failed to download CreamLinux: HTTP {}", response.status())
));
}
// Save to temporary file
progress_callback(0.4, "Saving downloaded files...");
let temp_dir = tempdir()?;
let zip_path = temp_dir.path().join("creamlinux.zip");
let content = response.bytes().await?;
fs::write(&zip_path, &content)?;
// Extract the zip
progress_callback(0.5, "Extracting CreamLinux files...");
let file = fs::File::open(&zip_path)?;
let mut archive = ZipArchive::new(file)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let outpath = Path::new(game_path).join(file.name());
if file.name().ends_with('/') {
fs::create_dir_all(&outpath)?;
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
fs::create_dir_all(p)?;
}
}
let mut outfile = fs::File::create(&outpath)?;
io::copy(&mut file, &mut outfile)?;
}
// Set executable permissions for cream.sh
if file.name() == "cream.sh" {
progress_callback(0.6, "Setting executable permissions...");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&outpath)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&outpath, perms)?;
}
}
}
// Create cream_api.ini with DLC info
progress_callback(0.8, "Creating configuration file...");
let cream_api_path = Path::new(game_path).join("cream_api.ini");
let mut config = String::new();
config.push_str(&format!("APPID = {}\n[config]\n", app_id));
config.push_str("issubscribedapp_on_false_use_real = true\n");
config.push_str("[methods]\n");
config.push_str("disable_steamapps_issubscribedapp = false\n");
config.push_str("[dlc]\n");
for dlc in dlcs {
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
}
fs::write(cream_api_path, config)?;
progress_callback(1.0, "Installation completed successfully!");
Ok(())
}
/// Install CreamLinux for a game with pre-fetched DLC list
/// This avoids the redundant network calls to Steam API
pub async fn install_creamlinux_with_dlcs<F>(
game_path: &str,
app_id: &str,
dlcs: Vec<DlcInfo>,
progress_callback: F
) -> Result<(), InstallerError>
where
F: Fn(f32, &str) + Send + 'static
{
// Progress update
progress_callback(0.1, "Preparing to download CreamLinux...");
// Download CreamLinux zip
let client = reqwest::Client::new();
progress_callback(0.2, "Downloading CreamLinux...");
let response = client.get(CREAMLINUX_RELEASE_URL)
.timeout(Duration::from_secs(30))
.send()
.await?;
if !response.status().is_success() {
return Err(InstallerError::InstallationError(
format!("Failed to download CreamLinux: HTTP {}", response.status())
));
}
// Save to temporary file
progress_callback(0.4, "Saving downloaded files...");
let temp_dir = tempdir()?;
let zip_path = temp_dir.path().join("creamlinux.zip");
let content = response.bytes().await?;
fs::write(&zip_path, &content)?;
// Extract the zip
progress_callback(0.5, "Extracting CreamLinux files...");
let file = fs::File::open(&zip_path)?;
let mut archive = ZipArchive::new(file)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let outpath = Path::new(game_path).join(file.name());
if file.name().ends_with('/') {
fs::create_dir_all(&outpath)?;
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
fs::create_dir_all(p)?;
}
}
let mut outfile = fs::File::create(&outpath)?;
io::copy(&mut file, &mut outfile)?;
}
// Set executable permissions for cream.sh
if file.name() == "cream.sh" {
progress_callback(0.6, "Setting executable permissions...");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&outpath)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&outpath, perms)?;
}
}
}
// Create cream_api.ini with DLC info - using the provided DLCs directly
progress_callback(0.8, "Creating configuration file...");
let cream_api_path = Path::new(game_path).join("cream_api.ini");
let mut config = String::new();
config.push_str(&format!("APPID = {}\n[config]\n", app_id));
config.push_str("issubscribedapp_on_false_use_real = true\n");
config.push_str("[methods]\n");
config.push_str("disable_steamapps_issubscribedapp = false\n");
config.push_str("[dlc]\n");
for dlc in dlcs {
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
}
fs::write(cream_api_path, config)?;
progress_callback(1.0, "Installation completed successfully!");
Ok(())
}
/// Uninstall CreamLinux from a game
fn uninstall_creamlinux(game_path: &str) -> Result<(), InstallerError> {
info!("Uninstalling CreamLinux from: {}", game_path);
// Files to remove during uninstallation
let files_to_remove = [
"cream.sh",
"cream_api.ini",
"cream_api.so",
"lib32Creamlinux.so",
"lib64Creamlinux.so"
];
for file in &files_to_remove {
let file_path = Path::new(game_path).join(file);
if file_path.exists() {
match fs::remove_file(&file_path) {
Ok(_) => info!("Removed file: {}", file_path.display()),
Err(e) => {
error!("Failed to remove {}: {}", file_path.display(), e);
// Continue with other files even if one fails
}
}
}
}
info!("CreamLinux uninstallation completed for: {}", game_path);
Ok(())
}
/// Fetch DLC details from Steam API
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, InstallerError> {
let client = reqwest::Client::new();
let base_url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id);
let response = client.get(&base_url)
.timeout(Duration::from_secs(10))
.send()
.await?;
if !response.status().is_success() {
return Err(InstallerError::InstallationError(
format!("Failed to fetch game details: HTTP {}", response.status())
));
}
let data: serde_json::Value = response.json().await?;
let dlc_ids = match data.get(app_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("dlc"))
{
Some(dlc_array) => {
match dlc_array.as_array() {
Some(array) => array.iter()
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
.collect::<Vec<String>>(),
_ => Vec::new(),
}
},
_ => Vec::new(),
};
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
let mut dlc_details = Vec::new();
for dlc_id in dlc_ids {
let dlc_url = format!("https://store.steampowered.com/api/appdetails?appids={}", dlc_id);
// Add a small delay to avoid rate limiting
tokio::time::sleep(Duration::from_millis(300)).await;
let dlc_response = client.get(&dlc_url)
.timeout(Duration::from_secs(10))
.send()
.await?;
if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response.json().await?;
let dlc_name = match dlc_data.get(&dlc_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("name"))
{
Some(name) => {
match name.as_str() {
Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(),
}
},
_ => "Unknown DLC".to_string(),
};
info!("Found DLC: {} ({})", dlc_name, dlc_id);
dlc_details.push(DlcInfo {
appid: dlc_id,
name: dlc_name,
});
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
// If rate limited, wait longer
error!("Rate limited by Steam API, waiting 10 seconds");
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
info!("Successfully retrieved details for {} DLCs", dlc_details.len());
Ok(dlc_details)
}
/// Fetch DLC details from Steam API with progress updates
pub async fn fetch_dlc_details_with_progress(app_id: &str, app_handle: &tauri::AppHandle) -> Result<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
let state = app_handle.state::<AppState>();
let should_cancel = state.fetch_cancellation.clone();
let client = reqwest::Client::new();
let base_url = format!("https://store.steampowered.com/api/appdetails?appids={}", app_id);
// Emit initial progress
emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
info!("Emitted initial DLC progress: 5%");
let response = client.get(&base_url)
.timeout(Duration::from_secs(10))
.send()
.await?;
if !response.status().is_success() {
let error_msg = format!("Failed to fetch game details: HTTP {}", response.status());
error!("{}", error_msg);
return Err(InstallerError::InstallationError(error_msg));
}
let data: serde_json::Value = response.json().await?;
let dlc_ids = match data.get(app_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("dlc"))
{
Some(dlc_array) => {
match dlc_array.as_array() {
Some(array) => array.iter()
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
.collect::<Vec<String>>(),
_ => Vec::new(),
}
},
_ => Vec::new(),
};
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
emit_dlc_progress(app_handle, &format!("Found {} DLCs. Fetching details...", dlc_ids.len()), 10, None);
info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len());
let mut dlc_details = Vec::new();
let total_dlcs = dlc_ids.len();
for (index, dlc_id) in dlc_ids.iter().enumerate() {
// Check if cancellation was requested
if should_cancel.load(Ordering::SeqCst) {
info!("DLC fetch cancelled for game {}", app_id);
return Err(InstallerError::InstallationError("Operation cancelled by user".to_string()));
}
let progress_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0;
let progress_rounded = progress_percent as u32;
let remaining_dlcs = total_dlcs - index;
// Estimate time remaining (rough calculation - 300ms per DLC)
let est_time_left = if remaining_dlcs > 0 {
let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32;
if seconds < 60 {
format!("~{} seconds", seconds)
} else {
format!("~{} minute(s)", (seconds as f32 / 60.0).ceil() as u32)
}
} else {
"almost done".to_string()
};
info!("Processing DLC {}/{} - Progress: {}%", index + 1, total_dlcs, progress_rounded);
emit_dlc_progress(
app_handle,
&format!("Processing DLC {}/{}", index + 1, total_dlcs),
progress_rounded,
Some(&est_time_left)
);
let dlc_url = format!("https://store.steampowered.com/api/appdetails?appids={}", dlc_id);
// Add a small delay to avoid rate limiting
tokio::time::sleep(Duration::from_millis(300)).await;
let dlc_response = client.get(&dlc_url)
.timeout(Duration::from_secs(10))
.send()
.await?;
if dlc_response.status().is_success() {
let dlc_data: serde_json::Value = dlc_response.json().await?;
let dlc_name = match dlc_data.get(&dlc_id)
.and_then(|app| app.get("data"))
.and_then(|data| data.get("name"))
{
Some(name) => {
match name.as_str() {
Some(s) => s.to_string(),
_ => "Unknown DLC".to_string(),
}
},
_ => "Unknown DLC".to_string(),
};
info!("Found DLC: {} ({})", dlc_name, dlc_id);
let dlc_info = DlcInfo {
appid: dlc_id.clone(),
name: dlc_name,
};
// Emit each DLC as we find it
if let Ok(json) = serde_json::to_string(&dlc_info) {
if let Err(e) = app_handle.emit("dlc-found", json) {
warn!("Failed to emit dlc-found event: {}", e);
} else {
info!("Emitted dlc-found event for DLC: {}", dlc_id);
}
}
dlc_details.push(dlc_info);
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
// If rate limited, wait longer
error!("Rate limited by Steam API, waiting 10 seconds");
emit_dlc_progress(app_handle, "Rate limited by Steam. Waiting...", progress_rounded, None);
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
// Final progress update
info!("Completed DLC fetch. Found {} DLCs in total", dlc_details.len());
emit_dlc_progress(app_handle, &format!("Completed! Found {} DLCs", dlc_details.len()), 100, None);
info!("Emitted final DLC progress: 100%");
Ok(dlc_details)
}
/// Emit DLC progress updates to the frontend
fn emit_dlc_progress(
app_handle: &tauri::AppHandle,
message: &str,
progress: u32,
time_left: Option<&str>
) {
let mut payload = json!({
"message": message,
"progress": progress
});
if let Some(time) = time_left {
payload["timeLeft"] = json!(time);
}
if let Err(e) = app_handle.emit("dlc-progress", payload) {
warn!("Failed to emit dlc-progress event: {}", e);
}
}
//
// SmokeAPI specific functions
//
/// Install SmokeAPI for a game
async fn install_smokeapi<F>(
game_path: &str,
api_files: &[String],
progress_callback: F
) -> Result<(), InstallerError>
where
F: Fn(f32, &str) + Send + 'static
{
// 1. Get the latest SmokeAPI release
progress_callback(0.1, "Fetching latest SmokeAPI release...");
let client = reqwest::Client::new();
let releases_url = format!("https://api.github.com/repos/{}/releases/latest", SMOKEAPI_REPO);
let response = client.get(&releases_url)
.header("User-Agent", "CreamLinux")
.timeout(Duration::from_secs(10))
.send()
.await?;
if !response.status().is_success() {
return Err(InstallerError::InstallationError(
format!("Failed to fetch SmokeAPI releases: HTTP {}", response.status())
));
}
let release_info: serde_json::Value = response.json().await?;
let latest_version = match release_info.get("tag_name") {
Some(tag) => tag.as_str().unwrap_or("latest"),
_ => "latest",
};
info!("Latest SmokeAPI version: {}", latest_version);
// 2. Construct download URL
let zip_url = format!(
"https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip",
SMOKEAPI_REPO, latest_version, latest_version
);
// 3. Download the zip
progress_callback(0.3, "Downloading SmokeAPI...");
let response = client.get(&zip_url)
.timeout(Duration::from_secs(30))
.send()
.await?;
if !response.status().is_success() {
return Err(InstallerError::InstallationError(
format!("Failed to download SmokeAPI: HTTP {}", response.status())
));
}
// 4. Save to temporary file
progress_callback(0.5, "Saving downloaded files...");
let temp_dir = tempdir()?;
let zip_path = temp_dir.path().join("smokeapi.zip");
let content = response.bytes().await?;
fs::write(&zip_path, &content)?;
// 5. Extract and install for each API file
progress_callback(0.6, "Extracting SmokeAPI files...");
let file = fs::File::open(&zip_path)?;
let mut archive = ZipArchive::new(file)?;
for (i, api_file) in api_files.iter().enumerate() {
let progress = 0.6 + (i as f32 / api_files.len() as f32) * 0.3;
progress_callback(progress, &format!("Installing SmokeAPI for {}", api_file));
let api_dir = Path::new(game_path).join(Path::new(api_file).parent().unwrap_or_else(|| Path::new("")));
let api_name = Path::new(api_file).file_name().unwrap_or_default();
// Backup original file
let original_path = api_dir.join(api_name);
let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll"));
info!("Processing: {}", original_path.display());
info!("Backup path: {}", backup_path.display());
// Only backup if not already backed up
if !backup_path.exists() && original_path.exists() {
fs::copy(&original_path, &backup_path)?;
info!("Created backup: {}", backup_path.display());
}
// Extract the appropriate DLL directly to the game directory
if let Ok(mut file) = archive.by_name(&api_name.to_string_lossy()) {
let mut outfile = fs::File::create(&original_path)?;
io::copy(&mut file, &mut outfile)?;
info!("Installed SmokeAPI as: {}", original_path.display());
} else {
return Err(InstallerError::InstallationError(
format!("Could not find {} in the SmokeAPI zip file", api_name.to_string_lossy())
));
}
}
progress_callback(1.0, "SmokeAPI installation completed!");
info!("SmokeAPI installation completed for: {}", game_path);
Ok(())
}
/// Uninstall SmokeAPI from a game
fn uninstall_smokeapi(game_path: &str, api_files: &[String]) -> Result<(), InstallerError> {
info!("Uninstalling SmokeAPI from: {}", game_path);
for api_file in api_files {
let api_path = Path::new(game_path).join(api_file);
let api_dir = api_path.parent().unwrap_or_else(|| Path::new(game_path));
let api_name = api_path.file_name().unwrap_or_default();
let original_path = api_dir.join(api_name);
let backup_path = api_dir.join(api_name.to_string_lossy().replace(".dll", "_o.dll"));
info!("Processing: {}", original_path.display());
info!("Backup path: {}", backup_path.display());
if backup_path.exists() {
// Remove the SmokeAPI version
if original_path.exists() {
match fs::remove_file(&original_path) {
Ok(_) => info!("Removed SmokeAPI file: {}", original_path.display()),
Err(e) => error!("Failed to remove SmokeAPI file: {}, error: {}", original_path.display(), e)
}
}
// Restore the original file
match fs::rename(&backup_path, &original_path) {
Ok(_) => info!("Restored original file: {}", original_path.display()),
Err(e) => {
error!("Failed to restore original file: {}, error: {}", original_path.display(), e);
// 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)) {
error!("Failed to copy backup file: {}", copy_err);
}
}
}
} else {
info!("No backup found for: {}", api_file);
}
}
info!("SmokeAPI uninstallation completed for: {}", game_path);
Ok(())
}

487
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,487 @@
// src/main.rs
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod searcher;
mod installer;
mod dlc_manager;
mod cache; // Keep the module for now, but we won't use its functionality
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use parking_lot::Mutex;
use tokio::time::Instant;
use tokio::time::Duration;
use tauri::State;
use tauri::{Manager, Emitter};
use log::{info, warn, error, debug};
use installer::{InstallerType, InstallerAction, Game};
use dlc_manager::DlcInfoWithState;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GameAction {
game_id: String,
action: String,
}
#[derive(Debug, Clone)]
struct DlcCache {
data: Vec<DlcInfoWithState>,
timestamp: Instant,
}
// Structure to hold the state of installed games
struct AppState {
games: Mutex<HashMap<String, Game>>,
dlc_cache: Mutex<HashMap<String, DlcCache>>,
fetch_cancellation: Arc<AtomicBool>,
}
#[tauri::command]
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
dlc_manager::get_all_dlcs(&game_path)
}
// Scan and get the list of Steam games
#[tauri::command]
async fn scan_steam_games(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<Vec<Game>, String> {
info!("Starting Steam games scan");
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
// Get default Steam paths
let paths = searcher::get_default_steam_paths();
// Find Steam libraries
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
let libraries = searcher::find_steam_libraries(&paths);
// Group libraries by path to avoid duplicates in logs
let mut unique_libraries = std::collections::HashSet::new();
for lib in &libraries {
unique_libraries.insert(lib.to_string_lossy().to_string());
}
info!("Found {} Steam library directories:", unique_libraries.len());
for (i, lib) in unique_libraries.iter().enumerate() {
info!(" Library {}: {}", i+1, lib);
}
emit_scan_progress(&app_handle, &format!("Found {} Steam libraries. Starting game scan...", unique_libraries.len()), 20);
// Find installed games
let games_info = searcher::find_installed_games(&libraries).await;
emit_scan_progress(&app_handle, &format!("Found {} games. Processing...", games_info.len()), 90);
// Log summary of games found
info!("Games scan complete - Found {} games", games_info.len());
info!("Native games: {}", games_info.iter().filter(|g| g.native).count());
info!("Proton games: {}", games_info.iter().filter(|g| !g.native).count());
info!("Games with CreamLinux: {}", games_info.iter().filter(|g| g.cream_installed).count());
info!("Games with SmokeAPI: {}", games_info.iter().filter(|g| g.smoke_installed).count());
// Convert to our Game struct
let mut result = Vec::new();
info!("Processing games into application state...");
for game_info in games_info {
// Only log detailed game info at Debug level to keep Info logs cleaner
debug!("Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed);
let game = Game {
id: game_info.id,
title: game_info.title,
path: game_info.path.to_string_lossy().to_string(),
native: game_info.native,
api_files: game_info.api_files,
cream_installed: game_info.cream_installed,
smoke_installed: game_info.smoke_installed,
installing: false,
};
result.push(game.clone());
// Store in state for later use
state.games.lock().insert(game.id.clone(), game);
}
emit_scan_progress(&app_handle, &format!("Scan complete. Found {} games.", result.len()), 100);
info!("Game scan completed successfully");
Ok(result)
}
// Helper function to emit scan progress events
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
// Log first, then emit the event
info!("Scan progress: {}% - {}", progress, message);
let payload = serde_json::json!({
"message": message,
"progress": progress
});
if let Err(e) = app_handle.emit("scan-progress", payload) {
warn!("Failed to emit scan-progress event: {}", e);
}
}
// Fetch game info by ID - useful for single game updates
#[tauri::command]
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
let games = state.games.lock();
games.get(&game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id))
}
// Unified action handler for installation and uninstallation
#[tauri::command]
async fn process_game_action(
game_action: GameAction,
state: State<'_, AppState>,
app_handle: tauri::AppHandle
) -> Result<Game, String> {
// Clone the information we need from state to avoid lifetime issues
let game = {
let games = state.games.lock();
games.get(&game_action.game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
};
// Parse the action string to determine type and operation
let (installer_type, action) = match game_action.action.as_str() {
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
_ => return Err(format!("Invalid action: {}", game_action.action))
};
// Execute the action
installer::process_action(
game_action.game_id.clone(),
installer_type,
action,
game.clone(),
app_handle.clone()
).await?;
// Update game status in state based on the action
let updated_game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_action.game_id)
.ok_or_else(|| format!("Game with ID {} not found after action", game_action.game_id))?;
// Update installation status
match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => {
game.cream_installed = true;
},
(InstallerType::Cream, InstallerAction::Uninstall) => {
game.cream_installed = false;
},
(InstallerType::Smoke, InstallerAction::Install) => {
game.smoke_installed = true;
},
(InstallerType::Smoke, InstallerAction::Uninstall) => {
game.smoke_installed = false;
}
}
// Reset installing flag
game.installing = false;
// Return updated game info
game.clone()
};
// Removed cache update
// Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
Ok(updated_game)
}
// Fetch DLC list for a game
#[tauri::command]
async fn fetch_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<Vec<DlcInfoWithState>, String> {
info!("Fetching DLCs for game ID: {}", game_id);
// Removed cache checking
// Always fetch fresh DLC data instead of using cache
match installer::fetch_dlc_details(&game_id).await {
Ok(dlcs) => {
// Convert to DlcInfoWithState (all enabled by default)
let dlcs_with_state = dlcs.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Cache in memory for this session (but not on disk)
let state = app_handle.state::<AppState>();
let mut cache = state.dlc_cache.lock();
cache.insert(game_id.clone(), DlcCache {
data: dlcs_with_state.clone(),
timestamp: Instant::now(),
});
Ok(dlcs_with_state)
},
Err(e) => Err(format!("Failed to fetch DLC details: {}", e))
}
}
#[tauri::command]
fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Request to abort DLC fetch for game ID: {}", game_id);
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(true, Ordering::SeqCst);
// Reset after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst);
});
Ok(())
}
// Fetch DLC list with progress updates (streaming)
#[tauri::command]
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Streaming DLCs for game ID: {}", game_id);
// Removed cached DLC check - always fetch fresh data
// Always fetch fresh DLC data from API
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
Ok(dlcs) => {
info!("Successfully streamed {} DLCs for game {}", dlcs.len(), game_id);
// Convert to DLCInfoWithState for in-memory caching only
let dlcs_with_state = dlcs.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Update in-memory cache without storing to disk
let state = app_handle.state::<AppState>();
let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(game_id.clone(), DlcCache {
data: dlcs_with_state,
timestamp: tokio::time::Instant::now(),
});
Ok(())
},
Err(e) => {
error!("Failed to stream DLC details: {}", e);
// Emit error event
let error_payload = serde_json::json!({
"error": format!("Failed to fetch DLC details: {}", e)
});
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
warn!("Failed to emit dlc-error event: {}", emit_err);
}
Err(format!("Failed to fetch DLC details: {}", e))
}
}
}
// Clear caches command renamed to flush_data for clarity
#[tauri::command]
fn clear_caches() -> Result<(), String> {
info!("Data flush requested - cleaning in-memory state only");
Ok(())
}
// Get the list of enabled DLCs for a game
#[tauri::command]
fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
info!("Getting enabled DLCs for: {}", game_path);
dlc_manager::get_enabled_dlcs(&game_path)
}
// Update the DLC configuration for a game
#[tauri::command]
fn update_dlc_configuration_command(game_path: String, dlcs: Vec<DlcInfoWithState>) -> Result<(), String> {
info!("Updating DLC configuration for: {}", game_path);
dlc_manager::update_dlc_configuration(&game_path, dlcs)
}
// Install CreamLinux with selected DLCs
#[tauri::command]
async fn install_cream_with_dlcs_command(
game_id: String,
selected_dlcs: Vec<DlcInfoWithState>,
app_handle: tauri::AppHandle
) -> Result<Game, String> {
info!("Installing CreamLinux with selected DLCs for game: {}", game_id);
// Clone selected_dlcs for later use
let selected_dlcs_clone = selected_dlcs.clone();
// Install CreamLinux with the selected DLCs
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs).await {
Ok(_) => {
// Return updated game info
let state = app_handle.state::<AppState>();
// Get a mutable reference and update the game
let game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_id)
.ok_or_else(|| format!("Game with ID {} not found after installation", game_id))?;
// Update installation status
game.cream_installed = true;
game.installing = false;
// Clone the game for returning later
game.clone()
}; // mutable borrow ends here
// Removed game caching
// Emit an event to update the UI
if let Err(e) = app_handle.emit("game-updated", &game) {
warn!("Failed to emit game-updated event: {}", e);
}
// Show installation complete dialog with instructions
let instructions = installer::InstallationInstructions {
type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(),
game_title: game.title.clone(),
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count())
};
installer::emit_progress(
&app_handle,
&format!("Installation Completed: {}", game.title),
"CreamLinux has been installed successfully!",
100.0,
true,
true,
Some(instructions)
);
Ok(game)
},
Err(e) => {
error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!("Failed to install CreamLinux with selected DLCs: {}", e))
}
}
}
// Setup logging
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
use log::LevelFilter;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Config, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs;
// Get XDG cache directory
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
// Clear the log file on startup
if log_path.exists() {
if let Err(e) = fs::write(&log_path, "") {
eprintln!("Warning: Failed to clear log file: {}", e);
}
}
// Create a file appender with improved log format
let file = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n"
)))
.build(log_path)?;
// Build the config
let config = Config::builder()
.appender(Appender::builder().build("file", Box::new(file)))
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
// Initialize log4rs with this config
log4rs::init_config(config)?;
info!("CreamLinux started with a clean log file");
Ok(())
}
fn main() {
// Set up logging first
if let Err(e) = setup_logging() {
eprintln!("Warning: Failed to initialize logging: {}", e);
}
info!("Initializing CreamLinux application");
let app_state = AppState {
games: Mutex::new(HashMap::new()),
dlc_cache: Mutex::new(HashMap::new()),
fetch_cancellation: Arc::new(AtomicBool::new(false)),
};
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.manage(app_state)
.invoke_handler(tauri::generate_handler![
scan_steam_games,
get_game_info,
process_game_action,
fetch_game_dlcs,
stream_game_dlcs,
get_enabled_dlcs_command,
update_dlc_configuration_command,
install_cream_with_dlcs_command,
get_all_dlcs_command,
clear_caches,
abort_dlc_fetch,
])
.setup(|app| {
// Add a setup handler to do any initialization work
info!("Tauri application setup");
#[cfg(debug_assertions)]
{
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
if let Some(window) = app.get_webview_window("main") {
window.open_devtools();
}
}
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

587
src-tauri/src/searcher.rs Normal file
View File

@@ -0,0 +1,587 @@
// src/searcher.rs
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::collections::HashSet;
use log::{info, debug, warn, error};
use regex::Regex;
use walkdir::WalkDir;
use tokio::sync::mpsc;
use std::sync::Arc;
/// Game information structure
#[derive(Debug, Clone)]
pub struct GameInfo {
pub id: String,
pub title: String,
pub path: PathBuf,
pub native: bool,
pub api_files: Vec<String>,
pub cream_installed: bool,
pub smoke_installed: bool,
}
/// Find potential Steam installation directories
pub fn get_default_steam_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Get user's home directory
if let Ok(home) = std::env::var("HOME") {
info!("Searching for Steam in home directory: {}", home);
// Common Steam installation locations on Linux
let common_paths = [
".steam/steam", // Steam symlink directory
".steam/root", // Alternative symlink
".local/share/Steam", // Flatpak Steam installation
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
"/run/media/mmcblk0p1", // Removable Storage path
];
for path in &common_paths {
let full_path = PathBuf::from(&home).join(path);
if full_path.exists() {
debug!("Found Steam directory: {}", full_path.display());
paths.push(full_path);
}
}
}
// Add Steam Deck paths if they exist (these don't rely on HOME)
let deck_paths = [
"/home/deck/.steam/steam",
"/home/deck/.local/share/Steam",
];
for path in &deck_paths {
let p = PathBuf::from(path);
if p.exists() && !paths.contains(&p) {
debug!("Found Steam Deck path: {}", p.display());
paths.push(p);
}
}
// Try to extract paths from Steam registry file
if let Some(registry_paths) = read_steam_registry() {
for path in registry_paths {
if !paths.contains(&path) && path.exists() {
debug!("Adding Steam path from registry: {}", path.display());
paths.push(path);
}
}
}
info!("Found {} potential Steam directories", paths.len());
paths
}
/// Try to read the Steam registry file to find installation paths
fn read_steam_registry() -> Option<Vec<PathBuf>> {
let home = match std::env::var("HOME") {
Ok(h) => h,
Err(_) => return None,
};
let registry_paths = [
format!("{}/.steam/registry.vdf", home),
format!("{}/.steam/steam/registry.vdf", home),
format!("{}/.local/share/Steam/registry.vdf", home),
];
for registry_path in registry_paths {
let path = Path::new(&registry_path);
if path.exists() {
debug!("Found Steam registry at: {}", path.display());
if let Ok(content) = fs::read_to_string(path) {
let mut paths = Vec::new();
// Extract Steam installation paths
let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap();
if let Some(cap) = re_steam_path.captures(&content) {
let steam_path = PathBuf::from(&cap[1]);
paths.push(steam_path);
}
// Look for install path
let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap();
if let Some(cap) = re_install_path.captures(&content) {
let install_path = PathBuf::from(&cap[1]);
if !paths.contains(&install_path) {
paths.push(install_path);
}
}
if !paths.is_empty() {
return Some(paths);
}
}
}
}
None
}
/// Find all Steam library folders from base Steam installation paths
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
let mut libraries = HashSet::new();
for base_path in base_paths {
debug!("Looking for Steam libraries in: {}", base_path.display());
// Check if this path contains a steamapps directory
let steamapps_path = base_path.join("steamapps");
if steamapps_path.exists() && steamapps_path.is_dir() {
debug!("Found steamapps directory: {}", steamapps_path.display());
libraries.insert(steamapps_path.clone());
// Check for additional libraries in libraryfolders.vdf
parse_library_folders_vdf(&steamapps_path, &mut libraries);
}
// Also check for steamapps in common locations relative to this path
let possible_steamapps = [
base_path.join("steam/steamapps"),
base_path.join("Steam/steamapps"),
];
for path in &possible_steamapps {
if path.exists() && path.is_dir() && !libraries.contains(path) {
debug!("Found steamapps directory: {}", path.display());
libraries.insert(path.clone());
// Check for additional libraries in libraryfolders.vdf
parse_library_folders_vdf(path, &mut libraries);
}
}
}
let result: Vec<PathBuf> = libraries.into_iter().collect();
info!("Found {} Steam library directories", result.len());
for (i, lib) in result.iter().enumerate() {
info!(" Library {}: {}", i+1, lib.display());
}
result
}
/// Parse libraryfolders.vdf to extract additional library paths
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
// Check both possible locations of the VDF file
let vdf_paths = [
steamapps_path.join("libraryfolders.vdf"),
steamapps_path.join("config/libraryfolders.vdf"),
];
for vdf_path in &vdf_paths {
if vdf_path.exists() {
debug!("Found library folders VDF: {}", vdf_path.display());
if let Ok(content) = fs::read_to_string(vdf_path) {
// Extract library paths using regex for both new and old format VDFs
let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap();
for cap in re_path.captures_iter(&content) {
let path_str = &cap[1];
let lib_path = PathBuf::from(path_str).join("steamapps");
if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) {
debug!("Found library from VDF: {}", lib_path.display());
// Clone lib_path before inserting to avoid ownership issues
let lib_path_clone = lib_path.clone();
libraries.insert(lib_path_clone);
// Recursively check this library for more libraries
parse_library_folders_vdf(&lib_path, libraries);
}
}
}
}
}
}
/// Parse an appmanifest ACF file to extract game information
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
match fs::read_to_string(path) {
Ok(content) => {
// Use regex to extract the app ID, name, and install directory
let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap();
let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap();
let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap();
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
re_appid.captures(&content),
re_name.captures(&content),
re_installdir.captures(&content)
) {
let app_id = app_id_cap[1].to_string();
let name = name_cap[1].to_string();
let install_dir = dir_cap[1].to_string();
return Some((app_id, name, install_dir));
}
}
Err(e) => {
error!("Failed to read ACF file {}: {}", path.display(), e);
}
}
None
}
/// Check if a file is a Linux ELF binary
fn is_elf_binary(path: &Path) -> bool {
if let Ok(mut file) = fs::File::open(path) {
let mut buffer = [0; 4];
if file.read_exact(&mut buffer).is_ok() {
// Check for ELF magic number (0x7F 'E' 'L' 'F')
return buffer[0] == 0x7F && buffer[1] == b'E' && buffer[2] == b'L' && buffer[3] == b'F';
}
}
false
}
/// Check if a game has CreamLinux installed
fn check_creamlinux_installed(game_path: &Path) -> bool {
let cream_files = [
"cream.sh",
"cream_api.ini",
"cream_api.so",
];
for file in &cream_files {
if game_path.join(file).exists() {
debug!("CreamLinux installation detected: {}", file);
return true;
}
}
false
}
/// Check if a game has SmokeAPI installed
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
if api_files.is_empty() {
return false;
}
// SmokeAPI creates backups with _o.dll suffix
for api_file in api_files {
let api_path = game_path.join(api_file);
let api_dir = api_path.parent().unwrap_or(game_path);
let api_filename = api_path.file_name().unwrap_or_default();
// Check for backup file (original file renamed with _o.dll suffix)
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
let backup_path = api_dir.join(backup_name);
if backup_path.exists() {
debug!("SmokeAPI backup file found: {}", backup_path.display());
return true;
}
}
false
}
/// Scan a game directory to determine if it's native or needs Proton
/// Also collect any Steam API DLLs for potential SmokeAPI installation
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
let mut found_exe = false;
let mut found_linux_binary = false;
let mut steam_api_files = Vec::new();
// Directories to skip for better performance
let skip_dirs = [
"videos", "video", "movies", "movie",
"sound", "sounds", "audio",
"textures", "music", "localization",
"shaders", "logs", "assets/audio",
"assets/video", "assets/textures"
];
// Only scan to a reasonable depth (avoid extreme recursion)
const MAX_DEPTH: usize = 8;
// File extensions to check for (executable and Steam API files)
let exe_extensions = ["exe", "bat", "cmd", "msi"];
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
// Recursively walk through the game directory with optimized settings
for entry in WalkDir::new(game_path)
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
.follow_links(false) // Don't follow symlinks to prevent cycles
.into_iter()
.filter_entry(|e| {
// Skip certain directories for performance
if e.file_type().is_dir() {
let file_name = e.file_name().to_string_lossy().to_lowercase();
if skip_dirs.iter().any(|&dir| file_name == dir) {
debug!("Skipping directory: {}", e.path().display());
return false;
}
}
true
})
.filter_map(Result::ok) {
let path = entry.path();
if !path.is_file() {
continue;
}
// Check file extension
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
// Check for Windows executables
if exe_extensions.iter().any(|&e| ext_str == e) {
found_exe = true;
}
// Check for Steam API DLLs
if ext_str == "dll" {
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
if let Ok(rel_path) = path.strip_prefix(game_path) {
let rel_path_str = rel_path.to_string_lossy().to_string();
debug!("Found Steam API DLL: {}", rel_path_str);
steam_api_files.push(rel_path_str);
}
}
}
// Check for Linux binary files
if binary_extensions.iter().any(|&e| ext_str == e) {
found_linux_binary = true;
// Check if it's actually an ELF binary for more certainty
if ext_str == "so" && is_elf_binary(path) {
found_linux_binary = true;
}
}
}
// Check for Linux executables (no extension)
#[cfg(unix)]
if !path.extension().is_some() {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let is_executable = metadata.permissions().mode() & 0o111 != 0;
// Check executable permission and ELF format
if is_executable && is_elf_binary(path) {
found_linux_binary = true;
}
}
}
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
// This early break greatly improves performance for large game directories
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
debug!("Found sufficient evidence, breaking scan early");
break;
}
}
// A game is considered native if it has Linux binaries but no Windows executables
let is_native = found_linux_binary && !found_exe;
debug!("Game scan results: native={}, exe={}, api_dlls={}", is_native, found_exe, steam_api_files.len());
(is_native, steam_api_files)
}
/// Find all installed Steam games from library folders
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
let mut games = Vec::new();
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
// IDs to skip (tools, redistributables, etc.)
let skip_ids = Arc::new([
"228980", // Steamworks Common Redistributables
"1070560", // Steam Linux Runtime
"1391110", // Steam Linux Runtime - Soldier
"1628350", // Steam Linux Runtime - Sniper
"1493710", // Proton Experimental
"2180100", // Steam Linux Runtime - Scout
].iter().copied().collect::<HashSet<&str>>());
// Name patterns to skip (case insensitive)
let skip_patterns = Arc::new(
[
r"(?i)steam linux runtime",
r"(?i)proton",
r"(?i)steamworks common",
r"(?i)redistributable",
r"(?i)dotnet",
r"(?i)vc redist",
]
.iter()
.map(|pat| Regex::new(pat).unwrap())
.collect::<Vec<_>>()
);
info!("Scanning for installed games in parallel...");
// Create a channel to collect results
let (tx, mut rx) = mpsc::channel(32);
// First collect all appmanifest files to process
let mut app_manifests = Vec::new();
for steamapps_dir in steamapps_paths {
if let Ok(entries) = fs::read_dir(steamapps_dir) {
for entry in entries.flatten() {
let path = entry.path();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Check for appmanifest files
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
app_manifests.push((path, steamapps_dir.clone()));
}
}
}
}
info!("Found {} appmanifest files to process", app_manifests.len());
// Process each appmanifest file in parallel with a maximum concurrency
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
info!("Using {} concurrent scanners", max_concurrent);
// Use a semaphore to limit concurrency
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
// Create a Vec to store all our task handles
let mut handles = Vec::new();
// Process each manifest file
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
// Clone what we need for the task
let path = path.clone();
let steamapps_dir = steamapps_dir.clone();
let skip_patterns = Arc::clone(&skip_patterns);
let tx = tx.clone();
let seen_ids = Arc::clone(&seen_ids);
let semaphore = Arc::clone(&semaphore);
let skip_ids = Arc::clone(&skip_ids);
// Create a new task
let handle = tokio::spawn(async move {
// Acquire a permit from the semaphore
let _permit = semaphore.acquire().await.unwrap();
// Parse the appmanifest file
if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
// Skip if in exclusion list
if skip_ids.contains(id.as_str()) {
return;
}
// Add a guard against duplicates
{
let mut seen = seen_ids.lock().await;
if seen.contains(&id) {
return;
}
seen.insert(id.clone());
}
// Skip if the name matches any exclusion patterns
if skip_patterns.iter().any(|re| re.is_match(&name)) {
debug!("Skipping runtime/tool: {} ({})", name, id);
return;
}
// Full path to the game directory
let game_path = steamapps_dir.join("common").join(&install_dir);
// Skip if game directory doesn't exist
if !game_path.exists() {
warn!("Game directory not found: {}", game_path.display());
return;
}
// Scan the game directory to determine platform and find Steam API DLLs
info!("Scanning game: {} at {}", name, game_path.display());
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly
let (is_native, api_files) = scan_game_directory(&game_path);
// Check for CreamLinux installation
let cream_installed = check_creamlinux_installed(&game_path);
// Check for SmokeAPI installation (only for non-native games with Steam API DLLs)
let smoke_installed = if !is_native && !api_files.is_empty() {
check_smokeapi_installed(&game_path, &api_files)
} else {
false
};
// Create the game info
let game_info = GameInfo {
id,
title: name,
path: game_path,
native: is_native,
api_files,
cream_installed,
smoke_installed,
};
// Send the game info through the channel
if tx.send(game_info).await.is_err() {
error!("Failed to send game info through channel");
}
}
});
handles.push(handle);
// Every 10 files, yield to allow progress updates
if manifest_idx % 10 == 0 {
// We would update progress here in a full implementation
tokio::task::yield_now().await;
}
}
// Drop the original sender so the receiver knows when we're done
drop(tx);
// Spawn a task to collect all the results
let receiver_task = tokio::spawn(async move {
let mut results = Vec::new();
while let Some(game) = rx.recv().await {
info!("Found game: {} ({})", game.title, game.id);
info!(" Path: {}", game.path.display());
info!(" Status: Native={}, Cream={}, Smoke={}",
game.native, game.cream_installed, game.smoke_installed);
// Log Steam API DLLs if any
if !game.api_files.is_empty() {
info!(" Steam API files:");
for api_file in &game.api_files {
info!(" - {}", api_file);
}
}
results.push(game);
}
results
});
// Wait for all scan tasks to complete - but don't wait for the results yet
for handle in handles {
// Ignore errors - the receiver task will just get fewer results
let _ = handle.await;
}
// Now wait for all results to be collected
if let Ok(results) = receiver_task.await {
games = results;
}
info!("Found {} installed games", games.len());
games
}

41
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"bundle": {
"active": true,
"targets": "all",
"category": "Utility",
"icon": [
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png"
]
},
"productName": "Creamlinux",
"mainBinaryName": "creamlinux",
"version": "0.1.0",
"identifier": "com.creamlinux.dev",
"plugins": {},
"app": {
"withGlobalTauri": false,
"windows": [
{
"title": "Creamlinux",
"width": 1000,
"height": 700,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
}
}

866
src/App.tsx Normal file
View File

@@ -0,0 +1,866 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import './styles/main.scss';
import GameList from './components/GameList';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import ProgressDialog from './components/ProgressDialog';
import DlcSelectionDialog from './components/DlcSelectionDialog';
import AnimatedBackground from './components/AnimatedBackground';
import InitialLoadingScreen from './components/InitialLoadingScreen';
import { ActionType } from './components/ActionButton';
// Game interface
interface Game {
id: string;
title: string;
path: string;
native: boolean;
platform?: string;
api_files: string[];
cream_installed?: boolean;
smoke_installed?: boolean;
installing?: boolean;
}
// Interface for installation instructions
interface InstructionInfo {
type: string;
command: string;
game_title: string;
dlc_count?: number;
}
// Interface for DLC information
interface DlcInfo {
appid: string;
name: string;
enabled: boolean;
}
function App() {
const [games, setGames] = useState<Game[]>([]);
const [filter, setFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState(""); // Added search query state
const [isLoading, setIsLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [scanProgress, setScanProgress] = useState({
message: "Initializing...",
progress: 0
});
const [error, setError] = useState<string | null>(null);
const refreshInProgress = useRef(false);
const [isFetchingDlcs, setIsFetchingDlcs] = useState(false);
const dlcFetchController = useRef<AbortController | null>(null);
const activeDlcFetchId = useRef<string | null>(null);
// Progress dialog state
const [progressDialog, setProgressDialog] = useState({
visible: false,
title: '',
message: '',
progress: 0,
showInstructions: false,
instructions: undefined as InstructionInfo | undefined
});
// DLC selection dialog state
const [dlcDialog, setDlcDialog] = useState({
visible: false,
gameId: '',
gameTitle: '',
dlcs: [] as DlcInfo[],
enabledDlcs: [] as string[],
isLoading: false,
isEditMode: false,
progress: 0,
progressMessage: '',
timeLeft: '',
error: null as string | null
});
// Handle search query changes
const handleSearchChange = (query: string) => {
setSearchQuery(query);
};
// Move the loadGames function outside of the useEffect to make it reusable
const loadGames = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
console.log("Invoking scan_steam_games");
const steamGames = await invoke<Game[]>('scan_steam_games').catch(err => {
console.error('Error from scan_steam_games:', err);
throw err;
});
// Add platform property to match the GameList component's expectation
const gamesWithPlatform = steamGames.map(game => ({
...game,
platform: 'Steam'
}));
console.log(`Loaded ${gamesWithPlatform.length} games`);
setGames(gamesWithPlatform);
setIsInitialLoad(false); // Mark initial load as complete
return true;
} catch (error) {
console.error('Error loading games:', error);
setError(`Failed to load games: ${error}`);
setIsInitialLoad(false); // Mark initial load as complete even on error
return false;
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
// Set up event listeners first
const setupEventListeners = async () => {
try {
console.log("Setting up event listeners");
// Listen for progress updates from the backend
const unlistenProgress = await listen('installation-progress', (event) => {
console.log("Received installation-progress event:", event);
const {
title,
message,
progress,
complete,
show_instructions,
instructions
} = event.payload as {
title: string;
message: string;
progress: number;
complete: boolean;
show_instructions?: boolean;
instructions?: InstructionInfo;
};
if (complete && !show_instructions) {
// Hide dialog when complete if no instructions
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
// Only refresh games list if dialog is closing without instructions
if (!refreshInProgress.current) {
refreshInProgress.current = true;
setTimeout(() => {
loadGames().then(() => {
refreshInProgress.current = false;
});
}, 100);
}
}, 1000);
} else {
// Update progress dialog
setProgressDialog({
visible: true,
title,
message,
progress,
showInstructions: show_instructions || false,
instructions
});
}
});
// Listen for scan progress events
const unlistenScanProgress = await listen('scan-progress', (event) => {
const { message, progress } = event.payload as {
message: string;
progress: number;
};
console.log("Received scan-progress event:", message, progress);
// Update scan progress state
setScanProgress({
message,
progress
});
});
// Listen for individual game updates
const unlistenGameUpdated = await listen('game-updated', (event) => {
console.log("Received game-updated event:", event);
const updatedGame = event.payload as Game;
// Update only the specific game in the state
setGames(prevGames =>
prevGames.map(game =>
game.id === updatedGame.id ? { ...updatedGame, platform: 'Steam' } : game
)
);
});
return () => {
unlistenProgress();
unlistenScanProgress();
unlistenGameUpdated();
};
} catch (error) {
console.error("Error setting up event listeners:", error);
return () => {};
}
};
// First set up event listeners, then load games
let unlisten: (() => void) | null = null;
setupEventListeners()
.then(unlistenFn => {
unlisten = unlistenFn;
return loadGames();
})
.catch(error => {
console.error("Failed to initialize:", error);
});
return () => {
if (unlisten) {
unlisten();
}
};
}, [loadGames]);
// Debugging for state changes
useEffect(() => {
// Debug state changes
if (games.length > 0) {
// Count native and installed games
const nativeCount = games.filter(g => g.native).length;
const creamInstalledCount = games.filter(g => g.cream_installed).length;
const smokeInstalledCount = games.filter(g => g.smoke_installed).length;
console.log(`Game state updated: ${games.length} total games, ${nativeCount} native, ${creamInstalledCount} with CreamLinux, ${smokeInstalledCount} with SmokeAPI`);
// Log any games with unexpected states
const problematicGames = games.filter(g => {
// Native games that have SmokeAPI installed (shouldn't happen)
if (g.native && g.smoke_installed) return true;
// Non-native games with CreamLinux installed (shouldn't happen)
if (!g.native && g.cream_installed) return true;
// Non-native games without API files but with SmokeAPI installed (shouldn't happen)
if (!g.native && (!g.api_files || g.api_files.length === 0) && g.smoke_installed) return true;
return false;
});
if (problematicGames.length > 0) {
console.warn("Found games with unexpected states:", problematicGames);
}
}
}, [games]);
// Set up event listeners for DLC streaming
useEffect(() => {
// Listen for individual DLC found events
const setupDlcEventListeners = async () => {
try {
// This event is emitted for each DLC as it's found
const unlistenDlcFound = await listen('dlc-found', (event) => {
const dlc = JSON.parse(event.payload as string) as { appid: string, name: string };
// Add the DLC to the current list with enabled=true
setDlcDialog(prev => ({
...prev,
dlcs: [...prev.dlcs, { ...dlc, enabled: true }]
}));
});
// When progress is 100%, mark loading as complete and reset fetch state
const unlistenDlcProgress = await listen('dlc-progress', (event) => {
const { message, progress, timeLeft } = event.payload as {
message: string,
progress: number,
timeLeft?: string
};
// Update the progress indicator
setDlcDialog(prev => ({
...prev,
progress,
progressMessage: message,
timeLeft: timeLeft || ''
}));
// If progress is 100%, mark loading as complete
if (progress === 100) {
setTimeout(() => {
setDlcDialog(prev => ({
...prev,
isLoading: false
}));
// Reset fetch state
setIsFetchingDlcs(false);
activeDlcFetchId.current = null;
}, 500);
}
});
// This event is emitted if there's an error
const unlistenDlcError = await listen('dlc-error', (event) => {
const { error } = event.payload as { error: string };
console.error('DLC streaming error:', error);
// Show error in dialog
setDlcDialog(prev => ({
...prev,
error,
isLoading: false
}));
});
return () => {
unlistenDlcFound();
unlistenDlcProgress();
unlistenDlcError();
};
} catch (error) {
console.error("Error setting up DLC event listeners:", error);
return () => {};
}
};
const unlisten = setupDlcEventListeners();
return () => {
unlisten.then(fn => fn());
};
}, []);
// Listen for scan progress events
useEffect(() => {
const listenToScanProgress = async () => {
try {
const unlistenScanProgress = await listen('scan-progress', (event) => {
const { message, progress } = event.payload as {
message: string;
progress: number;
};
// Update loading message
setProgressDialog(prev => ({
...prev,
visible: true,
title: "Scanning for Games",
message,
progress,
showInstructions: false,
instructions: undefined
}));
// Auto-close when complete
if (progress >= 100) {
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 1500);
}
});
return unlistenScanProgress;
} catch (error) {
console.error("Error setting up scan progress listener:", error);
return () => {};
}
};
const unlistenPromise = listenToScanProgress();
return () => {
unlistenPromise.then(unlisten => unlisten());
};
}, []);
const handleCloseProgressDialog = () => {
// Just hide the dialog without refreshing game list
setProgressDialog(prev => ({ ...prev, visible: false }));
// Only refresh if we need to (instructions didn't trigger update)
if (progressDialog.showInstructions === false && !refreshInProgress.current) {
refreshInProgress.current = true;
setTimeout(() => {
loadGames().then(() => {
refreshInProgress.current = false;
});
}, 100);
}
};
// Function to fetch DLCs for a game with streaming updates
const streamGameDlcs = async (gameId: string): Promise<void> => {
try {
// Set up flag to indicate we're fetching DLCs
setIsFetchingDlcs(true);
activeDlcFetchId.current = gameId;
// Start streaming DLCs - this won't return DLCs directly
// Instead, it triggers events that we'll listen for
await invoke('stream_game_dlcs', { gameId });
return;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('DLC fetching was aborted');
} else {
console.error('Error starting DLC stream:', error);
throw error;
}
} finally {
// Reset state when done or on error
setIsFetchingDlcs(false);
activeDlcFetchId.current = null;
}
};
// Clean up if component unmounts during a fetch
useEffect(() => {
return () => {
// Clean up any ongoing fetch operations
if (dlcFetchController.current) {
dlcFetchController.current.abort();
dlcFetchController.current = null;
}
};
}, []);
// Handle game edit (show DLC management dialog)
const handleGameEdit = async (gameId: string) => {
const game = games.find(g => g.id === gameId);
if (!game || !game.cream_installed) return;
// Check if we're already fetching DLCs for this game
if (isFetchingDlcs && activeDlcFetchId.current === gameId) {
console.log(`Already fetching DLCs for ${gameId}, ignoring duplicate request`);
return;
}
try {
// Show dialog immediately with empty DLC list
setDlcDialog({
visible: true,
gameId,
gameTitle: game.title,
dlcs: [],
enabledDlcs: [] as string[],
isLoading: true,
isEditMode: true,
progress: 0,
progressMessage: 'Reading DLC configuration...',
timeLeft: '',
error: null
});
// Try to read all DLCs from the configuration file first (including disabled ones)
try {
const allDlcs = await invoke<DlcInfo[]>('get_all_dlcs_command', { gamePath: game.path })
.catch(() => [] as DlcInfo[]);
if (allDlcs.length > 0) {
// If we have DLCs from the config file, use them
console.log("Loaded existing DLC configuration:", allDlcs);
setDlcDialog(prev => ({
...prev,
dlcs: allDlcs,
isLoading: false,
progress: 100,
progressMessage: 'Loaded existing DLC configuration'
}));
return;
}
} catch (error) {
console.warn("Could not read existing DLC configuration, falling back to API:", error);
// Continue with API loading if config reading fails
}
// Mark that we're fetching DLCs for this game
setIsFetchingDlcs(true);
activeDlcFetchId.current = gameId;
// Create abort controller for fetch operation
dlcFetchController.current = new AbortController();
// Start streaming DLCs
await streamGameDlcs(gameId).catch(error => {
if (error.name !== 'AbortError') {
console.error('Error streaming DLCs:', error);
setDlcDialog(prev => ({
...prev,
error: `Failed to load DLCs: ${error}`,
isLoading: false
}));
}
});
// In parallel, try to get the enabled DLCs
const enabledDlcs = await invoke<string[]>('get_enabled_dlcs_command', { gamePath: game.path })
.catch(() => [] as string[]);
// We'll update the enabled state of DLCs as they come in
setDlcDialog(prev => ({
...prev,
enabledDlcs
}));
} catch (error) {
console.error('Error preparing DLC edit:', error);
setDlcDialog(prev => ({
...prev,
error: `Failed to prepare DLC editor: ${error}`,
isLoading: false
}));
}
};
// Unified handler for all game actions (install/uninstall cream/smoke)
const handleGameAction = async (gameId: string, action: ActionType) => {
try {
// Find game to get title
const game = games.find(g => g.id === gameId);
if (!game) return;
// If we're installing CreamLinux, show DLC selection first
if (action === 'install_cream') {
try {
// Show dialog immediately with empty DLC list and loading state
setDlcDialog({
visible: true,
gameId,
gameTitle: game.title,
dlcs: [], // Start with an empty array
enabledDlcs: [] as string[],
isLoading: true,
isEditMode: false,
progress: 0,
progressMessage: 'Fetching DLC list...',
timeLeft: '',
error: null
});
// Start streaming DLCs - only once
await streamGameDlcs(gameId).catch(error => {
console.error('Error streaming DLCs:', error);
setDlcDialog(prev => ({
...prev,
error: `Failed to load DLCs: ${error}`,
isLoading: false
}));
});
} catch (error) {
console.error('Error fetching DLCs:', error);
// If DLC fetching fails, close dialog and show error
setDlcDialog(prev => ({
...prev,
visible: false,
isLoading: false
}));
setProgressDialog({
visible: true,
title: `Error fetching DLCs for ${game.title}`,
message: `Failed to fetch DLCs: ${error}`,
progress: 100,
showInstructions: false,
instructions: undefined
});
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 3000);
}
return;
}
// For other actions, proceed directly
// Update local state to show installation in progress
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: true } : g
));
// Get title based on action
const isCream = action.includes('cream');
const isInstall = action.includes('install');
const product = isCream ? "CreamLinux" : "SmokeAPI";
const operation = isInstall ? "Installing" : "Uninstalling";
// Show progress dialog
setProgressDialog({
visible: true,
title: `${operation} ${product} for ${game.title}`,
message: isInstall ? 'Downloading required files...' : 'Removing files...',
progress: isInstall ? 0 : 30,
showInstructions: false,
instructions: undefined
});
console.log(`Invoking process_game_action for game ${gameId} with action ${action}`);
// Call the backend with the unified action
const updatedGame = await invoke('process_game_action', {
gameAction: {
game_id: gameId,
action
}
}).catch(err => {
console.error(`Error from process_game_action:`, err);
throw err;
});
console.log('Game action completed, updated game:', updatedGame);
// Update our local state with the result from the backend
if (updatedGame) {
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: false } : g
));
}
} catch (error) {
console.error(`Error processing action ${action} for game ${gameId}:`, error);
// Show error in progress dialog
setProgressDialog(prev => ({
...prev,
message: `Error: ${error}`,
progress: 100
}));
// Reset installing state
setGames(prevGames => prevGames.map(game =>
game.id === gameId ? { ...game, installing: false } : game
));
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 3000);
}
};
// Handle DLC selection dialog close
const handleDlcDialogClose = () => {
// Cancel any in-progress DLC fetching
if (isFetchingDlcs && activeDlcFetchId.current) {
console.log(`Aborting DLC fetch for game ${activeDlcFetchId.current}`);
// This will signal to the Rust backend that we want to stop the process
// You could implement this on the backend if needed with something like:
invoke('abort_dlc_fetch', { gameId: activeDlcFetchId.current })
.catch(err => console.error('Error aborting DLC fetch:', err));
// Reset state
activeDlcFetchId.current = null;
setIsFetchingDlcs(false);
}
// Clear controller
if (dlcFetchController.current) {
dlcFetchController.current.abort();
dlcFetchController.current = null;
}
// Close dialog
setDlcDialog(prev => ({ ...prev, visible: false }));
};
// Handle DLC selection confirmation
const handleDlcConfirm = async (selectedDlcs: DlcInfo[]) => {
// Close the dialog first
setDlcDialog(prev => ({ ...prev, visible: false }));
const gameId = dlcDialog.gameId;
const game = games.find(g => g.id === gameId);
if (!game) return;
// Update local state to show installation in progress
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: true } : g
));
try {
if (dlcDialog.isEditMode) {
// If in edit mode, we're updating existing cream_api.ini
// Show progress dialog for editing
setProgressDialog({
visible: true,
title: `Updating DLCs for ${game.title}`,
message: 'Updating DLC configuration...',
progress: 30,
showInstructions: false,
instructions: undefined
});
// Call the backend to update the DLC configuration
await invoke('update_dlc_configuration_command', {
gamePath: game.path,
dlcs: selectedDlcs
});
// Update progress dialog for completion
setProgressDialog(prev => ({
...prev,
title: `Update Complete: ${game.title}`,
message: 'DLC configuration updated successfully!',
progress: 100
}));
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
// Reset installing state
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: false } : g
));
}, 2000);
} else {
// We're doing a fresh install with selected DLCs
// Show progress dialog for installation right away
setProgressDialog({
visible: true,
title: `Installing CreamLinux for ${game.title}`,
message: 'Processing...',
progress: 0,
showInstructions: false,
instructions: undefined
});
// Invoke the installation with the selected DLCs
await invoke('install_cream_with_dlcs_command', {
gameId,
selectedDlcs
}).catch(err => {
console.error(`Error installing CreamLinux with selected DLCs:`, err);
throw err;
});
// Note: we don't need to manually close the dialog or update the game state
// because the backend will emit progress events that handle this
}
} catch (error) {
console.error('Error processing DLC selection:', error);
// Show error in progress dialog
setProgressDialog(prev => ({
...prev,
message: `Error: ${error}`,
progress: 100
}));
// Reset installing state
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: false } : g
));
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 3000);
}
};
// Update DLCs being streamed with enabled state
useEffect(() => {
if (dlcDialog.enabledDlcs.length > 0) {
setDlcDialog(prev => ({
...prev,
dlcs: prev.dlcs.map(dlc => ({
...dlc,
enabled: prev.enabledDlcs.length === 0 || prev.enabledDlcs.includes(dlc.appid)
}))
}));
}
}, [dlcDialog.dlcs, dlcDialog.enabledDlcs]);
// Filter games based on sidebar filter AND search query
const filteredGames = games.filter(game => {
// First filter by the platform/type
const platformMatch = filter === "all" ||
(filter === "native" && game.native) ||
(filter === "proton" && !game.native);
// Then filter by search query (if any)
const searchMatch = searchQuery.trim() === '' ||
game.title.toLowerCase().includes(searchQuery.toLowerCase());
// Both filters must match
return platformMatch && searchMatch;
});
// Check if we should show the initial loading screen
if (isInitialLoad) {
return (
<InitialLoadingScreen
message={scanProgress.message}
progress={scanProgress.progress}
/>
);
}
return (
<div className="app-container">
{/* Animated background */}
<AnimatedBackground />
<Header
onRefresh={loadGames}
onSearch={handleSearchChange}
searchQuery={searchQuery}
/>
<div className="main-content">
<Sidebar setFilter={setFilter} currentFilter={filter} />
{error ? (
<div className="error-message">
<h3>Error Loading Games</h3>
<p>{error}</p>
<button onClick={loadGames}>Retry</button>
</div>
) : (
<GameList
games={filteredGames}
isLoading={isLoading}
onAction={handleGameAction}
onEdit={handleGameEdit}
/>
)}
</div>
{/* Progress Dialog */}
<ProgressDialog
visible={progressDialog.visible}
title={progressDialog.title}
message={progressDialog.message}
progress={progressDialog.progress}
showInstructions={progressDialog.showInstructions}
instructions={progressDialog.instructions}
onClose={handleCloseProgressDialog}
/>
{/* DLC Selection Dialog */}
<DlcSelectionDialog
visible={dlcDialog.visible}
gameTitle={dlcDialog.gameTitle}
dlcs={dlcDialog.dlcs}
isLoading={dlcDialog.isLoading}
isEditMode={dlcDialog.isEditMode}
loadingProgress={dlcDialog.progress}
estimatedTimeLeft={dlcDialog.timeLeft}
onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm}
/>
</div>
);
}
export default App;

BIN
src/assets/fonts/Roboto.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
src/assets/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

View File

@@ -0,0 +1,46 @@
// src/components/ActionButton.tsx
import React from 'react';
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke';
interface ActionButtonProps {
action: ActionType;
isInstalled: boolean;
isWorking: boolean;
onClick: () => void;
disabled?: boolean;
}
const ActionButton: React.FC<ActionButtonProps> = ({
action,
isInstalled,
isWorking,
onClick,
disabled = false
}) => {
const getButtonText = () => {
if (isWorking) return "Working...";
const isCream = action.includes('cream');
const product = isCream ? "CreamLinux" : "SmokeAPI";
return isInstalled ? `Uninstall ${product}` : `Install ${product}`;
};
const getButtonClass = () => {
const baseClass = "action-button";
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`;
};
return (
<button
className={getButtonClass()}
onClick={onClick}
disabled={disabled || isWorking}
>
{getButtonText()}
</button>
);
};
export default ActionButton;

View File

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

View File

@@ -0,0 +1,50 @@
// src/components/AnimatedCheckbox.tsx
import React from 'react';
interface AnimatedCheckboxProps {
checked: boolean;
onChange: () => void;
label?: string;
sublabel?: string;
className?: string;
}
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
checked,
onChange,
label,
sublabel,
className = ''
}) => {
return (
<label className={`animated-checkbox ${className}`}>
<input
type="checkbox"
checked={checked}
onChange={onChange}
className="checkbox-original"
/>
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
<svg viewBox="0 0 24 24" className="checkmark-icon">
<path
className={`checkmark ${checked ? 'checked' : ''}`}
d="M5 12l5 5L20 7"
stroke="#fff"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
{(label || sublabel) && (
<div className="checkbox-content">
{label && <span className="checkbox-label">{label}</span>}
{sublabel && <span className="checkbox-sublabel">{sublabel}</span>}
</div>
)}
</label>
);
};
export default AnimatedCheckbox;

View File

@@ -0,0 +1,242 @@
// src/components/DlcSelectionDialog.tsx
import React, { useState, useEffect, useMemo } from 'react';
import AnimatedCheckbox from './AnimatedCheckbox';
interface DlcInfo {
appid: string;
name: string;
enabled: boolean;
}
interface DlcSelectionDialogProps {
visible: boolean;
gameTitle: string;
dlcs: DlcInfo[];
onClose: () => void;
onConfirm: (selectedDlcs: DlcInfo[]) => void;
isLoading: boolean;
isEditMode?: boolean;
loadingProgress?: number;
estimatedTimeLeft?: string;
}
const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
visible,
gameTitle,
dlcs,
onClose,
onConfirm,
isLoading,
isEditMode = false,
loadingProgress = 0,
estimatedTimeLeft = ''
}) => {
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([]);
const [showContent, setShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectAll, setSelectAll] = useState(true);
const [initialized, setInitialized] = useState(false);
// Initialize selected DLCs when DLC list changes
useEffect(() => {
if (visible && dlcs.length > 0 && !initialized) {
setSelectedDlcs(dlcs);
// Determine initial selectAll state based on if all DLCs are enabled
const allSelected = dlcs.every(dlc => dlc.enabled);
setSelectAll(allSelected);
// Mark as initialized so we don't reset selections on subsequent DLC additions
setInitialized(true);
}
}, [visible, dlcs, initialized]);
// Handle visibility changes
useEffect(() => {
if (visible) {
// Show content immediately for better UX
const timer = setTimeout(() => {
setShowContent(true);
}, 50);
return () => clearTimeout(timer);
} else {
setShowContent(false);
setInitialized(false); // Reset initialized state when dialog closes
}
}, [visible]);
// Memoize filtered DLCs to avoid unnecessary recalculations
const filteredDlcs = useMemo(() => {
return searchQuery.trim() === ''
? selectedDlcs
: selectedDlcs.filter(dlc =>
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
dlc.appid.includes(searchQuery)
);
}, [selectedDlcs, searchQuery]);
// Update DLC selection status
const handleToggleDlc = (appid: string) => {
setSelectedDlcs(prev => prev.map(dlc =>
dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc
));
};
// Update selectAll state when individual DLC selections change
useEffect(() => {
const allSelected = selectedDlcs.every(dlc => dlc.enabled);
setSelectAll(allSelected);
}, [selectedDlcs]);
// Handle new DLCs being added while dialog is already open
useEffect(() => {
if (initialized && dlcs.length > selectedDlcs.length) {
// Find new DLCs that aren't in our current selection
const currentAppIds = new Set(selectedDlcs.map(dlc => dlc.appid));
const newDlcs = dlcs.filter(dlc => !currentAppIds.has(dlc.appid));
// Add new DLCs to our selection, maintaining their enabled state
if (newDlcs.length > 0) {
setSelectedDlcs(prev => [...prev, ...newDlcs]);
}
}
}, [dlcs, selectedDlcs, initialized]);
const handleToggleSelectAll = () => {
const newSelectAllState = !selectAll;
setSelectAll(newSelectAllState);
setSelectedDlcs(prev => prev.map(dlc => ({
...dlc,
enabled: newSelectAllState
})));
};
const handleConfirm = () => {
onConfirm(selectedDlcs);
};
// Modified to prevent closing when loading
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Prevent clicks from propagating through the overlay
e.stopPropagation();
// Only allow closing via overlay click if not loading
if (e.target === e.currentTarget && !isLoading) {
onClose();
}
};
// Count selected DLCs
const selectedCount = selectedDlcs.filter(dlc => dlc.enabled).length;
// Format loading message to show total number of DLCs found
const getLoadingInfoText = () => {
if (isLoading && loadingProgress < 100) {
return ` (Loading more DLCs...)`;
} else if (dlcs.length > 0) {
return ` (Total DLCs: ${dlcs.length})`;
}
return '';
};
if (!visible) return null;
return (
<div
className={`dlc-dialog-overlay ${showContent ? 'visible' : ''}`}
onClick={handleOverlayClick}
>
<div className={`dlc-selection-dialog ${showContent ? 'dialog-visible' : ''}`}>
<div className="dlc-dialog-header">
<h3>{isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'}</h3>
<div className="dlc-game-info">
<span className="game-title">{gameTitle}</span>
<span className="dlc-count">
{selectedCount} of {selectedDlcs.length} DLCs selected
{getLoadingInfoText()}
</span>
</div>
</div>
<div className="dlc-dialog-search">
<input
type="text"
placeholder="Search DLCs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input"
/>
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
/>
</div>
</div>
{isLoading && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${loadingProgress}%` }}
/>
</div>
<div className="loading-details">
<span>Loading DLCs: {loadingProgress}%</span>
{estimatedTimeLeft && <span className="time-left">Est. time left: {estimatedTimeLeft}</span>}
</div>
</div>
)}
<div className="dlc-list-container">
{selectedDlcs.length > 0 ? (
<ul className="dlc-list">
{filteredDlcs.map(dlc => (
<li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox
checked={dlc.enabled}
onChange={() => handleToggleDlc(dlc.appid)}
label={dlc.name}
sublabel={`ID: ${dlc.appid}`}
/>
</li>
))}
{isLoading && (
<li className="dlc-item dlc-item-loading">
<div className="loading-pulse"></div>
</li>
)}
</ul>
) : (
<div className="dlc-loading">
<div className="loading-spinner"></div>
<p>Loading DLC information...</p>
</div>
)}
</div>
<div className="dlc-dialog-actions">
<button
className="cancel-button"
onClick={onClose}
disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start
>
Cancel
</button>
<button
className="confirm-button"
onClick={handleConfirm}
disabled={isLoading}
>
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
</button>
</div>
</div>
</div>
);
};
export default DlcSelectionDialog;

172
src/components/GameItem.tsx Normal file
View File

@@ -0,0 +1,172 @@
// src/components/GameItem.tsx
import React, { useState, useEffect } from 'react';
import { findBestGameImage } from '../services/ImageService';
import { ActionType } from './ActionButton';
interface Game {
id: string;
title: string;
path: string;
platform?: string;
native: boolean;
api_files: string[];
cream_installed?: boolean;
smoke_installed?: boolean;
installing?: boolean;
}
interface GameItemProps {
game: Game;
onAction: (gameId: string, action: ActionType) => Promise<void>;
onEdit?: (gameId: string) => void;
}
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
useEffect(() => {
// Function to fetch the game cover/image
const fetchGameImage = async () => {
// First check if we already have it (to prevent flickering on re-renders)
if (imageUrl) return;
setIsLoading(true);
try {
// Try to find the best available image for this game
const bestImageUrl = await findBestGameImage(game.id);
if (bestImageUrl) {
setImageUrl(bestImageUrl);
setHasError(false);
} else {
setHasError(true);
}
} catch (error) {
console.error('Error fetching game image:', error);
setHasError(true);
} finally {
setIsLoading(false);
}
};
if (game.id) {
fetchGameImage();
}
}, [game.id, imageUrl]);
// Determine if we should show CreamLinux buttons (only for native games)
const shouldShowCream = game.native === true;
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0;
// Check if this is a Proton game without API files
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0);
const handleCreamAction = () => {
if (game.installing) return;
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream';
onAction(game.id, action);
};
const handleSmokeAction = () => {
if (game.installing) return;
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke';
onAction(game.id, action);
};
// Handle edit button click
const handleEdit = () => {
if (onEdit && game.cream_installed) {
onEdit(game.id);
}
};
// Determine background image
const backgroundImage = !isLoading && imageUrl ?
`url(${imageUrl})` :
hasError ? 'linear-gradient(135deg, #232323, #1A1A1A)' : 'linear-gradient(135deg, #232323, #1A1A1A)';
return (
<div
className="game-item-card"
style={{
backgroundImage,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="game-item-overlay">
<div className="game-badges">
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
{game.native ? 'Native' : 'Proton'}
</span>
{game.cream_installed && (
<span className="status-badge cream">CreamLinux</span>
)}
{game.smoke_installed && (
<span className="status-badge smoke">SmokeAPI</span>
)}
</div>
<div className="game-title">
<h3>{game.title}</h3>
</div>
<div className="game-actions">
{/* Show CreamLinux button only for native games */}
{shouldShowCream && (
<button
className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`}
onClick={handleCreamAction}
disabled={!!game.installing}
>
{game.installing ? "Working..." : (game.cream_installed ? "Uninstall CreamLinux" : "Install CreamLinux")}
</button>
)}
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
{shouldShowSmoke && (
<button
className={`action-button ${game.smoke_installed ? 'uninstall' : 'install'}`}
onClick={handleSmokeAction}
disabled={!!game.installing}
>
{game.installing ? "Working..." : (game.smoke_installed ? "Uninstall SmokeAPI" : "Install SmokeAPI")}
</button>
)}
{/* Show message for Proton games without API files */}
{isProtonNoApi && (
<div className="api-not-found-message">
<span>Steam API DLL not found</span>
<button
className="rescan-button"
onClick={() => onAction(game.id, 'install_smoke')}
title="Attempt to scan again"
>
Rescan
</button>
</div>
)}
{/* Edit button - only enabled if CreamLinux is installed */}
{game.cream_installed && (
<button
className="edit-button"
onClick={handleEdit}
disabled={!game.cream_installed || !!game.installing}
title="Manage DLCs"
>
Manage DLCs
</button>
)}
</div>
</div>
</div>
);
};
export default GameItem;

View File

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

40
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,40 @@
// src/components/Header.tsx
import React from 'react';
interface HeaderProps {
onRefresh: () => void;
refreshDisabled?: boolean;
onSearch: (query: string) => void;
searchQuery: string;
}
const Header: React.FC<HeaderProps> = ({
onRefresh,
refreshDisabled = false,
onSearch,
searchQuery
}) => {
return (
<header className="app-header">
<h1>CreamLinux</h1>
<div className="header-controls">
<button
className="refresh-button"
onClick={onRefresh}
disabled={refreshDisabled}
>
Refresh
</button>
<input
type="text"
placeholder="Search games..."
className="search-input"
value={searchQuery}
onChange={(e) => onSearch(e.target.value)}
/>
</div>
</header>
);
};
export default Header;

View File

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

View File

@@ -0,0 +1,36 @@
import React from 'react';
interface InitialLoadingScreenProps {
message: string;
progress: number;
}
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
message,
progress
}) => {
return (
<div className="initial-loading-screen">
<div className="loading-content">
<h1>CreamLinux</h1>
<div className="loading-animation">
<div className="loading-circles">
<div className="circle circle-1"></div>
<div className="circle circle-2"></div>
<div className="circle circle-3"></div>
</div>
</div>
<p className="loading-message">{message}</p>
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
<div className="progress-percentage">{Math.round(progress)}%</div>
</div>
</div>
);
};
export default InitialLoadingScreen;

View File

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

View File

@@ -0,0 +1,37 @@
// src/components/Sidebar.tsx
import React from 'react';
interface SidebarProps {
setFilter: (filter: string) => void;
currentFilter: string;
}
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
return (
<div className="sidebar">
<h2>Library</h2>
<ul className="filter-list">
<li
className={currentFilter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
All Games
</li>
<li
className={currentFilter === 'native' ? 'active' : ''}
onClick={() => setFilter('native')}
>
Native
</li>
<li
className={currentFilter === 'proton' ? 'active' : ''}
onClick={() => setFilter('proton')}
>
Proton Required
</li>
</ul>
</div>
);
};
export default Sidebar;

9
src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,96 @@
// src/services/ImageService.ts
/**
* Game image sources from Steam's CDN
*/
export const SteamImageType = {
HEADER: 'header', // 460x215
CAPSULE: 'capsule_616x353', // 616x353
LOGO: 'logo', // Game logo with transparency
LIBRARY_HERO: 'library_hero', // 1920x620
LIBRARY_CAPSULE: 'library_600x900', // 600x900
} as const;
export type SteamImageTypeKey = keyof typeof SteamImageType;
// Cache for images to prevent flickering
const imageCache: Map<string, string> = new Map();
/**
* Builds a Steam CDN URL for game images
* @param appId Steam application ID
* @param type Image type from SteamImageType enum
* @returns URL string for the image
*/
export const getSteamImageUrl = (appId: string, type: typeof SteamImageType[SteamImageTypeKey]) => {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`;
};
/**
* Checks if an image exists by performing a HEAD request
* @param url Image URL to check
* @returns Promise resolving to a boolean indicating if the image exists
*/
export const checkImageExists = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch (error) {
console.error('Error checking image existence:', error);
return false;
}
};
/**
* Preloads an image for faster rendering
* @param url URL of image to preload
* @returns Promise that resolves when image is loaded
*/
const preloadImage = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(url);
img.onerror = reject;
img.src = url;
});
};
/**
* Attempts to find a valid image for a Steam game, trying different image types
* @param appId Steam application ID
* @returns Promise resolving to a valid image URL or null if none found
*/
export const findBestGameImage = async (appId: string): Promise<string | null> => {
// Check cache first
if (imageCache.has(appId)) {
return imageCache.get(appId) || null;
}
// Try these image types in order of preference
const typesToTry = [
SteamImageType.HEADER,
SteamImageType.CAPSULE,
SteamImageType.LIBRARY_CAPSULE
];
for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type);
const exists = await checkImageExists(url);
if (exists) {
try {
// Preload the image to prevent flickering
const preloadedUrl = await preloadImage(url);
// Store in cache
imageCache.set(appId, preloadedUrl);
return preloadedUrl;
} catch {
// If preloading fails, just return the URL
imageCache.set(appId, url);
return url;
}
}
}
// If we've reached here, no valid image was found
return null;
};

10
src/styles/_fonts.scss Normal file
View File

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

262
src/styles/_layout.scss Normal file
View File

@@ -0,0 +1,262 @@
// src/styles/_layout.scss
@use './variables' as *;
@use './mixins' as *;
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--primary-bg);
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
pointer-events: none;
z-index: var(--z-bg);
}
}
// Header
.app-header {
@include flex-between;
padding: 1rem 2rem;
background-color: var(--tertiary-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
position: relative;
z-index: var(--z-header);
height: var(--header-height);
h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.5px;
@include text-shadow;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--cream-color), var(--primary-color), var(--smoke-color));
opacity: 0.7;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
}
}
.header-controls {
display: flex;
gap: 1rem;
align-items: center;
}
// Main content
.main-content {
display: flex;
flex: 1;
overflow: hidden;
width: 100%;
position: relative;
z-index: var(--z-elevate);
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background-color: var(--secondary-bg);
border-right: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: inset -5px 0 15px rgba(0, 0, 0, 0.2);
padding: 1.5rem 1rem;
@include flex-column;
height: 100%;
overflow-y: auto;
z-index: var(--z-elevate) + 1;
h2 {
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
letter-spacing: 0.5px;
opacity: 0.9;
}
@include custom-scrollbar;
}
// Game list container
.game-list {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
height: 100%;
width: 100%;
@include custom-scrollbar;
position: relative;
h2 {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--text-primary);
letter-spacing: 0.5px;
position: relative;
display: inline-block;
padding-bottom: 0.5rem;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, var(--primary-color), transparent);
border-radius: 3px;
}
}
}
// Game grid
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
width: 100%;
padding: 0.5rem 0.5rem 2rem 0.5rem;
scroll-behavior: smooth;
align-items: stretch;
opacity: 0;
transform: translateY(10px);
animation: fadeIn 0.5s forwards;
}
// Loading and empty state
.loading-indicator, .no-games-message {
@include flex-center;
height: 250px;
width: 100%;
font-size: 1.2rem;
color: var(--text-secondary);
text-align: center;
border-radius: var(--radius-lg);
background-color: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(5px);
}
.loading-indicator {
position: relative;
overflow: hidden;
&:after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
animation: loading-shimmer 2s infinite;
}
}
// Responsive adjustments
@include media-sm {
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
@include media-lg {
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
@include media-xl {
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
}
// Scroll to top button
.scroll-top-button {
position: fixed;
bottom: 30px;
right: 30px;
width: 44px;
height: 44px;
border-radius: 50%;
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color)));
color: var(--text-primary);
@include flex-center;
cursor: pointer;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateY(20px);
@include transition-standard;
z-index: var(--z-header);
&.visible {
opacity: 1;
transform: translateY(0);
}
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(var(--primary-color), 0.4);
}
&:active {
transform: translateY(0);
}
}
// Animation keyframes
@keyframes fadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes loading-shimmer {
to {
left: 100%;
}
}

107
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,107 @@
// src/styles/_mixins.scss
@use './variables' as *;
// src/styles/_mixins.scss
// Basic flex helpers
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin flex-column {
display: flex;
flex-direction: column;
}
// Glass effect for overlay
@mixin glass-overlay($opacity: 0.7) {
background-color: rgba(var(--primary-bg), var(--opacity));
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
@mixin gradient-bg($start-color, $end-color, $direction: 135deg) {
background: linear-gradient($direction, $start-color, $end-color);
}
// Basic transition
@mixin transition-standard {
transition: all var(--duration-normal) var(--easing-ease-out);
}
@mixin shadow-standard {
box-shadow: var(--shadow-standard);
}
@mixin shadow-hover {
box-shadow: var(--shadow-hover);;
}
@mixin text-shadow {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
}
// Simple animation for hover
@mixin hover-lift {
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
}
}
// Responsive mixins
@mixin media-sm {
@media (min-width: 576px) { @content; }
}
@mixin media-md {
@media (min-width: 768px) { @content; }
}
@mixin media-lg {
@media (min-width: 992px) { @content; }
}
@mixin media-xl {
@media (min-width: 1200px) { @content; }
}
// Card base styling
@mixin card {
background-color: var(--secondary-bg);
border-radius: var(--radius-sm);
@include shadow;
overflow: hidden;
position: relative;
}
// Custom scrollbar
@mixin custom-scrollbar {
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: rgba(var(--primary-bg), 0.5);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 10px;
border: 2px solid var(--primary-bg);
}
&::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, white 10%, var(--primary-color));
}
}

65
src/styles/_reset.scss Normal file
View File

@@ -0,0 +1,65 @@
// src/styles/_reset.scss
@use './variables' as *;
@use './mixins' as *;
@use './fonts' as *;
// src/styles/_reset.scss
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: 'Roboto';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--primary-bg);
color: var(--text-primary);
/* Prevent text selection by default */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
#root {
height: 100%;
width: 100%;
}
button {
background: none;
border: none;
cursor: pointer;
font-family: inherit;
&:focus {
outline: none;
}
}
a {
color: inherit;
text-decoration: none;
}
ul, ol {
list-style: none;
}
input, button, textarea, select {
font: inherit;
}
h1, h2, h3, h4, h5, h6 {
font-weight: inherit;
font-size: inherit;
}

116
src/styles/_variables.scss Normal file
View File

@@ -0,0 +1,116 @@
// src/styles/_variables.scss
@use './fonts' as *;
// Color palette
:root {
// Primary colors
--primary-color: #ffc896;
--secondary-color: #ffb278;
// Background
--primary-bg: #0f0f0f;
--secondary-bg: #151515;
--tertiary-bg: #121212;
--elevated-bg: #1a1a1a;
--disabled: #5E5E5E;
// Text
--text-primary: #f0f0f0;
--text-secondary: #c8c8c8;
--text-soft: #afafaf;
--text-heavy: #1a1a1a;
--text-muted: #4b4b4b;
// Borders
--border-dark: #1a1a1a;
--border-soft: #282828;
--border: #323232;
// Status colors - more vibrant
--success: #8cc893;
--warning: #ffc896;
--danger: #d96b6b;
--info: #80b4ff;
--success-light: #b0e0a9;
--warning-light: #ffdcb9;
--danger-light: #e69691;
--info-light: #a8d2ff;
--success-soft: rgba(176, 224, 169, 0.15);
--warning-soft: rgba(247, 200, 111, 0.15);
--danger-soft: rgba(230, 150, 145, 0.15);
--info-soft: rgba(168, 210, 255, 0.15);
// Feature colors
--native: #8cc893;
--proton: #ffc896;
--cream: #80b4ff;
--smoke: #fff096;
--modal-backdrop: rgba(30, 30, 30, 0.95);
// Animation durations
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
// Animation easings
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
// Layout values
--header-height: 64px;
--sidebar-width: 250px;
--card-height: 200px;
// Border radius
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
// Font weights
--thin: 100;
--extralight: 200;
--light: 300;
--normal: 400;
--medium: 500;
--semibold: 600;
--bold: 700;
--extrabold: 800;
--family: 'Satoshi';
// Shadows
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
// Z-index levels
//--z-index-bg: 0;
//--z-index-content: 1;
//--z-index-header: 100;
//--z-index-modal: 1000;
//--z-index-tooltip: 1500;
// Z-index levels
--z-bg: 0;
--z-elevate: 1;
--z-header: 100;
--z-modal: 1000;
--z-tooltip: 1500;
}
$success-color: #55e07a;
$danger-color: #ff5252;
$primary-color: #4a76c4;
$cream-color: #9b7dff;
$smoke-color: #fbb13c;
$warning-color: #fbb13c;

View File

@@ -0,0 +1,98 @@
// src/styles/components/_animated_checkbox.scss
@use '../variables' as *;
@use '../mixins' as *;
.animated-checkbox {
display: flex;
align-items: center;
cursor: pointer;
width: 100%;
position: relative;
&:hover .checkbox-custom {
border-color: rgba(255, 255, 255, 0.3);
}
}
.checkbox-original {
position: absolute;
opacity: 0;
height: 0;
width: 0;
}
.checkbox-custom {
width: 22px;
height: 22px;
background-color: rgba(255, 255, 255, 0.05);
border: 2px solid var(--border-soft, #323232);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s var(--easing-bounce);
margin-right: 15px;
flex-shrink: 0;
position: relative;
&.checked {
background-color: var(--primary-color, #ffc896);
border-color: var(--primary-color, #ffc896);
box-shadow: 0 0 10px rgba(255, 200, 150, 0.2);
}
}
.checkmark-icon {
width: 18px;
height: 18px;
}
.checkmark {
stroke-dasharray: 30;
stroke-dashoffset: 30;
opacity: 0;
transition: stroke-dashoffset 0.3s ease;
&.checked {
stroke-dashoffset: 0;
opacity: 1;
animation: checkmarkAnimation 0.3s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
}
.checkbox-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0; // Ensures text-overflow works properly
}
.checkbox-label {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkbox-sublabel {
font-size: 12px;
color: var(--text-muted);
}
// Animation for the checkmark
@keyframes checkmarkAnimation {
0% {
stroke-dashoffset: 30;
opacity: 0;
}
40% {
opacity: 1;
}
100% {
stroke-dashoffset: 0;
opacity: 1;
}
}

View File

@@ -0,0 +1,16 @@
// src/styles/_components/_background.scss
@use '../variables' as *;
@use '../mixins' as *;
@use 'sass:color';
.animated-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: var(--z-bg);
opacity: 0.4;
}

View File

@@ -0,0 +1,249 @@
// src/styles/_components/_dialog.scss
@use '../variables' as *;
@use '../mixins' as *;
/* Progress Dialog */
.progress-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--modal-backdrop);
backdrop-filter: blur(5px);
@include flex-center;
z-index: var(--z-modal);
opacity: 0;
animation: modal-appear 0.2s ease-out;
cursor: pointer;
&.visible {
opacity: 1;
}
@keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); }
100% { opacity: 1; transform: scale(1); }
}
}
.progress-dialog {
background-color: var(--elevated-bg);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); /* shadow-glow */
width: 450px;
max-width: 90vw;
border: 1px solid var(--border-soft);
opacity: 0;
cursor: default;
&.dialog-visible {
transform: scale(1);
opacity: 1;
}
&.with-instructions {
width: 500px;
}
h3 {
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-primary);
}
p {
margin-bottom: 1rem;
color: var(--text-secondary);
}
}
// Progress bar
.progress-bar-container {
height: 8px;
background-color: var(--border-soft);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 4px;
transition: width 0.3s ease;
background: var(--primary-color);
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.3);
}
.progress-percentage {
text-align: right;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
/* Instruction container in progress dialog */
.instruction-container {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-soft);
h4 {
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-primary);
}
}
.instruction-text {
line-height: 1.6;
margin-bottom: 1rem;
color: var(--text-secondary);
}
.dlc-count {
display: inline-block;
margin-bottom: 0.75rem;
padding: 0.4rem 0.8rem;
background-color: var(--info-soft);
color: var(--info);
border-radius: 4px;
font-size: 0.8rem;
&::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--info);
margin-right: 8px;
}
}
.command-box {
background-color: var(--border-dark);
border: 1px solid var(--border-soft);
border-radius: 4px;
padding: 1rem;
margin-bottom: 1.2rem;
font-family: monospace;
position: relative;
overflow: hidden;
&.command-box-smoke {
font-size: 0.9rem;
overflow-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
width: 100%;
max-width: 100%;
}
}
.selectable-text {
font-size: 0.9rem;
line-height: 1.5;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
cursor: text;
margin: 0;
color: var(--text-primary);
word-break: break-word;
white-space: pre-wrap;
}
.action-buttons {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.copy-button, .close-button {
padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm);
font-weight: 600;
letter-spacing: 0.5px;
@include transition-standard;
border: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.copy-button {
background-color: var(--primary-color);
color: white;
&:hover {
background-color: var(--primary-color);
transform: translateY(-2px) scale(1.02); /* hover-lift */
box-shadow: 0 6px 14px var(--info-soft);
}
}
.close-button {
background-color: var(--border-soft);
color: var(--text-primary);
&:hover {
background-color: var(--border);
transform: translateY(-2px) scale(1.02); /* hover-lift */
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
}
}
// Error message styling
.error-message {
@include flex-column;
align-items: center;
justify-content: center;
padding: 2rem;
margin: 2rem auto;
max-width: 600px;
border-radius: var(--radius-lg);
background-color: rgba(var(--danger), 0.05);
border: 1px solid rgb(var(--danger), 0.2);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
text-align: center;
h3 {
color: var(--danger);
font-weight: 700;
margin-bottom: 1rem;
}
p {
margin-bottom: 1.5rem;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
button {
background-color: var(--primary-color);
color: var(--text-primary);
border: none;
padding: 0.7rem 1.5rem;
border-radius: var(--radius-sm);
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
@include transition-standard;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
}
}
}
// Animation for progress bar
@keyframes progress-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}

View File

@@ -0,0 +1,314 @@
// src/styles/components/_dlc_dialog.scss
@use '../variables' as *;
@use '../mixins' as *;
.dlc-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--modal-backdrop);
backdrop-filter: blur(5px);
@include flex-center;
z-index: var(--z-modal);
opacity: 0;
cursor: pointer;
&.visible {
opacity: 1;
animation: modal-appear 0.2s ease-out;
}
}
.dlc-selection-dialog {
background-color: var(--elevated-bg);
border-radius: 8px;
width: 650px;
max-width: 90vw;
max-height: 80vh;
border: 1px solid var(--border-soft);
box-shadow: 0px 10px 25px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
cursor: default;
opacity: 0;
transform: scale(0.95);
&.dialog-visible {
transform: scale(1);
opacity: 1;
transition: transform 0.2s var(--easing-bounce), opacity 0.2s ease-out;
}
}
.dlc-dialog-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-soft);
h3 {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
}
.dlc-game-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
.game-title {
font-weight: 500;
color: var(--text-secondary);
}
.dlc-count {
font-size: 0.9rem;
padding: 0.3rem 0.6rem;
background-color: var(--info-soft);
color: var(--info);
border-radius: 4px;
}
}
.dlc-dialog-search {
padding: 0.75rem 1.5rem;
background-color: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.dlc-search-input {
flex: 1;
background-color: var(--border-dark);
border: 1px solid var(--border-soft);
border-radius: 4px;
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 0.9rem;
@include transition-standard;
&:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
}
&::placeholder {
color: var(--text-muted);
}
}
.select-all-container {
display: flex;
align-items: center;
min-width: 100px;
// Custom styling for the select all checkbox
:global(.animated-checkbox) {
margin-left: auto;
}
:global(.checkbox-label) {
font-size: 0.9rem;
color: var(--text-secondary);
}
}
.dlc-loading-progress {
padding: 0.75rem 1.5rem;
background-color: rgba(0, 0, 0, 0.05);
border-bottom: 1px solid var(--border-soft);
.progress-bar-container {
height: 6px;
background-color: var(--border-soft);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 3px;
transition: width 0.3s ease;
background: var(--primary-color);
box-shadow: 0px 0px 6px rgba(128, 181, 255, 0.3);
}
.loading-details {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-secondary);
.time-left {
color: var(--text-muted);
}
}
}
.dlc-list-container {
flex: 1;
overflow-y: auto;
min-height: 200px;
@include custom-scrollbar;
}
.dlc-list {
padding: 0.5rem 0;
}
.dlc-item {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-soft);
@include transition-standard;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
}
&:last-child {
border-bottom: none;
}
&.dlc-item-loading {
height: 30px;
display: flex;
align-items: center;
justify-content: center;
.loading-pulse {
width: 70%;
height: 20px;
background: linear-gradient(90deg,
var(--border-soft) 0%,
var(--border) 50%,
var(--border-soft) 100%);
background-size: 200% 100%;
border-radius: 4px;
animation: loading-pulse 1.5s infinite;
}
}
// Enhanced styling for the checkbox component inside dlc-item
:global(.animated-checkbox) {
width: 100%;
.checkbox-label {
color: var(--text-primary);
font-weight: 500;
transition: color 0.15s ease;
}
.checkbox-sublabel {
color: var(--text-muted);
}
// Optional hover effect
&:hover {
.checkbox-label {
color: var(--primary-color);
}
.checkbox-custom {
border-color: var(--primary-color, #ffc896);
transform: scale(1.05);
}
}
}
}
.dlc-loading {
height: 200px;
@include flex-center;
flex-direction: column;
gap: 1rem;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
p {
color: var(--text-secondary);
}
}
.no-dlcs-message {
height: 200px;
@include flex-center;
color: var(--text-secondary);
}
.dlc-dialog-actions {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-soft);
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.cancel-button, .confirm-button {
padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm);
font-weight: 600;
letter-spacing: 0.5px;
@include transition-standard;
border: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.cancel-button {
background-color: var(--border-soft);
color: var(--text-primary);
&:hover {
background-color: var(--border);
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
}
}
.confirm-button {
background-color: var(--primary-color);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px var(--info-soft);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes loading-pulse {
0% { background-position: 200% 50%; }
100% { background-position: 0% 50%; }
}

View File

@@ -0,0 +1,287 @@
// src/styles/components/_gamecard.scss
@use '../variables' as *;
@use '../mixins' as *;
.game-item-card {
position: relative;
height: var(--card-height);
border-radius: var(--radius-lg);
overflow: hidden;
will-change: opacity, transform;
@include shadow-standard;
@include transition-standard;
transform-origin: center;
// Simple image loading animation
opacity: 0;
animation: fadeIn 0.5s forwards;
}
// Hover effects for the card
.game-item-card:hover {
transform: translateY(-8px) scale(1.02);
@include shadow-hover;
z-index: 5;
.status-badge.native {
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5)
}
.status-badge.proton {
box-shadow: 0 0 10px rgba(255, 201, 150, 0.5);
}
.status-badge.cream {
box-shadow: 0 0 10px rgba(128, 181, 255, 0.5);
}
.status-badge.smoke {
box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
}
}
// Special styling for cards with different statuses
.game-item-card:has(.status-badge.cream) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15);
}
.game-item-card:has(.status-badge.smoke) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15);
}
// Simple clean overlay
.game-item-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.6) 50%,
rgba(0, 0, 0, 0.8) 100%
);
@include flex-column;
justify-content: space-between;
padding: 1rem;
box-sizing: border-box;
font-weight: var(--bold);
font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-heavy);;
z-index: 1;
}
.game-badges {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
margin-bottom: 0.5rem;
position: relative;
z-index: 2;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: var(--bold);
font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-heavy);;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
@include transition-standard;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-badge.native {
background-color: var(--native);
color: var(--text-heavy);
}
.status-badge.proton {
background-color: var(--proton);
color: var(--text-heavy);
}
.status-badge.cream {
background-color: var(--cream);
color: var(--text-heavy);
}
.status-badge.smoke {
background-color: var(--smoke);
color: var(--text-heavy);
}
.game-title {
padding: 0;
position: relative;
}
.game-title h3 {
color: var(--text-primary);
font-size: 1.6rem;
font-weight: var(--bold);
margin: 0;
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
transform: translateZ(0); // or
will-change: opacity, transform;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-actions {
display: flex;
gap: 0.5rem;
position: relative;
z-index: 3;
}
.action-button {
flex: 1;
padding: 0.5rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: var(--bold);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-heavy);
min-width: 0;
white-space: nowrap;
@include transition-standard;
}
.action-button.install {
background-color: var(--success);
}
.action-button.install:hover {
background-color: var(--success-light);
transform: translateY(-2px) scale(1.02);
box-shadow: 0px 0px 12px rgba(140, 200, 147, 0.3);
}
.action-button.uninstall {
background-color: var(--danger);
}
.action-button.uninstall:hover {
background-color: var(--danger-light);
transform: translateY(-2px) scale(1.02);
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3)
}
.action-button:active {
transform: scale(0.97);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.action-button:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: var(--disabled);
transform: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
position: relative;
overflow: hidden;
}
.action-button:disabled::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
animation: button-loading 1.5s infinite;
}
.edit-button {
padding: 0 0.7rem;
background-color: rgba(255, 255, 255, 0.2);
font-weight: var(--bold);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-primary);
border-radius: var(--radius-sm);
cursor: pointer;
letter-spacing: 1px;
@include transition-standard;
}
.edit-button:hover {
background-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3);
}
.edit-button:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.api-not-found-message {
display: flex;
align-items: center;
justify-content: space-between;
background-color: rgba(255, 100, 100, 0.2);
border: 1px solid rgba(255, 100, 100, 0.3);
border-radius: var(--radius-sm);
padding: 0.4rem 0.8rem;
width: 100%;
font-size: 0.85rem;
color: var(--text-primary);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
span {
flex: 1;
}
.rescan-button {
background-color: var(--warning);
color: var(--text-heavy);
border: none;
border-radius: var(--radius-sm);
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-weight: var(--bold);
margin-left: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: var(--warning-light);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
}
// Apply staggered delay to cards
@for $i from 1 through 12 {
.game-grid .game-item-card:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
// Simple animations
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes button-loading {
to { left: 100%; }
}

View File

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

View File

@@ -0,0 +1,100 @@
.initial-loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--primary-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal) + 1;
.loading-content {
text-align: center;
padding: 2rem;
max-width: 500px;
width: 90%;
h1 {
font-size: 2.5rem;
margin-bottom: 2rem;
font-weight: var(--bold);
color: var(--primary-color);
text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4);
}
.loading-animation {
margin-bottom: 2rem;
}
.loading-circles {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
.circle {
width: 20px;
height: 20px;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
&.circle-1 {
background-color: var(--primary-color);
animation-delay: -0.32s;
}
&.circle-2 {
background-color: var(--cream-color);
animation-delay: -0.16s;
}
&.circle-3 {
background-color: var(--smoke-color);
}
}
}
.loading-message {
font-size: 1.1rem;
color: var(--text-secondary);
margin-bottom: 1.5rem;
min-height: 3rem;
}
.progress-bar-container {
height: 8px;
background-color: var(--border-soft);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 4px;
transition: width 0.5s ease;
background: linear-gradient(to right, var(--cream-color), var(--primary-color), var(--smoke-color));
box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
}
.progress-percentage {
text-align: right;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
}
}
// Animation for the bouncing circles
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1.0);
}
}

View File

@@ -0,0 +1,206 @@
// src/styles/_components/_sidebar.scss
@use '../variables' as *;
@use '../mixins' as *;
.filter-list {
list-style: none;
margin-bottom: 1.5rem;
li {
@include transition-standard;
border-radius: var(--radius-sm);
padding: 0.7rem 1rem;
margin-bottom: 0.3rem;
font-weight: 500;
cursor: pointer;
&:hover {
background-color: rgba(255, 255, 255, 0.07);
}
&.active {
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color)));
box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
}
}
}
// Custom select dropdown styling
.custom-select {
position: relative;
display: inline-block;
.select-selected {
background-color: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 0.9rem;
cursor: pointer;
@include transition-standard;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 150px;
&:after {
content: '';
font-size: 0.7rem;
opacity: 0.7;
}
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
.select-items {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: var(--secondary-bg);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
margin-top: 5px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
z-index: 10;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
&.show {
max-height: 300px;
}
.select-item {
padding: 0.5rem 1rem;
cursor: pointer;
@include transition-standard;
&:hover {
background-color: rgba(255, 255, 255, 0.07);
}
&.selected {
background-color: var(--primary-color);
color: var(--text-primary);
}
}
}
}
// App logo styles
.app-logo {
display: flex;
align-items: center;
gap: 10px;
svg {
width: 28px;
height: 28px;
fill: var(--text-primary);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
}
// Tooltip styles
.tooltip {
position: relative;
display: inline-block;
&:hover .tooltip-content {
visibility: visible;
opacity: 1;
transform: translateY(0);
}
.tooltip-content {
visibility: hidden;
width: 200px;
background-color: var(--secondary-bg);
color: var(--text-primary);
text-align: center;
border-radius: var(--radius-sm);
padding: 8px;
position: absolute;
z-index: var(--z-tooltip);
bottom: 125%;
left: 50%;
margin-left: -100px;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s, transform 0.3s;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.8rem;
pointer-events: none;
&::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: var(--secondary-bg) transparent transparent transparent;
}
}
}
// Header controls
.refresh-button {
background-color: var(--primary-color);
color: var(--text-heavy);
border: none;
border-radius: var(--radius-sm);
padding: 0.6rem 1.2rem;
font-weight: var(--bold);
letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
@include transition-standard;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
background-color: var(--primary-color);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
}
.search-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
}
.search-input {
background-color: var(--border-dark);
border: 1px solid var(--border-soft);
border-radius: 4px;
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 0.9rem;
@include transition-standard;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
min-width: 200px;
&:focus {
border-color: var(--primary-color);
background-color: rgba(255, 255, 255, 0.1);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2);
}
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
}

21
src/styles/main.scss Normal file
View File

@@ -0,0 +1,21 @@
// src/styles/main.scss
// Import variables and mixins first
@use './variables' as *;
@use './mixins' as *;
@use './fonts' as *;
// Reset
@use './reset';
// Layout
@use './layout';
// Components
@use './components/gamecard';
@use './components/dialog';
@use './components/background';
@use './components/sidebar';
@use './components/dlc_dialog';
@use './components/loading_screen';
@use './components/animated_checkbox';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

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

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

24
vite.config.ts Normal file
View File

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