mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2025-12-06 03:55:37 -05:00
Initial commit
This commit is contained in:
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
|
||||||
|
```
|
||||||
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
55
.github/workflows/build.yml
vendored
Normal 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
24
.gitignore
vendored
Normal 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
21
LICENSE.md
Normal 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
117
README.md
Normal 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
4044
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
1
public/vite.svg
Normal 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
4
src-tauri/.gitignore
vendored
Normal 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
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
42
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal 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
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
src-tauri/icons/icon.png
Normal file
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
176
src-tauri/src/cache.rs
Normal 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(())
|
||||||
|
}
|
||||||
339
src-tauri/src/dlc_manager.rs
Normal file
339
src-tauri/src/dlc_manager.rs
Normal 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
997
src-tauri/src/installer.rs
Normal 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
487
src-tauri/src/main.rs
Normal 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
587
src-tauri/src/searcher.rs
Normal 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(®istry_path);
|
||||||
|
if path.exists() {
|
||||||
|
debug!("Found Steam registry at: {}", path.display());
|
||||||
|
|
||||||
|
if let Ok(content) = fs::read_to_string(path) {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
|
// Extract Steam installation paths
|
||||||
|
let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap();
|
||||||
|
if let Some(cap) = re_steam_path.captures(&content) {
|
||||||
|
let steam_path = PathBuf::from(&cap[1]);
|
||||||
|
paths.push(steam_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for install path
|
||||||
|
let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap();
|
||||||
|
if let Some(cap) = re_install_path.captures(&content) {
|
||||||
|
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
41
src-tauri/tauri.conf.json
Normal 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
866
src/App.tsx
Normal 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
BIN
src/assets/fonts/Roboto.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Satoshi.ttf
Normal file
BIN
src/assets/fonts/Satoshi.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WorkSans.ttf
Normal file
BIN
src/assets/fonts/WorkSans.ttf
Normal file
Binary file not shown.
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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
BIN
src/assets/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 362 KiB |
46
src/components/ActionButton.tsx
Normal file
46
src/components/ActionButton.tsx
Normal 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;
|
||||||
127
src/components/AnimatedBackground.tsx
Normal file
127
src/components/AnimatedBackground.tsx
Normal 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;
|
||||||
50
src/components/AnimatedCheckbox.tsx
Normal file
50
src/components/AnimatedCheckbox.tsx
Normal 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;
|
||||||
242
src/components/DlcSelectionDialog.tsx
Normal file
242
src/components/DlcSelectionDialog.tsx
Normal 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
172
src/components/GameItem.tsx
Normal 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;
|
||||||
92
src/components/GameList.tsx
Normal file
92
src/components/GameList.tsx
Normal 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
40
src/components/Header.tsx
Normal 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;
|
||||||
48
src/components/ImagePreloader.tsx
Normal file
48
src/components/ImagePreloader.tsx
Normal 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;
|
||||||
36
src/components/InitialLoadingScreen.tsx
Normal file
36
src/components/InitialLoadingScreen.tsx
Normal 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;
|
||||||
215
src/components/ProgressDialog.tsx
Normal file
215
src/components/ProgressDialog.tsx
Normal 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;
|
||||||
37
src/components/Sidebar.tsx
Normal file
37
src/components/Sidebar.tsx
Normal 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
9
src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
96
src/services/ImageService.ts
Normal file
96
src/services/ImageService.ts
Normal 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
10
src/styles/_fonts.scss
Normal 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
262
src/styles/_layout.scss
Normal 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
107
src/styles/_mixins.scss
Normal 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
65
src/styles/_reset.scss
Normal 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
116
src/styles/_variables.scss
Normal 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;
|
||||||
98
src/styles/components/_animated_checkbox.scss
Normal file
98
src/styles/components/_animated_checkbox.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/styles/components/_background.scss
Normal file
16
src/styles/components/_background.scss
Normal 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;
|
||||||
|
}
|
||||||
249
src/styles/components/_dialog.scss
Normal file
249
src/styles/components/_dialog.scss
Normal 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%); }
|
||||||
|
}
|
||||||
314
src/styles/components/_dlc_dialog.scss
Normal file
314
src/styles/components/_dlc_dialog.scss
Normal 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%; }
|
||||||
|
}
|
||||||
287
src/styles/components/_gamecard.scss
Normal file
287
src/styles/components/_gamecard.scss
Normal 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%; }
|
||||||
|
}
|
||||||
82
src/styles/components/_header.scss
Normal file
82
src/styles/components/_header.scss
Normal 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);
|
||||||
|
}
|
||||||
100
src/styles/components/_loading_screen.scss
Normal file
100
src/styles/components/_loading_screen.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/styles/components/_sidebar.scss
Normal file
206
src/styles/components/_sidebar.scss
Normal 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
21
src/styles/main.scss
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal 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
24
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user