Compare commits
174 Commits
v1.0.0-bet
...
v1.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5f9d50248 | ||
|
|
d70b174dd4 | ||
|
|
2164492934 | ||
|
|
7733d9732e | ||
|
|
f151f5ee4f | ||
|
|
ad910cce0a | ||
|
|
9621cba58d | ||
|
|
f18cffaa09 | ||
|
|
17de5172e4 | ||
|
|
1d4c75bffd | ||
|
|
2d524de661 | ||
|
|
832841134a | ||
|
|
b3e92d2165 | ||
|
|
348b1a5ed0 | ||
|
|
cf7fe20aa6 | ||
|
|
62a1dca0aa | ||
|
|
214564d67f | ||
|
|
9c70530890 | ||
|
|
568c02495c | ||
|
|
285256bfb8 | ||
|
|
42d8618f37 | ||
|
|
483e58dfd1 | ||
|
|
ae9c012040 | ||
|
|
220763b389 | ||
|
|
3d894266a7 | ||
|
|
33492a6a55 | ||
|
|
92f4d82e6c | ||
|
|
5896733fd4 | ||
|
|
1d72f24afa | ||
|
|
aea8a84335 | ||
|
|
a476819312 | ||
|
|
1bb62877a3 | ||
|
|
f8ea256637 | ||
|
|
0480d523e3 | ||
|
|
1571e9d87d | ||
|
|
f949ecf2f3 | ||
|
|
ecee6529ff | ||
|
|
d9819ef115 | ||
|
|
ff53cc7a46 | ||
|
|
1a1c7dfb3d | ||
|
|
769213288e | ||
|
|
85d670931a | ||
|
|
487e974274 | ||
|
|
1b8fdadbf2 | ||
|
|
ecd7b4dceb | ||
|
|
640eb9a0d5 | ||
|
|
b42086ca27 | ||
|
|
b9beb0d704 | ||
|
|
09e7bcac6f | ||
|
|
b7f219a25f | ||
|
|
2b205d8376 | ||
|
|
4cf1e2caf4 | ||
|
|
0ee10d07fc | ||
|
|
365063d30d | ||
|
|
61ad3f1d54 | ||
|
|
d3a91f5722 | ||
|
|
9ba307f9f8 | ||
|
|
1123012737 | ||
|
|
7a07399946 | ||
|
|
40b9ec9b01 | ||
|
|
05e4275962 | ||
|
|
03cae08df1 | ||
|
|
6b16ec6168 | ||
|
|
a786530572 | ||
|
|
ef7dfdd6c5 | ||
|
|
5998e77272 | ||
|
|
fab29f5778 | ||
|
|
bec190691b | ||
|
|
58217d61d1 | ||
|
|
0f4db7bbb7 | ||
|
|
22c8f41f93 | ||
|
|
5ff51d1174 | ||
|
|
169b7d5edd | ||
|
|
41da6731a7 | ||
|
|
5f8f389687 | ||
|
|
1d8422dc65 | ||
|
|
677e3ef12d | ||
|
|
33266f3781 | ||
|
|
9703f21209 | ||
|
|
3459158d3f | ||
|
|
418b470d4a | ||
|
|
fd606cbc2e | ||
|
|
5845cf9bd8 | ||
|
|
6294b99a14 | ||
|
|
595fe53254 | ||
|
|
3801404138 | ||
|
|
919749d0ae | ||
|
|
d4ae5d74e9 | ||
|
|
7fd3147f44 | ||
|
|
87dc328434 | ||
|
|
b227dff339 | ||
|
|
04910e84cf | ||
|
|
7960019cd9 | ||
|
|
a00cc92b70 | ||
|
|
85520f8916 | ||
|
|
ac96e7be69 | ||
|
|
3675ff8fae | ||
|
|
ab057b8d10 | ||
|
|
952749cc93 | ||
|
|
4c4e087be7 | ||
|
|
1e52c2071c | ||
|
|
fc8c69a915 | ||
|
|
2d7077a05b | ||
|
|
081d61afc7 | ||
|
|
0bfd36aea9 | ||
|
|
d730fe61ae | ||
|
|
f657c18c54 | ||
|
|
4bf0d1819d | ||
|
|
a05cce1c18 | ||
|
|
ae7dd9dbd0 | ||
|
|
c484c8958c | ||
|
|
6f4f53f7f5 | ||
|
|
cbf791a348 | ||
|
|
9397e1508f | ||
|
|
294ab81211 | ||
|
|
7a631543fa | ||
|
|
e0a62d72d4 | ||
|
|
e54c71abed | ||
|
|
e646858e43 | ||
|
|
dc7c2682cf | ||
|
|
08282c8a22 | ||
|
|
308b284d17 | ||
|
|
51c6b7337b | ||
|
|
bb73d535ce | ||
|
|
38f536bc1c | ||
|
|
686a5219eb | ||
|
|
9f3cf1cb1f | ||
|
|
0a5f00d3fb | ||
|
|
931ecc0d92 | ||
|
|
f7f70a0b8a | ||
|
|
d280c6c5f3 | ||
|
|
9bbe1c7de8 | ||
|
|
18a51e37a1 | ||
|
|
1eb8f92946 | ||
|
|
62b80cc565 | ||
|
|
82bd475383 | ||
|
|
53be3e3bb2 | ||
|
|
69135fc4a4 | ||
|
|
d1871a5384 | ||
|
|
acce153720 | ||
|
|
a97dc69cee | ||
|
|
c8318ede9f | ||
|
|
c7593b6c6c | ||
|
|
a460e9d3b7 | ||
|
|
6559b15894 | ||
|
|
653c301ba9 | ||
|
|
a6f21c34b1 | ||
|
|
a2d5a38f68 | ||
|
|
ec95d8e975 | ||
|
|
cd80e81d0b | ||
|
|
2324afaa50 | ||
|
|
68a458e612 | ||
|
|
5a6ec9e6cf | ||
|
|
2c0e67eaf3 | ||
|
|
039d0702c7 | ||
|
|
37f872c6bd | ||
|
|
2ad81160ba | ||
|
|
caae074587 | ||
|
|
8d2da35a93 | ||
|
|
6d5b595883 | ||
|
|
1ac1931a08 | ||
|
|
41dba65879 | ||
|
|
0c57cb75c2 | ||
|
|
b7a850f2d5 | ||
|
|
b29bdef058 | ||
|
|
c6e671587b | ||
|
|
5a89757855 | ||
|
|
b701f7f63c | ||
|
|
116e2cfea0 | ||
|
|
45dc70d4ae | ||
|
|
fd6ca8a158 | ||
|
|
2d4c87d1e7 | ||
|
|
4d1a0e2199 | ||
|
|
2a7999eae7 |
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -3,7 +3,7 @@ name: Bug Report
|
||||
about: Create a report to help improve CreamLinux
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
assignees: 'Novattz'
|
||||
---
|
||||
|
||||
## Bug Description
|
||||
@@ -31,6 +31,7 @@ If applicable, add screenshots to help explain your problem.
|
||||
- Desktop Environment: [e.g. GNOME, KDE, etc.]
|
||||
- CreamLinux Version: [e.g. 0.1.0]
|
||||
- Steam Version: [e.g. latest]
|
||||
- Graphics card: [e.g. 2060 rtx]
|
||||
|
||||
## Game Information
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -3,7 +3,7 @@ name: Feature Request
|
||||
about: Suggest an idea for CreamLinux
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
assignees: 'Novattz'
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
165
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
name: 'Build and Release'
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: 'ubuntu-24.04'
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
version: ${{ steps.get-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: get version
|
||||
id: get-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package version: $VERSION"
|
||||
|
||||
- name: get changelog notes for version
|
||||
id: changelog
|
||||
env:
|
||||
VERSION: ${{ steps.get-version.outputs.version }}
|
||||
run: |
|
||||
NOTES="$(awk -v ver="$VERSION" '
|
||||
BEGIN { found=0 }
|
||||
$0 ~ "^## \\[" ver "\\] - " { found=1 }
|
||||
found {
|
||||
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" ver "\\] - " ) exit
|
||||
print
|
||||
}
|
||||
' CHANGELOG.md)"
|
||||
|
||||
if [ -z "$NOTES" ]; then
|
||||
echo "No changelog entry found for version $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: create draft release
|
||||
id: create-release
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
VERSION: ${{ steps.get-version.outputs.version }}
|
||||
NOTES: ${{ steps.changelog.outputs.notes }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${process.env.VERSION}`,
|
||||
name: `v${process.env.VERSION}`,
|
||||
body: process.env.NOTES,
|
||||
draft: true,
|
||||
prerelease: false
|
||||
})
|
||||
return data.id
|
||||
|
||||
build-tauri:
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'ubuntu-24.04'
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-0=2.44.0-2 \
|
||||
libwebkit2gtk-4.1-dev=2.44.0-2 \
|
||||
libjavascriptcoregtk-4.1-0=2.44.0-2 \
|
||||
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
|
||||
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
|
||||
gir1.2-webkit2-4.1=2.44.0-2 \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libssl-dev \
|
||||
libgtk-3-dev
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app with updater
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
projectPath: '.'
|
||||
includeDebug: false
|
||||
includeRelease: true
|
||||
includeUpdaterJson: true
|
||||
tauriScript: 'npm run tauri'
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
publish-release:
|
||||
name: Publish release
|
||||
needs: [create-release, build-tauri]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Publish GitHub release (unset draft)
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const release_id = Number("${{ needs.create-release.outputs.release_id }}");
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id,
|
||||
draft: false
|
||||
});
|
||||
4
.gitignore
vendored
@@ -12,11 +12,7 @@ dist
|
||||
dist-ssr
|
||||
docs
|
||||
*.local
|
||||
*.lock
|
||||
.env
|
||||
CHANGELOG.md
|
||||
scripts/prepare-release.js
|
||||
scripts/update-server.js
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
90
CHANGELOG.md
@@ -0,0 +1,90 @@
|
||||
## [1.5.5] - 30-04-2026
|
||||
|
||||
### Added
|
||||
- Epic Games library scanning via Heroic/Legendary
|
||||
- ScreamAPI support (Tested and working with SnowRunner)
|
||||
- Koaloader support (currently not working, fix coming in a future update)
|
||||
|
||||
## [1.5.0] - 28-03-2026
|
||||
|
||||
### Added
|
||||
- Anonymous reporting system. Vote on whether CreamLinux or SmokeAPI works for a game
|
||||
- Opt-in dialog on first launch explaining what is collected and why
|
||||
- Rating button on game cards (only visible when opted in and an unlocker is installed)
|
||||
- Community vote display in the unlocker selection dialog and before installing SmokeAPI on Proton games
|
||||
- Votes track per-unlocker so CreamLinux and SmokeAPI ratings are independent
|
||||
- Previously submitted votes are stored locally so already-cast buttons are disabled on re-open
|
||||
- Config now automatically migrates missing fields on update without overwriting existing values
|
||||
- API source available at https://github.com/Novattz/Lactose/
|
||||
|
||||
## [1.4.2] - 13-03-2026
|
||||
|
||||
### Added
|
||||
- Added a dialog so users can manually add DLC's incase they are missing from the steam api
|
||||
|
||||
### Fixed
|
||||
- Fixed an issue where if the libsteam_api.so file is nested too deeply in a game causing the app to not find it.
|
||||
|
||||
## [1.4.1] - 18-01-2026
|
||||
|
||||
### Added
|
||||
- Dramatically reduced the time that bitness detection takes to detect game bitness
|
||||
|
||||
## [1.4.0] - 17-01-2026
|
||||
|
||||
### Added
|
||||
- Unlocker selection dialog for native games, allowing users to choose between CreamLinux and SmokeAPI
|
||||
- Game bitness detection
|
||||
|
||||
### Fixed
|
||||
- Cache now validates if expected files are missing.
|
||||
|
||||
## [1.3.5] - 09-01-2026
|
||||
|
||||
### Changed
|
||||
- Redesigned conflict detection dialog to show all conflicts at once
|
||||
- Integrated Steam launch option reminder directly into the conflict dialog
|
||||
|
||||
### Fixed
|
||||
- Improved UX by allowing users to resolve conflicts in any order or defer to later
|
||||
|
||||
## [1.3.4] - 03-01-2026
|
||||
|
||||
### Added
|
||||
- Disclaimer dialog explaining that CreamLinux Installer manages DLC IDs, not actual DLC files
|
||||
- User config stored in `~/.config/creamlinux/config.json`
|
||||
- **"Don't show again" option**: Users can permanently dismiss the disclaimer via checkbox
|
||||
|
||||
## [1.3.3] - 26-12-2025
|
||||
|
||||
### Added
|
||||
- Platform conflict detection
|
||||
- Automatic removal of incompatible unlocker files when switching between Native/Proton
|
||||
- Reminder dialog for steam launch options after creamlinux removal
|
||||
- Conflict dialog to show which game had the conflict
|
||||
|
||||
## [1.3.2] - 23-12-2025
|
||||
|
||||
### Added
|
||||
- New dropdown component
|
||||
- Settings dialog for SmokeAPI configuration
|
||||
- Update creamlinux config functionality
|
||||
|
||||
### Changed
|
||||
- Adjusted styling for CreamLinux settings dialog
|
||||
|
||||
## [1.3.0] - 22-12-2025
|
||||
|
||||
### Added
|
||||
- New icons
|
||||
- Unlockers are now cached in `~/.cache/creamlinux/` with automatic version management
|
||||
- Check for new SmokeAPI/CreamLinux versions on every app startup
|
||||
- Each game gets a `creamlinux.json` manifest tracking installed versions
|
||||
- Outdated installations automatically sync with latest cached versions
|
||||
|
||||
### Changed
|
||||
- Polished toast notifications alot
|
||||
- Complete modular rewrite with clear separation of concerns
|
||||
|
||||
### Fixed
|
||||
- Fixed toast message where uninstall actions incorrectly showed success notifications
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Tickbase
|
||||
Copyright (c) 2026 Tickbase
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
94
README.md
@@ -1,8 +1,10 @@
|
||||
# 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).
|
||||
CreamLinux is a GUI application for Linux that simplifies the management of DLC IDs 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).
|
||||
|
||||

|
||||
## Watch the demo here:
|
||||
|
||||
[](https://www.youtube.com/watch?v=neUDotrqnDM)
|
||||
|
||||
## Beta Status
|
||||
|
||||
@@ -27,22 +29,96 @@ While the core functionality is working, please be aware that this is an early r
|
||||
|
||||
### AppImage (Recommended)
|
||||
|
||||
1. Download the latest `CreamLinux.AppImage` from the [Releases](https://github.com/Novattz/creamlinux-installer/releases) page
|
||||
1. Download the latest `creamlinux.AppImage` from the [Releases](https://github.com/Novattz/creamlinux-installer/releases) page
|
||||
2. Make it executable:
|
||||
```bash
|
||||
chmod +x CreamLinux.AppImage
|
||||
chmod +x creamlinux.AppImage
|
||||
```
|
||||
3. Run it:
|
||||
|
||||
```bash
|
||||
./CreamLinux.AppImage
|
||||
./creamlinux.AppImage
|
||||
```
|
||||
|
||||
For Nvidia users use this command:
|
||||
|
||||
```
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.AppImage
|
||||
```
|
||||
|
||||
### Nix
|
||||
You can fetch this repository in your configuration using `pkgs.fetchFromGithub`:
|
||||
```nix
|
||||
let
|
||||
creamlinux = pkgs.callPackage (pkgs.fetchFromGitHub {
|
||||
owner = "Novattz";
|
||||
repo = "creamlinux-installer";
|
||||
rev = "main";
|
||||
hash = ""; # You can use nix-prefetch-url to determine which value to put here, or paste the value returned by the error your rebuild will output
|
||||
}) {};
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ creamlinux ];
|
||||
}
|
||||
```
|
||||
or, using `builtins.fetchTarball`:
|
||||
```nix
|
||||
let
|
||||
creamlinux-src = builtins.fetchTarball {
|
||||
url = "https://github.com/Novattz/creamlinux-installer/archive/main.tar.gz";
|
||||
sha256 = ""; # See above
|
||||
};
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage creamlinux-src {})
|
||||
];
|
||||
}
|
||||
```
|
||||
alternatively and if you want to pin the package version, using [npins](https://github.com/andir/npins):
|
||||
```bash
|
||||
npins add github Novattz creamlinux-installer --branch main
|
||||
```
|
||||
```nix
|
||||
let
|
||||
sources = import ./npins;
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage "${sources.creamlinux-installer}/default.nix" {})
|
||||
];
|
||||
}
|
||||
```
|
||||
Those are the recommended methods to add creamlinux-installer to your environment. However, you could also add it as an input of your flake, like so:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
creamlinux-installer = {
|
||||
type = "github";
|
||||
owner = "Novattz";
|
||||
repo = "creamlinux-installer";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
Then, in your configuration:
|
||||
```nix
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage inputs.creamlinux-installer {})
|
||||
];
|
||||
```
|
||||
Similarly to running the AppImage, you will need to set `WEBKIT_DISABLE_DMABUF_RENDERER=1` if your GPU is from Nvidia in order to run the package.
|
||||
|
||||
### Building from Source
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Rust 1.77.2 or later
|
||||
- Node.js 18 or later
|
||||
- webkit2gtk-4.1 (libwebkit2gtk-4.1 for debian)
|
||||
- npm or yarn
|
||||
|
||||
#### Steps
|
||||
@@ -50,8 +126,8 @@ While the core functionality is working, please be aware that this is an early r
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/novattz/creamlinux.git
|
||||
cd creamlinux
|
||||
git clone https://github.com/Novattz/creamlinux-installer.git
|
||||
cd creamlinux-installer
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
@@ -102,7 +178,7 @@ update-desktop-database ~/.local/share/applications
|
||||
|
||||
- **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 (Flatpak is not supported yet)
|
||||
- **Cannot find Steam**: Ensure Steam is installed and you've launched it at least once
|
||||
|
||||
### Debug Logs
|
||||
|
||||
@@ -114,7 +190,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) f
|
||||
|
||||
## Credits
|
||||
|
||||
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support
|
||||
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native 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
|
||||
|
||||
57
default.nix
Normal file
@@ -0,0 +1,57 @@
|
||||
{pkgs ? import <nixpkgs> {}}: let
|
||||
cargoRoot = "src-tauri";
|
||||
src = ./.;
|
||||
|
||||
patchSassEmbedded = pkgs.writeShellScriptBin "patch-sass-embedded" ''
|
||||
NIX_LD="$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)"
|
||||
for dart_bin in node_modules/sass-embedded-linux-*/dart-sass/src/dart; do
|
||||
if [ -f "$dart_bin" ]; then
|
||||
${pkgs.patchelf}/bin/patchelf --set-interpreter "$NIX_LD" "$dart_bin"
|
||||
fi
|
||||
done
|
||||
'';
|
||||
in
|
||||
pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "creamlinux-installer";
|
||||
version = "1.5.0-unstable-2026-04-23";
|
||||
inherit src;
|
||||
|
||||
cargoLock.lockFile = ./src-tauri/Cargo.lock;
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-anYTERlnfOGDsGW0joy+h7wECJNDy6q+0a2to6t36pg=";
|
||||
};
|
||||
|
||||
nativeBuildInputs =
|
||||
[
|
||||
pkgs.cargo-tauri.hook
|
||||
pkgs.nodejs
|
||||
pkgs.npmHooks.npmConfigHook
|
||||
pkgs.pkg-config
|
||||
]
|
||||
++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.wrapGAppsHook4
|
||||
];
|
||||
|
||||
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.glib-networking
|
||||
pkgs.openssl
|
||||
pkgs.webkitgtk_4_1
|
||||
];
|
||||
|
||||
inherit cargoRoot;
|
||||
|
||||
buildAndTestSubdir = cargoRoot;
|
||||
|
||||
postPatch = ''
|
||||
substituteInPlace src-tauri/tauri.conf.json \
|
||||
--replace-fail '"createUpdaterArtifacts": true' '"createUpdaterArtifacts": false'
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
${patchSassEmbedded}/bin/patch-sass-embedded
|
||||
'';
|
||||
|
||||
env.NO_STRIP = true;
|
||||
}
|
||||
6544
package-lock.json
generated
10
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "creamlinux",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.5.5",
|
||||
"type": "module",
|
||||
"author": "Tickbase",
|
||||
"repository": "https://github.com/Novattz/creamlinux-installer",
|
||||
@@ -13,7 +13,7 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"optimize-svg": "node scripts/optimize-svg.js",
|
||||
"prepare-release": "node scripts/prepare-release.js"
|
||||
"set-version": "node scripts/set-version.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
@@ -40,14 +40,14 @@
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"glob": "^11.0.2",
|
||||
"glob": "^11.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"semantic-release": "^24.2.4",
|
||||
"semantic-release": "^25.0.2",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"vite": "^6.3.1",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
92
scripts/set-version.js
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// Get version from command line argument
|
||||
const newVersion = process.argv[2]
|
||||
|
||||
if (!newVersion) {
|
||||
console.error('Error: No version specified')
|
||||
console.log('Usage: npm run set-version <version>')
|
||||
console.log('Example: npm run set-version 1.2.3')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Validate version format (basic semver check)
|
||||
if (!/^\d+\.\d+\.\d+$/.test(newVersion)) {
|
||||
console.error('Error: Invalid version format. Use semver format: X.Y.Z')
|
||||
console.log('Example: 1.2.3')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Setting version to ${newVersion}...\n`)
|
||||
|
||||
let errors = 0
|
||||
|
||||
// 1. Update package.json
|
||||
try {
|
||||
const packageJsonPath = path.join(process.cwd(), 'package.json')
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||
packageJson.version = newVersion
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n')
|
||||
console.log('Updated package.json')
|
||||
} catch (err) {
|
||||
console.error('Failed to update package.json:', err.message)
|
||||
errors++
|
||||
}
|
||||
|
||||
// 2. Update package-lock.json
|
||||
try {
|
||||
const packageLockPath = path.join(process.cwd(), 'package-lock.json')
|
||||
if (fs.existsSync(packageLockPath)) {
|
||||
const packageLock = JSON.parse(fs.readFileSync(packageLockPath, 'utf8'))
|
||||
packageLock.version = newVersion
|
||||
if (packageLock.packages && packageLock.packages['']) {
|
||||
packageLock.packages[''].version = newVersion
|
||||
}
|
||||
fs.writeFileSync(packageLockPath, JSON.stringify(packageLock, null, 2) + '\n')
|
||||
console.log('Updated package-lock.json')
|
||||
} else {
|
||||
console.log('package-lock.json not found (skipping)')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update package-lock.json:', err.message)
|
||||
errors++
|
||||
}
|
||||
|
||||
// 3. Update Cargo.toml
|
||||
try {
|
||||
const cargoTomlPath = path.join(process.cwd(), 'src-tauri', 'Cargo.toml')
|
||||
let cargoToml = fs.readFileSync(cargoTomlPath, 'utf8')
|
||||
|
||||
// Replace version in [package] section
|
||||
cargoToml = cargoToml.replace(/^version\s*=\s*"[^"]*"/m, `version = "${newVersion}"`)
|
||||
|
||||
fs.writeFileSync(cargoTomlPath, cargoToml)
|
||||
console.log('Updated Cargo.toml')
|
||||
} catch (err) {
|
||||
console.error('Failed to update Cargo.toml:', err.message)
|
||||
errors++
|
||||
}
|
||||
|
||||
// 4. Update tauri.conf.json
|
||||
try {
|
||||
const tauriConfPath = path.join(process.cwd(), 'src-tauri', 'tauri.conf.json')
|
||||
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf8'))
|
||||
tauriConf.version = newVersion
|
||||
fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n')
|
||||
console.log('Updated tauri.conf.json')
|
||||
} catch (err) {
|
||||
console.error('Failed to update tauri.conf.json:', err.message)
|
||||
errors++
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(50))
|
||||
if (errors === 0) {
|
||||
console.log(`Successfully set version to ${newVersion} in all files!`)
|
||||
} else {
|
||||
console.log(`Completed with ${errors} error(s)`)
|
||||
process.exit(1)
|
||||
}
|
||||
1
src-tauri/.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
/resources/
|
||||
6607
src-tauri/Cargo.lock
generated
Normal file
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "1.0.0"
|
||||
name = "creamlinux-installer"
|
||||
version = "1.5.5"
|
||||
description = "DLC Manager for Steam games on Linux"
|
||||
authors = ["tickbase"]
|
||||
license = "MIT"
|
||||
@@ -30,10 +30,13 @@ 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"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-process = "2.2.1"
|
||||
async-trait = "0.1.89"
|
||||
sha2 = "0.10.9"
|
||||
rand = "0.9.2"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-updater = "2.7.1"
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": ["core:default", "updater:default", "process:default"]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,5 @@
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// This is a placeholder file - cache functionality has been removed
|
||||
// and now only exists in memory within the App state
|
||||
|
||||
pub fn cache_dlcs(_game_id: &str, _dlcs: &[crate::dlc_manager::DlcInfoWithState]) -> std::io::Result<()> {
|
||||
// This function is kept only for compatibility, but now does nothing
|
||||
// The DLCs are only cached in memory
|
||||
log::info!("Cache functionality has been removed - DLCs are only stored in memory");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_cached_dlcs(_game_id: &str) -> Option<Vec<crate::dlc_manager::DlcInfoWithState>> {
|
||||
// This function is kept only for compatibility, but now always returns None
|
||||
log::info!("Cache functionality has been removed - DLCs are only stored in memory");
|
||||
None
|
||||
}
|
||||
|
||||
pub fn clear_all_caches() -> std::io::Result<()> {
|
||||
// This function is kept only for compatibility, but now does nothing
|
||||
log::info!("Cache functionality has been removed - DLCs are only stored in memory");
|
||||
Ok(())
|
||||
}
|
||||
367
src-tauri/src/cache/mod.rs
vendored
Normal file
@@ -0,0 +1,367 @@
|
||||
mod storage;
|
||||
mod version;
|
||||
|
||||
pub use storage::{
|
||||
get_creamlinux_version_dir, get_smokeapi_version_dir,
|
||||
list_creamlinux_files, list_smokeapi_files, read_versions,
|
||||
update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache,
|
||||
validate_creamlinux_cache, get_cache_dir, get_koaloader_version_dir, get_screamapi_version_dir,
|
||||
};
|
||||
|
||||
pub use version::{
|
||||
read_manifest, remove_creamlinux_version, remove_smokeapi_version,
|
||||
update_creamlinux_version as update_game_creamlinux_version,
|
||||
update_smokeapi_version as update_game_smokeapi_version,
|
||||
};
|
||||
|
||||
use crate::{cache::storage::{update_koaloader_version, update_screamapi_version, validate_koaloader_cache, validate_screamapi_cache}, unlockers::{CreamLinux, Koaloader, ScreamAPI, SmokeAPI, Unlocker}};
|
||||
use log::{error, info, warn};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Initialize the cache on app startup
|
||||
// Downloads both unlockers if they don't exist
|
||||
pub async fn initialize_cache() -> Result<(), String> {
|
||||
info!("Initializing cache...");
|
||||
|
||||
let versions = read_versions()?;
|
||||
let mut needs_smokeapi = false;
|
||||
let mut needs_creamlinux = false;
|
||||
let mut needs_screamapi = false;
|
||||
let mut needs_koaloader = false;
|
||||
|
||||
// Check if SmokeAPI is properly cached
|
||||
if versions.smokeapi.latest.is_empty() {
|
||||
info!("No SmokeAPI version in manifest");
|
||||
needs_smokeapi = true
|
||||
} else {
|
||||
// Validate that all files exist
|
||||
match validate_smokeapi_cache(&versions.smokeapi.latest) {
|
||||
Ok(true) => {
|
||||
info!("SmokeAPI cache validated successfully");
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("SmokeAPI cache incomplete, re-downloading");
|
||||
needs_smokeapi = true;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to validate SmokeAPI cache: {}, re-downloading", e);
|
||||
needs_smokeapi = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CreamLinux is properly cached
|
||||
if versions.creamlinux.latest.is_empty() {
|
||||
info!("No CreamLinux version in manifest");
|
||||
needs_creamlinux = true;
|
||||
} else {
|
||||
match validate_creamlinux_cache(&versions.creamlinux.latest) {
|
||||
Ok(true) => {
|
||||
info!("CreamLinux cache validated successfully");
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("CreamLinux cache incomplete, re-downloading");
|
||||
needs_creamlinux = true;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to validate CreamLinux cache: {}, re-downloading", e);
|
||||
needs_creamlinux = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ScreamAPI is properly cached
|
||||
if versions.screamapi.latest.is_empty() {
|
||||
info!("No ScreamAPI version in manifest");
|
||||
needs_screamapi = true
|
||||
} else {
|
||||
match validate_screamapi_cache(&versions.screamapi.latest) {
|
||||
Ok(true) => {
|
||||
info!("ScreamAPI cache validated successfully");
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("ScreamAPI cache incomplete, re-downloading");
|
||||
needs_smokeapi = true;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to validate ScreamAPI cache: {}, re-downloading", e);
|
||||
needs_screamapi = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Koaloader is properly cached
|
||||
if versions.koaloader.latest.is_empty() {
|
||||
info!("No Koaloader version in manifest");
|
||||
needs_koaloader = true
|
||||
} else {
|
||||
match validate_koaloader_cache(&versions.koaloader.latest) {
|
||||
Ok(true) => {
|
||||
info!("Koaloader cache validated successfully");
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("Koaloader cache incomplete, re-downloading");
|
||||
needs_koaloader = true;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to validate Koaloader cache: {}, re-downloading", e);
|
||||
needs_koaloader = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download SmokeAPI
|
||||
if needs_smokeapi {
|
||||
info!("Downloading SmokeAPI...");
|
||||
match SmokeAPI::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
info!("Downloaded SmokeAPI version: {}", version);
|
||||
update_smokeapi_version(&version)?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download SmokeAPI: {}", e);
|
||||
return Err(format!("Failed to download SmokeAPI: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download CreamLinux
|
||||
if needs_creamlinux {
|
||||
info!("Downloading CreamLinux...");
|
||||
match CreamLinux::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
info!("Downloaded CreamLinux version: {}", version);
|
||||
update_creamlinux_version(&version)?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download CreamLinux: {}", e);
|
||||
return Err(format!("Failed to download CreamLinux: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download ScreamAPI
|
||||
if needs_screamapi {
|
||||
info!("Downloading ScreamAPI...");
|
||||
match ScreamAPI::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
info!("Downloaded ScreamAPI version: {}", version);
|
||||
update_screamapi_version(&version)?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download SmokeAPI: {}", e);
|
||||
return Err(format!("Failed to download ScreamAPI: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download Koaloader
|
||||
if needs_koaloader {
|
||||
info!("Downloading Koaloader...");
|
||||
match Koaloader::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
info!("Downloaded Koaloader version: {}", version);
|
||||
update_koaloader_version(&version)?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download Koaloader: {}", e);
|
||||
return Err(format!("Failed to download Koaloader: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !needs_smokeapi && !needs_creamlinux && !needs_smokeapi && !needs_koaloader {
|
||||
info!("Cache already initialized and validated");
|
||||
} else {
|
||||
info!("Cache initialization complete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Check for updates and download new versions if available
|
||||
pub async fn check_and_update_cache() -> Result<UpdateResult, String> {
|
||||
info!("Checking for unlocker updates...");
|
||||
|
||||
let mut result = UpdateResult::default();
|
||||
|
||||
// Check SmokeAPI
|
||||
let current_smokeapi = read_versions()?.smokeapi.latest;
|
||||
match SmokeAPI::get_latest_version().await {
|
||||
Ok(latest_version) => {
|
||||
if current_smokeapi != latest_version {
|
||||
info!(
|
||||
"SmokeAPI update available: {} -> {}",
|
||||
current_smokeapi, latest_version
|
||||
);
|
||||
|
||||
match SmokeAPI::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
update_smokeapi_version(&version)?;
|
||||
result.smokeapi_updated = true;
|
||||
result.new_smokeapi_version = Some(version);
|
||||
info!("SmokeAPI updated successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download SmokeAPI update: {}", e);
|
||||
return Err(format!("Failed to download SmokeAPI update: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("SmokeAPI is up to date: {}", current_smokeapi);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to check SmokeAPI version: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check CreamLinux
|
||||
let current_creamlinux = read_versions()?.creamlinux.latest;
|
||||
match CreamLinux::get_latest_version().await {
|
||||
Ok(latest_version) => {
|
||||
if current_creamlinux != latest_version {
|
||||
info!(
|
||||
"CreamLinux update available: {} -> {}",
|
||||
current_creamlinux, latest_version
|
||||
);
|
||||
|
||||
match CreamLinux::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
update_creamlinux_version(&version)?;
|
||||
result.creamlinux_updated = true;
|
||||
result.new_creamlinux_version = Some(version);
|
||||
info!("CreamLinux updated successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download CreamLinux update: {}", e);
|
||||
return Err(format!("Failed to download CreamLinux update: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("CreamLinux is up to date: {}", current_creamlinux);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to check CreamLinux version: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Update all games that have outdated unlocker versions
|
||||
pub async fn update_outdated_games(
|
||||
games: &HashMap<String, crate::installer::Game>,
|
||||
) -> Result<GameUpdateStats, String> {
|
||||
info!("Checking for outdated game installations...");
|
||||
|
||||
let cached_versions = read_versions()?;
|
||||
let mut stats = GameUpdateStats::default();
|
||||
|
||||
for (game_id, game) in games {
|
||||
// Read the game's manifest
|
||||
let manifest = match read_manifest(&game.path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("Failed to read manifest for {}: {}", game.title, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if SmokeAPI needs updating
|
||||
if manifest.has_smokeapi()
|
||||
&& manifest.is_smokeapi_outdated(&cached_versions.smokeapi.latest)
|
||||
{
|
||||
info!(
|
||||
"Game '{}' has outdated SmokeAPI, updating...",
|
||||
game.title
|
||||
);
|
||||
|
||||
// Convert api_files Vec to comma-separated string
|
||||
let api_files_str = game.api_files.join(",");
|
||||
match SmokeAPI::install_to_game(&game.path, &api_files_str).await {
|
||||
Ok(_) => {
|
||||
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest.clone())?;
|
||||
stats.smokeapi_updated += 1;
|
||||
info!("Updated SmokeAPI for '{}'", game.title);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update SmokeAPI for '{}': {}", game.title, e);
|
||||
stats.smokeapi_failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CreamLinux needs updating
|
||||
if manifest.has_creamlinux()
|
||||
&& manifest.is_creamlinux_outdated(&cached_versions.creamlinux.latest)
|
||||
{
|
||||
info!(
|
||||
"Game '{}' has outdated CreamLinux, updating...",
|
||||
game.title
|
||||
);
|
||||
|
||||
// For CreamLinux, we need to preserve the DLC configuration
|
||||
match CreamLinux::install_to_game(&game.path, game_id).await {
|
||||
Ok(_) => {
|
||||
update_game_creamlinux_version(&game.path, cached_versions.creamlinux.latest.clone())?;
|
||||
stats.creamlinux_updated += 1;
|
||||
info!("Updated CreamLinux for '{}'", game.title);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update CreamLinux for '{}': {}", game.title, e);
|
||||
stats.creamlinux_failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Game update complete - SmokeAPI: {} updated, {} failed | CreamLinux: {} updated, {} failed",
|
||||
stats.smokeapi_updated,
|
||||
stats.smokeapi_failed,
|
||||
stats.creamlinux_updated,
|
||||
stats.creamlinux_failed
|
||||
);
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
// Result of checking for cache updates
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct UpdateResult {
|
||||
pub smokeapi_updated: bool,
|
||||
pub creamlinux_updated: bool,
|
||||
pub new_smokeapi_version: Option<String>,
|
||||
pub new_creamlinux_version: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateResult {
|
||||
pub fn any_updated(&self) -> bool {
|
||||
self.smokeapi_updated || self.creamlinux_updated
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics about game updates
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct GameUpdateStats {
|
||||
pub smokeapi_updated: u32,
|
||||
pub smokeapi_failed: u32,
|
||||
pub creamlinux_updated: u32,
|
||||
pub creamlinux_failed: u32,
|
||||
}
|
||||
|
||||
impl GameUpdateStats {
|
||||
pub fn total_updated(&self) -> u32 {
|
||||
self.smokeapi_updated + self.creamlinux_updated
|
||||
}
|
||||
|
||||
pub fn total_failed(&self) -> u32 {
|
||||
self.smokeapi_failed + self.creamlinux_failed
|
||||
}
|
||||
|
||||
pub fn has_failures(&self) -> bool {
|
||||
self.total_failed() > 0
|
||||
}
|
||||
}
|
||||
468
src-tauri/src/cache/storage.rs
vendored
Normal file
@@ -0,0 +1,468 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Represents the versions.json file in the cache root
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct CacheVersions {
|
||||
pub smokeapi: VersionInfo,
|
||||
pub creamlinux: VersionInfo,
|
||||
pub screamapi: VersionInfo,
|
||||
pub koaloader: VersionInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VersionInfo {
|
||||
pub latest: String,
|
||||
}
|
||||
|
||||
impl Default for CacheVersions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
smokeapi: VersionInfo { latest: String::new() },
|
||||
creamlinux: VersionInfo { latest: String::new() },
|
||||
screamapi: VersionInfo { latest: String::new() },
|
||||
koaloader: VersionInfo { latest: String::new() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the cache directory path (~/.cache/creamlinux)
|
||||
pub fn get_cache_dir() -> Result<PathBuf, String> {
|
||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")
|
||||
.map_err(|e| format!("Failed to get XDG directories: {}", e))?;
|
||||
|
||||
let cache_dir = xdg_dirs
|
||||
.get_cache_home()
|
||||
.parent()
|
||||
.ok_or_else(|| "Failed to get cache parent directory".to_string())?
|
||||
.join("creamlinux");
|
||||
|
||||
// Create the directory if it doesn't exist
|
||||
if !cache_dir.exists() {
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
info!("Created cache directory: {}", cache_dir.display());
|
||||
}
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
// Get the SmokeAPI cache directory path
|
||||
pub fn get_smokeapi_dir() -> Result<PathBuf, String> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let smokeapi_dir = cache_dir.join("smokeapi");
|
||||
|
||||
if !smokeapi_dir.exists() {
|
||||
fs::create_dir_all(&smokeapi_dir)
|
||||
.map_err(|e| format!("Failed to create SmokeAPI directory: {}", e))?;
|
||||
info!("Created SmokeAPI directory: {}", smokeapi_dir.display());
|
||||
}
|
||||
|
||||
Ok(smokeapi_dir)
|
||||
}
|
||||
|
||||
pub fn get_screamapi_dir() -> Result<PathBuf, String> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let dir = cache_dir.join("screamapi");
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create ScreamAPI directory: {}", e))?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn get_koaloader_dir() -> Result<PathBuf, String> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let dir = cache_dir.join("koaloader");
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create Koaloader directory: {}", e))?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// Get the CreamLinux cache directory path
|
||||
pub fn get_creamlinux_dir() -> Result<PathBuf, String> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let creamlinux_dir = cache_dir.join("creamlinux");
|
||||
|
||||
if !creamlinux_dir.exists() {
|
||||
fs::create_dir_all(&creamlinux_dir)
|
||||
.map_err(|e| format!("Failed to create CreamLinux directory: {}", e))?;
|
||||
info!("Created CreamLinux directory: {}", creamlinux_dir.display());
|
||||
}
|
||||
|
||||
Ok(creamlinux_dir)
|
||||
}
|
||||
|
||||
// Get the path to a versioned SmokeAPI directory
|
||||
pub fn get_smokeapi_version_dir(version: &str) -> Result<PathBuf, String> {
|
||||
let smokeapi_dir = get_smokeapi_dir()?;
|
||||
let version_dir = smokeapi_dir.join(version);
|
||||
|
||||
if !version_dir.exists() {
|
||||
fs::create_dir_all(&version_dir)
|
||||
.map_err(|e| format!("Failed to create SmokeAPI version directory: {}", e))?;
|
||||
info!(
|
||||
"Created SmokeAPI version directory: {}",
|
||||
version_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(version_dir)
|
||||
}
|
||||
|
||||
pub fn get_screamapi_version_dir(version: &str) -> Result<PathBuf, String> {
|
||||
let dir = get_screamapi_dir()?.join(version);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create ScreamAPI version directory: {}", e))?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn get_koaloader_version_dir(version: &str) -> Result<PathBuf, String> {
|
||||
let dir = get_koaloader_dir()?.join(version);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create Koaloader version directory: {}", e))?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// Get the path to a versioned CreamLinux directory
|
||||
pub fn get_creamlinux_version_dir(version: &str) -> Result<PathBuf, String> {
|
||||
let creamlinux_dir = get_creamlinux_dir()?;
|
||||
let version_dir = creamlinux_dir.join(version);
|
||||
|
||||
if !version_dir.exists() {
|
||||
fs::create_dir_all(&version_dir)
|
||||
.map_err(|e| format!("Failed to create CreamLinux version directory: {}", e))?;
|
||||
info!(
|
||||
"Created CreamLinux version directory: {}",
|
||||
version_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(version_dir)
|
||||
}
|
||||
|
||||
// Read the versions.json file from cache
|
||||
pub fn read_versions() -> Result<CacheVersions, String> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let versions_path = cache_dir.join("versions.json");
|
||||
|
||||
if !versions_path.exists() {
|
||||
info!("versions.json doesn't exist, creating default");
|
||||
return Ok(CacheVersions::default());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&versions_path)
|
||||
.map_err(|e| format!("Failed to read versions.json: {}", e))?;
|
||||
|
||||
// Parse into a raw Value first so we can inject missing fields without
|
||||
// breaking on older versions.json files that predate new unlockers.
|
||||
let mut raw: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse versions.json: {}", e))?;
|
||||
|
||||
let empty = serde_json::json!({ "latest": "" });
|
||||
|
||||
if let Some(obj) = raw.as_object_mut() {
|
||||
if !obj.contains_key("smokeapi") { obj.insert("smokeapi".into(), empty.clone()); }
|
||||
if !obj.contains_key("creamlinux") { obj.insert("creamlinux".into(), empty.clone()); }
|
||||
if !obj.contains_key("screamapi") { obj.insert("screamapi".into(), empty.clone()); }
|
||||
if !obj.contains_key("koaloader") { obj.insert("koaloader".into(), empty.clone()); }
|
||||
}
|
||||
|
||||
let versions: CacheVersions = serde_json::from_value(raw)
|
||||
.map_err(|e| format!("Failed to deserialize versions.json: {}", e))?;
|
||||
|
||||
// If we injected any missing fields, persist them so the file is up to date
|
||||
write_versions(&versions)?;
|
||||
|
||||
info!(
|
||||
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
|
||||
versions.smokeapi.latest,
|
||||
versions.creamlinux.latest,
|
||||
versions.screamapi.latest,
|
||||
versions.koaloader.latest,
|
||||
);
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
// Write the versions.json file to cache
|
||||
pub fn write_versions(versions: &CacheVersions) -> Result<(), String> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
let versions_path = cache_dir.join("versions.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(versions)
|
||||
.map_err(|e| format!("Failed to serialize versions: {}", e))?;
|
||||
|
||||
fs::write(&versions_path, content)
|
||||
.map_err(|e| format!("Failed to write versions.json: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
|
||||
versions.smokeapi.latest,
|
||||
versions.creamlinux.latest,
|
||||
versions.screamapi.latest,
|
||||
versions.koaloader.latest,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Update the SmokeAPI version in versions.json and clean old version directories
|
||||
pub fn update_smokeapi_version(new_version: &str) -> Result<(), String> {
|
||||
let mut versions = read_versions()?;
|
||||
let old_version = versions.smokeapi.latest.clone();
|
||||
|
||||
versions.smokeapi.latest = new_version.to_string();
|
||||
write_versions(&versions)?;
|
||||
|
||||
// Delete old version directory if it exists and is different
|
||||
if !old_version.is_empty() && old_version != new_version {
|
||||
let old_dir = get_smokeapi_dir()?.join(&old_version);
|
||||
if old_dir.exists() {
|
||||
match fs::remove_dir_all(&old_dir) {
|
||||
Ok(_) => info!("Deleted old SmokeAPI version directory: {}", old_version),
|
||||
Err(e) => warn!(
|
||||
"Failed to delete old SmokeAPI version directory: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_screamapi_version(new_version: &str) -> Result<(), String> {
|
||||
let mut versions = read_versions()?;
|
||||
let old_version = versions.screamapi.latest.clone();
|
||||
versions.screamapi.latest = new_version.to_string();
|
||||
write_versions(&versions)?;
|
||||
if !old_version.is_empty() && old_version != new_version {
|
||||
let old_dir = get_screamapi_dir()?.join(&old_version);
|
||||
if old_dir.exists() {
|
||||
let _ = fs::remove_dir_all(&old_dir);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_koaloader_version(new_version: &str) -> Result<(), String> {
|
||||
let mut versions = read_versions()?;
|
||||
let old_version = versions.koaloader.latest.clone();
|
||||
versions.koaloader.latest = new_version.to_string();
|
||||
write_versions(&versions)?;
|
||||
if !old_version.is_empty() && old_version != new_version {
|
||||
let old_dir = get_koaloader_dir()?.join(&old_version);
|
||||
if old_dir.exists() {
|
||||
let _ = fs::remove_dir_all(&old_dir);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Update the CreamLinux version in versions.json and clean old version directories
|
||||
pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
|
||||
let mut versions = read_versions()?;
|
||||
let old_version = versions.creamlinux.latest.clone();
|
||||
|
||||
versions.creamlinux.latest = new_version.to_string();
|
||||
write_versions(&versions)?;
|
||||
|
||||
// Delete old version directory if it exists and is different
|
||||
if !old_version.is_empty() && old_version != new_version {
|
||||
let old_dir = get_creamlinux_dir()?.join(&old_version);
|
||||
if old_dir.exists() {
|
||||
match fs::remove_dir_all(&old_dir) {
|
||||
Ok(_) => info!("Deleted old CreamLinux version directory: {}", old_version),
|
||||
Err(e) => warn!(
|
||||
"Failed to delete old CreamLinux version directory: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get the SmokeAPI DLL path for the latest cached version
|
||||
#[allow(dead_code)]
|
||||
pub fn get_smokeapi_dll_path() -> Result<PathBuf, String> {
|
||||
let versions = read_versions()?;
|
||||
if versions.smokeapi.latest.is_empty() {
|
||||
return Err("SmokeAPI is not cached".to_string());
|
||||
}
|
||||
|
||||
let version_dir = get_smokeapi_version_dir(&versions.smokeapi.latest)?;
|
||||
Ok(version_dir.join("SmokeAPI.dll"))
|
||||
}
|
||||
|
||||
// Get the CreamLinux files directory path for the latest cached version
|
||||
#[allow(dead_code)]
|
||||
pub fn get_creamlinux_files_dir() -> Result<PathBuf, String> {
|
||||
let versions = read_versions()?;
|
||||
if versions.creamlinux.latest.is_empty() {
|
||||
return Err("CreamLinux is not cached".to_string());
|
||||
}
|
||||
|
||||
get_creamlinux_version_dir(&versions.creamlinux.latest)
|
||||
}
|
||||
|
||||
/// List all SmokeAPI files in the cached version directory
|
||||
pub fn list_smokeapi_files() -> Result<Vec<PathBuf>, String> {
|
||||
let versions = read_versions()?;
|
||||
if versions.smokeapi.latest.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let version_dir = get_smokeapi_version_dir(&versions.smokeapi.latest)?;
|
||||
|
||||
if !version_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&version_dir)
|
||||
.map_err(|e| format!("Failed to read SmokeAPI directory: {}", e))?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in entries {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
// Get both .dll and .so files
|
||||
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
|
||||
if ext == "dll" || ext == "so" {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
// List all CreamLinux files in the cached version directory
|
||||
pub fn list_creamlinux_files() -> Result<Vec<PathBuf>, String> {
|
||||
let versions = read_versions()?;
|
||||
if versions.creamlinux.latest.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let version_dir = get_creamlinux_version_dir(&versions.creamlinux.latest)?;
|
||||
|
||||
if !version_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&version_dir)
|
||||
.map_err(|e| format!("Failed to read CreamLinux directory: {}", e))?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in entries {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Validate that all required files exist for SmokeAPI
|
||||
pub fn validate_smokeapi_cache(version: &str) -> Result<bool, String> {
|
||||
let version_dir = get_smokeapi_version_dir(version)?;
|
||||
|
||||
if !version_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Required files for SmokeAPI
|
||||
let required_files = vec![
|
||||
"smoke_api32.dll",
|
||||
"smoke_api64.dll",
|
||||
"libsmoke_api32.so",
|
||||
"libsmoke_api64.so",
|
||||
];
|
||||
|
||||
let mut missing_files = Vec::new();
|
||||
|
||||
for file in &required_files {
|
||||
let file_path = version_dir.join(file);
|
||||
if !file_path.exists() {
|
||||
missing_files.push(file.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_files.is_empty() {
|
||||
info!("Missing required files in cache: {:?}", missing_files);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn validate_screamapi_cache(version: &str) -> Result<bool, String> {
|
||||
let version_dir = get_screamapi_version_dir(version)?;
|
||||
if !version_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
let required = ["ScreamAPI32.dll", "ScreamAPI64.dll"];
|
||||
for file in &required {
|
||||
if !version_dir.join(file).exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn validate_koaloader_cache(version: &str) -> Result<bool, String> {
|
||||
let version_dir = get_koaloader_version_dir(version)?;
|
||||
if !version_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Check for at least one proxy folder (version-64 is universally present)
|
||||
let check = version_dir.join("version-64").join("version.dll");
|
||||
Ok(check.exists())
|
||||
}
|
||||
|
||||
/// Validate that all required files exist for CreamLinux
|
||||
pub fn validate_creamlinux_cache(version: &str) -> Result<bool, String> {
|
||||
let version_dir = get_creamlinux_version_dir(version)?;
|
||||
|
||||
if !version_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Required files for CreamLinux
|
||||
let required_files = vec![
|
||||
"cream.sh",
|
||||
"cream_api.ini",
|
||||
"lib32Creamlinux.so",
|
||||
"lib64Creamlinux.so",
|
||||
];
|
||||
|
||||
let mut missing_files = Vec::new();
|
||||
|
||||
for file in &required_files {
|
||||
let file_path = version_dir.join(file);
|
||||
if !file_path.exists() {
|
||||
missing_files.push(file.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_files.is_empty() {
|
||||
info!("Missing required files in cache: {:?}", missing_files);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
177
src-tauri/src/cache/version.rs
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
use log::{info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
// Represents the version manifest stored in each game directory
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct GameManifest {
|
||||
pub smokeapi_version: Option<String>,
|
||||
pub creamlinux_version: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl GameManifest {
|
||||
// Create a new manifest with SmokeAPI version
|
||||
pub fn with_smokeapi(version: String) -> Self {
|
||||
Self {
|
||||
smokeapi_version: Some(version),
|
||||
creamlinux_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new manifest with CreamLinux version
|
||||
pub fn with_creamlinux(version: String) -> Self {
|
||||
Self {
|
||||
smokeapi_version: None,
|
||||
creamlinux_version: Some(version),
|
||||
}
|
||||
}
|
||||
|
||||
// Check if SmokeAPI is installed
|
||||
pub fn has_smokeapi(&self) -> bool {
|
||||
self.smokeapi_version.is_some()
|
||||
}
|
||||
|
||||
// Check if CreamLinux is installed
|
||||
pub fn has_creamlinux(&self) -> bool {
|
||||
self.creamlinux_version.is_some()
|
||||
}
|
||||
|
||||
// Check if SmokeAPI version is outdated
|
||||
pub fn is_smokeapi_outdated(&self, latest_version: &str) -> bool {
|
||||
match &self.smokeapi_version {
|
||||
Some(version) => version != latest_version,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CreamLinux version is outdated
|
||||
pub fn is_creamlinux_outdated(&self, latest_version: &str) -> bool {
|
||||
match &self.creamlinux_version {
|
||||
Some(version) => version != latest_version,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read the creamlinux.json manifest from a game directory
|
||||
pub fn read_manifest(game_path: &str) -> Result<GameManifest, String> {
|
||||
let manifest_path = Path::new(game_path).join("creamlinux.json");
|
||||
|
||||
if !manifest_path.exists() {
|
||||
return Ok(GameManifest::default());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| format!("Failed to read manifest: {}", e))?;
|
||||
|
||||
let manifest: GameManifest = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Read manifest from {}: SmokeAPI: {:?}, CreamLinux: {:?}",
|
||||
game_path, manifest.smokeapi_version, manifest.creamlinux_version
|
||||
);
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
// Write the creamlinux.json manifest to a game directory
|
||||
pub fn write_manifest(game_path: &str, manifest: &GameManifest) -> Result<(), String> {
|
||||
let manifest_path = Path::new(game_path).join("creamlinux.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|e| format!("Failed to serialize manifest: {}", e))?;
|
||||
|
||||
fs::write(&manifest_path, content)
|
||||
.map_err(|e| format!("Failed to write manifest: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Wrote manifest to {}: SmokeAPI: {:?}, CreamLinux: {:?}",
|
||||
game_path, manifest.smokeapi_version, manifest.creamlinux_version
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Update the SmokeAPI version in the manifest
|
||||
pub fn update_smokeapi_version(game_path: &str, version: String) -> Result<(), String> {
|
||||
let mut manifest = read_manifest(game_path)?;
|
||||
manifest.smokeapi_version = Some(version);
|
||||
write_manifest(game_path, &manifest)
|
||||
}
|
||||
|
||||
// Update the CreamLinux version in the manifest
|
||||
pub fn update_creamlinux_version(game_path: &str, version: String) -> Result<(), String> {
|
||||
let mut manifest = read_manifest(game_path)?;
|
||||
manifest.creamlinux_version = Some(version);
|
||||
write_manifest(game_path, &manifest)
|
||||
}
|
||||
|
||||
// Remove SmokeAPI version from the manifest
|
||||
pub fn remove_smokeapi_version(game_path: &str) -> Result<(), String> {
|
||||
let mut manifest = read_manifest(game_path)?;
|
||||
manifest.smokeapi_version = None;
|
||||
|
||||
// If both versions are None, delete the manifest file
|
||||
if manifest.smokeapi_version.is_none() && manifest.creamlinux_version.is_none() {
|
||||
let manifest_path = Path::new(game_path).join("creamlinux.json");
|
||||
if manifest_path.exists() {
|
||||
fs::remove_file(&manifest_path)
|
||||
.map_err(|e| format!("Failed to delete manifest: {}", e))?;
|
||||
info!("Deleted empty manifest from {}", game_path);
|
||||
}
|
||||
} else {
|
||||
write_manifest(game_path, &manifest)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Remove CreamLinux version from the manifest
|
||||
pub fn remove_creamlinux_version(game_path: &str) -> Result<(), String> {
|
||||
let mut manifest = read_manifest(game_path)?;
|
||||
manifest.creamlinux_version = None;
|
||||
|
||||
// If both versions are None, delete the manifest file
|
||||
if manifest.smokeapi_version.is_none() && manifest.creamlinux_version.is_none() {
|
||||
let manifest_path = Path::new(game_path).join("creamlinux.json");
|
||||
if manifest_path.exists() {
|
||||
fs::remove_file(&manifest_path)
|
||||
.map_err(|e| format!("Failed to delete manifest: {}", e))?;
|
||||
info!("Deleted empty manifest from {}", game_path);
|
||||
}
|
||||
} else {
|
||||
write_manifest(game_path, &manifest)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_manifest_creation() {
|
||||
let manifest = GameManifest::with_smokeapi("v1.0.0".to_string());
|
||||
assert_eq!(manifest.smokeapi_version, Some("v1.0.0".to_string()));
|
||||
assert_eq!(manifest.creamlinux_version, None);
|
||||
|
||||
let manifest = GameManifest::with_creamlinux("v2.0.0".to_string());
|
||||
assert_eq!(manifest.smokeapi_version, None);
|
||||
assert_eq!(manifest.creamlinux_version, Some("v2.0.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outdated_check() {
|
||||
let mut manifest = GameManifest::with_smokeapi("v1.0.0".to_string());
|
||||
assert!(manifest.is_smokeapi_outdated("v2.0.0"));
|
||||
assert!(!manifest.is_smokeapi_outdated("v1.0.0"));
|
||||
|
||||
manifest.creamlinux_version = Some("v1.5.0".to_string());
|
||||
assert!(manifest.is_creamlinux_outdated("v2.0.0"));
|
||||
assert!(!manifest.is_creamlinux_outdated("v1.5.0"));
|
||||
}
|
||||
}
|
||||
162
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use log::info;
|
||||
|
||||
// User configuration structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
// Whether to show the disclaimer on startup
|
||||
pub show_disclaimer: bool,
|
||||
// Reporting / compatibility voting
|
||||
pub reporting_opted_in: bool,
|
||||
pub reporting_has_seen_prompt: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_disclaimer: true,
|
||||
reporting_opted_in: false,
|
||||
reporting_has_seen_prompt: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the config directory path (~/.config/creamlinux)
|
||||
fn get_config_dir() -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME")
|
||||
.map_err(|_| "Failed to get HOME directory".to_string())?;
|
||||
|
||||
let config_dir = PathBuf::from(home).join(".config").join("creamlinux");
|
||||
Ok(config_dir)
|
||||
}
|
||||
|
||||
// Get the config file path
|
||||
fn get_config_path() -> Result<PathBuf, String> {
|
||||
let config_dir = get_config_dir()?;
|
||||
Ok(config_dir.join("config.json"))
|
||||
}
|
||||
|
||||
// Ensure the config directory exists
|
||||
fn ensure_config_dir() -> Result<(), String> {
|
||||
let config_dir = get_config_dir()?;
|
||||
|
||||
if !config_dir.exists() {
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
info!("Created config directory at {:?}", config_dir);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Load configuration from disk
|
||||
pub fn load_config() -> Result<Config, String> {
|
||||
ensure_config_dir()?;
|
||||
|
||||
let config_path = get_config_path()?;
|
||||
|
||||
// If config file doesn't exist, create default config
|
||||
if !config_path.exists() {
|
||||
let default_config = Config::default();
|
||||
save_config(&default_config)?;
|
||||
info!("Created default config file at {:?}", config_path);
|
||||
return Ok(default_config);
|
||||
}
|
||||
|
||||
// Read and parse config file
|
||||
let config_str = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read config file: {}", e))?;
|
||||
|
||||
let mut on_disk: serde_json::Value = serde_json::from_str(&config_str)
|
||||
.map_err(|e| format!("Failed to parse config file: {}", e))?;
|
||||
|
||||
// Serialize the defaults into a Value so we can iterate their keys
|
||||
let defaults = serde_json::to_value(Config::default())
|
||||
.map_err(|e| format!("Failed to serialize default config: {}", e))?;
|
||||
|
||||
// For every key that exists in the current Config but is absent from the
|
||||
// on-disk JSON, inject the default value. Keys that are already present
|
||||
// are left completely untouched.
|
||||
let mut migrated = false;
|
||||
if let Some(default_obj) = defaults.as_object() {
|
||||
let missing: Vec<(String, serde_json::Value)> = default_obj
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
on_disk
|
||||
.as_object()
|
||||
.map_or(false, |d| !d.contains_key(*key))
|
||||
})
|
||||
.map(|(key, val)| (key.clone(), val.clone()))
|
||||
.collect();
|
||||
|
||||
if let Some(disk_obj) = on_disk.as_object_mut() {
|
||||
for (key, value) in missing {
|
||||
info!("Config migration: adding missing field '{}' with default value", key);
|
||||
disk_obj.insert(key, value);
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deserialize the (possiblyh augmented) value into Config
|
||||
let config: Config = serde_json::from_value(on_disk)
|
||||
.map_err(|e| format!("Failed to deserialize config: {}", e))?;
|
||||
|
||||
// Persist the migrated file so the next launch doesn't need to do this again
|
||||
if migrated {
|
||||
save_config(&config)?;
|
||||
info!("Config migrated - new fields written to disk");
|
||||
} else {
|
||||
info!("Loaded config from {:?}", config_path);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
// Save configuration to disk
|
||||
pub fn save_config(config: &Config) -> Result<(), String> {
|
||||
ensure_config_dir()?;
|
||||
|
||||
let config_path = get_config_path()?;
|
||||
|
||||
let config_str = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, config_str)
|
||||
.map_err(|e| format!("Failed to write config file: {}", e))?;
|
||||
|
||||
info!("Saved config to {:?}", config_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Update a specific config value
|
||||
pub fn update_config<F>(updater: F) -> Result<Config, String>
|
||||
where
|
||||
F: FnOnce(&mut Config),
|
||||
{
|
||||
let mut config = load_config()?;
|
||||
updater(&mut config);
|
||||
save_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = Config::default();
|
||||
assert!(config.show_disclaimer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serialization() {
|
||||
let config = Config::default();
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: Config = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(config.show_disclaimer, parsed.show_disclaimer);
|
||||
}
|
||||
}
|
||||
@@ -232,15 +232,15 @@ pub fn update_dlc_configuration(
|
||||
}
|
||||
processed_dlcs.insert(appid.to_string());
|
||||
} else {
|
||||
// Not in our list keep the original line
|
||||
// Not in our list, keep the original line
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
} else {
|
||||
// Invalid format or not a DLC line keep as is
|
||||
// Invalid format or not a DLC line, keep as is
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
} else if !in_dlc_section || trimmed.is_empty() {
|
||||
// Not a DLC line or empty line keep as is
|
||||
// Not a DLC line or empty line, keep as is
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
}
|
||||
@@ -274,18 +274,6 @@ pub fn update_dlc_configuration(
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -316,9 +304,6 @@ pub async fn install_cream_with_dlcs(
|
||||
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()
|
||||
@@ -329,40 +314,40 @@ pub async fn install_cream_with_dlcs(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let game_title = game.title.clone();
|
||||
// Install CreamLinux binaries from cache
|
||||
use crate::unlockers::{CreamLinux, Unlocker};
|
||||
|
||||
// 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))
|
||||
}
|
||||
let game_path = game.path.clone();
|
||||
|
||||
// Install binaries
|
||||
CreamLinux::install_to_game(&game.path, &game_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install CreamLinux binaries: {}", e))?;
|
||||
|
||||
// Write cream_api.ini with DLCs
|
||||
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", game_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 &enabled_dlcs {
|
||||
config.push_str(&format!("{} = {}\n", dlc.appid, dlc.name));
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&cream_api_path, config)
|
||||
.map_err(|e| format!("Failed to write cream_api.ini: {}", e))?;
|
||||
|
||||
// Update version manifest
|
||||
let cached_versions = crate::cache::read_versions()?;
|
||||
crate::cache::update_game_creamlinux_version(&game_path, cached_versions.creamlinux.latest)?;
|
||||
|
||||
info!(
|
||||
"CreamLinux installation completed successfully for game: {}",
|
||||
game.title
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
184
src-tauri/src/epic_scanner.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EpicGame {
|
||||
pub app_name: String,
|
||||
pub title: String,
|
||||
pub install_path: String,
|
||||
pub executable: String,
|
||||
pub box_art_url: Option<String>,
|
||||
pub scream_installed: bool,
|
||||
pub koaloader_installed: bool,
|
||||
/// True when Koaloader was installed using version.dll as a fallback
|
||||
/// because no matching proxy import was detected in the game's PE files.
|
||||
pub proxy_fallback_used: bool,
|
||||
}
|
||||
|
||||
/// Minimal fields we need from installed.json entries.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InstalledEntry {
|
||||
title: String,
|
||||
install_path: String,
|
||||
executable: String,
|
||||
#[serde(default)]
|
||||
is_dlc: bool,
|
||||
}
|
||||
|
||||
fn legendary_config_dir() -> Option<PathBuf> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
let path = PathBuf::from(&home)
|
||||
.join(".config")
|
||||
.join("heroic")
|
||||
.join("legendaryConfig")
|
||||
.join("legendary");
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
warn!("Heroic legendary config dir not found at: {}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_epic_games() -> Vec<EpicGame> {
|
||||
let legendary_dir = match legendary_config_dir() {
|
||||
Some(d) => d,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let installed_path = legendary_dir.join("installed.json");
|
||||
if !installed_path.exists() {
|
||||
warn!("installed.json not found at: {}", installed_path.display());
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&installed_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to read installed.json: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let installed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse installed.json: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let metadata_dir = legendary_dir.join("metadata");
|
||||
let mut games = Vec::new();
|
||||
|
||||
if let Some(obj) = installed.as_object() {
|
||||
for (app_name, entry_val) in obj {
|
||||
let entry: InstalledEntry = match serde_json::from_value(entry_val.clone()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse installed entry {}: {}", app_name, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if entry.is_dlc {
|
||||
continue;
|
||||
}
|
||||
|
||||
let install_path = PathBuf::from(&entry.install_path);
|
||||
if !install_path.exists() {
|
||||
warn!(
|
||||
"Install path does not exist for {}: {}",
|
||||
app_name, entry.install_path
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let box_art_url = get_box_art(&metadata_dir, app_name);
|
||||
let scream_installed = check_screamapi_installed(&install_path);
|
||||
let koaloader_installed = check_koaloader_installed(&install_path);
|
||||
|
||||
info!(
|
||||
"Found Epic game: {} ({}), ScreamAPI={}, Koaloader={}",
|
||||
entry.title, app_name, scream_installed, koaloader_installed
|
||||
);
|
||||
|
||||
games.push(EpicGame {
|
||||
app_name: app_name.clone(),
|
||||
title: entry.title,
|
||||
install_path: entry.install_path,
|
||||
executable: entry.executable,
|
||||
box_art_url,
|
||||
scream_installed,
|
||||
koaloader_installed,
|
||||
proxy_fallback_used: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} Epic games", games.len());
|
||||
games
|
||||
}
|
||||
|
||||
/// Extract the "DieselGameBox" image URL from a game's metadata JSON.
|
||||
/// We read the top-level keyImages array directly from the JSON value,
|
||||
/// which avoids pulling in DLC images from dlcItemList.
|
||||
fn get_box_art(metadata_dir: &Path, app_name: &str) -> Option<String> {
|
||||
let meta_path = metadata_dir.join(format!("{}.json", app_name));
|
||||
if !meta_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&meta_path).ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
|
||||
let key_images = val
|
||||
.get("metadata")
|
||||
.and_then(|m| m.get("keyImages"))
|
||||
.and_then(|k| k.as_array())?;
|
||||
|
||||
// Prefer landscape (DieselGameBox), fall back to portrait or logo
|
||||
for preferred in &["DieselGameBox", "DieselGameBoxTall", "DieselGameBoxLogo"] {
|
||||
if let Some(url) = key_images.iter().find_map(|img| {
|
||||
if img.get("type").and_then(|t| t.as_str()) == Some(preferred) {
|
||||
img.get("url").and_then(|u| u.as_str()).map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn check_screamapi_installed(install_path: &Path) -> bool {
|
||||
for entry in WalkDir::new(install_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let filename = entry.file_name().to_string_lossy().to_lowercase();
|
||||
if filename.starts_with("eossdk-win") && filename.ends_with("_o.dll") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn check_koaloader_installed(install_path: &Path) -> bool {
|
||||
for entry in WalkDir::new(install_path)
|
||||
.max_depth(4)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
if entry.file_name().to_string_lossy() == "Koaloader.config.json" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
44
src-tauri/src/installer/file_ops.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// This module contains helper functions for file operations during installation
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
// Copy a file with backup
|
||||
#[allow(dead_code)]
|
||||
pub fn copy_with_backup(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
// If destination exists, create a backup
|
||||
if dest.exists() {
|
||||
let backup = dest.with_extension("bak");
|
||||
fs::copy(dest, &backup)?;
|
||||
}
|
||||
|
||||
fs::copy(src, dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Safely remove a file (doesn't error if it doesn't exist)
|
||||
#[allow(dead_code)]
|
||||
pub fn safe_remove(path: &Path) -> io::Result<()> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Make a file executable (Unix only)
|
||||
#[cfg(unix)]
|
||||
#[allow(dead_code)]
|
||||
pub fn make_executable(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(path, perms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn make_executable(_path: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
976
src-tauri/src/installer/mod.rs
Normal file
@@ -0,0 +1,976 @@
|
||||
mod file_ops;
|
||||
|
||||
use crate::cache::{
|
||||
remove_creamlinux_version, remove_smokeapi_version,
|
||||
update_game_creamlinux_version, update_game_smokeapi_version,
|
||||
};
|
||||
use crate::unlockers::{CreamLinux, SmokeAPI, ScreamAPI, Unlocker};
|
||||
use crate::epic_scanner::EpicGame;
|
||||
use crate::AppState;
|
||||
use log::{error, info, warn};
|
||||
use reqwest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use tauri::Manager;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
// Type of installer
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum InstallerType {
|
||||
Cream,
|
||||
Smoke,
|
||||
}
|
||||
|
||||
// Action to perform
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum InstallerAction {
|
||||
Install,
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
// 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
|
||||
#[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) => {
|
||||
install_creamlinux(game_id, game, app_handle).await
|
||||
}
|
||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||
uninstall_creamlinux(game, app_handle).await
|
||||
}
|
||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||
install_smokeapi(game, app_handle).await
|
||||
}
|
||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||
uninstall_smokeapi(game, app_handle).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Install CreamLinux to a game
|
||||
async fn install_creamlinux(
|
||||
game_id: String,
|
||||
game: Game,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
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),
|
||||
"Installing from cache...",
|
||||
50.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Install CreamLinux binaries from cache
|
||||
CreamLinux::install_to_game(&game.path, &game_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install CreamLinux: {}", e))?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing CreamLinux for {}", game_title),
|
||||
"Writing DLC configuration...",
|
||||
80.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Write cream_api.ini with DLCs
|
||||
write_cream_api_ini(&game.path, &game_id, &dlcs)?;
|
||||
|
||||
// Update version manifest
|
||||
let cached_versions = crate::cache::read_versions()?;
|
||||
update_game_creamlinux_version(&game.path, cached_versions.creamlinux.latest)?;
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
// Uninstall CreamLinux from a game
|
||||
async fn uninstall_creamlinux(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||
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...",
|
||||
50.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
CreamLinux::uninstall_from_game(&game.path, &game.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to uninstall CreamLinux: {}", e))?;
|
||||
|
||||
// Remove version from manifest
|
||||
remove_creamlinux_version(&game.path)?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstallation Completed: {}", game_title),
|
||||
"CreamLinux has been removed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
info!("CreamLinux uninstallation completed for: {}", game_title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||
// Check if native or proton and route accordingly
|
||||
if game.native {
|
||||
install_smokeapi_native(game, app_handle).await
|
||||
} else {
|
||||
install_smokeapi_proton(game, app_handle).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||
// Check if native or proton and route accordingly
|
||||
if game.native {
|
||||
uninstall_smokeapi_native(game, app_handle).await
|
||||
} else {
|
||||
uninstall_smokeapi_proton(game, app_handle).await
|
||||
}
|
||||
}
|
||||
|
||||
// Install SmokeAPI to a proton game
|
||||
async fn install_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||
if game.native {
|
||||
return Err("SmokeAPI can only be installed on Proton/Windows games".to_string());
|
||||
}
|
||||
|
||||
info!("Installing SmokeAPI for game: {}", game.title);
|
||||
let game_title = game.title.clone();
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing SmokeAPI for {}", game_title),
|
||||
"Installing from cache...",
|
||||
50.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Join api_files into a comma-separated string for the context
|
||||
let api_files_str = game.api_files.join(",");
|
||||
|
||||
// Install SmokeAPI from cache
|
||||
SmokeAPI::install_to_game(&game.path, &api_files_str)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install SmokeAPI: {}", e))?;
|
||||
|
||||
// Update version manifest
|
||||
let cached_versions = crate::cache::read_versions()?;
|
||||
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest)?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game_title),
|
||||
"SmokeAPI has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
info!("SmokeAPI installation completed for: {}", game_title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Uninstall SmokeAPI from a proton game
|
||||
async fn uninstall_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||
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),
|
||||
"Removing SmokeAPI files...",
|
||||
50.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Join api_files into a comma-separated string for the context
|
||||
let api_files_str = game.api_files.join(",");
|
||||
|
||||
SmokeAPI::uninstall_from_game(&game.path, &api_files_str)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to uninstall SmokeAPI: {}", e))?;
|
||||
|
||||
// Remove version from manifest
|
||||
remove_smokeapi_version(&game.path)?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstallation Completed: {}", game_title),
|
||||
"SmokeAPI has been removed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
info!("SmokeAPI uninstallation completed for: {}", game_title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Install SmokeAPI to a native Linux game
|
||||
async fn install_smokeapi_native(
|
||||
game: Game,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
|
||||
info!("Installing SmokeAPI (native) for game: {}", game.title);
|
||||
let game_title = game.title.clone();
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing SmokeAPI for {}", game_title),
|
||||
"Detecting game architecture...",
|
||||
20.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing SmokeAPI for {}", game_title),
|
||||
"Installing from cache...",
|
||||
50.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Install SmokeAPI for native Linux (empty string for api_files_str)
|
||||
SmokeAPI::install_to_game(&game.path, "")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install SmokeAPI: {}", e))?;
|
||||
|
||||
// Update version manifest
|
||||
let cached_versions = crate::cache::read_versions()?;
|
||||
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest)?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game_title),
|
||||
"SmokeAPI has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
info!("SmokeAPI (native) installation completed for: {}", game_title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Uninstall SmokeAPI from a native Linux game
|
||||
async fn uninstall_smokeapi_native(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||
if !game.native {
|
||||
return Err("This function is only for native Linux games".to_string());
|
||||
}
|
||||
|
||||
let game_title = game.title.clone();
|
||||
info!("Uninstalling SmokeAPI (native) from game: {}", game_title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstalling SmokeAPI from {}", game_title),
|
||||
"Removing SmokeAPI files...",
|
||||
50.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Uninstall SmokeAPI (empty string for api_files_str)
|
||||
SmokeAPI::uninstall_from_game(&game.path, "")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to uninstall SmokeAPI: {}", e))?;
|
||||
|
||||
// Remove version from manifest
|
||||
remove_smokeapi_version(&game.path)?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstallation Completed: {}", game_title),
|
||||
"SmokeAPI has been removed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
info!("SmokeAPI (native) uninstallation completed for: {}", game_title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn install_screamapi(game: EpicGame, app_handle: AppHandle) -> Result<(), String> {
|
||||
let title = game.title.clone();
|
||||
info!("Installing ScreamAPI for: {}", title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing ScreamAPI for {}", title),
|
||||
"Scanning for EOS SDK DLLs...",
|
||||
15.0, false, false, None,
|
||||
);
|
||||
|
||||
let eos_dlls = crate::unlockers::ScreamAPI::find_eossdk_dlls(
|
||||
std::path::Path::new(&game.install_path)
|
||||
);
|
||||
if eos_dlls.is_empty() {
|
||||
return Err(format!("No EOSSDK-Win*-Shipping.dll found in {}", game.install_path));
|
||||
}
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing ScreamAPI for {}", title),
|
||||
&format!("Replacing {} EOS SDK DLL(s)...", eos_dlls.len()),
|
||||
50.0, false, false, None,
|
||||
);
|
||||
|
||||
ScreamAPI::install_to_game(&game.install_path, "")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install ScreamAPI: {}", e))?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Complete: {}", title),
|
||||
"ScreamAPI installed successfully!",
|
||||
100.0, true, false, None,
|
||||
);
|
||||
|
||||
info!("ScreamAPI installation complete for: {}", title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uninstall_screamapi(game: EpicGame, app_handle: AppHandle) -> Result<(), String> {
|
||||
let title = game.title.clone();
|
||||
info!("Uninstalling ScreamAPI from: {}", title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstalling ScreamAPI from {}", title),
|
||||
"Restoring original EOS SDK DLLs...",
|
||||
30.0, false, false, None,
|
||||
);
|
||||
|
||||
ScreamAPI::uninstall_from_game(&game.install_path, "")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to uninstall ScreamAPI: {}", e))?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstallation Complete: {}", title),
|
||||
"ScreamAPI removed successfully!",
|
||||
100.0, true, false, None,
|
||||
);
|
||||
|
||||
info!("ScreamAPI uninstallation complete for: {}", title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns is_fallback so process_epic_action can set proxy_fallback_used.
|
||||
pub async fn install_koaloader(
|
||||
game: EpicGame,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<bool, String> {
|
||||
let title = game.title.clone();
|
||||
info!("Installing Koaloader for: {}", title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Locating game executable...",
|
||||
10.0, false, false, None,
|
||||
);
|
||||
|
||||
let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?;
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Scanning PE imports for best proxy DLL...",
|
||||
30.0, false, false, None,
|
||||
);
|
||||
|
||||
let scan = crate::pe_inspector::find_best_proxy(&exe_path);
|
||||
let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string();
|
||||
let is_fallback = scan.is_fallback;
|
||||
|
||||
info!("Selected proxy: {} (fallback={})", scan.proxy_name, is_fallback);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
&format!("Installing proxy DLL ({})...", scan.proxy_name),
|
||||
50.0, false, false, None,
|
||||
);
|
||||
|
||||
let proxy_src = crate::unlockers::Koaloader::get_proxy_dll(&proxy_stem, is_64bit)?;
|
||||
std::fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name))
|
||||
.map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Installing ScreamAPI payload...",
|
||||
70.0, false, false, None,
|
||||
);
|
||||
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
ScreamAPI::install_to_game(&exe_dir_str, "koaloader")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install ScreamAPI payload: {}", e))?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Writing configuration files...",
|
||||
88.0, false, false, None,
|
||||
);
|
||||
|
||||
let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let koa_config = serde_json::json!({
|
||||
"logging": false,
|
||||
"enabled": true,
|
||||
"auto_load": true,
|
||||
"targets": [exe_name],
|
||||
"modules": []
|
||||
});
|
||||
std::fs::write(
|
||||
exe_dir.join("Koaloader.config.json"),
|
||||
serde_json::to_string_pretty(&koa_config).unwrap(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write Koaloader config: {}", e))?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Complete: {}", title),
|
||||
"Koaloader + ScreamAPI installed successfully!",
|
||||
100.0, true, false, None,
|
||||
);
|
||||
|
||||
info!("Koaloader installation complete for: {}", title);
|
||||
Ok(is_fallback)
|
||||
}
|
||||
|
||||
pub async fn uninstall_koaloader(game: EpicGame, app_handle: AppHandle) -> Result<(), String> {
|
||||
let title = game.title.clone();
|
||||
info!("Uninstalling Koaloader from: {}", title);
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstalling Koaloader from {}", title),
|
||||
"Removing proxy DLL...",
|
||||
25.0, false, false, None,
|
||||
);
|
||||
|
||||
let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?;
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
|
||||
// Remove Koaloader config
|
||||
let koa_config_path = exe_dir.join("Koaloader.config.json");
|
||||
if koa_config_path.exists() {
|
||||
std::fs::remove_file(&koa_config_path)
|
||||
.map_err(|e| format!("Failed to remove Koaloader config: {}", e))?;
|
||||
}
|
||||
|
||||
// Remove any Koaloader proxy DLL
|
||||
if let Ok(entries) = std::fs::read_dir(exe_dir) {
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let name_lower = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
|
||||
if crate::unlockers::koaloader::KOA_VARIANTS.contains(&name_lower.as_str()) {
|
||||
std::fs::remove_file(&path).ok();
|
||||
info!("Removed proxy DLL: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstalling Koaloader from {}", title),
|
||||
"Removing ScreamAPI files...",
|
||||
65.0, false, false, None,
|
||||
);
|
||||
|
||||
ScreamAPI::uninstall_from_game(&exe_dir_str, "koaloader")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove ScreamAPI payload: {}", e))?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Uninstallation Complete: {}", title),
|
||||
"Koaloader + ScreamAPI removed successfully!",
|
||||
100.0, true, false, None,
|
||||
);
|
||||
|
||||
info!("Koaloader uninstallation complete for: {}", title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Fetch DLC details from Steam API (simple version without progress)
|
||||
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
|
||||
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
|
||||
.map_err(|e| format!("Failed to fetch game details: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch game details: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
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
|
||||
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
|
||||
|
||||
if dlc_response.status().is_success() {
|
||||
let dlc_data: serde_json::Value = dlc_response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
|
||||
|
||||
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>, String> {
|
||||
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
|
||||
.map_err(|e| format!("Failed to fetch game details: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_msg = format!("Failed to fetch game details: HTTP {}", response.status());
|
||||
error!("{}", error_msg);
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
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("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
|
||||
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
|
||||
|
||||
if dlc_response.status().is_success() {
|
||||
let dlc_data: serde_json::Value = dlc_response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Write cream_api.ini configuration file
|
||||
fn write_cream_api_ini(game_path: &str, app_id: &str, dlcs: &[DlcInfo]) -> Result<(), String> {
|
||||
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)
|
||||
.map_err(|e| format!("Failed to write cream_api.ini: {}", e))?;
|
||||
|
||||
info!("Wrote cream_api.ini to {}", cream_api_path.display());
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,10 +3,22 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
mod cache;
|
||||
mod reporting;
|
||||
mod utils;
|
||||
mod dlc_manager;
|
||||
mod installer;
|
||||
mod searcher;
|
||||
mod unlockers;
|
||||
mod smokeapi_config;
|
||||
mod config;
|
||||
mod epic_scanner;
|
||||
mod pe_inspector;
|
||||
mod screamapi_config;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
||||
use epic_scanner::EpicGame;
|
||||
use dlc_manager::DlcInfoWithState;
|
||||
use installer::{Game, InstallerAction, InstallerType};
|
||||
use log::{debug, error, info, warn};
|
||||
@@ -18,6 +30,7 @@ use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_updater::Builder as UpdaterBuilder;
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -26,7 +39,22 @@ pub struct GameAction {
|
||||
action: String,
|
||||
}
|
||||
|
||||
// Mark fields with # to allow unused fields
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EpicAction {
|
||||
InstallScream,
|
||||
UninstallScream,
|
||||
InstallKoaloader,
|
||||
UninstallKoaloader,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EpicGameAction {
|
||||
pub game: EpicGame,
|
||||
/// "install_scream" | "uninstall_scream" | "install_koaloader" | "uninstall_koaloader"
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DlcCache {
|
||||
#[allow(dead_code)]
|
||||
@@ -36,19 +64,39 @@ struct DlcCache {
|
||||
}
|
||||
|
||||
// Structure to hold the state of installed games
|
||||
struct AppState {
|
||||
pub struct AppState {
|
||||
games: Mutex<HashMap<String, Game>>,
|
||||
dlc_cache: Mutex<HashMap<String, DlcCache>>,
|
||||
fetch_cancellation: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
// Load the current configuration
|
||||
#[tauri::command]
|
||||
fn load_config() -> Result<Config, String> {
|
||||
config::load_config()
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
#[tauri::command]
|
||||
fn update_config(config_data: Config) -> Result<Config, String> {
|
||||
config::save_config(&config_data)?;
|
||||
Ok(config_data)
|
||||
}
|
||||
|
||||
#[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_epic_games() -> Result<Vec<EpicGame>, String> {
|
||||
info!("Scanning for Epic games via Heroic...");
|
||||
let games = epic_scanner::scan_epic_games();
|
||||
info!("Found {} Epic games", games.len());
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn scan_steam_games(
|
||||
state: State<'_, AppState>,
|
||||
@@ -57,14 +105,11 @@ async fn scan_steam_games(
|
||||
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());
|
||||
@@ -87,7 +132,6 @@ async fn scan_steam_games(
|
||||
20,
|
||||
);
|
||||
|
||||
// Find installed games
|
||||
let games_info = searcher::find_installed_games(&libraries).await;
|
||||
|
||||
emit_scan_progress(
|
||||
@@ -96,7 +140,6 @@ async fn scan_steam_games(
|
||||
90,
|
||||
);
|
||||
|
||||
// Log summary of games found
|
||||
info!("Games scan complete - Found {} games", games_info.len());
|
||||
info!(
|
||||
"Native games: {}",
|
||||
@@ -115,12 +158,10 @@ async fn scan_steam_games(
|
||||
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
|
||||
@@ -138,8 +179,6 @@ async fn scan_steam_games(
|
||||
};
|
||||
|
||||
result.push(game.clone());
|
||||
|
||||
// Store in state for later use
|
||||
state.games.lock().insert(game.id.clone(), game);
|
||||
}
|
||||
|
||||
@@ -153,9 +192,7 @@ async fn scan_steam_games(
|
||||
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!({
|
||||
@@ -168,7 +205,6 @@ fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u3
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -178,14 +214,12 @@ fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String
|
||||
.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
|
||||
@@ -194,7 +228,6 @@ async fn process_game_action(
|
||||
.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),
|
||||
@@ -203,7 +236,6 @@ async fn process_game_action(
|
||||
_ => return Err(format!("Invalid action: {}", game_action.action)),
|
||||
};
|
||||
|
||||
// Execute the action
|
||||
installer::process_action(
|
||||
game_action.game_id.clone(),
|
||||
installer_type,
|
||||
@@ -213,7 +245,6 @@ async fn process_game_action(
|
||||
)
|
||||
.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(|| {
|
||||
@@ -223,7 +254,6 @@ async fn process_game_action(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update installation status
|
||||
match (installer_type, action) {
|
||||
(InstallerType::Cream, InstallerAction::Install) => {
|
||||
game.cream_installed = true;
|
||||
@@ -239,14 +269,10 @@ async fn process_game_action(
|
||||
}
|
||||
}
|
||||
|
||||
// Reset installing flag
|
||||
game.installing = false;
|
||||
|
||||
// Return updated game info
|
||||
game.clone()
|
||||
};
|
||||
|
||||
// Emit an event to update the UI for this specific game
|
||||
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
@@ -254,18 +280,83 @@ async fn process_game_action(
|
||||
Ok(updated_game)
|
||||
}
|
||||
|
||||
// Fetch DLC list for a game
|
||||
#[tauri::command]
|
||||
async fn process_epic_action(
|
||||
epic_action: EpicGameAction,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<EpicGame, String> {
|
||||
let mut game = epic_action.game;
|
||||
let action = epic_action.action.as_str();
|
||||
|
||||
info!("Processing epic action '{}' for: {}", action, game.title);
|
||||
|
||||
game.proxy_fallback_used = false;
|
||||
|
||||
match action {
|
||||
"install_scream" => {
|
||||
installer::install_screamapi(game.clone(), app_handle.clone()).await
|
||||
.map_err(|e| format!("Failed to install ScreamAPI: {}", e))?;
|
||||
game.scream_installed = true;
|
||||
}
|
||||
"uninstall_scream" => {
|
||||
installer::uninstall_screamapi(game.clone(), app_handle.clone()).await
|
||||
.map_err(|e| format!("Failed to uninstall ScreamAPI: {}", e))?;
|
||||
game.scream_installed = false;
|
||||
}
|
||||
"install_koaloader" => {
|
||||
let fallback_used = installer::install_koaloader(game.clone(), app_handle.clone()).await
|
||||
.map_err(|e| format!("Failed to install Koaloader: {}", e))?;
|
||||
game.koaloader_installed = true;
|
||||
game.proxy_fallback_used = fallback_used;
|
||||
}
|
||||
"uninstall_koaloader" => {
|
||||
installer::uninstall_koaloader(game.clone(), app_handle.clone()).await
|
||||
.map_err(|e| format!("Failed to uninstall Koaloader: {}", e))?;
|
||||
game.koaloader_installed = false;
|
||||
}
|
||||
_ => return Err(format!("Invalid epic action: {}", action)),
|
||||
}
|
||||
|
||||
if let Err(e) = app_handle.emit("epic-game-updated", &game) {
|
||||
warn!("Failed to emit epic-game-updated event: {}", e);
|
||||
}
|
||||
|
||||
Ok(game)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_screamapi_config(
|
||||
game_path: String,
|
||||
) -> Result<Option<screamapi_config::ScreamAPIConfig>, String> {
|
||||
screamapi_config::read_config(&game_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_screamapi_config(
|
||||
game_path: String,
|
||||
config: screamapi_config::ScreamAPIConfig,
|
||||
) -> Result<(), String> {
|
||||
screamapi_config::write_config(&game_path, &config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_screamapi_config(game_path: String) -> Result<(), String> {
|
||||
screamapi_config::delete_config(&game_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_game_dlcs(
|
||||
game_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Fetching DLCs for game ID: {}", game_id);
|
||||
info!("Fetching DLC list for game ID: {}", game_id);
|
||||
|
||||
// Fetch DLC data
|
||||
// Fetch DLC data from API
|
||||
match installer::fetch_dlc_details(&game_id).await {
|
||||
Ok(dlcs) => {
|
||||
// Convert to DlcInfoWithState
|
||||
info!("Successfully fetched {} DLCs for game {}", dlcs.len(), game_id);
|
||||
|
||||
// Convert to DLCInfoWithState for in-memory caching
|
||||
let dlcs_with_state = dlcs
|
||||
.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
@@ -275,31 +366,31 @@ async fn fetch_game_dlcs(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Cache in memory for this session
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut cache = state.dlc_cache.lock();
|
||||
cache.insert(
|
||||
// Update in-memory cache
|
||||
let mut dlc_cache = state.dlc_cache.lock();
|
||||
dlc_cache.insert(
|
||||
game_id.clone(),
|
||||
DlcCache {
|
||||
data: dlcs_with_state.clone(),
|
||||
timestamp: Instant::now(),
|
||||
timestamp: tokio::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(dlcs_with_state)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
|
||||
Err(e) => {
|
||||
error!("Failed to fetch DLC details: {}", 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>();
|
||||
fn abort_dlc_fetch(state: State<'_, AppState>, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
info!("Aborting DLC fetch request received");
|
||||
state.fetch_cancellation.store(true, Ordering::SeqCst);
|
||||
|
||||
// Reset after a short delay
|
||||
// Reset cancellation flag after a short delay
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let state = app_handle.state::<AppState>();
|
||||
@@ -309,7 +400,6 @@ fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(),
|
||||
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);
|
||||
@@ -333,7 +423,7 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Update in-memory
|
||||
// Update in-memory cache
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut dlc_cache = state.dlc_cache.lock();
|
||||
dlc_cache.insert(
|
||||
@@ -362,21 +452,18 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -386,7 +473,6 @@ fn update_dlc_configuration_command(
|
||||
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,
|
||||
@@ -459,7 +545,242 @@ async fn install_cream_with_dlcs_command(
|
||||
}
|
||||
}
|
||||
|
||||
// Setup logging
|
||||
#[tauri::command]
|
||||
fn read_smokeapi_config(game_path: String) -> Result<Option<smokeapi_config::SmokeAPIConfig>, String> {
|
||||
info!("Reading SmokeAPI config for: {}", game_path);
|
||||
smokeapi_config::read_config(&game_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_smokeapi_config(
|
||||
game_path: String,
|
||||
config: smokeapi_config::SmokeAPIConfig,
|
||||
) -> Result<(), String> {
|
||||
info!("Writing SmokeAPI config for: {}", game_path);
|
||||
smokeapi_config::write_config(&game_path, &config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_smokeapi_config(game_path: String) -> Result<(), String> {
|
||||
info!("Deleting SmokeAPI config for: {}", game_path);
|
||||
smokeapi_config::delete_config(&game_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn resolve_platform_conflict(
|
||||
game_id: String,
|
||||
conflict_type: String, // "cream-to-proton" or "smoke-to-native"
|
||||
state: State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Game, String> {
|
||||
info!(
|
||||
"Resolving platform conflict for game {}: {}",
|
||||
game_id, conflict_type
|
||||
);
|
||||
|
||||
let game = {
|
||||
let games = state.games.lock();
|
||||
games
|
||||
.get(&game_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Game with ID {} not found", game_id))?
|
||||
};
|
||||
|
||||
let game_title = game.title.clone();
|
||||
|
||||
// Emit progress
|
||||
installer::emit_progress(
|
||||
&app_handle,
|
||||
&format!("Resolving Conflict: {}", game_title),
|
||||
"Removing conflicting files...",
|
||||
50.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Perform the appropriate removal based on conflict type
|
||||
match conflict_type.as_str() {
|
||||
"cream-to-proton" => {
|
||||
// Remove CreamLinux files (bypassing native check)
|
||||
info!("Removing CreamLinux files from Proton game: {}", game_title);
|
||||
|
||||
CreamLinux::uninstall_from_game(&game.path, &game.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove CreamLinux files: {}", e))?;
|
||||
|
||||
// Remove version from manifest
|
||||
crate::cache::remove_creamlinux_version(&game.path)?;
|
||||
}
|
||||
"smoke-to-native" => {
|
||||
// Remove SmokeAPI files (bypassing proton check)
|
||||
info!("Removing SmokeAPI files from native game: {}", game_title);
|
||||
|
||||
// For native games, we need to manually remove backup files since
|
||||
// the main DLL might already be gone
|
||||
// Look for and remove *_o.dll backup files
|
||||
use walkdir::WalkDir;
|
||||
let mut removed_files = false;
|
||||
|
||||
for entry in WalkDir::new(&game.path)
|
||||
.max_depth(5)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Remove steam_api*_o.dll backup files
|
||||
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(_) => {
|
||||
info!("Removed SmokeAPI backup file: {}", path.display());
|
||||
removed_files = true;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to remove backup file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try the normal uninstall if api_files are present
|
||||
if !game.api_files.is_empty() {
|
||||
let api_files_str = game.api_files.join(",");
|
||||
if let Err(e) = SmokeAPI::uninstall_from_game(&game.path, &api_files_str).await {
|
||||
// Don't fail if this errors - we might have already cleaned up manually above
|
||||
warn!("SmokeAPI uninstall warning: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if !removed_files {
|
||||
warn!("No SmokeAPI files found to remove for: {}", game_title);
|
||||
}
|
||||
|
||||
// Remove version from manifest
|
||||
crate::cache::remove_smokeapi_version(&game.path)?;
|
||||
}
|
||||
_ => return Err(format!("Invalid conflict type: {}", conflict_type)),
|
||||
}
|
||||
|
||||
installer::emit_progress(
|
||||
&app_handle,
|
||||
&format!("Conflict Resolved: {}", game_title),
|
||||
"Conflicting files have been removed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Update game state
|
||||
let updated_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 conflict resolution", game_id))?;
|
||||
|
||||
match conflict_type.as_str() {
|
||||
"cream-to-proton" => {
|
||||
game.cream_installed = false;
|
||||
}
|
||||
"smoke-to-native" => {
|
||||
game.smoke_installed = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
game.installing = false;
|
||||
game.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
info!("Platform conflict resolved successfully for: {}", game_title);
|
||||
Ok(updated_game)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_reporting_opt_in(opted_in: bool) -> Result<(), String> {
|
||||
config::update_config(|cfg| {
|
||||
cfg.reporting_opted_in = opted_in;
|
||||
cfg.reporting_has_seen_prompt = true;
|
||||
})?;
|
||||
|
||||
if opted_in {
|
||||
// Ensure a salt exists so future hashes work immediately
|
||||
reporting::delete_salt().ok(); // clear any stale one first
|
||||
// re-create via generate_user_hash is fine; salt is lazy-created there
|
||||
} else {
|
||||
reporting::delete_salt()?;
|
||||
}
|
||||
|
||||
info!("Reporting opt-in set to: {}", opted_in);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn submit_report(
|
||||
game_id: String,
|
||||
unlocker: String,
|
||||
worked: bool,
|
||||
steam_path: String,
|
||||
) -> Result<(), String> {
|
||||
let user_hash = reporting::generate_user_hash(&steam_path)?;
|
||||
|
||||
reporting::post_report(reporting::ReportPayload {
|
||||
user_hash,
|
||||
game_id: game_id.clone(),
|
||||
unlocker: unlocker.clone(),
|
||||
worked,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Always save locally so the UI can reflect the vote immediately,
|
||||
// regardless of opt-in status (the local file is only used client-side).
|
||||
reporting::save_local_report(reporting::LocalReport {
|
||||
game_id,
|
||||
unlocker,
|
||||
worked,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_local_reports() -> Vec<reporting::LocalReport> {
|
||||
reporting::load_local_reports()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_game_votes(game_id: String) -> Result<Vec<reporting::VoteResult>, String> {
|
||||
let url = format!("https://api.shibe.fun/v1/votes/{}", game_id);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch votes: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// Non-critical - return empty rather than surfacing an error to the UI
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<reporting::VoteResult>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse votes: {}", e))
|
||||
}
|
||||
|
||||
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use log::LevelFilter;
|
||||
use log4rs::append::file::FileAppender;
|
||||
@@ -467,30 +788,25 @@ fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use log4rs::encode::pattern::PatternEncoder;
|
||||
use std::fs;
|
||||
|
||||
// Get XDG cache directory
|
||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
|
||||
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
|
||||
|
||||
// Clear the log file on startup
|
||||
if log_path.exists() {
|
||||
if let Err(e) = fs::write(&log_path, "") {
|
||||
eprintln!("Warning: Failed to clear log file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a file appender
|
||||
let file = FileAppender::builder()
|
||||
.encoder(Box::new(PatternEncoder::new(
|
||||
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
|
||||
)))
|
||||
.build(log_path)?;
|
||||
|
||||
// Build the config
|
||||
let config = Config::builder()
|
||||
.appender(Appender::builder().build("file", Box::new(file)))
|
||||
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
|
||||
|
||||
// Initialize log4rs with this config
|
||||
log4rs::init_config(config)?;
|
||||
|
||||
info!("CreamLinux started with a clean log file");
|
||||
@@ -498,26 +814,18 @@ fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
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(UpdaterBuilder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.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,
|
||||
@@ -530,9 +838,23 @@ fn main() {
|
||||
get_all_dlcs_command,
|
||||
clear_caches,
|
||||
abort_dlc_fetch,
|
||||
read_smokeapi_config,
|
||||
write_smokeapi_config,
|
||||
delete_smokeapi_config,
|
||||
resolve_platform_conflict,
|
||||
load_config,
|
||||
update_config,
|
||||
set_reporting_opt_in,
|
||||
submit_report,
|
||||
get_local_reports,
|
||||
get_game_votes,
|
||||
scan_epic_games,
|
||||
process_epic_action,
|
||||
read_screamapi_config,
|
||||
write_screamapi_config,
|
||||
delete_screamapi_config,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Add a setup handler to do any initialization work
|
||||
info!("Tauri application setup");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -543,8 +865,71 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle = app.handle().clone();
|
||||
let state = AppState {
|
||||
games: Mutex::new(HashMap::new()),
|
||||
dlc_cache: Mutex::new(HashMap::new()),
|
||||
fetch_cancellation: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
app.manage(state);
|
||||
|
||||
// Initialize cache on startup in a background task
|
||||
tauri::async_runtime::spawn(async move {
|
||||
info!("Starting cache initialization...");
|
||||
|
||||
// Step 1: Initialize cache if needed (downloads unlockers)
|
||||
if let Err(e) = cache::initialize_cache().await {
|
||||
error!("Failed to initialize cache: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Cache initialized successfully");
|
||||
|
||||
// Step 2: Check for updates
|
||||
match cache::check_and_update_cache().await {
|
||||
Ok(result) => {
|
||||
if result.any_updated() {
|
||||
info!(
|
||||
"Updates found - SmokeAPI: {:?}, CreamLinux: {:?}",
|
||||
result.new_smokeapi_version, result.new_creamlinux_version
|
||||
);
|
||||
|
||||
// Step 3: Update outdated games
|
||||
let state_for_update = app_handle.state::<AppState>();
|
||||
let games = state_for_update.games.lock().clone();
|
||||
|
||||
match cache::update_outdated_games(&games).await {
|
||||
Ok(stats) => {
|
||||
info!(
|
||||
"Game updates complete - {} games updated, {} failed",
|
||||
stats.total_updated(),
|
||||
stats.total_failed()
|
||||
);
|
||||
|
||||
if stats.has_failures() {
|
||||
warn!(
|
||||
"Some game updates failed: SmokeAPI failed: {}, CreamLinux failed: {}",
|
||||
stats.smokeapi_failed, stats.creamlinux_failed
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update games: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("All unlockers are up to date");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to check for updates: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
}
|
||||
287
src-tauri/src/pe_inspector.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
/// PE import scanner for finding a suitable Koaloader proxy DLL.
|
||||
/// scan ALL PE files (exe + dll) in the executable's directory
|
||||
/// and collect every import that matches a Koaloader proxy variant.
|
||||
use log::{info, warn};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// All DLL names Koaloader can proxy as, ordered by preference.
|
||||
/// Common system DLLs that games almost always load come first.
|
||||
pub const KOA_VARIANTS: &[&str] = &[
|
||||
"version.dll",
|
||||
"winmm.dll",
|
||||
"winhttp.dll",
|
||||
"iphlpapi.dll",
|
||||
"dinput8.dll",
|
||||
"d3d11.dll",
|
||||
"dxgi.dll",
|
||||
"d3d9.dll",
|
||||
"d3d10.dll",
|
||||
"dwmapi.dll",
|
||||
"hid.dll",
|
||||
"msimg32.dll",
|
||||
"mswsock.dll",
|
||||
"opengl32.dll",
|
||||
"profapi.dll",
|
||||
"propsys.dll",
|
||||
"textshaping.dll",
|
||||
"glu32.dll",
|
||||
"audioses.dll",
|
||||
"msasn1.dll",
|
||||
"wldp.dll",
|
||||
"xinput9_1_0.dll",
|
||||
];
|
||||
|
||||
/// Result of a proxy scan. Which proxy was chosen and whether it was a
|
||||
/// direct match or a fallback.
|
||||
pub struct ProxyScanResult {
|
||||
pub proxy_name: String,
|
||||
pub is_fallback: bool,
|
||||
}
|
||||
|
||||
/// Scan all PE files in the exe's directory (both .exe and .dll, exactly like
|
||||
/// the Python script) and return the best Koaloader proxy to use.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Variants imported by the main exe itself
|
||||
/// 2. Variants imported by any other PE file in the same directory
|
||||
/// 3. Fallback to version.dll with is_fallback = true
|
||||
pub fn find_best_proxy(exe_path: &Path) -> ProxyScanResult {
|
||||
let exe_dir = match exe_path.parent() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!("Could not get exe directory, falling back to version.dll");
|
||||
return ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true };
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all PE files in the directory (.exe and .dll)
|
||||
let all_pe_files: Vec<PathBuf> = match fs::read_dir(exe_dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.is_file() && p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("exe") || e.eq_ignore_ascii_case("dll"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter(|p| is_pe_file(p))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!("Could not read exe directory: {}, falling back to version.dll", e);
|
||||
return ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true };
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Scanning {} PE files in: {}",
|
||||
all_pe_files.len(),
|
||||
exe_dir.display()
|
||||
);
|
||||
|
||||
// Build two import sets: main exe and everything else
|
||||
let exe_name = exe_path.file_name().unwrap_or_default();
|
||||
let mut exe_imports: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut other_imports: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for pe_path in &all_pe_files {
|
||||
let imports = get_pe_imports(pe_path);
|
||||
if pe_path.file_name().unwrap_or_default() == exe_name {
|
||||
info!(
|
||||
" {} (main exe): {} imports",
|
||||
pe_path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
imports.len()
|
||||
);
|
||||
for imp in imports { exe_imports.insert(imp); }
|
||||
} else {
|
||||
info!(
|
||||
" {}: {} imports",
|
||||
pe_path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
imports.len()
|
||||
);
|
||||
for imp in imports { other_imports.insert(imp); }
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1: prefer a variant the main exe itself imports
|
||||
for &variant in KOA_VARIANTS {
|
||||
if exe_imports.contains(variant) {
|
||||
info!("Best proxy (main exe imports): {}", variant);
|
||||
return ProxyScanResult { proxy_name: variant.to_string(), is_fallback: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: fall back to a variant imported by any other PE in the directory
|
||||
for &variant in KOA_VARIANTS {
|
||||
if other_imports.contains(variant) {
|
||||
info!("Best proxy (sibling PE imports): {}", variant);
|
||||
return ProxyScanResult { proxy_name: variant.to_string(), is_fallback: false };
|
||||
}
|
||||
}
|
||||
|
||||
// No match at all - use version.dll and flag it so the caller can warn the user
|
||||
warn!(
|
||||
"No Koaloader-compatible import found in {} PE files, falling back to version.dll",
|
||||
all_pe_files.len()
|
||||
);
|
||||
ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true }
|
||||
}
|
||||
|
||||
/// Detect if a Windows PE executable is 64-bit.
|
||||
/// Returns true for AMD64, false for i386. Defaults to true on parse failure.
|
||||
pub fn is_64bit_exe(path: &Path) -> bool {
|
||||
let data = match fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return true,
|
||||
};
|
||||
|
||||
if data.len() < 0x40 || &data[0..2] != b"MZ" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let e_lfanew =
|
||||
u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap_or([0; 4])) as usize;
|
||||
|
||||
if e_lfanew + 6 > data.len() || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 0x8664 = AMD64 (64-bit), 0x014C = i386 (32-bit)
|
||||
let machine = u16::from_le_bytes(
|
||||
data[e_lfanew + 4..e_lfanew + 6].try_into().unwrap_or([0; 2]),
|
||||
);
|
||||
|
||||
machine != 0x014C
|
||||
}
|
||||
|
||||
// Internal helpers
|
||||
|
||||
fn is_pe_file(path: &Path) -> bool {
|
||||
let mut file = match fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut magic = [0u8; 2];
|
||||
file.read_exact(&mut magic).unwrap_or(());
|
||||
magic == [0x4D, 0x5A] // "MZ"
|
||||
}
|
||||
|
||||
pub fn get_pe_imports(path: &Path) -> Vec<String> {
|
||||
match parse_pe_imports(path) {
|
||||
Ok(imports) => imports,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse PE imports for {}: {}", path.display(), e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pe_imports(path: &Path) -> std::io::Result<Vec<String>> {
|
||||
let mut f = fs::File::open(path)?;
|
||||
let mut buf = Vec::new();
|
||||
f.read_to_end(&mut buf)?;
|
||||
|
||||
let data = &buf;
|
||||
|
||||
if data.len() < 0x40 || &data[0..2] != b"MZ" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let e_lfanew =
|
||||
u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap_or([0; 4])) as usize;
|
||||
if e_lfanew + 4 > data.len() || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let coff_offset = e_lfanew + 4;
|
||||
if coff_offset + 20 > data.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let opt_header_size =
|
||||
u16::from_le_bytes(data[coff_offset + 16..coff_offset + 18].try_into().unwrap()) as usize;
|
||||
let opt_offset = coff_offset + 20;
|
||||
if opt_header_size < 4 || opt_offset + opt_header_size > data.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Magic: 0x10B = PE32, 0x20B = PE32+
|
||||
let magic = u16::from_le_bytes(data[opt_offset..opt_offset + 2].try_into().unwrap());
|
||||
let is_pe32_plus = magic == 0x20B;
|
||||
|
||||
let data_dir_offset = if is_pe32_plus { opt_offset + 112 } else { opt_offset + 96 };
|
||||
if data_dir_offset + 8 > data.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let import_rva =
|
||||
u32::from_le_bytes(data[data_dir_offset..data_dir_offset + 4].try_into().unwrap())
|
||||
as usize;
|
||||
let import_size =
|
||||
u32::from_le_bytes(data[data_dir_offset + 4..data_dir_offset + 8].try_into().unwrap())
|
||||
as usize;
|
||||
|
||||
if import_rva == 0 || import_size == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let sections_offset = opt_offset + opt_header_size;
|
||||
let num_sections =
|
||||
u16::from_le_bytes(data[coff_offset + 2..coff_offset + 4].try_into().unwrap()) as usize;
|
||||
|
||||
let rva_to_offset = |rva: usize| -> Option<usize> {
|
||||
for i in 0..num_sections {
|
||||
let sec = sections_offset + i * 40;
|
||||
if sec + 40 > data.len() { break; }
|
||||
let virt_addr =
|
||||
u32::from_le_bytes(data[sec + 12..sec + 16].try_into().unwrap()) as usize;
|
||||
let raw_size =
|
||||
u32::from_le_bytes(data[sec + 16..sec + 20].try_into().unwrap()) as usize;
|
||||
let raw_offset =
|
||||
u32::from_le_bytes(data[sec + 20..sec + 24].try_into().unwrap()) as usize;
|
||||
if rva >= virt_addr && rva < virt_addr + raw_size {
|
||||
return Some(raw_offset + (rva - virt_addr));
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let import_file_offset = match rva_to_offset(import_rva) {
|
||||
Some(o) => o,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut imports = Vec::new();
|
||||
let mut entry_offset = import_file_offset;
|
||||
|
||||
loop {
|
||||
if entry_offset + 20 > data.len() { break; }
|
||||
|
||||
let name_rva =
|
||||
u32::from_le_bytes(data[entry_offset + 12..entry_offset + 16].try_into().unwrap())
|
||||
as usize;
|
||||
|
||||
if name_rva == 0 { break; }
|
||||
|
||||
if let Some(name_offset) = rva_to_offset(name_rva) {
|
||||
let end = data[name_offset..]
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.map(|n| name_offset + n)
|
||||
.unwrap_or(data.len());
|
||||
|
||||
if let Ok(name) = std::str::from_utf8(&data[name_offset..end]) {
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
imports.push(trimmed.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry_offset += 20;
|
||||
}
|
||||
|
||||
Ok(imports)
|
||||
}
|
||||
177
src-tauri/src/reporting.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use crate::cache::get_cache_dir;
|
||||
use crate::config;
|
||||
use log::{info, warn};
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
const API_BASE: &str = "https://api.shibe.fun/v1";
|
||||
const SALT_LENGTH: usize = 32;
|
||||
|
||||
// Report payload
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ReportPayload {
|
||||
pub user_hash: String,
|
||||
pub game_id: String,
|
||||
/// "creamlinux" | "smokeapi"
|
||||
pub unlocker: String,
|
||||
/// true = worked, false = didn't work
|
||||
pub worked: bool,
|
||||
}
|
||||
|
||||
/// Mirrors the JSON returned by GET /v1/votes/:game_id
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VoteResult {
|
||||
pub unlocker: String,
|
||||
pub success: u32,
|
||||
pub fail: u32,
|
||||
}
|
||||
|
||||
// Local report record
|
||||
|
||||
/// One entry in the local reports.json cache.
|
||||
/// Tracks what the user has already voted so we can disable buttons in the UI.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LocalReport {
|
||||
pub game_id: String,
|
||||
pub unlocker: String, // "creamlinux" | "smokeapi"
|
||||
pub worked: bool,
|
||||
}
|
||||
|
||||
// reports.json helpers
|
||||
|
||||
fn reports_cache_path() -> Result<std::path::PathBuf, String> {
|
||||
Ok(get_cache_dir()?.join("reports.json"))
|
||||
}
|
||||
|
||||
/// Load all locally recorded votes.
|
||||
pub fn load_local_reports() -> Vec<LocalReport> {
|
||||
match reports_cache_path() {
|
||||
Ok(path) if path.exists() => {
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a new vote to reports.json (or overwrite an existing one for the same
|
||||
/// game_id + unlocker combo).
|
||||
pub fn save_local_report(report: LocalReport) -> Result<(), String> {
|
||||
let path = reports_cache_path()?;
|
||||
let mut reports = load_local_reports();
|
||||
|
||||
// Upsert: replace existing entry for the same game + unlocker, otherwise push
|
||||
let pos = reports
|
||||
.iter()
|
||||
.position(|r| r.game_id == report.game_id && r.unlocker == report.unlocker);
|
||||
|
||||
match pos {
|
||||
Some(i) => reports[i] = report,
|
||||
None => reports.push(report),
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&reports)
|
||||
.map_err(|e| format!("Failed to serialize reports cache: {}", e))?;
|
||||
fs::write(&path, json)
|
||||
.map_err(|e| format!("Failed to write reports cache: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Salt management
|
||||
|
||||
fn get_or_create_salt() -> Result<String, String> {
|
||||
let salt_path = get_cache_dir()?.join("salt");
|
||||
|
||||
if salt_path.exists() {
|
||||
let salt = fs::read_to_string(&salt_path)
|
||||
.map_err(|e| format!("Failed to read salt file: {}", e))?;
|
||||
let salt = salt.trim().to_string();
|
||||
|
||||
if salt.len() == SALT_LENGTH {
|
||||
return Ok(salt);
|
||||
}
|
||||
|
||||
warn!("Salt file has invalid data, regenerating...");
|
||||
}
|
||||
|
||||
let salt: String = rand::rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(SALT_LENGTH)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
fs::write(&salt_path, &salt)
|
||||
.map_err(|e| format!("Failed to write salt file: {}", e))?;
|
||||
|
||||
info!("Generated new reporting salt");
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
pub fn delete_salt() -> Result<(), String> {
|
||||
let salt_path = get_cache_dir()?.join("salt");
|
||||
|
||||
if salt_path.exists() {
|
||||
fs::remove_file(&salt_path)
|
||||
.map_err(|e| format!("Failed to delete salt: {}", e))?;
|
||||
info!("Deleted reporting salt (user opted out)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Hash generation
|
||||
|
||||
pub fn generate_user_hash(steam_path: &str) -> Result<String, String> {
|
||||
let machine_id = fs::read_to_string("/etc/machine-id")
|
||||
.map_err(|e| format!("Failed to read machine-id: {}", e))?;
|
||||
let machine_id = machine_id.trim();
|
||||
|
||||
let salt = get_or_create_salt()?;
|
||||
let combined = format!("{}{}{}", machine_id, steam_path, salt);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(combined.as_bytes());
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
// HTTP
|
||||
|
||||
pub async fn post_report(payload: ReportPayload) -> Result<(), String> {
|
||||
let cfg = config::load_config()?;
|
||||
|
||||
if !cfg.reporting_opted_in {
|
||||
info!("Reporting disabled - skipping report for game {}", payload.game_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = Client::new();
|
||||
let url = format!("{}/report", API_BASE);
|
||||
|
||||
info!(
|
||||
"Submitting report: game={}, unlocker={}, worked={}",
|
||||
payload.game_id, payload.unlocker, payload.worked
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send report: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
info!("Report submitted successfully");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Report submission failed: HTTP {}", response.status()))
|
||||
}
|
||||
}
|
||||
137
src-tauri/src/screamapi_config.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ScreamAPIConfig {
|
||||
#[serde(rename = "$schema")]
|
||||
pub schema: String,
|
||||
#[serde(rename = "$version")]
|
||||
pub version: u32,
|
||||
pub logging: bool,
|
||||
pub log_eos: bool,
|
||||
pub block_metrics: bool,
|
||||
pub namespace_id: String,
|
||||
pub default_dlc_status: String,
|
||||
pub override_dlc_status: HashMap<String, String>,
|
||||
pub extra_graphql_endpoints: Vec<String>,
|
||||
pub extra_entitlements: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for ScreamAPIConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: "https://raw.githubusercontent.com/acidicoala/ScreamAPI/master/res/ScreamAPI.schema.json".to_string(),
|
||||
version: 3,
|
||||
logging: false,
|
||||
log_eos: false,
|
||||
block_metrics: false,
|
||||
namespace_id: String::new(),
|
||||
default_dlc_status: "unlocked".to_string(),
|
||||
override_dlc_status: HashMap::new(),
|
||||
extra_graphql_endpoints: Vec::new(),
|
||||
extra_entitlements: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a default ScreamAPI config to a specific directory.
|
||||
/// Called internally by the installer when first setting up ScreamAPI.
|
||||
pub fn write_default_config(dir: &Path) -> Result<(), String> {
|
||||
write_config_to_dir(dir, &ScreamAPIConfig::default())
|
||||
}
|
||||
|
||||
/// Write ScreamAPI config to a specific directory (where the ScreamAPI DLL lives)
|
||||
pub fn write_config_to_dir(dir: &Path, config: &ScreamAPIConfig) -> Result<(), String> {
|
||||
let config_path = dir.join("ScreamAPI.config.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize ScreamAPI config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write ScreamAPI config: {}", e))?;
|
||||
|
||||
info!("Wrote ScreamAPI config to: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read ScreamAPI config from a game's install path.
|
||||
/// Looks for EOSSDK backup files to find the directory.
|
||||
pub fn read_config(game_path: &str) -> Result<Option<ScreamAPIConfig>, String> {
|
||||
let config_path = match find_screamapi_config_path(game_path) {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read ScreamAPI config: {}", e))?;
|
||||
|
||||
let config: ScreamAPIConfig = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse ScreamAPI config: {}", e))?;
|
||||
|
||||
info!("Read ScreamAPI config from: {}", config_path.display());
|
||||
Ok(Some(config))
|
||||
}
|
||||
|
||||
/// Write ScreamAPI config to the directory where ScreamAPI DLLs are installed.
|
||||
pub fn write_config(game_path: &str, config: &ScreamAPIConfig) -> Result<(), String> {
|
||||
// Find existing config location or fall back to game root
|
||||
let config_path = find_screamapi_config_path(game_path)
|
||||
.unwrap_or_else(|| Path::new(game_path).join("ScreamAPI.config.json"));
|
||||
|
||||
let content = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize ScreamAPI config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write ScreamAPI config: {}", e))?;
|
||||
|
||||
info!("Wrote ScreamAPI config to: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete ScreamAPI config from a game directory
|
||||
pub fn delete_config(game_path: &str) -> Result<(), String> {
|
||||
let config_path = match find_screamapi_config_path(game_path) {
|
||||
Some(p) => p,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if config_path.exists() {
|
||||
fs::remove_file(&config_path)
|
||||
.map_err(|e| format!("Failed to delete ScreamAPI config: {}", e))?;
|
||||
info!("Deleted ScreamAPI config from: {}", config_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find where the ScreamAPI config should live by looking for EOSSDK backup files
|
||||
/// (EOSSDK-Win64-Shipping_o.dll or EOSSDK-Win32-Shipping_o.dll)
|
||||
fn find_screamapi_config_path(game_path: &str) -> Option<PathBuf> {
|
||||
use walkdir::WalkDir;
|
||||
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let filename = path.file_name()?.to_string_lossy();
|
||||
|
||||
if (filename.starts_with("EOSSDK-Win") && filename.ends_with("_o.dll"))
|
||||
|| filename == "ScreamAPI.config.json"
|
||||
{
|
||||
let dir = path.parent()?;
|
||||
return Some(dir.join("ScreamAPI.config.json"));
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Could not find ScreamAPI install dir in {}, using game root", game_path);
|
||||
None
|
||||
}
|
||||
@@ -256,22 +256,62 @@ fn check_creamlinux_installed(game_path: &Path) -> bool {
|
||||
|
||||
// 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;
|
||||
// For Proton games: check for backup DLL files
|
||||
if !api_files.is_empty() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
// For Native games: check for lib_steam_api_o.so backup
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(3)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
if backup_path.exists() {
|
||||
debug!("SmokeAPI backup file found: {}", backup_path.display());
|
||||
// Check for native SmokeAPI backup
|
||||
if filename == "libsteam_api_o.so" {
|
||||
debug!("Found native SmokeAPI backup: {}", path.display());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan for orphaned backup files (in case the main DLL was removed)
|
||||
// This handles the Proton->Native switch case where steam_api*.dll is gone
|
||||
// but steam_api*_o.dll backup remains
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(5)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Look for steam_api*_o.dll backup files (SmokeAPI pattern)
|
||||
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
|
||||
debug!("Found orphaned SmokeAPI backup file: {}", path.display());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -284,8 +324,15 @@ fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
||||
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
let mut found_exe = false;
|
||||
let mut found_linux_binary = false;
|
||||
let mut found_main_executable = false;
|
||||
let mut steam_api_files = Vec::new();
|
||||
|
||||
// Strong indicators for native Linux games
|
||||
let mut has_libsteam_api = false;
|
||||
let mut has_linux_steam_libs = false;
|
||||
let mut linux_binary_count = 0;
|
||||
let mut windows_exe_count = 0;
|
||||
|
||||
// Directories to skip for better performance
|
||||
let skip_dirs = [
|
||||
"videos",
|
||||
@@ -312,6 +359,11 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
let exe_extensions = ["exe", "bat", "cmd", "msi"];
|
||||
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
|
||||
|
||||
// Files that indicate this is likely a launcher/installer
|
||||
let installer_patterns = [
|
||||
"setup", "install", "launcher", "uninstall", "redist", "vcredist", "directx", "_commonredist", "dotnet", "PhysX"
|
||||
];
|
||||
|
||||
// Recursively walk through the game directory
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(MAX_DEPTH) // Limit depth to avoid traversing too deep
|
||||
@@ -335,22 +387,46 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
|
||||
// Check for strong Linux indicators first
|
||||
if filename == "libsteam_api.so" {
|
||||
has_libsteam_api = true;
|
||||
debug!("Found strong Linux indicator: {}", path.display());
|
||||
}
|
||||
|
||||
// Check for other Linux Steam libraries
|
||||
if filename.starts_with("lib") && filename.contains("steam") && filename.ends_with(".so") {
|
||||
has_linux_steam_libs = true;
|
||||
debug!("Found Linux Steam library: {}", path.display());
|
||||
}
|
||||
|
||||
// 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 if this looks like an installer/utility rather than main game
|
||||
let is_likely_installer = installer_patterns.iter()
|
||||
.any(|&pattern| filename.contains(pattern));
|
||||
|
||||
if !is_likely_installer {
|
||||
found_exe = true;
|
||||
windows_exe_count += 1;
|
||||
|
||||
// If its in the root directory and not an installer, its likely the main executable
|
||||
if path.parent() == Some(game_path) {
|
||||
found_main_executable = 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();
|
||||
@@ -363,6 +439,7 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
// Check for Linux binary files
|
||||
if binary_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_linux_binary = true;
|
||||
linux_binary_count += 1;
|
||||
|
||||
// Check if it's actually an ELF binary for more certainty
|
||||
if ext_str == "so" && is_elf_binary(path) {
|
||||
@@ -382,29 +459,94 @@ fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
// Check executable permission and ELF format
|
||||
if is_executable && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
linux_binary_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we've found enough evidence for both platforms and Steam API DLLs, we can stop
|
||||
if found_exe && found_linux_binary && !steam_api_files.is_empty() {
|
||||
debug!("Found sufficient evidence, breaking scan early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// A game is considered native if it has Linux binaries but no Windows executables
|
||||
let is_native = found_linux_binary && !found_exe;
|
||||
// Detection logic with priority system
|
||||
let has_steam_api_dll = !steam_api_files.is_empty();
|
||||
let is_native = determine_platform(
|
||||
has_libsteam_api,
|
||||
has_linux_steam_libs,
|
||||
found_linux_binary,
|
||||
found_exe,
|
||||
found_main_executable,
|
||||
linux_binary_count,
|
||||
windows_exe_count,
|
||||
has_steam_api_dll,
|
||||
);
|
||||
|
||||
debug!(
|
||||
"Game scan results: native={}, exe={}, api_dlls={}",
|
||||
"Game scan results: native={}, libsteam_api={}, linux_libs={}, linux_binaries={}, exe_files={}, api_dlls={}",
|
||||
is_native,
|
||||
found_exe,
|
||||
has_libsteam_api,
|
||||
has_linux_steam_libs,
|
||||
linux_binary_count,
|
||||
windows_exe_count,
|
||||
steam_api_files.len()
|
||||
);
|
||||
|
||||
(is_native, steam_api_files)
|
||||
}
|
||||
|
||||
// Priority-based platform detection
|
||||
fn determine_platform(
|
||||
has_libsteam_api: bool,
|
||||
has_linux_steam_libs: bool,
|
||||
found_linux_binary: bool,
|
||||
found_exe: bool,
|
||||
found_main_executable: bool,
|
||||
linux_binary_count: usize,
|
||||
windows_exe_count: usize,
|
||||
has_steam_api_dll: bool,
|
||||
) -> bool {
|
||||
// Priority 1: Strong Linux indicators
|
||||
if has_libsteam_api {
|
||||
debug!("Detected as native: libsteam_api.so found");
|
||||
return true;
|
||||
}
|
||||
|
||||
if has_linux_steam_libs {
|
||||
debug!("Detected as native: Linux steam libraries found");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Priority 2: Strong Windows indicators - DLL files are Windows-only
|
||||
if has_steam_api_dll {
|
||||
debug!("Detected as Windows/Proton: steam_api.dll or steam_api64.dll found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Priority 3: High confidence Linux indicators
|
||||
if found_linux_binary && linux_binary_count >= 3 && !found_main_executable {
|
||||
debug!("Detected as native: Multiple Linux binaries, no main Windows executable");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Priority 4: Balanced assessment
|
||||
if found_linux_binary && !found_main_executable && windows_exe_count <= 2 {
|
||||
debug!("Detected as native: Linux binaries present, only installer/utility Windows files");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Priority 5: Windows indicators
|
||||
if found_main_executable || (found_exe && !found_linux_binary) {
|
||||
debug!("Detected as Windows/Proton: Main executable or only Windows files found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Priority 6: Default fallback
|
||||
if found_linux_binary {
|
||||
debug!("Detected as native: Linux binaries found (default fallback)");
|
||||
return true;
|
||||
}
|
||||
|
||||
debug!("Detected as Windows/Proton: No strong indicators found");
|
||||
false
|
||||
}
|
||||
|
||||
// Find all installed Steam games from library folders
|
||||
pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo> {
|
||||
let mut games = Vec::new();
|
||||
@@ -529,12 +671,10 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
|
||||
// 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
|
||||
};
|
||||
// Check for SmokeAPI installation
|
||||
// For Proton games: check if api_files exist
|
||||
// For Native games: ALSO check for orphaned backup files (proton->native switch)
|
||||
let smoke_installed = check_smokeapi_installed(&game_path, &api_files);
|
||||
|
||||
// Create the game info
|
||||
let game_info = GameInfo {
|
||||
@@ -602,4 +742,4 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
|
||||
|
||||
info!("Found {} installed games", games.len());
|
||||
games
|
||||
}
|
||||
}
|
||||
128
src-tauri/src/smokeapi_config.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SmokeAPIConfig {
|
||||
#[serde(rename = "$schema")]
|
||||
pub schema: String,
|
||||
#[serde(rename = "$version")]
|
||||
pub version: u32,
|
||||
pub logging: bool,
|
||||
pub log_steam_http: bool,
|
||||
pub default_app_status: String,
|
||||
pub override_app_status: HashMap<String, String>,
|
||||
pub override_dlc_status: HashMap<String, String>,
|
||||
pub auto_inject_inventory: bool,
|
||||
pub extra_inventory_items: Vec<u32>,
|
||||
pub extra_dlcs: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for SmokeAPIConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: "https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json".to_string(),
|
||||
version: 4,
|
||||
logging: false,
|
||||
log_steam_http: false,
|
||||
default_app_status: "unlocked".to_string(),
|
||||
override_app_status: HashMap::new(),
|
||||
override_dlc_status: HashMap::new(),
|
||||
auto_inject_inventory: true,
|
||||
extra_inventory_items: Vec::new(),
|
||||
extra_dlcs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read SmokeAPI config from a game directory
|
||||
// Returns None if the config doesn't exist
|
||||
pub fn read_config(game_path: &str) -> Result<Option<SmokeAPIConfig>, String> {
|
||||
info!("Reading SmokeAPI config from: {}", game_path);
|
||||
|
||||
// Find the SmokeAPI DLL location in the game directory
|
||||
let config_path = find_smokeapi_config_path(game_path)?;
|
||||
|
||||
if !config_path.exists() {
|
||||
info!("No SmokeAPI config found at: {}", config_path.display());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read SmokeAPI config: {}", e))?;
|
||||
|
||||
let config: SmokeAPIConfig = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse SmokeAPI config: {}", e))?;
|
||||
|
||||
info!("Successfully read SmokeAPI config");
|
||||
Ok(Some(config))
|
||||
}
|
||||
|
||||
// Write SmokeAPI config to a game directory
|
||||
pub fn write_config(game_path: &str, config: &SmokeAPIConfig) -> Result<(), String> {
|
||||
info!("Writing SmokeAPI config to: {}", game_path);
|
||||
|
||||
let config_path = find_smokeapi_config_path(game_path)?;
|
||||
|
||||
let content = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize SmokeAPI config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write SmokeAPI config: {}", e))?;
|
||||
|
||||
info!("Successfully wrote SmokeAPI config to: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Delete SmokeAPI config from a game directory
|
||||
pub fn delete_config(game_path: &str) -> Result<(), String> {
|
||||
info!("Deleting SmokeAPI config from: {}", game_path);
|
||||
|
||||
let config_path = find_smokeapi_config_path(game_path)?;
|
||||
|
||||
if config_path.exists() {
|
||||
fs::remove_file(&config_path)
|
||||
.map_err(|e| format!("Failed to delete SmokeAPI config: {}", e))?;
|
||||
info!("Successfully deleted SmokeAPI config");
|
||||
} else {
|
||||
info!("No SmokeAPI config to delete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Find the path where SmokeAPI.config.json should be located
|
||||
// This is in the same directory as the SmokeAPI DLL files
|
||||
fn find_smokeapi_config_path(game_path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let game_path_obj = Path::new(game_path);
|
||||
|
||||
// Search for steam_api*.dll files with _o.dll backups (indicating SmokeAPI installation)
|
||||
let mut smokeapi_dir: Option<std::path::PathBuf> = None;
|
||||
|
||||
// Use walkdir to search recursively
|
||||
for entry in walkdir::WalkDir::new(game_path_obj)
|
||||
.max_depth(5)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Look for steam_api*_o.dll (backup files created by SmokeAPI)
|
||||
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
|
||||
smokeapi_dir = path.parent().map(|p| p.to_path_buf());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a SmokeAPI directory, return the config path
|
||||
if let Some(dir) = smokeapi_dir {
|
||||
Ok(dir.join("SmokeAPI.config.json"))
|
||||
} else {
|
||||
// Fallback to game root directory
|
||||
warn!("Could not find SmokeAPI DLL directory, using game root");
|
||||
Ok(game_path_obj.join("SmokeAPI.config.json"))
|
||||
}
|
||||
}
|
||||
225
src-tauri/src/unlockers/creamlinux.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
use super::Unlocker;
|
||||
use async_trait::async_trait;
|
||||
use log::{info, warn};
|
||||
use reqwest;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
pub struct CreamLinux;
|
||||
|
||||
#[async_trait]
|
||||
impl Unlocker for CreamLinux {
|
||||
async fn get_latest_version() -> Result<String, String> {
|
||||
info!("Fetching latest CreamLinux version...");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Fetch the latest release from GitHub API
|
||||
let api_url = "https://api.github.com/repos/anticitizn/creamlinux/releases/latest";
|
||||
|
||||
let response = client
|
||||
.get(api_url)
|
||||
.header("User-Agent", "CreamLinux-Installer")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch CreamLinux releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch CreamLinux releases: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let release_info: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let version = release_info
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Failed to extract version from release info".to_string())?
|
||||
.to_string();
|
||||
|
||||
info!("Latest CreamLinux version: {}", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn download_to_cache() -> Result<String, String> {
|
||||
let version = Self::get_latest_version().await?;
|
||||
info!("Downloading CreamLinux version {}...", version);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Construct the download URL using the version
|
||||
let download_url = format!(
|
||||
"https://github.com/anticitizn/creamlinux/releases/download/{}/creamlinux.zip",
|
||||
version
|
||||
);
|
||||
|
||||
// Download the zip
|
||||
let response = client
|
||||
.get(&download_url)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download CreamLinux: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download CreamLinux: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
// Save to temporary file
|
||||
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
||||
let zip_path = temp_dir.path().join("creamlinux.zip");
|
||||
let content = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
|
||||
fs::write(&zip_path, &content).map_err(|e| format!("Failed to write zip file: {}", e))?;
|
||||
|
||||
// Extract to cache directory
|
||||
let version_dir = crate::cache::get_creamlinux_version_dir(&version)?;
|
||||
let file = fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
// Extract all files
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
|
||||
|
||||
let file_name = file.name().to_string(); // Clone the name early
|
||||
|
||||
// Skip directories
|
||||
if file_name.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let output_path = version_dir.join(
|
||||
Path::new(&file_name)
|
||||
.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new(&file_name)),
|
||||
);
|
||||
|
||||
let mut outfile = fs::File::create(&output_path)
|
||||
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||
io::copy(&mut file, &mut outfile)
|
||||
.map_err(|e| format!("Failed to extract file: {}", e))?;
|
||||
|
||||
// Make .sh files executable
|
||||
if file_name.ends_with(".sh") {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&output_path)
|
||||
.map_err(|e| format!("Failed to get file metadata: {}", e))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&output_path, perms)
|
||||
.map_err(|e| format!("Failed to set permissions: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Extracted: {}", output_path.display());
|
||||
}
|
||||
|
||||
info!(
|
||||
"CreamLinux version {} downloaded to cache successfully",
|
||||
version
|
||||
);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn install_to_game(game_path: &str, _game_id: &str) -> Result<(), String> {
|
||||
info!("Installing CreamLinux to {}", game_path);
|
||||
|
||||
// Get the cached CreamLinux files
|
||||
let cached_files = crate::cache::list_creamlinux_files()?;
|
||||
if cached_files.is_empty() {
|
||||
return Err("No CreamLinux files found in cache".to_string());
|
||||
}
|
||||
|
||||
let game_path_obj = Path::new(game_path);
|
||||
|
||||
// Copy all files to the game directory
|
||||
for file in &cached_files {
|
||||
let file_name = file.file_name().ok_or_else(|| {
|
||||
format!("Failed to get filename from: {}", file.display())
|
||||
})?;
|
||||
|
||||
let dest_path = game_path_obj.join(file_name);
|
||||
|
||||
fs::copy(file, &dest_path)
|
||||
.map_err(|e| format!("Failed to copy {} to game directory: {}", file_name.to_string_lossy(), e))?;
|
||||
|
||||
// Make .sh files executable
|
||||
if file_name.to_string_lossy().ends_with(".sh") {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&dest_path)
|
||||
.map_err(|e| format!("Failed to get file metadata: {}", e))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&dest_path, perms)
|
||||
.map_err(|e| format!("Failed to set permissions: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Installed: {}", dest_path.display());
|
||||
}
|
||||
|
||||
// Note: cream_api.ini is managed separately by dlc_manager
|
||||
// This function only installs the binaries
|
||||
|
||||
info!("CreamLinux installation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_from_game(game_path: &str, _game_id: &str) -> Result<(), String> {
|
||||
info!("Uninstalling CreamLinux from: {}", game_path);
|
||||
|
||||
let game_path_obj = Path::new(game_path);
|
||||
|
||||
// List of CreamLinux files to remove
|
||||
let files_to_remove = vec![
|
||||
"cream.sh",
|
||||
"lib32Creamlinux.so",
|
||||
"lib64Creamlinux.so",
|
||||
"cream_api.ini",
|
||||
];
|
||||
|
||||
for file_name in files_to_remove {
|
||||
let file_path = game_path_obj.join(file_name);
|
||||
|
||||
if file_path.exists() {
|
||||
match fs::remove_file(&file_path) {
|
||||
Ok(_) => info!("Removed: {}", file_path.display()),
|
||||
Err(e) => warn!(
|
||||
"Failed to remove {}: {}",
|
||||
file_path.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("CreamLinux uninstallation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"CreamLinux"
|
||||
}
|
||||
}
|
||||
289
src-tauri/src/unlockers/koaloader.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use super::Unlocker;
|
||||
use async_trait::async_trait;
|
||||
use log::info;
|
||||
use reqwest;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const KOALOADER_REPO: &str = "acidicoala/Koaloader";
|
||||
|
||||
pub const KOA_VARIANTS: &[&str] = &[
|
||||
"version.dll", "winmm.dll", "winhttp.dll", "iphlpapi.dll", "dinput8.dll",
|
||||
"d3d11.dll", "dxgi.dll", "d3d9.dll", "d3d10.dll", "dwmapi.dll", "hid.dll",
|
||||
"msimg32.dll", "mswsock.dll", "opengl32.dll", "profapi.dll", "propsys.dll",
|
||||
"textshaping.dll", "glu32.dll", "audioses.dll", "msasn1.dll", "wldp.dll",
|
||||
"xinput9_1_0.dll",
|
||||
];
|
||||
|
||||
pub struct Koaloader;
|
||||
|
||||
#[async_trait]
|
||||
impl Unlocker for Koaloader {
|
||||
async fn get_latest_version() -> Result<String, String> {
|
||||
info!("Fetching latest Koaloader version...");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let releases_url = format!(
|
||||
"https://api.github.com/repos/{}/releases/latest",
|
||||
KOALOADER_REPO
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&releases_url)
|
||||
.header("User-Agent", "CreamLinux")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Koaloader releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch Koaloader releases: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let release_info: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let version = release_info
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Failed to extract version from release info".to_string())?
|
||||
.to_string();
|
||||
|
||||
info!("Latest Koaloader version: {}", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn download_to_cache() -> Result<String, String> {
|
||||
let version = Self::get_latest_version().await?;
|
||||
info!("Downloading Koaloader version {}...", version);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let releases_url = format!(
|
||||
"https://api.github.com/repos/{}/releases/latest",
|
||||
KOALOADER_REPO
|
||||
);
|
||||
let release_info: serde_json::Value = client
|
||||
.get(&releases_url)
|
||||
.header("User-Agent", "CreamLinux")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Koaloader release: {}", e))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let zip_url = release_info
|
||||
.get("assets")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|assets| {
|
||||
assets.iter().find(|asset| {
|
||||
asset
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| n.ends_with(".zip"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.and_then(|asset| asset.get("browser_download_url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.ok_or_else(|| "No zip asset found in Koaloader release".to_string())?
|
||||
.to_string();
|
||||
|
||||
let response = client
|
||||
.get(&zip_url)
|
||||
.timeout(Duration::from_secs(60))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download Koaloader: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download Koaloader: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
||||
let zip_path = temp_dir.path().join("koaloader.zip");
|
||||
let content = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
|
||||
fs::write(&zip_path, &content)
|
||||
.map_err(|e| format!("Failed to write zip file: {}", e))?;
|
||||
|
||||
let version_dir = crate::cache::get_koaloader_version_dir(&version)?;
|
||||
let file =
|
||||
fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
|
||||
|
||||
let zip_entry = file.name().to_string();
|
||||
if zip_entry.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = version_dir.join(&zip_entry);
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
}
|
||||
|
||||
let mut outfile = fs::File::create(&out_path).map_err(|e| {
|
||||
format!("Failed to create output file {}: {}", out_path.display(), e)
|
||||
})?;
|
||||
io::copy(&mut file, &mut outfile)
|
||||
.map_err(|e| format!("Failed to extract file: {}", e))?;
|
||||
}
|
||||
|
||||
info!("Koaloader version {} downloaded to cache successfully", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// context = relative executable path (e.g. "en_us/Sources/Bin/SnowRunner.exe")
|
||||
/// Progress events are emitted by installer/mod.rs, not here.
|
||||
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> {
|
||||
// Install without progress called internally (e.g. from installer/mod.rs
|
||||
// after it has already emitted its own progress steps)
|
||||
let exe_path = Self::resolve_exe(game_path, context)?;
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
|
||||
let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path);
|
||||
let scan = crate::pe_inspector::find_best_proxy(&exe_path);
|
||||
let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string();
|
||||
|
||||
let proxy_src = Self::get_proxy_dll(&proxy_stem, is_64bit)?;
|
||||
fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name))
|
||||
.map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?;
|
||||
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
crate::unlockers::ScreamAPI::install_to_game(&exe_dir_str, "koaloader").await?;
|
||||
|
||||
let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let koa_config = serde_json::json!({
|
||||
"logging": false,
|
||||
"enabled": true,
|
||||
"auto_load": true,
|
||||
"targets": [exe_name],
|
||||
"modules": []
|
||||
});
|
||||
fs::write(
|
||||
exe_dir.join("Koaloader.config.json"),
|
||||
serde_json::to_string_pretty(&koa_config).unwrap(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write Koaloader config: {}", e))?;
|
||||
|
||||
info!("Koaloader installation complete for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String> {
|
||||
let exe_path = Self::resolve_exe(game_path, context)?;
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
|
||||
let koa_config = exe_dir.join("Koaloader.config.json");
|
||||
if koa_config.exists() {
|
||||
fs::remove_file(&koa_config)
|
||||
.map_err(|e| format!("Failed to remove Koaloader config: {}", e))?;
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(exe_dir) {
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let name_lower = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if KOA_VARIANTS.contains(&name_lower.as_str()) {
|
||||
fs::remove_file(&path).ok();
|
||||
info!("Removed proxy DLL: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::unlockers::ScreamAPI::uninstall_from_game(&exe_dir_str, "koaloader").await?;
|
||||
|
||||
info!("Koaloader uninstallation complete for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"Koaloader"
|
||||
}
|
||||
}
|
||||
|
||||
impl Koaloader {
|
||||
/// Public wrapper for installer/mod.rs to call.
|
||||
pub fn resolve_exe_pub(game_path: &str, exe_relative: &str) -> Result<std::path::PathBuf, String> {
|
||||
Self::resolve_exe(game_path, exe_relative)
|
||||
}
|
||||
|
||||
fn resolve_exe(game_path: &str, exe_relative: &str) -> Result<std::path::PathBuf, String> {
|
||||
use walkdir::WalkDir;
|
||||
|
||||
let full = Path::new(game_path).join(exe_relative);
|
||||
if full.exists() {
|
||||
return Ok(full);
|
||||
}
|
||||
|
||||
let exe_name = Path::new(exe_relative)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
if entry.file_name().to_string_lossy() == exe_name {
|
||||
return Ok(entry.path().to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Executable not found: {} (searched in {})",
|
||||
exe_relative, game_path
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_proxy_dll(proxy_stem: &str, is_64bit: bool) -> Result<std::path::PathBuf, String> {
|
||||
let versions = crate::cache::read_versions()?;
|
||||
if versions.koaloader.latest.is_empty() {
|
||||
return Err("Koaloader is not cached. Please restart the app.".to_string());
|
||||
}
|
||||
|
||||
let version_dir = crate::cache::get_koaloader_version_dir(&versions.koaloader.latest)?;
|
||||
let bitness = if is_64bit { "64" } else { "32" };
|
||||
let folder = format!("{}-{}", proxy_stem, bitness);
|
||||
let dll_path = version_dir.join(&folder).join(format!("{}.dll", proxy_stem));
|
||||
|
||||
if !dll_path.exists() {
|
||||
return Err(format!(
|
||||
"Koaloader proxy DLL not found in cache: {}",
|
||||
dll_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(dll_path)
|
||||
}
|
||||
}
|
||||
31
src-tauri/src/unlockers/mod.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
mod creamlinux;
|
||||
mod smokeapi;
|
||||
pub mod koaloader;
|
||||
mod screamapi;
|
||||
|
||||
pub use creamlinux::CreamLinux;
|
||||
pub use smokeapi::SmokeAPI;
|
||||
pub use screamapi::ScreamAPI;
|
||||
pub use koaloader::Koaloader;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
// Common trait for all unlockers (CreamLinux, SmokeAPI)
|
||||
#[async_trait]
|
||||
pub trait Unlocker {
|
||||
// Get the latest version from the remote source
|
||||
async fn get_latest_version() -> Result<String, String>;
|
||||
|
||||
// Download the unlocker to the cache directory
|
||||
async fn download_to_cache() -> Result<String, String>;
|
||||
|
||||
// Install the unlocker from cache to a game directory
|
||||
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String>;
|
||||
|
||||
// Uninstall the unlocker from a game directory
|
||||
async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String>;
|
||||
|
||||
// Get the name of the unlocker
|
||||
#[allow(dead_code)]
|
||||
fn name() -> &'static str;
|
||||
}
|
||||
339
src-tauri/src/unlockers/screamapi.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use super::Unlocker;
|
||||
use async_trait::async_trait;
|
||||
use log::info;
|
||||
use reqwest;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use walkdir::WalkDir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const SCREAMAPI_REPO: &str = "acidicoala/ScreamAPI";
|
||||
|
||||
pub struct ScreamAPI;
|
||||
|
||||
#[async_trait]
|
||||
impl Unlocker for ScreamAPI {
|
||||
async fn get_latest_version() -> Result<String, String> {
|
||||
info!("Fetching latest ScreamAPI version...");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let releases_url = format!(
|
||||
"https://api.github.com/repos/{}/releases/latest",
|
||||
SCREAMAPI_REPO
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&releases_url)
|
||||
.header("User-Agent", "CreamLinux")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch ScreamAPI releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch ScreamAPI releases: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let release_info: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let version = release_info
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Failed to extract version from release info".to_string())?
|
||||
.to_string();
|
||||
|
||||
info!("Latest ScreamAPI version: {}", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn download_to_cache() -> Result<String, String> {
|
||||
let version = Self::get_latest_version().await?;
|
||||
info!("Downloading ScreamAPI version {}...", version);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let releases_url = format!(
|
||||
"https://api.github.com/repos/{}/releases/latest",
|
||||
SCREAMAPI_REPO
|
||||
);
|
||||
let release_info: serde_json::Value = client
|
||||
.get(&releases_url)
|
||||
.header("User-Agent", "CreamLinux")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch ScreamAPI release: {}", e))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let zip_url = release_info
|
||||
.get("assets")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|assets| {
|
||||
assets.iter().find(|asset| {
|
||||
asset
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| n.ends_with(".zip"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.and_then(|asset| asset.get("browser_download_url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.ok_or_else(|| "No zip asset found in ScreamAPI release".to_string())?
|
||||
.to_string();
|
||||
|
||||
info!("Downloading ScreamAPI from: {}", zip_url);
|
||||
|
||||
let response = client
|
||||
.get(&zip_url)
|
||||
.timeout(Duration::from_secs(60))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download ScreamAPI: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download ScreamAPI: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
||||
let zip_path = temp_dir.path().join("screamapi.zip");
|
||||
let content = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
|
||||
fs::write(&zip_path, &content)
|
||||
.map_err(|e| format!("Failed to write zip file: {}", e))?;
|
||||
|
||||
let version_dir = crate::cache::get_screamapi_version_dir(&version)?;
|
||||
let file =
|
||||
fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
|
||||
|
||||
let file_name = file.name().to_string();
|
||||
let base_name = Path::new(&file_name)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let should_extract = base_name.to_lowercase().ends_with(".dll")
|
||||
|| base_name == "ScreamAPI.config.json";
|
||||
|
||||
if should_extract {
|
||||
let output_path = version_dir.join(&base_name);
|
||||
let mut outfile = fs::File::create(&output_path)
|
||||
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||
io::copy(&mut file, &mut outfile)
|
||||
.map_err(|e| format!("Failed to extract file: {}", e))?;
|
||||
info!("Extracted: {}", output_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
info!("ScreamAPI version {} downloaded to cache successfully", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// context = "" -> direct install (replace EOSSDK DLLs)
|
||||
/// context = "koaloader" -> payload install (drop DLL in exe dir)
|
||||
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> {
|
||||
if context == "koaloader" {
|
||||
Self::install_as_koaloader_payload(game_path).await
|
||||
} else {
|
||||
Self::install_direct(game_path).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String> {
|
||||
if context == "koaloader" {
|
||||
Self::uninstall_as_koaloader_payload(game_path).await
|
||||
} else {
|
||||
Self::uninstall_direct(game_path).await
|
||||
}
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"ScreamAPI"
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreamAPI {
|
||||
// Direct install
|
||||
|
||||
async fn install_direct(game_path: &str) -> Result<(), String> {
|
||||
info!("Installing ScreamAPI (direct) to: {}", game_path);
|
||||
|
||||
let install_path = Path::new(game_path);
|
||||
let eos_dlls = Self::find_eossdk_dlls(install_path);
|
||||
|
||||
if eos_dlls.is_empty() {
|
||||
return Err(format!(
|
||||
"No EOSSDK-Win*-Shipping.dll found in {}",
|
||||
game_path
|
||||
));
|
||||
}
|
||||
|
||||
info!("Found {} EOSSDK DLL(s)", eos_dlls.len());
|
||||
|
||||
let versions = crate::cache::read_versions()?;
|
||||
if versions.screamapi.latest.is_empty() {
|
||||
return Err("ScreamAPI is not cached. Please restart the app.".to_string());
|
||||
}
|
||||
let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?;
|
||||
|
||||
for eos_dll in &eos_dlls {
|
||||
let filename = eos_dll.file_name().unwrap_or_default().to_string_lossy();
|
||||
let is_64bit = filename.to_lowercase().contains("64");
|
||||
|
||||
let stem = filename.trim_end_matches(".dll");
|
||||
let backup = eos_dll.with_file_name(format!("{}_o.dll", stem));
|
||||
|
||||
if !backup.exists() && eos_dll.exists() {
|
||||
fs::copy(eos_dll, &backup)
|
||||
.map_err(|e| format!("Failed to backup {}: {}", filename, e))?;
|
||||
info!("Backed up {} -> {}", eos_dll.display(), backup.display());
|
||||
}
|
||||
|
||||
let scream_dll_name = if is_64bit { "ScreamAPI64.dll" } else { "ScreamAPI32.dll" };
|
||||
let src = scream_dir.join(scream_dll_name);
|
||||
if !src.exists() {
|
||||
return Err(format!("ScreamAPI DLL not found in cache: {}", src.display()));
|
||||
}
|
||||
|
||||
fs::copy(&src, eos_dll)
|
||||
.map_err(|e| format!("Failed to install ScreamAPI DLL: {}", e))?;
|
||||
info!("Installed {} as {}", scream_dll_name, eos_dll.display());
|
||||
}
|
||||
|
||||
let config_dir = eos_dlls[0].parent().ok_or("Failed to get parent of EOS DLL")?;
|
||||
crate::screamapi_config::write_default_config(config_dir)?;
|
||||
|
||||
info!("ScreamAPI (direct) installation complete for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_direct(game_path: &str) -> Result<(), String> {
|
||||
info!("Uninstalling ScreamAPI (direct) from: {}", game_path);
|
||||
|
||||
let install_path = Path::new(game_path);
|
||||
|
||||
for entry in WalkDir::new(install_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let lower = filename.to_lowercase();
|
||||
|
||||
if lower.starts_with("eossdk-win") && lower.ends_with("_o.dll") {
|
||||
let original_name = filename.trim_end_matches("_o.dll").to_string() + ".dll";
|
||||
let original = path.parent().unwrap_or(install_path).join(&original_name);
|
||||
|
||||
fs::copy(path, &original)
|
||||
.map_err(|e| format!("Failed to restore {}: {}", original_name, e))?;
|
||||
fs::remove_file(path)
|
||||
.map_err(|e| format!("Failed to remove backup file: {}", e))?;
|
||||
info!("Restored {} from backup", original.display());
|
||||
}
|
||||
}
|
||||
|
||||
crate::screamapi_config::delete_config(game_path)?;
|
||||
info!("ScreamAPI (direct) uninstallation complete for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Koaloader payload
|
||||
|
||||
async fn install_as_koaloader_payload(exe_dir: &str) -> Result<(), String> {
|
||||
info!("Installing ScreamAPI as Koaloader payload in: {}", exe_dir);
|
||||
|
||||
let versions = crate::cache::read_versions()?;
|
||||
if versions.screamapi.latest.is_empty() {
|
||||
return Err("ScreamAPI is not cached. Please restart the app.".to_string());
|
||||
}
|
||||
let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?;
|
||||
let exe_dir_path = Path::new(exe_dir);
|
||||
|
||||
for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] {
|
||||
let src = scream_dir.join(dll_name);
|
||||
if src.exists() {
|
||||
let dest = exe_dir_path.join(dll_name);
|
||||
fs::copy(&src, &dest)
|
||||
.map_err(|e| format!("Failed to copy {}: {}", dll_name, e))?;
|
||||
info!("Placed {} in exe dir", dll_name);
|
||||
}
|
||||
}
|
||||
|
||||
crate::screamapi_config::write_default_config(exe_dir_path)?;
|
||||
info!("ScreamAPI (Koaloader payload) install complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_as_koaloader_payload(exe_dir: &str) -> Result<(), String> {
|
||||
info!("Removing ScreamAPI Koaloader payload from: {}", exe_dir);
|
||||
|
||||
let exe_dir_path = Path::new(exe_dir);
|
||||
for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] {
|
||||
let path = exe_dir_path.join(dll_name);
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to remove {}: {}", dll_name, e))?;
|
||||
info!("Removed {}", dll_name);
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = exe_dir_path.join("ScreamAPI.config.json");
|
||||
if cfg.exists() {
|
||||
fs::remove_file(&cfg).ok();
|
||||
}
|
||||
|
||||
info!("ScreamAPI (Koaloader payload) uninstall complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
pub fn find_eossdk_dlls(root: &Path) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
for entry in WalkDir::new(root)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let lower = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
|
||||
if lower.starts_with("eossdk-win")
|
||||
&& lower.ends_with("-shipping.dll")
|
||||
&& !lower.contains("_o")
|
||||
{
|
||||
found.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
432
src-tauri/src/unlockers/smokeapi.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
use super::Unlocker;
|
||||
use async_trait::async_trait;
|
||||
use log::{error, info, warn};
|
||||
use reqwest;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const SMOKEAPI_REPO: &str = "acidicoala/SmokeAPI";
|
||||
|
||||
pub struct SmokeAPI;
|
||||
|
||||
#[async_trait]
|
||||
impl Unlocker for SmokeAPI {
|
||||
async fn get_latest_version() -> Result<String, String> {
|
||||
info!("Fetching latest SmokeAPI version...");
|
||||
|
||||
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
|
||||
.map_err(|e| format!("Failed to fetch SmokeAPI releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch SmokeAPI releases: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let release_info: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let version = release_info
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Failed to extract version from release info".to_string())?
|
||||
.to_string();
|
||||
|
||||
info!("Latest SmokeAPI version: {}", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn download_to_cache() -> Result<String, String> {
|
||||
let version = Self::get_latest_version().await?;
|
||||
info!("Downloading SmokeAPI version {}...", version);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let zip_url = format!(
|
||||
"https://github.com/{}/releases/download/{}/SmokeAPI-{}.zip",
|
||||
SMOKEAPI_REPO, version, version
|
||||
);
|
||||
|
||||
// Download the zip
|
||||
let response = client
|
||||
.get(&zip_url)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download SmokeAPI: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download SmokeAPI: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
// Save to temporary file
|
||||
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
||||
let zip_path = temp_dir.path().join("smokeapi.zip");
|
||||
let content = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
|
||||
fs::write(&zip_path, &content).map_err(|e| format!("Failed to write zip file: {}", e))?;
|
||||
|
||||
// Extract to cache directory
|
||||
let version_dir = crate::cache::get_smokeapi_version_dir(&version)?;
|
||||
let file = fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
// Extract both DLL files (for Proton) and .so files (for native Linux)
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
|
||||
|
||||
let file_name = file.name();
|
||||
|
||||
// Extract DLL files for Proton and .so files for native Linux
|
||||
let should_extract = file_name.to_lowercase().ends_with(".dll")
|
||||
|| file_name.to_lowercase().ends_with(".so");
|
||||
|
||||
if should_extract {
|
||||
let output_path = version_dir.join(
|
||||
Path::new(file_name)
|
||||
.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new(file_name)),
|
||||
);
|
||||
|
||||
let mut outfile = fs::File::create(&output_path)
|
||||
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||
io::copy(&mut file, &mut outfile)
|
||||
.map_err(|e| format!("Failed to extract file: {}", e))?;
|
||||
|
||||
info!("Extracted: {}", output_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"SmokeAPI version {} downloaded to cache successfully",
|
||||
version
|
||||
);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn install_to_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||
// Check if this is a native Linux game or Proton game
|
||||
// Native games have empty api_files_str, Proton games have DLL paths
|
||||
let is_native = api_files_str.is_empty();
|
||||
|
||||
if is_native {
|
||||
Self::install_to_native_game(game_path).await
|
||||
} else {
|
||||
Self::install_to_proton_game(game_path, api_files_str).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||
// Check if this is a native Linux game or Proton game
|
||||
let is_native = api_files_str.is_empty();
|
||||
|
||||
if is_native {
|
||||
Self::uninstall_from_native_game(game_path).await
|
||||
} else {
|
||||
Self::uninstall_from_proton_game(game_path, api_files_str).await
|
||||
}
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"SmokeAPI"
|
||||
}
|
||||
}
|
||||
|
||||
impl SmokeAPI {
|
||||
/// Install SmokeAPI to a Proton/Windows game
|
||||
async fn install_to_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||
// Parse api_files from the context string (comma-separated)
|
||||
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
|
||||
|
||||
info!(
|
||||
"Installing SmokeAPI (Proton) to {} for {} API files",
|
||||
game_path,
|
||||
api_files.len()
|
||||
);
|
||||
|
||||
// Get the cached SmokeAPI DLLs
|
||||
let cached_files = crate::cache::list_smokeapi_files()?;
|
||||
if cached_files.is_empty() {
|
||||
return Err("No SmokeAPI files found in cache".to_string());
|
||||
}
|
||||
|
||||
let cached_dlls: Vec<_> = cached_files
|
||||
.iter()
|
||||
.filter(|f| f.extension().and_then(|e| e.to_str()) == Some("dll"))
|
||||
.collect();
|
||||
|
||||
if cached_dlls.is_empty() {
|
||||
return Err("No SmokeAPI DLLs found in cache".to_string());
|
||||
}
|
||||
|
||||
for api_file in &api_files {
|
||||
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());
|
||||
|
||||
// Only backup if not already backed up
|
||||
if !backup_path.exists() && original_path.exists() {
|
||||
fs::copy(&original_path, &backup_path)
|
||||
.map_err(|e| format!("Failed to backup original file: {}", e))?;
|
||||
info!("Created backup: {}", backup_path.display());
|
||||
}
|
||||
|
||||
// Determine if we need 32-bit or 64-bit SmokeAPI DLL
|
||||
let is_64bit = api_name.to_string_lossy().contains("64");
|
||||
let target_arch = if is_64bit { "64" } else { "32" };
|
||||
|
||||
// Find the matching DLL
|
||||
let matching_dll = cached_dlls
|
||||
.iter()
|
||||
.find(|dll| {
|
||||
let dll_name = dll.file_name().unwrap_or_default().to_string_lossy();
|
||||
dll_name.to_lowercase().contains("smoke")
|
||||
&& dll_name
|
||||
.to_lowercase()
|
||||
.contains(&format!("{}.dll", target_arch))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No matching {}-bit SmokeAPI DLL found in cache",
|
||||
target_arch
|
||||
)
|
||||
})?;
|
||||
|
||||
// Copy the DLL to the game directory
|
||||
fs::copy(matching_dll, &original_path)
|
||||
.map_err(|e| format!("Failed to install SmokeAPI DLL: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Installed {} as: {}",
|
||||
matching_dll.display(),
|
||||
original_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
info!("SmokeAPI (Proton) installation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install SmokeAPI to a native Linux game
|
||||
async fn install_to_native_game(game_path: &str) -> Result<(), String> {
|
||||
info!("Installing SmokeAPI (native) to {}", game_path);
|
||||
|
||||
// Detect game bitness
|
||||
let bitness = crate::utils::bitness::detect_game_bitness(game_path)?;
|
||||
info!("Detected game bitness: {:?}", bitness);
|
||||
|
||||
// Get the cached SmokeAPI files
|
||||
let cached_files = crate::cache::list_smokeapi_files()?;
|
||||
if cached_files.is_empty() {
|
||||
return Err("No SmokeAPI files found in cache".to_string());
|
||||
}
|
||||
|
||||
// Determine which .so file to use based on bitness
|
||||
let target_so = match bitness {
|
||||
crate::utils::bitness::Bitness::Bit32 => "libsmoke_api32.so",
|
||||
crate::utils::bitness::Bitness::Bit64 => "libsmoke_api64.so",
|
||||
};
|
||||
|
||||
// Find the matching .so file in cache
|
||||
let matching_so = cached_files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
file.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
== target_so
|
||||
})
|
||||
.ok_or_else(|| format!("No matching {} found in cache", target_so))?;
|
||||
|
||||
let game_path_obj = Path::new(game_path);
|
||||
|
||||
// Look for libsteam_api.so in the game directory (scan up to depth 3)
|
||||
let libsteam_path = Self::find_libsteam_api(game_path_obj)?;
|
||||
|
||||
info!("Found libsteam_api.so at: {}", libsteam_path.display());
|
||||
|
||||
// Create backup of original libsteam_api.so
|
||||
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
|
||||
|
||||
// Only backup if not already backed up
|
||||
if !backup_path.exists() && libsteam_path.exists() {
|
||||
fs::copy(&libsteam_path, &backup_path)
|
||||
.map_err(|e| format!("Failed to backup libsteam_api.so: {}", e))?;
|
||||
info!("Created backup: {}", backup_path.display());
|
||||
}
|
||||
|
||||
// Replace libsteam_api.so with SmokeAPI's libsmoke_api.so
|
||||
fs::copy(matching_so, &libsteam_path)
|
||||
.map_err(|e| format!("Failed to replace libsteam_api.so: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Replaced libsteam_api.so with {}",
|
||||
target_so
|
||||
);
|
||||
|
||||
info!("SmokeAPI (native) installation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall SmokeAPI from a Proton/Windows game
|
||||
async fn uninstall_from_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||
// Parse api_files from the context string (comma-separated)
|
||||
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
|
||||
|
||||
info!("Uninstalling SmokeAPI (Proton) 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());
|
||||
|
||||
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) => warn!(
|
||||
"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) => {
|
||||
warn!(
|
||||
"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 (Proton) uninstallation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall SmokeAPI from a native Linux game
|
||||
async fn uninstall_from_native_game(game_path: &str) -> Result<(), String> {
|
||||
info!("Uninstalling SmokeAPI (native) from: {}", game_path);
|
||||
|
||||
let game_path_obj = Path::new(game_path);
|
||||
|
||||
// Look for libsteam_api.so (which is actually our SmokeAPI now)
|
||||
let libsteam_path = match Self::find_libsteam_api(game_path_obj) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
warn!("libsteam_api.so not found, nothing to uninstall");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Look for backup
|
||||
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
|
||||
|
||||
if backup_path.exists() {
|
||||
// Remove the SmokeAPI version
|
||||
if libsteam_path.exists() {
|
||||
match fs::remove_file(&libsteam_path) {
|
||||
Ok(_) => info!("Removed SmokeAPI version: {}", libsteam_path.display()),
|
||||
Err(e) => warn!("Failed to remove SmokeAPI file: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the original file
|
||||
match fs::rename(&backup_path, &libsteam_path) {
|
||||
Ok(_) => info!("Restored original libsteam_api.so"),
|
||||
Err(e) => {
|
||||
warn!("Failed to restore original file: {}", e);
|
||||
// Try to copy instead if rename fails
|
||||
if let Err(copy_err) = fs::copy(&backup_path, &libsteam_path)
|
||||
.and_then(|_| fs::remove_file(&backup_path))
|
||||
{
|
||||
error!("Failed to copy backup file: {}", copy_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("No backup found (libsteam_api_o.so), cannot restore original");
|
||||
}
|
||||
|
||||
info!("SmokeAPI (native) uninstallation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find libsteam_api.so in the game directory
|
||||
fn find_libsteam_api(game_path: &Path) -> Result<std::path::PathBuf, String> {
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// Scan for libsteam_api.so (some games place it several subdirectories deep)
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
if filename == "libsteam_api.so" {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
Err("libsteam_api.so not found in game directory".to_string())
|
||||
}
|
||||
}
|
||||
204
src-tauri/src/utils/bitness.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use log::{debug, info, warn};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Represents the bitness of a binary
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Bitness {
|
||||
Bit32,
|
||||
Bit64,
|
||||
}
|
||||
|
||||
/// Detect the bitness of a Linux Binary by reading ELF header
|
||||
/// ELF format: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
|
||||
fn detect_binary_bitness(file_path: &Path) -> Option<Bitness> {
|
||||
use std::io::Read;
|
||||
|
||||
// Only read first 5 bytes
|
||||
let mut file = fs::File::open(file_path).ok()?;
|
||||
let mut bytes = [0u8; 5];
|
||||
|
||||
// Read exactly 5 bytes or fail
|
||||
if file.read_exact(&mut bytes).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
||||
if &bytes[0..4] != b"\x7FELF" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Byte 4 (EI_CLASS) indicates 32-bit or 64-bit
|
||||
// 1 = ELFCLASS32 (32-bit)
|
||||
// 2 = ELFCLASS64 (64-bit)
|
||||
match bytes[4] {
|
||||
1 => Some(Bitness::Bit32),
|
||||
2 => Some(Bitness::Bit64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan game directory for Linux binaries and determine bitness
|
||||
/// Returns the detected bitness, prioritizing the main game executable
|
||||
pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
|
||||
info!("Detecting bitness for game at: {}", game_path);
|
||||
|
||||
let game_path_obj = Path::new(game_path);
|
||||
if !game_path_obj.exists() {
|
||||
return Err("Game path does not exist".to_string());
|
||||
}
|
||||
|
||||
// Directories to skip for performance
|
||||
let skip_dirs = [
|
||||
"videos",
|
||||
"video",
|
||||
"movies",
|
||||
"movie",
|
||||
"sound",
|
||||
"sounds",
|
||||
"audio",
|
||||
"textures",
|
||||
"music",
|
||||
"localization",
|
||||
"shaders",
|
||||
"logs",
|
||||
"assets",
|
||||
"_CommonRedist",
|
||||
"data",
|
||||
"Data",
|
||||
"Docs",
|
||||
"docs",
|
||||
"screenshots",
|
||||
"Screenshots",
|
||||
"saves",
|
||||
"Saves",
|
||||
"mods",
|
||||
"Mods",
|
||||
"maps",
|
||||
"Maps",
|
||||
];
|
||||
|
||||
// Limit scan depth to avoid deep recursion
|
||||
const MAX_DEPTH: usize = 3;
|
||||
|
||||
// Stop after finding reasonable confidence (10 binaries)
|
||||
const CONFIDENCE_THRESHOLD: usize = 10;
|
||||
|
||||
let mut bit64_binaries = Vec::new();
|
||||
let mut bit32_binaries = Vec::new();
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// Scan for Linux binaries
|
||||
for entry in WalkDir::new(game_path_obj)
|
||||
.max_depth(MAX_DEPTH)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
if e.file_type().is_dir() {
|
||||
let dir_name = e.file_name().to_string_lossy().to_lowercase();
|
||||
!skip_dirs.iter().any(|&skip| dir_name.contains(skip))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
// Early termination when we have high confidence
|
||||
if bit64_binaries.len() >= CONFIDENCE_THRESHOLD || bit32_binaries.len() >= CONFIDENCE_THRESHOLD {
|
||||
debug!("Reached confidence threshold, stopping scan early");
|
||||
break;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
// Only check files
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-binary files early for performance
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Check for common Linux executable extensions or shared libraries
|
||||
let has_binary_extension = filename.ends_with(".x86")
|
||||
|| filename.ends_with(".x86_64")
|
||||
|| filename.ends_with(".bin")
|
||||
|| filename.ends_with(".so")
|
||||
|| filename.contains(".so.")
|
||||
|| filename.starts_with("lib");
|
||||
|
||||
// Check if file is executable
|
||||
let is_executable = {
|
||||
{
|
||||
// Get metadata once and check both extension and permissions
|
||||
if let Ok(metadata) = fs::metadata(path) {
|
||||
let permissions = metadata.permissions();
|
||||
let executable = permissions.mode() & 0o111 != 0;
|
||||
|
||||
// Skip files that are neither executable nor have binary extensions
|
||||
executable || has_binary_extension
|
||||
} else {
|
||||
// If we can't read metadata, only proceed if it has binary extension
|
||||
has_binary_extension
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !is_executable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect bitness
|
||||
if let Some(bitness) = detect_binary_bitness(path) {
|
||||
debug!("Found {:?} binary: {}", bitness, path.display());
|
||||
|
||||
match bitness {
|
||||
Bitness::Bit64 => {
|
||||
bit64_binaries.push(path.to_path_buf());
|
||||
|
||||
// If we find libsteam_api.so and it's 64-bit, we can be very confident
|
||||
if filename == "libsteam_api.so" {
|
||||
info!("Found 64-bit libsteam_api.so");
|
||||
return Ok(Bitness::Bit64);
|
||||
}
|
||||
},
|
||||
Bitness::Bit32 => {
|
||||
bit32_binaries.push(path.to_path_buf());
|
||||
|
||||
// If we find libsteam_api.so and it's 32-bit, we can be very confident
|
||||
if filename == "libsteam_api.so" {
|
||||
info!("Found 32-bit libsteam_api.so");
|
||||
return Ok(Bitness::Bit32);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decision logic: prioritize finding the main game executable
|
||||
// 1. If we found any 64-bit binaries and no 32-bit, it's 64-bit
|
||||
// 2. If we found any 32-bit binaries and no 64-bit, it's 32-bit
|
||||
// 3. If we found both, prefer 64-bit (modern games are usually 64-bit)
|
||||
// 4. If we found neither, return an error
|
||||
|
||||
if !bit64_binaries.is_empty() && bit32_binaries.is_empty() {
|
||||
info!("Detected 64-bit game (Only 64-bit binaries found)");
|
||||
Ok(Bitness::Bit64)
|
||||
} else if !bit32_binaries.is_empty() && bit64_binaries.is_empty() {
|
||||
info!("Detected 32-bit game (Only 32-bit binaries found)");
|
||||
Ok(Bitness::Bit32)
|
||||
} else if !bit64_binaries.is_empty() && !bit32_binaries.is_empty() {
|
||||
warn!(
|
||||
"Found both 32-bit and 64-bit binaries, defaulting to 64-bit. 32-bit: {}, 64-bit: {}",
|
||||
bit32_binaries.len(),
|
||||
bit64_binaries.len()
|
||||
);
|
||||
info!("Detected 64-bit game (mixed binaries, defaulting to 64-bit)");
|
||||
Ok(Bitness::Bit64)
|
||||
} else {
|
||||
Err("Could not detect game bitness: no Linux binaries found".to_string())
|
||||
}
|
||||
}
|
||||
1
src-tauri/src/utils/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod bitness;
|
||||
@@ -10,24 +10,17 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Utility",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
|
||||
"icon": [
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
"version": "1.0.0",
|
||||
"version": "1.5.5",
|
||||
"identifier": "com.creamlinux.dev",
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDNEI1NzBBRDUxODQ3RjEKUldUeFJ4alZDbGRMTE5Vc241NG5yL080UklnaW1iUGdUWElPRXloRGtKZ3M2SWkzK0RGSDh3Q2kK",
|
||||
"endpoints": [
|
||||
"https://github.com/Novattz/creamlinux-installer/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
@@ -44,5 +37,13 @@
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERENzBFNjU0RTBBMUMyNzgKUldSNHdxSGdWT1p3M1liUE0vOGFCRkc2cEQwdWdRR2UyY2VmN3kzckNONCtsaGF0Y1d2WjdOWVEK",
|
||||
"endpoints": [
|
||||
"https://github.com/Novattz/creamlinux-installer/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
146
src/App.tsx
@@ -1,22 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useAppContext } from '@/contexts/useAppContext'
|
||||
import { UpdateNotifier } from '@/components/updater'
|
||||
import { useAppLogic } from '@/hooks'
|
||||
import { useAppLogic, useConflictDetection, useDisclaimer } from '@/hooks'
|
||||
import './styles/main.scss'
|
||||
|
||||
// Layout components
|
||||
import { Header, Sidebar, InitialLoadingScreen, ErrorBoundary } from '@/components/layout'
|
||||
import AnimatedBackground from '@/components/layout/AnimatedBackground'
|
||||
import {
|
||||
Header,
|
||||
Sidebar,
|
||||
InitialLoadingScreen,
|
||||
ErrorBoundary,
|
||||
UpdateScreen,
|
||||
AnimatedBackground,
|
||||
} from '@/components/layout'
|
||||
|
||||
// Dialog components
|
||||
import { ProgressDialog, DlcSelectionDialog } from '@/components/dialogs'
|
||||
import {
|
||||
ProgressDialog,
|
||||
DlcSelectionDialog,
|
||||
SettingsDialog,
|
||||
ConflictDialog,
|
||||
DisclaimerDialog,
|
||||
UnlockerSelectionDialog,
|
||||
} from '@/components/dialogs'
|
||||
|
||||
// Game components
|
||||
import { GameList } from '@/components/games'
|
||||
import { GameList, EpicGameList } from '@/components/games'
|
||||
|
||||
/**
|
||||
* Main application component
|
||||
*/
|
||||
function App() {
|
||||
const [updateComplete, setUpdateComplete] = useState(false)
|
||||
|
||||
const { showDisclaimer, handleDisclaimerClose } = useDisclaimer()
|
||||
|
||||
// Get application logic from hook
|
||||
const {
|
||||
filter,
|
||||
@@ -29,10 +47,11 @@ function App() {
|
||||
handleRefresh,
|
||||
isLoading,
|
||||
error,
|
||||
} = useAppLogic({ autoLoad: true })
|
||||
} = useAppLogic({ autoLoad: updateComplete })
|
||||
|
||||
// Get action handlers from context
|
||||
const {
|
||||
games,
|
||||
dlcDialog,
|
||||
handleDlcDialogClose,
|
||||
handleProgressDialogClose,
|
||||
@@ -40,9 +59,66 @@ function App() {
|
||||
handleGameAction,
|
||||
handleDlcConfirm,
|
||||
handleGameEdit,
|
||||
handleUpdateDlcs,
|
||||
settingsDialog,
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleOpenRating,
|
||||
reportingEnabled,
|
||||
showToast,
|
||||
unlockerSelectionDialog,
|
||||
handleSelectCreamLinux,
|
||||
handleSelectSmokeAPI,
|
||||
closeUnlockerDialog,
|
||||
epicGames,
|
||||
epicLoading,
|
||||
epicInstallingId,
|
||||
loadEpicGames,
|
||||
handleEpicInstall,
|
||||
handleEpicUninstallScream,
|
||||
handleEpicUninstallKoaloader,
|
||||
handleEpicSettings,
|
||||
} = useAppContext()
|
||||
|
||||
// Show loading screen during initial load
|
||||
// Conflict detection
|
||||
const { conflicts, showDialog, resolveConflict, closeDialog } = useConflictDetection(games)
|
||||
|
||||
const handleSetFilter = async (f: string) => {
|
||||
setFilter(f)
|
||||
if (f === 'epic' && epicGames.length === 0 && !epicLoading) {
|
||||
await loadEpicGames()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle conflict resolution
|
||||
const handleConflictResolve = async (
|
||||
gameId: string,
|
||||
conflictType: 'cream-to-proton' | 'smoke-to-native'
|
||||
) => {
|
||||
try {
|
||||
// Invoke backend to resolve the conflict
|
||||
await invoke('resolve_platform_conflict', {
|
||||
gameId,
|
||||
conflictType,
|
||||
})
|
||||
|
||||
// Remove from UI
|
||||
resolveConflict(gameId, conflictType)
|
||||
|
||||
showToast('Conflict resolved successfully', 'success')
|
||||
} catch (error) {
|
||||
console.error('Error resolving conflict:', error)
|
||||
showToast('Failed to resolve conflict', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// Show update screen first
|
||||
if (!updateComplete) {
|
||||
return <UpdateScreen onComplete={() => setUpdateComplete(true)} />
|
||||
}
|
||||
|
||||
// Then show initial loading screen
|
||||
if (isInitialLoad) {
|
||||
return <InitialLoadingScreen message={scanProgress.message} progress={scanProgress.progress} />
|
||||
}
|
||||
@@ -63,10 +139,23 @@ function App() {
|
||||
|
||||
<div className="main-content">
|
||||
{/* Sidebar for filtering */}
|
||||
<Sidebar setFilter={setFilter} currentFilter={filter} />
|
||||
<Sidebar
|
||||
setFilter={handleSetFilter}
|
||||
currentFilter={filter}
|
||||
onSettingsClick={handleSettingsOpen}
|
||||
/>
|
||||
|
||||
{/* Show error or game list */}
|
||||
{error ? (
|
||||
{filter === 'epic' ? (
|
||||
<EpicGameList
|
||||
games={epicGames}
|
||||
isLoading={epicLoading}
|
||||
installingId={epicInstallingId}
|
||||
onInstall={handleEpicInstall}
|
||||
onUninstallScream={handleEpicUninstallScream}
|
||||
onUninstallKoaloader={handleEpicUninstallKoaloader}
|
||||
onSettings={handleEpicSettings}
|
||||
/>
|
||||
) : error ? (
|
||||
<div className="error-message">
|
||||
<h3>Error Loading Games</h3>
|
||||
<p>{error}</p>
|
||||
@@ -78,6 +167,9 @@ function App() {
|
||||
isLoading={isLoading}
|
||||
onAction={handleGameAction}
|
||||
onEdit={handleGameEdit}
|
||||
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
||||
onRate={handleOpenRating}
|
||||
reportingEnabled={reportingEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -97,17 +189,43 @@ function App() {
|
||||
<DlcSelectionDialog
|
||||
visible={dlcDialog.visible}
|
||||
gameTitle={dlcDialog.gameTitle}
|
||||
gameId={dlcDialog.gameId}
|
||||
dlcs={dlcDialog.dlcs}
|
||||
isLoading={dlcDialog.isLoading}
|
||||
isEditMode={dlcDialog.isEditMode}
|
||||
isUpdating={dlcDialog.isUpdating}
|
||||
updateAttempted={dlcDialog.updateAttempted}
|
||||
loadingProgress={dlcDialog.progress}
|
||||
estimatedTimeLeft={dlcDialog.timeLeft}
|
||||
newDlcsCount={dlcDialog.newDlcsCount}
|
||||
onClose={handleDlcDialogClose}
|
||||
onConfirm={handleDlcConfirm}
|
||||
onUpdate={handleUpdateDlcs}
|
||||
/>
|
||||
|
||||
{/* Simple update notifier that uses toast - no UI component */}
|
||||
<UpdateNotifier />
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
|
||||
|
||||
{/* Conflict Detection Dialog */}
|
||||
<ConflictDialog
|
||||
visible={showDialog}
|
||||
conflicts={conflicts}
|
||||
onResolve={handleConflictResolve}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
|
||||
{/* Unlocker Selection Dialog */}
|
||||
<UnlockerSelectionDialog
|
||||
visible={unlockerSelectionDialog.visible}
|
||||
gameId={unlockerSelectionDialog.gameId}
|
||||
gameTitle={unlockerSelectionDialog.gameTitle || ''}
|
||||
onClose={closeUnlockerDialog}
|
||||
onSelectCreamLinux={handleSelectCreamLinux}
|
||||
onSelectSmokeAPI={handleSelectSmokeAPI}
|
||||
/>
|
||||
|
||||
{/* Disclaimer Dialog - Shows AFTER everything is loaded */}
|
||||
<DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
BIN
src/assets/screenshot1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,9 +1,9 @@
|
||||
import { FC } from 'react'
|
||||
import Button, { ButtonVariant } from '../buttons/Button'
|
||||
import { Icon, layers, download } from '@/components/icons'
|
||||
import { Icon, trash, download } from '@/components/icons'
|
||||
|
||||
// Define available action types
|
||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
|
||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' | 'install_unlocker'
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: ActionType
|
||||
@@ -18,7 +18,6 @@ interface ActionButtonProps {
|
||||
* Specialized button for game installation actions
|
||||
*/
|
||||
const ActionButton: FC<ActionButtonProps> = ({
|
||||
action,
|
||||
isInstalled,
|
||||
isWorking,
|
||||
onClick,
|
||||
@@ -29,10 +28,7 @@ const ActionButton: FC<ActionButtonProps> = ({
|
||||
const getButtonText = () => {
|
||||
if (isWorking) return 'Working...'
|
||||
|
||||
const isCream = action.includes('cream')
|
||||
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
||||
|
||||
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
|
||||
return isInstalled ? 'Uninstall' : 'Install'
|
||||
}
|
||||
|
||||
// Map to button variant
|
||||
@@ -45,14 +41,12 @@ const ActionButton: FC<ActionButtonProps> = ({
|
||||
|
||||
// Select appropriate icon based on action type and state
|
||||
const getIconInfo = () => {
|
||||
const isCream = action.includes('cream')
|
||||
|
||||
if (isInstalled) {
|
||||
// Uninstall actions
|
||||
return { name: layers, variant: 'bold' }
|
||||
return { name: trash, variant: 'solid' }
|
||||
} else {
|
||||
// Install actions
|
||||
return { name: download, variant: isCream ? 'bold' : 'outline' }
|
||||
return { name: download, variant: 'solid' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const AnimatedCheckbox = ({
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
|
||||
|
||||
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
|
||||
{checked && <Icon name={check} variant="bold" size="sm" className="checkbox-icon" />}
|
||||
{checked && <Icon name={check} variant="solid" size="sm" className="checkbox-icon" />}
|
||||
</span>
|
||||
|
||||
{(label || sublabel) && (
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
fullWidth?: boolean
|
||||
iconOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,7 @@ const Button: FC<ButtonProps> = ({
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
iconOnly = false,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
@@ -43,11 +45,14 @@ const Button: FC<ButtonProps> = ({
|
||||
warning: 'btn-warning',
|
||||
}[variant]
|
||||
|
||||
// Determine if this is an icon-only button
|
||||
const isIconOnly = iconOnly || (!children && (leftIcon || rightIcon))
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`btn ${variantClass} ${sizeClass} ${fullWidth ? 'btn-full' : ''} ${
|
||||
isLoading ? 'btn-loading' : ''
|
||||
} ${className}`}
|
||||
} ${isIconOnly ? 'btn-icon-only' : ''} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
@@ -58,10 +63,10 @@ const Button: FC<ButtonProps> = ({
|
||||
)}
|
||||
|
||||
{leftIcon && !isLoading && <span className="btn-icon btn-icon-left">{leftIcon}</span>}
|
||||
<span className="btn-text">{children}</span>
|
||||
{children && <span className="btn-text">{children}</span>}
|
||||
{rightIcon && !isLoading && <span className="btn-icon btn-icon-right">{rightIcon}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
export default Button
|
||||
97
src/components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Icon, arrowUp } from '@/components/icons'
|
||||
|
||||
export interface DropdownOption<T = string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
interface DropdownProps<T = string> {
|
||||
label: string
|
||||
description?: string
|
||||
value: T
|
||||
options: DropdownOption<T>[]
|
||||
onChange: (value: T) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown component for selecting from a list of options
|
||||
*/
|
||||
const Dropdown = <T extends string | number | boolean>({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: DropdownProps<T>) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
const handleSelect = (optionValue: T) => {
|
||||
onChange(optionValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`dropdown-container ${className}`}>
|
||||
<div className="dropdown-label-container">
|
||||
<label className="dropdown-label">{label}</label>
|
||||
{description && <p className="dropdown-description">{description}</p>}
|
||||
</div>
|
||||
|
||||
<div className={`dropdown ${disabled ? 'disabled' : ''}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="dropdown-trigger"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="dropdown-value">{selectedOption?.label || 'Select...'}</span>
|
||||
<Icon
|
||||
name={arrowUp}
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className={`dropdown-icon ${isOpen ? 'open' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="dropdown-menu">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
className={`dropdown-option ${option.value === value ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dropdown
|
||||
22
src/components/common/ProgressBar.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface ProgressBarProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple progress bar component
|
||||
*/
|
||||
const ProgressBar = ({ progress }: ProgressBarProps) => {
|
||||
return (
|
||||
<div className="progress-container">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-text">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressBar
|
||||
47
src/components/common/VotesDisplay.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface GameVotes {
|
||||
unlocker: string
|
||||
success: number
|
||||
fail: number
|
||||
}
|
||||
|
||||
interface VotesDisplayProps {
|
||||
votes: GameVotes | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact vote bar shown inside the unlocker selection dialog.
|
||||
* Shows a green/red progress bar with a label, or "No votes yet" when empty.
|
||||
*/
|
||||
const VotesDisplay: React.FC<VotesDisplayProps> = ({ votes }) => {
|
||||
if (!votes || (votes.success === 0 && votes.fail === 0)) {
|
||||
return (
|
||||
<div className="unlocker-votes">
|
||||
<span className="votes-label votes-label--none">No votes yet</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const total = votes.success + votes.fail
|
||||
const pct = Math.round((votes.success / total) * 100)
|
||||
|
||||
const labelClass =
|
||||
pct >= 70 ? 'votes-label--positive' : pct >= 40 ? '' : 'votes-label--negative'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="unlocker-votes"
|
||||
title={`${votes.success} worked · ${votes.fail} didn't work`}
|
||||
>
|
||||
<div className="votes-bar-wrap">
|
||||
<div className="votes-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`votes-label ${labelClass}`}>
|
||||
{pct}% working ({total})
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VotesDisplay
|
||||
@@ -1,3 +1,8 @@
|
||||
export { default as LoadingIndicator } from './LoadingIndicator'
|
||||
export { default as ProgressBar } from './ProgressBar'
|
||||
export { default as Dropdown } from './Dropdown'
|
||||
export { default as VotesDisplay } from './VotesDisplay'
|
||||
|
||||
export type { LoadingSize, LoadingType } from './LoadingIndicator'
|
||||
export type { DropdownOption } from './Dropdown'
|
||||
export type { GameVotes } from './VotesDisplay'
|
||||
93
src/components/dialogs/AddDlcDialog.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Dialog from './Dialog'
|
||||
import DialogHeader from './DialogHeader'
|
||||
import DialogBody from './DialogBody'
|
||||
import DialogFooter from './DialogFooter'
|
||||
import DialogActions from './DialogActions'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { DlcInfo } from '@/types'
|
||||
|
||||
export interface AddDlcDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onAdd: (dlc: DlcInfo) => void
|
||||
existingIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Add DLC Manually dialog
|
||||
* Allows users to manually enter a DLC ID and name when it is
|
||||
* missing from the Steam API and cannot be fetched automatically
|
||||
*/
|
||||
const AddDlcDialog = ({ visible, onClose, onAdd, existingIds }: AddDlcDialogProps) => {
|
||||
const [id, setId] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Reset form state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setId('')
|
||||
setName('')
|
||||
setError('')
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// Validate inputs and add the DLC to the list
|
||||
const handleSubmit = () => {
|
||||
const trimmedId = id.trim()
|
||||
const trimmedName = name.trim()
|
||||
|
||||
if (!trimmedId) return setError('DLC ID is required.')
|
||||
if (!/^\d+$/.test(trimmedId)) return setError('DLC ID must be a number.')
|
||||
if (existingIds.has(trimmedId)) return setError('A DLC with this ID already exists.')
|
||||
if (!trimmedName) return setError('DLC name is required.')
|
||||
|
||||
onAdd({ appid: trimmedId, name: trimmedName, enabled: true })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="small">
|
||||
<DialogHeader onClose={onClose}>
|
||||
<h3>Add DLC Manually</h3>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="add-dlc-form">
|
||||
<div className="add-dlc-field">
|
||||
<label className="add-dlc-label">DLC ID</label>
|
||||
<input
|
||||
type="text"
|
||||
className="add-dlc-input"
|
||||
placeholder="e.g. 1234560"
|
||||
value={id}
|
||||
onChange={(e) => { setId(e.target.value); setError('') }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="add-dlc-field">
|
||||
<label className="add-dlc-label">DLC Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="add-dlc-input"
|
||||
placeholder="e.g. Expansion - My DLC"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError('') }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="add-dlc-error">{error}</p>}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>Add DLC</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddDlcDialog
|
||||
106
src/components/dialogs/ConflictDialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, warning, info } from '@/components/icons'
|
||||
|
||||
export interface Conflict {
|
||||
gameId: string
|
||||
gameTitle: string
|
||||
type: 'cream-to-proton' | 'smoke-to-native'
|
||||
}
|
||||
|
||||
export interface ConflictDialogProps {
|
||||
visible: boolean
|
||||
conflicts: Conflict[]
|
||||
onResolve: (gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native') => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict Dialog component
|
||||
* Shows all conflicts at once with individual resolve buttons
|
||||
*/
|
||||
const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
visible,
|
||||
conflicts,
|
||||
onResolve,
|
||||
onClose,
|
||||
}) => {
|
||||
// Check if any CreamLinux conflicts exist
|
||||
const hasCreamConflicts = conflicts.some((c) => c.type === 'cream-to-proton')
|
||||
|
||||
const getConflictDescription = (type: 'cream-to-proton' | 'smoke-to-native') => {
|
||||
if (type === 'cream-to-proton') {
|
||||
return 'Will remove existing unlocker files and restore the game to a clean state.'
|
||||
} else {
|
||||
return 'Will remove existing unlocker files and restore the game to a clean state.'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} size="large" preventBackdropClose={true}>
|
||||
<DialogHeader hideCloseButton={true}>
|
||||
<div className="conflict-dialog-header">
|
||||
<Icon name={warning} variant="solid" size="lg" />
|
||||
<h3>Unlocker conflicts detected</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="conflict-dialog-body">
|
||||
<p className="conflict-intro">
|
||||
Some games have conflicting unlocker states that need attention.
|
||||
</p>
|
||||
|
||||
<div className="conflict-list">
|
||||
{conflicts.map((conflict) => (
|
||||
<div key={conflict.gameId} className="conflict-item">
|
||||
<div className="conflict-info">
|
||||
<div className="conflict-icon">
|
||||
<Icon name={warning} variant="solid" size="md" />
|
||||
</div>
|
||||
<div className="conflict-details">
|
||||
<h4>{conflict.gameTitle}</h4>
|
||||
<p>{getConflictDescription(conflict.type)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onResolve(conflict.gameId, conflict.type)}
|
||||
className="conflict-resolve-btn"
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{hasCreamConflicts && (
|
||||
<div className="conflict-reminder">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
Remember to remove <code>sh ./cream.sh %command%</code> from Steam launch options
|
||||
after resolving CreamLinux conflicts.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConflictDialog
|
||||
69
src/components/dialogs/DisclaimerDialog.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface DisclaimerDialogProps {
|
||||
visible: boolean
|
||||
onClose: (dontShowAgain: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Disclaimer dialog that appears on app startup
|
||||
* Informs users that CreamLinux manages DLC IDs, not actual DLC files
|
||||
*/
|
||||
const DisclaimerDialog = ({ visible, onClose }: DisclaimerDialogProps) => {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
const handleOkClick = () => {
|
||||
onClose(dontShowAgain)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={() => onClose(false)} size="medium" preventBackdropClose>
|
||||
<DialogHeader hideCloseButton={true}>
|
||||
<div className="disclaimer-header">
|
||||
<h3>Important Notice</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="disclaimer-content">
|
||||
<p>
|
||||
<strong>CreamLinux Installer</strong> does not install any DLC content files.
|
||||
</p>
|
||||
<p>
|
||||
This application manages the <strong>DLC IDs</strong> associated with DLCs you want to
|
||||
use. You must obtain the actual DLC files separately.
|
||||
</p>
|
||||
<p>
|
||||
This tool only configures which DLC IDs are recognized by the game unlockers
|
||||
(CreamLinux and SmokeAPI).
|
||||
</p>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<div className="disclaimer-footer">
|
||||
<AnimatedCheckbox
|
||||
checked={dontShowAgain}
|
||||
onChange={() => setDontShowAgain(!dontShowAgain)}
|
||||
label="Don't show this disclaimer again"
|
||||
/>
|
||||
<Button variant="primary" onClick={handleOkClick}>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisclaimerDialog
|
||||
@@ -4,19 +4,26 @@ import DialogHeader from './DialogHeader'
|
||||
import DialogBody from './DialogBody'
|
||||
import DialogFooter from './DialogFooter'
|
||||
import DialogActions from './DialogActions'
|
||||
import AddDlcDialog from './AddDlcDialog'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { DlcInfo } from '@/types'
|
||||
import { Icon, check, info } from '@/components/icons'
|
||||
|
||||
export interface DlcSelectionDialogProps {
|
||||
visible: boolean
|
||||
gameTitle: string
|
||||
gameId: string
|
||||
dlcs: DlcInfo[]
|
||||
onClose: () => void
|
||||
onConfirm: (selectedDlcs: DlcInfo[]) => void
|
||||
onUpdate?: (gameId: string) => void
|
||||
isLoading: boolean
|
||||
isEditMode?: boolean
|
||||
isUpdating?: boolean
|
||||
updateAttempted?: boolean
|
||||
loadingProgress?: number
|
||||
estimatedTimeLeft?: string
|
||||
newDlcsCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,19 +34,25 @@ export interface DlcSelectionDialogProps {
|
||||
const DlcSelectionDialog = ({
|
||||
visible,
|
||||
gameTitle,
|
||||
gameId,
|
||||
dlcs,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onUpdate,
|
||||
isLoading,
|
||||
isEditMode = false,
|
||||
isUpdating = false,
|
||||
updateAttempted = false,
|
||||
loadingProgress = 0,
|
||||
estimatedTimeLeft = '',
|
||||
newDlcsCount = 0,
|
||||
}: DlcSelectionDialogProps) => {
|
||||
// State for DLC management
|
||||
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectAll, setSelectAll] = useState(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [showAddDlc, setShowAddDlc] = useState(false)
|
||||
|
||||
// Reset dialog state when it opens or closes
|
||||
useEffect(() => {
|
||||
@@ -115,6 +128,11 @@ const DlcSelectionDialog = ({
|
||||
)
|
||||
}, [selectAll])
|
||||
|
||||
// Add a manually-entered DLC to the list
|
||||
const handleAddDlc = useCallback((dlc: DlcInfo) => {
|
||||
setSelectedDlcs((prev) => [...prev, dlc])
|
||||
}, [])
|
||||
|
||||
// Submit selected DLCs to parent component
|
||||
const handleConfirm = useCallback(() => {
|
||||
// Create a deep copy to prevent reference issues
|
||||
@@ -140,91 +158,140 @@ const DlcSelectionDialog = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>{dialogTitle}</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>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>{dialogTitle}</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>
|
||||
</DialogHeader>
|
||||
|
||||
<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 className="dlc-dialog-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dlc-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && loadingProgress > 0 && (
|
||||
<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 className="select-all-container">
|
||||
<AnimatedCheckbox
|
||||
checked={selectAll}
|
||||
onChange={handleToggleSelectAll}
|
||||
label="Select All"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogBody 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>
|
||||
{(isLoading || isUpdating) && loadingProgress > 0 && (
|
||||
<div className="dlc-loading-progress">
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
||||
</div>
|
||||
<div className="loading-details">
|
||||
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
|
||||
{estimatedTimeLeft && (
|
||||
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isLoading && loadingProgress < 10}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleConfirm} disabled={isLoading}>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
<DialogBody 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>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{/* Show update results */}
|
||||
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
|
||||
<>
|
||||
{newDlcsCount > 0 && (
|
||||
<div className="dlc-update-results dlc-update-success">
|
||||
<span className="update-message">
|
||||
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newDlcsCount === 0 && (
|
||||
<div className="dlc-update-results dlc-update-info">
|
||||
<span className="update-message">
|
||||
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={(isLoading || isUpdating) && loadingProgress < 10}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowAddDlc(true)}
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
Add DLC Manually
|
||||
</Button>
|
||||
|
||||
{/* Update button - only show in edit mode */}
|
||||
{isEditMode && onUpdate && (
|
||||
<Button
|
||||
variant="warning"
|
||||
onClick={() => onUpdate(gameId)}
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? 'Updating...' : 'Update DLC List'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
|
||||
<AddDlcDialog
|
||||
visible={showAddDlc}
|
||||
onClose={() => setShowAddDlc(false)}
|
||||
onAdd={handleAddDlc}
|
||||
existingIds={new Set(selectedDlcs.map((d) => d.appid))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
97
src/components/dialogs/EpicUnlockerSelectionDialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import { EpicGame } from '@/types/EpicGame'
|
||||
|
||||
export interface EpicUnlockerSelectionDialogProps {
|
||||
visible: boolean
|
||||
game: EpicGame | null
|
||||
onClose: () => void
|
||||
onSelectScreamAPI: () => void
|
||||
onSelectKoaloader: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocker selection dialog for Epic games.
|
||||
* Recommended: ScreamAPI (direct EOSSDK replacement).
|
||||
* Alternative: Koaloader + ScreamAPI (proxy DLL injection).
|
||||
*/
|
||||
const EpicUnlockerSelectionDialog: React.FC<EpicUnlockerSelectionDialogProps> = ({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
onSelectScreamAPI,
|
||||
onSelectKoaloader,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="unlocker-selection-header">
|
||||
<h3>Choose Unlocker</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="unlocker-selection-content">
|
||||
<p className="game-title-info">
|
||||
Select which unlocker to install for <strong>{game?.title}</strong>:
|
||||
</p>
|
||||
|
||||
<div className="unlocker-options">
|
||||
<div className="unlocker-option recommended">
|
||||
<div className="option-header">
|
||||
<h4>ScreamAPI</h4>
|
||||
<span className="recommended-badge">Recommended</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Replaces the EOS SDK DLL directly with ScreamAPI. Works for most Epic games and
|
||||
requires no additional files. DLC unlocking is automatic.
|
||||
</p>
|
||||
<Button variant="primary" onClick={onSelectScreamAPI} fullWidth>
|
||||
Install ScreamAPI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="unlocker-option">
|
||||
<div className="option-header">
|
||||
<h4>Koaloader + ScreamAPI</h4>
|
||||
<span className="alternative-badge">Alternative</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Uses a proxy DLL to inject ScreamAPI without modifying the EOS SDK. Try this if the
|
||||
recommended method doesn't work for your game.
|
||||
</p>
|
||||
<Button variant="secondary" onClick={onSelectKoaloader} fullWidth>
|
||||
Install Koaloader
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="selection-info">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
You can always uninstall and try the other option if one doesn't work properly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default EpicUnlockerSelectionDialog
|
||||
82
src/components/dialogs/OptInDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
|
||||
interface OptInDialogProps {
|
||||
visible: boolean
|
||||
onAccept: () => void
|
||||
onDecline: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* First-launch opt-in dialog for the compatibility reporting system.
|
||||
* Shown once when the app fully starts. Does not close until the user makes
|
||||
* an explicit choice.
|
||||
*/
|
||||
const OptInDialog: React.FC<OptInDialogProps> = ({ visible, onAccept, onDecline }) => {
|
||||
return (
|
||||
<Dialog visible={visible} onClose={() => {}} size="medium">
|
||||
<DialogHeader onClose={() => {}} hideCloseButton={true}>
|
||||
<h3>Help improve CreamLinux</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="optin-content">
|
||||
|
||||
<p className="optin-intro">
|
||||
CreamLinux can collect anonymous compatibility reports to help users know which
|
||||
games work with CreamLinux and SmokeAPI before they install them.
|
||||
</p>
|
||||
|
||||
<div className="optin-details">
|
||||
<h4>What we collect</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>A one-way anonymous hash</strong> derived from your machine ID, Steam
|
||||
install path, and a locally-stored random salt. <em>This cannot be reversed
|
||||
to identify you</em>, and even we cannot link it to your machine.
|
||||
</li>
|
||||
<li>The Steam App ID of the game you rated.</li>
|
||||
<li>Which unlocker you used (CreamLinux or SmokeAPI).</li>
|
||||
<li>Whether it worked or not.</li>
|
||||
</ul>
|
||||
|
||||
<h4>What we do not collect</h4>
|
||||
<ul>
|
||||
<li>Your username, IP address, or any personally identifiable information.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="optin-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
If you opt out, the local salt will be deleted and no data will ever be sent.
|
||||
You will not be able to submit compatibility votes, but the app works fully
|
||||
without this feature.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onDecline}>
|
||||
No thanks
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onAccept}>
|
||||
Enable reporting
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default OptInDialog
|
||||
164
src/components/dialogs/RatingDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
|
||||
interface LocalReport {
|
||||
game_id: string
|
||||
unlocker: string
|
||||
worked: boolean
|
||||
}
|
||||
|
||||
export interface RatingDialogProps {
|
||||
visible: boolean
|
||||
gameTitle: string
|
||||
gameId: string
|
||||
/** 'creamlinux' | 'smokeapi' – whichever is currently installed */
|
||||
unlocker: 'creamlinux' | 'smokeapi'
|
||||
onClose: () => void
|
||||
onSubmit: (worked: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const UNLOCKER_LABELS: Record<string, string> = {
|
||||
creamlinux: 'CreamLinux',
|
||||
smokeapi: 'SmokeAPI',
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-game rating dialog. Submits exactly one report for the installed unlocker.
|
||||
*/
|
||||
const RatingDialog: React.FC<RatingDialogProps> = ({
|
||||
visible,
|
||||
gameTitle,
|
||||
gameId,
|
||||
unlocker,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
// Which vote the user has already cast for this game+unlocker, if any
|
||||
const [previousVote, setPreviousVote] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
// Reset submit state each time the dialog opens
|
||||
setSubmitted(false)
|
||||
|
||||
// Load the local reports to see if this game+unlocker has already been started
|
||||
invoke<LocalReport[]>('get_local_reports')
|
||||
.then((reports) => {
|
||||
const existing = reports.find(
|
||||
(r) => r.game_id === gameId && r.unlocker === unlocker
|
||||
)
|
||||
setPreviousVote(existing ? existing.worked : null)
|
||||
})
|
||||
.catch(() => setPreviousVote(null))
|
||||
}, [visible, gameId, unlocker])
|
||||
|
||||
const handleSubmit = async (worked: boolean) => {
|
||||
if (submitting || submitted) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit(worked)
|
||||
setSubmitted(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSubmitted(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const label = UNLOCKER_LABELS[unlocker] ?? unlocker
|
||||
|
||||
// A button is "already chosen" if it matches the previous vote
|
||||
const workedAlreadyChosen = previousVote === true
|
||||
const brokenAlreadyChosen = previousVote === false
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleClose} size="small">
|
||||
<DialogHeader onClose={handleClose} hideCloseButton={true}>
|
||||
<h3>Submit rating</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{submitted ? (
|
||||
<div className="rating-submitted">
|
||||
<p>Thanks for your report! Your vote helps other users.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rating-content">
|
||||
<p>
|
||||
You have <strong>{label}</strong> installed for{' '}
|
||||
<strong>{gameTitle}</strong>. Did it work?
|
||||
</p>
|
||||
|
||||
{previousVote !== null && (
|
||||
<p className="rating-subtext">
|
||||
You previously voted <strong>{previousVote ? 'worked' : "didn't work"}</strong>.
|
||||
You can change your vote below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{previousVote === null && (
|
||||
<p className="rating-subtext">
|
||||
Your rating is anonymous and helps other users know if{' '}
|
||||
{label} works with this game.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rating-buttons">
|
||||
<Button
|
||||
variant="success"
|
||||
className={`rating-btn rating-btn--worked${workedAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={submitting || workedAlreadyChosen}
|
||||
title={workedAlreadyChosen ? 'Already voted' : undefined}
|
||||
leftIcon={<Icon name="Check" variant="solid" size="sm" />}
|
||||
>
|
||||
It worked
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
className={`rating-btn rating-btn--broken${brokenAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={submitting || brokenAlreadyChosen}
|
||||
title={brokenAlreadyChosen ? 'Already voted' : undefined}
|
||||
leftIcon={<Icon name="Close" variant="solid" size="sm" />}
|
||||
>
|
||||
Didn't work
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rating-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>Only the result for {label} will be submitted.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
{submitted ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default RatingDialog
|
||||
56
src/components/dialogs/ReminderDialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
|
||||
export interface ReminderDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder Dialog component
|
||||
* Reminds users to remove Steam launch options after removing CreamLinux
|
||||
*/
|
||||
const ReminderDialog: React.FC<ReminderDialogProps> = ({ visible, onClose }) => {
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="small">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="reminder-dialog-header">
|
||||
<Icon name={info} variant="solid" size="lg" />
|
||||
<h3>Reminder</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="reminder-dialog-body">
|
||||
<p>
|
||||
If you added a Steam launch option for CreamLinux, remember to remove it in Steam:
|
||||
</p>
|
||||
<ol className="reminder-steps">
|
||||
<li>Right-click the game in Steam</li>
|
||||
<li>Select "Properties"</li>
|
||||
<li>Go to "Launch Options"</li>
|
||||
<li>Remove the CreamLinux command</li>
|
||||
</ol>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Got it
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReminderDialog
|
||||
209
src/components/dialogs/ScreamAPISettingsDialog.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { Dropdown, DropdownOption } from '@/components/common'
|
||||
|
||||
interface ScreamAPIConfig {
|
||||
$schema: string
|
||||
$version: number
|
||||
logging: boolean
|
||||
log_eos: boolean
|
||||
block_metrics: boolean
|
||||
namespace_id: string
|
||||
default_dlc_status: 'unlocked' | 'locked' | 'original'
|
||||
override_dlc_status: Record<string, string>
|
||||
extra_graphql_endpoints: string[]
|
||||
extra_entitlements: Record<string, string>
|
||||
}
|
||||
|
||||
interface ScreamAPISettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ScreamAPIConfig = {
|
||||
$schema:
|
||||
'https://raw.githubusercontent.com/acidicoala/ScreamAPI/master/res/ScreamAPI.schema.json',
|
||||
$version: 3,
|
||||
logging: false,
|
||||
log_eos: false,
|
||||
block_metrics: false,
|
||||
namespace_id: '',
|
||||
default_dlc_status: 'unlocked',
|
||||
override_dlc_status: {},
|
||||
extra_graphql_endpoints: [],
|
||||
extra_entitlements: {},
|
||||
}
|
||||
|
||||
const DLC_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
|
||||
{ value: 'unlocked', label: 'Unlocked' },
|
||||
{ value: 'locked', label: 'Locked' },
|
||||
{ value: 'original', label: 'Original' },
|
||||
]
|
||||
|
||||
const ScreamAPISettingsDialog = ({
|
||||
visible,
|
||||
onClose,
|
||||
gamePath,
|
||||
gameTitle,
|
||||
}: ScreamAPISettingsDialogProps) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [config, setConfig] = useState<ScreamAPIConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const existingConfig = await invoke<ScreamAPIConfig | null>('read_screamapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
if (existingConfig) {
|
||||
setConfig(existingConfig)
|
||||
setEnabled(true)
|
||||
} else {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
}
|
||||
setHasChanges(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to load ScreamAPI config:', error)
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [gamePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && gamePath) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [visible, gamePath, loadConfig])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (enabled) {
|
||||
await invoke('write_screamapi_config', { gamePath, config })
|
||||
} else {
|
||||
await invoke('delete_screamapi_config', { gamePath })
|
||||
}
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to save ScreamAPI config:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const updateConfig = <K extends keyof ScreamAPIConfig>(key: K, value: ScreamAPIConfig[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleCancel} size="medium">
|
||||
<DialogHeader onClose={handleCancel} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
<h3>ScreamAPI Settings</h3>
|
||||
</div>
|
||||
<p className="dialog-subtitle">{gameTitle}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-settings-content">
|
||||
<div className="settings-section">
|
||||
<AnimatedCheckbox
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
setEnabled(!enabled)
|
||||
setHasChanges(true)
|
||||
}}
|
||||
label="Enable ScreamAPI Configuration"
|
||||
sublabel="Enable this to customise ScreamAPI settings for this game"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
|
||||
<div className="settings-section">
|
||||
<h4>General Settings</h4>
|
||||
|
||||
<Dropdown
|
||||
label="Default DLC Status"
|
||||
description="Specifies the default DLC unlock status"
|
||||
value={config.default_dlc_status}
|
||||
options={DLC_STATUS_OPTIONS}
|
||||
onChange={(value) => updateConfig('default_dlc_status', value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Logging</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.logging}
|
||||
onChange={() => updateConfig('logging', !config.logging)}
|
||||
label="Enable Logging"
|
||||
sublabel="Enables logging to ScreamAPI.log.log file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.log_eos}
|
||||
onChange={() => updateConfig('log_eos', !config.log_eos)}
|
||||
label="Log EOS SDK"
|
||||
sublabel="Intercept and log EOS SDK calls (requires logging enabled)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Privacy</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.block_metrics}
|
||||
onChange={() => updateConfig('block_metrics', !config.block_metrics)}
|
||||
label="Block Metrics"
|
||||
sublabel="Block game analytics/usage reporting to Epic Online Services"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={isLoading || !hasChanges}>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScreamAPISettingsDialog
|
||||
113
src/components/dialogs/SettingsDialog.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, settings } from '@/components/icons'
|
||||
|
||||
interface SettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings Dialog component
|
||||
* Contains application settings and configuration options
|
||||
*/
|
||||
const SettingsDialog: React.FC<SettingsDialogProps> = ({ visible, onClose }) => {
|
||||
const [appVersion, setAppVersion] = useState<string>('Loading...')
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch app version when component mounts
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const version = await getVersion()
|
||||
setAppVersion(version)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch app version:', error)
|
||||
setAppVersion('Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
fetchVersion()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
{/*<Icon name={settings} variant="solid" size="md" />*/}
|
||||
<h3>Settings</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="settings-content">
|
||||
<div className="settings-section">
|
||||
<h4>General Settings</h4>
|
||||
<p className="settings-description">
|
||||
Configure your CreamLinux preferences and application behavior.
|
||||
</p>
|
||||
|
||||
<div className="settings-placeholder">
|
||||
<div className="placeholder-icon"> <Icon name={settings} variant="solid" size="xl" /> </div>
|
||||
<div className="placeholder-text">
|
||||
<h5>Settings Coming Soon</h5>
|
||||
<p>
|
||||
Working on adding customizable settings to improve your experience.
|
||||
Future options may include:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Custom Steam library paths</li>
|
||||
<li>Automatic update settings</li>
|
||||
<li>Scan frequency options</li>
|
||||
<li>DLC catalog</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>About CreamLinux</h4>
|
||||
<div className="app-info">
|
||||
<div className="info-row">
|
||||
<span className="info-label">Version:</span>
|
||||
<span className="info-value">{appVersion}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Build:</span>
|
||||
<span className="info-value">Beta</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Repository:</span>
|
||||
<a
|
||||
href="https://github.com/Novattz/creamlinux-installer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="info-link"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsDialog
|
||||
228
src/components/dialogs/SmokeAPISettingsDialog.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { Dropdown, DropdownOption } from '@/components/common'
|
||||
//import { Icon, settings } from '@/components/icons'
|
||||
|
||||
interface SmokeAPIConfig {
|
||||
$schema: string
|
||||
$version: number
|
||||
logging: boolean
|
||||
log_steam_http: boolean
|
||||
default_app_status: 'unlocked' | 'locked' | 'original'
|
||||
override_app_status: Record<string, string>
|
||||
override_dlc_status: Record<string, string>
|
||||
auto_inject_inventory: boolean
|
||||
extra_inventory_items: number[]
|
||||
extra_dlcs: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SmokeAPISettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SmokeAPIConfig = {
|
||||
$schema:
|
||||
'https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json',
|
||||
$version: 4,
|
||||
logging: false,
|
||||
log_steam_http: false,
|
||||
default_app_status: 'unlocked',
|
||||
override_app_status: {},
|
||||
override_dlc_status: {},
|
||||
auto_inject_inventory: true,
|
||||
extra_inventory_items: [],
|
||||
extra_dlcs: {},
|
||||
}
|
||||
|
||||
const APP_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
|
||||
{ value: 'unlocked', label: 'Unlocked' },
|
||||
{ value: 'locked', label: 'Locked' },
|
||||
{ value: 'original', label: 'Original' },
|
||||
]
|
||||
|
||||
/**
|
||||
* SmokeAPI Settings Dialog
|
||||
* Allows configuration of SmokeAPI for a specific game
|
||||
*/
|
||||
const SmokeAPISettingsDialog = ({
|
||||
visible,
|
||||
onClose,
|
||||
gamePath,
|
||||
gameTitle,
|
||||
}: SmokeAPISettingsDialogProps) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [config, setConfig] = useState<SmokeAPIConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// Load existing config when dialog opens
|
||||
const loadConfig = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const existingConfig = await invoke<SmokeAPIConfig | null>('read_smokeapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
|
||||
if (existingConfig) {
|
||||
setConfig(existingConfig)
|
||||
setEnabled(true)
|
||||
} else {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
}
|
||||
setHasChanges(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to load SmokeAPI config:', error)
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [gamePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && gamePath) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [visible, gamePath, loadConfig])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (enabled) {
|
||||
// Save the config
|
||||
await invoke('write_smokeapi_config', {
|
||||
gamePath,
|
||||
config,
|
||||
})
|
||||
} else {
|
||||
// Delete the config
|
||||
await invoke('delete_smokeapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
}
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to save SmokeAPI config:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const updateConfig = <K extends keyof SmokeAPIConfig>(key: K, value: SmokeAPIConfig[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleCancel} size="medium">
|
||||
<DialogHeader onClose={handleCancel} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
{/*<Icon name={settings} variant="solid" size="md" />*/}
|
||||
<h3>SmokeAPI Settings</h3>
|
||||
</div>
|
||||
<p className="dialog-subtitle">{gameTitle}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-settings-content">
|
||||
{/* Enable/Disable Section */}
|
||||
<div className="settings-section">
|
||||
<AnimatedCheckbox
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
setEnabled(!enabled)
|
||||
setHasChanges(true)
|
||||
}}
|
||||
label="Enable SmokeAPI Configuration"
|
||||
sublabel="Enable this to customize SmokeAPI settings for this game"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Options */}
|
||||
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
|
||||
<div className="settings-section">
|
||||
<h4>General Settings</h4>
|
||||
|
||||
<Dropdown
|
||||
label="Default App Status"
|
||||
description="Specifies the default DLC status"
|
||||
value={config.default_app_status}
|
||||
options={APP_STATUS_OPTIONS}
|
||||
onChange={(value) => updateConfig('default_app_status', value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Logging</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.logging}
|
||||
onChange={() => updateConfig('logging', !config.logging)}
|
||||
label="Enable Logging"
|
||||
sublabel="Enables logging to SmokeAPI.log.log file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.log_steam_http}
|
||||
onChange={() => updateConfig('log_steam_http', !config.log_steam_http)}
|
||||
label="Log Steam HTTP"
|
||||
sublabel="Toggles logging of SteamHTTP traffic"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Inventory</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.auto_inject_inventory}
|
||||
onChange={() =>
|
||||
updateConfig('auto_inject_inventory', !config.auto_inject_inventory)
|
||||
}
|
||||
label="Auto Inject Inventory"
|
||||
sublabel="Automatically inject a list of all registered inventory items when the game queries user inventory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={isLoading || !hasChanges}>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmokeAPISettingsDialog
|
||||
110
src/components/dialogs/SmokeAPIVotesDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||
|
||||
export interface SmokeAPIVotesDialogProps {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown before installing SmokeAPI on a Proton game.
|
||||
* Fetches and displays community votes for SmokeAPI specifically,
|
||||
* then lets the user confirm or cancel the installation.
|
||||
*/
|
||||
const SmokeAPIVotesDialog: React.FC<SmokeAPIVotesDialogProps> = ({
|
||||
visible,
|
||||
gameId,
|
||||
gameTitle,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) => {
|
||||
const [votes, setVotes] = useState<GameVotes | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !gameId) {
|
||||
setVotes(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||
.then((results) => {
|
||||
setVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||
})
|
||||
.catch(() => setVotes(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [visible, gameId])
|
||||
|
||||
const hasVotes = votes && (votes.success > 0 || votes.fail > 0)
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="small">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>Install SmokeAPI</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-votes-content">
|
||||
<p className="smokeapi-votes-game">
|
||||
<strong>{gameTitle}</strong>
|
||||
</p>
|
||||
|
||||
<div className="smokeapi-votes-section">
|
||||
<p className="smokeapi-votes-label">Community compatibility</p>
|
||||
{loading ? (
|
||||
<p className="smokeapi-votes-loading">Fetching votes...</p>
|
||||
) : (
|
||||
<VotesDisplay votes={votes} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && !hasVotes && (
|
||||
<div className="smokeapi-votes-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
No one has rated this game yet. You'll be able to submit a rating after
|
||||
installing.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasVotes && (
|
||||
<div className="smokeapi-votes-notice">
|
||||
<Icon name={info} variant="solid" size="sm" />
|
||||
<span>
|
||||
These ratings are from other CreamLinux users. Results may vary.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onConfirm}>
|
||||
Install anyway
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmokeAPIVotesDialog
|
||||
124
src/components/dialogs/UnlockerSelectionDialog.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||
|
||||
export interface UnlockerSelectionDialogProps {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
onClose: () => void
|
||||
onSelectCreamLinux: () => void
|
||||
onSelectSmokeAPI: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocker Selection Dialog component
|
||||
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games.
|
||||
* Fetches and displays community vote data per unlocker.
|
||||
*/
|
||||
const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
|
||||
visible,
|
||||
gameId,
|
||||
gameTitle,
|
||||
onClose,
|
||||
onSelectCreamLinux,
|
||||
onSelectSmokeAPI,
|
||||
}) => {
|
||||
const [creamVotes, setCreamVotes] = useState<GameVotes | null>(null)
|
||||
const [smokeVotes, setSmokeVotes] = useState<GameVotes | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !gameId) {
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
return
|
||||
}
|
||||
|
||||
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||
.then((results) => {
|
||||
setCreamVotes(results.find((v) => v.unlocker === 'creamlinux') ?? null)
|
||||
setSmokeVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
// Votes are non-critical — silently fall back to "No votes yet"
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
})
|
||||
}, [visible, gameId])
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="unlocker-selection-header">
|
||||
<h3>Choose Unlocker</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="unlocker-selection-content">
|
||||
<p className="game-title-info">
|
||||
Select which unlocker to install for <strong>{gameTitle}</strong>:
|
||||
</p>
|
||||
|
||||
<div className="unlocker-options">
|
||||
<div className="unlocker-option recommended">
|
||||
<div className="option-header">
|
||||
<h4>CreamLinux</h4>
|
||||
<span className="recommended-badge">Recommended</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Native Linux DLC unlocker. Works best with most native Linux games and provides
|
||||
better compatibility.
|
||||
</p>
|
||||
<VotesDisplay votes={creamVotes} />
|
||||
<Button variant="primary" onClick={onSelectCreamLinux} fullWidth>
|
||||
Install CreamLinux
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="unlocker-option">
|
||||
<div className="option-header">
|
||||
<h4>SmokeAPI</h4>
|
||||
<span className="alternative-badge">Alternative</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Cross-platform DLC unlocker. Try this if CreamLinux doesn't work for your game.
|
||||
Automatically fetches DLC information.
|
||||
</p>
|
||||
<VotesDisplay votes={smokeVotes} />
|
||||
<Button variant="secondary" onClick={onSelectSmokeAPI} fullWidth>
|
||||
Install SmokeAPI
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="selection-info">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
You can always uninstall and try the other option if one doesn't work properly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnlockerSelectionDialog
|
||||
@@ -6,6 +6,17 @@ export { default as DialogFooter } from './DialogFooter'
|
||||
export { default as DialogActions } from './DialogActions'
|
||||
export { default as ProgressDialog } from './ProgressDialog'
|
||||
export { default as DlcSelectionDialog } from './DlcSelectionDialog'
|
||||
export { default as AddDlcDialog } from './AddDlcDialog'
|
||||
export { default as SettingsDialog } from './SettingsDialog'
|
||||
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
||||
export { default as ScreamAPISettingsDialog } from './ScreamAPISettingsDialog'
|
||||
export { default as ConflictDialog } from './ConflictDialog'
|
||||
export { default as DisclaimerDialog } from './DisclaimerDialog'
|
||||
export { default as UnlockerSelectionDialog } from './UnlockerSelectionDialog'
|
||||
export { default as OptInDialog } from './OptInDialog'
|
||||
export { default as RatingDialog } from './RatingDialog'
|
||||
export { default as SmokeAPIVotesDialog } from './SmokeAPIVotesDialog'
|
||||
export { default as EpicUnlockerSelectionDialog } from './EpicUnlockerSelectionDialog'
|
||||
|
||||
// Export types
|
||||
export type { DialogProps } from './Dialog'
|
||||
@@ -15,3 +26,8 @@ export type { DialogFooterProps } from './DialogFooter'
|
||||
export type { DialogActionsProps } from './DialogActions'
|
||||
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
|
||||
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
|
||||
export type { AddDlcDialogProps } from './AddDlcDialog'
|
||||
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
|
||||
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
|
||||
export type { RatingDialogProps } from './RatingDialog'
|
||||
export type { SmokeAPIVotesDialogProps } from './SmokeAPIVotesDialog'
|
||||
119
src/components/games/EpicGameItem.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { EpicGame } from '@/types/EpicGame'
|
||||
import { ActionButton, Button } from '@/components/buttons'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
interface EpicGameItemProps {
|
||||
game: EpicGame
|
||||
installing?: boolean
|
||||
onInstall: (game: EpicGame) => void
|
||||
onUninstallScream: (game: EpicGame) => void
|
||||
onUninstallKoaloader: (game: EpicGame) => void
|
||||
onSettings: (game: EpicGame) => void
|
||||
}
|
||||
|
||||
const EpicGameItem = ({
|
||||
game,
|
||||
installing,
|
||||
onInstall,
|
||||
onUninstallScream,
|
||||
onUninstallKoaloader,
|
||||
onSettings,
|
||||
}: EpicGameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (game.box_art_url) {
|
||||
setImageUrl(game.box_art_url)
|
||||
}
|
||||
}, [game.box_art_url])
|
||||
|
||||
const backgroundImage =
|
||||
imageUrl && !hasError
|
||||
? `url(${imageUrl})`
|
||||
: 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
|
||||
const anyInstalled = game.scream_installed || game.koaloader_installed
|
||||
const isWorking = !!installing
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-item-card"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{imageUrl && !hasError && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{ display: 'none' }}
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="game-item-overlay">
|
||||
<div className="game-badges">
|
||||
<span className="status-badge epic">Epic</span>
|
||||
{game.scream_installed && <span className="status-badge smoke">ScreamAPI</span>}
|
||||
{game.koaloader_installed && <span className="status-badge smoke">Koaloader</span>}
|
||||
</div>
|
||||
|
||||
<div className="game-title">
|
||||
<h3>{game.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-actions">
|
||||
{/* Nothing installed - install button */}
|
||||
{!anyInstalled && (
|
||||
<ActionButton
|
||||
action="install_unlocker"
|
||||
isInstalled={false}
|
||||
isWorking={isWorking}
|
||||
onClick={() => { if (!isWorking) onInstall(game) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ScreamAPI installed - uninstall + settings */}
|
||||
{game.scream_installed && (
|
||||
<ActionButton
|
||||
action="uninstall_smoke"
|
||||
isInstalled={true}
|
||||
isWorking={isWorking}
|
||||
onClick={() => { if (!isWorking) onUninstallScream(game) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Koaloader installed - uninstall */}
|
||||
{game.koaloader_installed && (
|
||||
<ActionButton
|
||||
action="uninstall_smoke"
|
||||
isInstalled={true}
|
||||
isWorking={isWorking}
|
||||
onClick={() => { if (!isWorking) onUninstallKoaloader(game) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings button - only for direct ScreamAPI (not Koaloader) */}
|
||||
{game.scream_installed && !game.koaloader_installed && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => onSettings(game)}
|
||||
disabled={isWorking}
|
||||
title="Configure ScreamAPI"
|
||||
className="edit-button settings-icon-button"
|
||||
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EpicGameItem
|
||||
65
src/components/games/EpicGameList.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react'
|
||||
import EpicGameItem from '@/components/games/EpicGameItem'
|
||||
import { EpicGame } from '@/types/EpicGame'
|
||||
import LoadingIndicator from '../common/LoadingIndicator'
|
||||
|
||||
interface EpicGameListProps {
|
||||
games: EpicGame[]
|
||||
isLoading: boolean
|
||||
installingId: string | null
|
||||
onInstall: (game: EpicGame) => void
|
||||
onUninstallScream: (game: EpicGame) => void
|
||||
onUninstallKoaloader: (game: EpicGame) => void
|
||||
onSettings: (game: EpicGame) => void
|
||||
}
|
||||
|
||||
const EpicGameList = ({
|
||||
games,
|
||||
isLoading,
|
||||
installingId,
|
||||
onInstall,
|
||||
onUninstallScream,
|
||||
onUninstallKoaloader,
|
||||
onSettings,
|
||||
}: EpicGameListProps) => {
|
||||
const sortedGames = useMemo(
|
||||
() => [...games].sort((a, b) => a.title.localeCompare(b.title)),
|
||||
[games]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="game-list">
|
||||
<LoadingIndicator type="spinner" size="large" message="Scanning for Epic games..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<h2>Epic Games ({games.length})</h2>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<div className="no-games-message">
|
||||
No Epic games found. Make sure Heroic is installed and has games downloaded.
|
||||
</div>
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map((game) => (
|
||||
<EpicGameItem
|
||||
key={game.app_name}
|
||||
game={game}
|
||||
installing={installingId === game.app_name}
|
||||
onInstall={onInstall}
|
||||
onUninstallScream={onUninstallScream}
|
||||
onUninstallKoaloader={onUninstallKoaloader}
|
||||
onSettings={onSettings}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EpicGameList
|
||||
@@ -2,18 +2,22 @@ import { useState, useEffect } from 'react'
|
||||
import { findBestGameImage } from '@/services/ImageService'
|
||||
import { Game } from '@/types'
|
||||
import { ActionButton, ActionType, Button } from '@/components/buttons'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
interface GameItemProps {
|
||||
game: Game
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
onRate?: (gameId: string) => void
|
||||
reportingEnabled?: boolean // When false/undefined, rate button is not rendered at all.
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual game card component
|
||||
* Displays game information and action buttons
|
||||
*/
|
||||
const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
@@ -49,11 +53,14 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
}, [game.id, imageUrl])
|
||||
|
||||
// Determine if we should show CreamLinux buttons (only for native games)
|
||||
const shouldShowCream = game.native === true
|
||||
const shouldShowCream = game.native && game.cream_installed // Only show if installed (for uninstall)
|
||||
|
||||
// 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
|
||||
|
||||
// Show generic button if nothing installed
|
||||
const shouldShowUnlocker = game.native && !game.cream_installed && !game.smoke_installed
|
||||
|
||||
// Check if this is a Proton game without API files
|
||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
|
||||
|
||||
@@ -69,6 +76,11 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
const handleUnlockerAction = () => {
|
||||
if (game.installing) return
|
||||
onAction(game.id, 'install_unlocker')
|
||||
}
|
||||
|
||||
// Handle edit button click
|
||||
const handleEdit = () => {
|
||||
if (onEdit && game.cream_installed) {
|
||||
@@ -76,6 +88,20 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// SmokeAPI settings handler
|
||||
const handleSmokeAPISettings = () => {
|
||||
if (onSmokeAPISettings && game.smoke_installed) {
|
||||
onSmokeAPISettings(game.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Rating handler
|
||||
const handleRate = () => {
|
||||
if (onRate && (game.cream_installed || game.smoke_installed)) {
|
||||
onRate(game.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine background image
|
||||
const backgroundImage =
|
||||
!isLoading && imageUrl
|
||||
@@ -107,17 +133,27 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
</div>
|
||||
|
||||
<div className="game-actions">
|
||||
{/* Show CreamLinux button only for native games */}
|
||||
{/* Show generic "Install" button for native games with nothing installed */}
|
||||
{shouldShowUnlocker && (
|
||||
<ActionButton
|
||||
action="install_unlocker"
|
||||
isInstalled={false}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleUnlockerAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show CreamLinux uninstall button if CreamLinux is installed */}
|
||||
{shouldShowCream && (
|
||||
<ActionButton
|
||||
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
|
||||
isInstalled={!!game.cream_installed}
|
||||
action="uninstall_cream"
|
||||
isInstalled={true}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleCreamAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
|
||||
{/* Show SmokeAPI button for Proton games OR native games with SmokeAPI installed */}
|
||||
{shouldShowSmoke && (
|
||||
<ActionButton
|
||||
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
||||
@@ -127,6 +163,16 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI uninstall for native games if installed */}
|
||||
{game.native && game.smoke_installed && (
|
||||
<ActionButton
|
||||
action="uninstall_smoke"
|
||||
isInstalled={true}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleSmokeAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show message for Proton games without API files */}
|
||||
{isProtonNoApi && (
|
||||
<div className="api-not-found-message">
|
||||
@@ -142,6 +188,20 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate button */}
|
||||
{(game.cream_installed || game.smoke_installed) && onRate && reportingEnabled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleRate}
|
||||
disabled={!!game.installing}
|
||||
title="Rate compatibility"
|
||||
className="edit-button rate-button"
|
||||
leftIcon={<Icon name="Star" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||
{game.cream_installed && (
|
||||
<Button
|
||||
@@ -150,10 +210,24 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
|
||||
onClick={handleEdit}
|
||||
disabled={!game.cream_installed || !!game.installing}
|
||||
title="Manage DLCs"
|
||||
className="edit-button"
|
||||
>
|
||||
Manage DLCs
|
||||
</Button>
|
||||
className="edit-button settings-icon-button"
|
||||
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if SmokeAPI is installed */}
|
||||
{game.smoke_installed && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleSmokeAPISettings}
|
||||
disabled={!game.smoke_installed || !!game.installing}
|
||||
title="Configure SmokeAPI"
|
||||
className="edit-button settings-icon-button"
|
||||
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,16 @@ interface GameListProps {
|
||||
isLoading: boolean
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
onRate?: (gameId: string) => void
|
||||
reportingEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game list component
|
||||
* Displays games in a grid with search and filtering applied
|
||||
*/
|
||||
const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title
|
||||
@@ -56,7 +59,7 @@ const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map((game) => (
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} />
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} onRate={onRate} reportingEnabled={reportingEnabled} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
export { default as GameList } from './GameList'
|
||||
export { default as GameItem } from './GameItem'
|
||||
export { default as ImagePreloader } from './ImagePreloader'
|
||||
export { default as EpicGameItem } from './EpicGameItem'
|
||||
export { default as EpicGameList } from './EpicGameList'
|
||||
@@ -4,25 +4,25 @@
|
||||
import React from 'react'
|
||||
|
||||
// Import all icon variants
|
||||
import * as OutlineIcons from './ui/outline'
|
||||
import * as BoldIcons from './ui/bold'
|
||||
import * as StrokeIcons from './ui/stroke'
|
||||
import * as SolidIcons from './ui/solid'
|
||||
import * as BrandIcons from './brands'
|
||||
|
||||
export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number
|
||||
export type IconVariant = 'bold' | 'outline' | 'brand' | undefined
|
||||
export type IconName = keyof typeof OutlineIcons | keyof typeof BoldIcons | keyof typeof BrandIcons
|
||||
export type IconVariant = 'solid' | 'stroke' | 'brand' | undefined
|
||||
export type IconName = keyof typeof StrokeIcons | keyof typeof SolidIcons | keyof typeof BrandIcons
|
||||
|
||||
// Sets of icon names by type for determining default variants
|
||||
const BRAND_ICON_NAMES = new Set(Object.keys(BrandIcons))
|
||||
const OUTLINE_ICON_NAMES = new Set(Object.keys(OutlineIcons))
|
||||
const BOLD_ICON_NAMES = new Set(Object.keys(BoldIcons))
|
||||
const STROKE_ICON_NAMES = new Set(Object.keys(StrokeIcons))
|
||||
const SOLID_ICON_NAMES = new Set(Object.keys(SolidIcons))
|
||||
|
||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** Name of the icon to render */
|
||||
name: IconName | string
|
||||
/** Size of the icon */
|
||||
size?: IconSize
|
||||
/** Icon variant - bold, outline, or brand */
|
||||
/** Icon variant - solid, stroke, or brand */
|
||||
variant?: IconVariant | string
|
||||
/** Title for accessibility */
|
||||
title?: string
|
||||
@@ -60,26 +60,26 @@ const getIconComponent = (
|
||||
): React.ComponentType<React.SVGProps<SVGSVGElement>> | null => {
|
||||
// Normalize variant to ensure it's a valid IconVariant
|
||||
const normalizedVariant =
|
||||
variant === 'bold' || variant === 'outline' || variant === 'brand'
|
||||
variant === 'solid' || variant === 'stroke' || variant === 'brand'
|
||||
? (variant as IconVariant)
|
||||
: undefined
|
||||
|
||||
// Try to get the icon from the specified variant
|
||||
switch (normalizedVariant) {
|
||||
case 'outline':
|
||||
return OutlineIcons[name as keyof typeof OutlineIcons] || null
|
||||
case 'bold':
|
||||
return BoldIcons[name as keyof typeof BoldIcons] || null
|
||||
case 'stroke':
|
||||
return StrokeIcons[name as keyof typeof StrokeIcons] || null
|
||||
case 'solid':
|
||||
return SolidIcons[name as keyof typeof SolidIcons] || null
|
||||
case 'brand':
|
||||
return BrandIcons[name as keyof typeof BrandIcons] || null
|
||||
default:
|
||||
// If no variant specified, determine best default
|
||||
if (BRAND_ICON_NAMES.has(name)) {
|
||||
return BrandIcons[name as keyof typeof BrandIcons] || null
|
||||
} else if (OUTLINE_ICON_NAMES.has(name)) {
|
||||
return OutlineIcons[name as keyof typeof OutlineIcons] || null
|
||||
} else if (BOLD_ICON_NAMES.has(name)) {
|
||||
return BoldIcons[name as keyof typeof BoldIcons] || null
|
||||
} else if (STROKE_ICON_NAMES.has(name)) {
|
||||
return StrokeIcons[name as keyof typeof StrokeIcons] || null
|
||||
} else if (SOLID_ICON_NAMES.has(name)) {
|
||||
return SolidIcons[name as keyof typeof SolidIcons] || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// BROKEN
|
||||
|
||||
//import React from 'react'
|
||||
//import Icon from './Icon'
|
||||
//import type { IconProps, IconVariant } from './Icon'
|
||||
//
|
||||
//export const createIconComponent = (
|
||||
// name: string,
|
||||
// defaultVariant: IconVariant = 'outline'
|
||||
//): React.FC<Omit<IconProps, 'name'>> => {
|
||||
// const IconComponent: React.FC<Omit<IconProps, 'name'>> = (props) => {
|
||||
// return (
|
||||
// <Icon
|
||||
// name={name}
|
||||
// variant={(props as any).variant ?? defaultVariant}
|
||||
// {...props}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// IconComponent.displayName = `${name}Icon`
|
||||
// return IconComponent
|
||||
//}
|
||||
//
|
||||
1
src/components/icons/brands/epic.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 1a1.5 1.5 0 0 0-1.5 1.5v16a.5.5 0 0 0 .297.457l9 4a.5.5 0 0 0 .406 0l9-4a.5.5 0 0 0 .297-.457v-16A1.5 1.5 0 0 0 20 1zm10.25 11.75h-1.5v-8.5h1.5zM8 18.5l4 2l4-2zM8 4.25H5.25v8.5H8v-1.5H6.75v-2H8v-1.5H6.75v-2H8zm2.5 0H8.75v8.5h1.5v-2.5h.25a1.75 1.75 0 0 0 1.75-1.75V6a1.75 1.75 0 0 0-1.75-1.75m0 4.5h-.25v-3h.25a.25.25 0 0 1 .25.25v2.5a.25.25 0 0 1-.25.25m4.25-3.25c0-.69.56-1.25 1.25-1.25h1.5c.69 0 1.25.56 1.25 1.25v2h-1.5V5.75h-1v5.5h1V9.5h1.5v2c0 .69-.56 1.25-1.25 1.25H16c-.69 0-1.25-.56-1.25-1.25zM5.5 16.25h12v-1.5h-12z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 680 B |
@@ -5,3 +5,4 @@ export { ReactComponent as Windows } from './windows.svg'
|
||||
export { ReactComponent as Github } from './github.svg'
|
||||
export { ReactComponent as Discord } from './discord.svg'
|
||||
export { ReactComponent as Proton } from './proton.svg'
|
||||
export { ReactComponent as Epic } from './epic.svg'
|
||||
@@ -3,11 +3,11 @@ export { default as Icon } from './Icon'
|
||||
export type { IconProps, IconSize, IconVariant, IconName } from './Icon'
|
||||
|
||||
// Re-export all icons by category for convenience
|
||||
import * as OutlineIcons from './ui/outline'
|
||||
import * as BoldIcons from './ui/bold'
|
||||
import * as StrokeIcons from './ui/stroke'
|
||||
import * as SolidIcons from './ui/solid'
|
||||
import * as BrandIcons from './brands'
|
||||
|
||||
export { OutlineIcons, BoldIcons, BrandIcons }
|
||||
export { StrokeIcons, SolidIcons, BrandIcons }
|
||||
|
||||
// Export individual icon names as constants
|
||||
// UI icons
|
||||
@@ -28,6 +28,8 @@ export const trash = 'Trash'
|
||||
export const warning = 'Warning'
|
||||
export const wine = 'Wine'
|
||||
export const diamond = 'Diamond'
|
||||
export const settings = 'Settings'
|
||||
export const star = 'Star'
|
||||
|
||||
// Brand icons
|
||||
export const discord = 'Discord'
|
||||
@@ -36,6 +38,7 @@ export const linux = 'Linux'
|
||||
export const proton = 'Proton'
|
||||
export const steam = 'Steam'
|
||||
export const windows = 'Windows'
|
||||
export const epic = 'Epic'
|
||||
|
||||
// Keep the IconNames object for backward compatibility and autocompletion
|
||||
export const IconNames = {
|
||||
@@ -57,6 +60,8 @@ export const IconNames = {
|
||||
Warning: warning,
|
||||
Wine: wine,
|
||||
Diamond: diamond,
|
||||
Settings: settings,
|
||||
Star: star,
|
||||
|
||||
// Brand icons
|
||||
Discord: discord,
|
||||
@@ -65,6 +70,7 @@ export const IconNames = {
|
||||
Proton: proton,
|
||||
Steam: steam,
|
||||
Windows: windows,
|
||||
Epic: epic,
|
||||
} as const
|
||||
|
||||
// Export direct icon components using createIconComponent from IconFactory
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m6-8l-6-6m-6 6l6-6"/></svg>
|
||||
|
Before Width: | Height: | Size: 225 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m9.55 15.15l8.475-8.475q.3-.3.7-.3t.7.3t.3.713t-.3.712l-9.175 9.2q-.3.3-.7.3t-.7-.3L4.55 13q-.3-.3-.288-.712t.313-.713t.713-.3t.712.3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 255 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m12 13.4l-4.9 4.9q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7l4.9-4.9l-4.9-4.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l4.9 4.9l4.9-4.9q.275-.275.7-.275t.7.275t.275.7t-.275.7L13.4 12l4.9 4.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275z"/></svg>
|
||||
|
Before Width: | Height: | Size: 352 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M4.725 20q-1.5 0-2.562-1.075t-1.113-2.6q0-.225.025-.45t.075-.45l2.1-8.4q.35-1.35 1.425-2.187T7.125 4h9.75q1.375 0 2.45.838t1.425 2.187l2.1 8.4q.05.225.088.463t.037.462q0 1.525-1.088 2.588T19.276 20q-1.05 0-1.95-.55t-1.35-1.5l-.7-1.45q-.125-.25-.375-.375T14.375 16h-4.75q-.275 0-.525.125t-.375.375l-.7 1.45q-.45.95-1.35 1.5t-1.95.55m8.775-9q.425 0 .713-.288T14.5 10t-.288-.712T13.5 9t-.712.288T12.5 10t.288.713t.712.287m2-2q.425 0 .713-.288T16.5 8t-.288-.712T15.5 7t-.712.288T14.5 8t.288.713T15.5 9m0 4q.425 0 .713-.288T16.5 12t-.288-.712T15.5 11t-.712.288T14.5 12t.288.713t.712.287m2-2q.425 0 .713-.288T18.5 10t-.288-.712T17.5 9t-.712.288T16.5 10t.288.713t.712.287m-9 1.5q.325 0 .538-.213t.212-.537v-1h1q.325 0 .538-.213T11 10t-.213-.537t-.537-.213h-1v-1q0-.325-.213-.537T8.5 7.5t-.537.213t-.213.537v1h-1q-.325 0-.537.213T6 10t.213.538t.537.212h1v1q0 .325.213.538t.537.212"/></svg>
|
||||
|
Before Width: | Height: | Size: 993 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M15.24 2h-3.894c-1.764 0-3.162 0-4.255.148c-1.126.152-2.037.472-2.755 1.193c-.719.721-1.038 1.636-1.189 2.766C3 7.205 3 8.608 3 10.379v5.838c0 1.508.92 2.8 2.227 3.342c-.067-.91-.067-2.185-.067-3.247v-5.01c0-1.281 0-2.386.118-3.27c.127-.948.413-1.856 1.147-2.593s1.639-1.024 2.583-1.152c.88-.118 1.98-.118 3.257-.118h3.07c1.276 0 2.374 0 3.255.118A3.6 3.6 0 0 0 15.24 2"/><path fill="currentColor" d="M6.6 11.397c0-2.726 0-4.089.844-4.936c.843-.847 2.2-.847 4.916-.847h2.88c2.715 0 4.073 0 4.917.847S21 8.671 21 11.397v4.82c0 2.726 0 4.089-.843 4.936c-.844.847-2.202.847-4.917.847h-2.88c-2.715 0-4.073 0-4.916-.847c-.844-.847-.844-2.21-.844-4.936z"/></svg>
|
||||
|
Before Width: | Height: | Size: 768 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9.2 8.25h5.6L12.15 3h-.3zm2.05 11.85V9.75H2.625zm1.5 0l8.625-10.35H12.75zm3.7-11.85h5.175L19.55 4.1q-.275-.5-.737-.8T17.775 3H13.85zm-14.075 0H7.55L10.15 3H6.225q-.575 0-1.037.3t-.738.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 308 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M21.086 8.804v2.21a.75.75 0 1 1-1.5 0v-2.21a2 2 0 0 0-.13-.76l-7.3 4.38v8.19q.172-.051.33-.14l2.53-1.4a.75.75 0 0 1 1 .29a.75.75 0 0 1-.3 1l-2.52 1.4a3.72 3.72 0 0 1-3.62 0l-6-3.3a3.79 3.79 0 0 1-1.92-3.27v-6.39c0-.669.18-1.325.52-1.9q.086-.155.2-.29l.12-.15a3.45 3.45 0 0 1 1.08-.93l6-3.31a3.81 3.81 0 0 1 3.62 0l6 3.31c.42.231.788.548 1.08.93a1 1 0 0 1 .12.15q.113.135.2.29a3.64 3.64 0 0 1 .49 1.9"/><path fill="currentColor" d="m22.196 17.624l-2 2a1.2 1.2 0 0 1-.39.26a1.1 1.1 0 0 1-.46.1q-.239 0-.46-.09a1.3 1.3 0 0 1-.4-.27l-2-2a.74.74 0 0 1 0-1.06a.75.75 0 0 1 1.06 0l1 1v-3.36a.75.75 0 0 1 1.5 0v3.38l1-1a.75.75 0 0 1 1.079-.02a.75.75 0 0 1-.02 1.08z"/></svg>
|
||||
|
Before Width: | Height: | Size: 778 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 15.575q-.2 0-.375-.062T11.3 15.3l-3.6-3.6q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L11 12.15V5q0-.425.288-.712T12 4t.713.288T13 5v7.15l1.875-1.875q.3-.3.713-.288t.712.313q.275.3.288.7t-.288.7l-3.6 3.6q-.15.15-.325.213t-.375.062M6 20q-.825 0-1.412-.587T4 18v-2q0-.425.288-.712T5 15t.713.288T6 16v2h12v-2q0-.425.288-.712T19 15t.713.288T20 16v2q0 .825-.587 1.413T18 20z"/></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M4 21q-.425 0-.712-.288T3 20v-2.425q0-.4.15-.763t.425-.637L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.437.65T21 6.4q0 .4-.138.763t-.437.662l-12.6 12.6q-.275.275-.638.425t-.762.15zM17.6 7.8L19 6.4L17.6 5l-1.4 1.4z"/></svg>
|
||||
|
Before Width: | Height: | Size: 358 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 17q.425 0 .713-.288T13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17m0-4q.425 0 .713-.288T13 12V8q0-.425-.288-.712T12 7t-.712.288T11 8v4q0 .425.288.713T12 13m0 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>
|
||||
|
Before Width: | Height: | Size: 453 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 17q.425 0 .713-.288T13 16v-4q0-.425-.288-.712T12 11t-.712.288T11 12v4q0 .425.288.713T12 17m0-8q.425 0 .713-.288T13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9m0 13q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>
|
||||
|
Before Width: | Height: | Size: 453 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M4.025 14.85q-.4-.3-.387-.787t.412-.788q.275-.2.6-.2t.6.2L12 18.5l6.75-5.225q.275-.2.6-.2t.6.2q.4.3.413.787t-.388.788l-6.75 5.25q-.55.425-1.225.425t-1.225-.425zm6.75.2l-5.75-4.475Q4.25 9.975 4.25 9t.775-1.575l5.75-4.475q.55-.425 1.225-.425t1.225.425l5.75 4.475q.775.6.775 1.575t-.775 1.575l-5.75 4.475q-.55.425-1.225.425t-1.225-.425"/></svg>
|
||||
|
Before Width: | Height: | Size: 453 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 20q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4q1.725 0 3.3.712T18 6.75V5q0-.425.288-.712T19 4t.713.288T20 5v5q0 .425-.288.713T19 11h-5q-.425 0-.712-.288T13 10t.288-.712T14 9h3.2q-.8-1.4-2.187-2.2T12 6Q9.5 6 7.75 7.75T6 12t1.75 4.25T12 18q1.7 0 3.113-.862t2.187-2.313q.2-.35.563-.487t.737-.013q.4.125.575.525t-.025.75q-1.025 2-2.925 3.2T12 20"/></svg>
|
||||
|
Before Width: | Height: | Size: 464 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l5.6 5.6q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-5.6-5.6q-.75.6-1.725.95T9.5 16m0-2q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M20 6a1 1 0 0 1 .117 1.993L20 8h-.081L19 19a3 3 0 0 1-2.824 2.995L16 22H8c-1.598 0-2.904-1.249-2.992-2.75l-.005-.167L4.08 8H4a1 1 0 0 1-.117-1.993L4 6zm-6-4a2 2 0 0 1 2 2a1 1 0 0 1-1.993.117L14 4h-4l-.007.117A1 1 0 0 1 8 4a2 2 0 0 1 1.85-1.995L10 2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 370 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M2.725 21q-.275 0-.5-.137t-.35-.363t-.137-.488t.137-.512l9.25-16q.15-.25.388-.375T12 3t.488.125t.387.375l9.25 16q.15.25.138.513t-.138.487t-.35.363t-.5.137zM12 18q.425 0 .713-.288T13 17t-.288-.712T12 16t-.712.288T11 17t.288.713T12 18m0-3q.425 0 .713-.288T13 14v-3q0-.425-.288-.712T12 10t-.712.288T11 11v3q0 .425.288.713T12 15"/></svg>
|
||||
|
Before Width: | Height: | Size: 445 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M11.47 3.47a.75.75 0 0 1 1.06 0l6 6a.75.75 0 1 1-1.06 1.06l-4.72-4.72V20a.75.75 0 0 1-1.5 0V5.81l-4.72 4.72a.75.75 0 1 1-1.06-1.06z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 292 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m9.55 15.88l8.802-8.801q.146-.146.344-.156t.363.156t.166.357t-.165.356l-8.944 8.95q-.243.243-.566.243t-.566-.243l-4.05-4.05q-.146-.146-.152-.347t.158-.366t.357-.165t.357.165z"/></svg>
|
||||
|
Before Width: | Height: | Size: 295 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m12 12.708l-5.246 5.246q-.14.14-.344.15t-.364-.15t-.16-.354t.16-.354L11.292 12L6.046 6.754q-.14-.14-.15-.344t.15-.364t.354-.16t.354.16L12 11.292l5.246-5.246q.14-.14.345-.15q.203-.01.363.15t.16.354t-.16.354L12.708 12l5.246 5.246q.14.14.15.345q.01.203-.15.363t-.354.16t-.354-.16z"/></svg>
|
||||
|
Before Width: | Height: | Size: 398 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M4.725 20q-1.5 0-2.562-1.075t-1.113-2.6q0-.225.025-.45t.075-.45l2.1-8.4q.35-1.35 1.425-2.187T7.125 4h9.75q1.375 0 2.45.838t1.425 2.187l2.1 8.4q.05.225.088.463t.037.462q0 1.525-1.088 2.588T19.276 20q-1.05 0-1.95-.55t-1.35-1.5l-.7-1.45q-.125-.25-.375-.375T14.375 16h-4.75q-.275 0-.525.125t-.375.375l-.7 1.45q-.45.95-1.35 1.5t-1.95.55m.075-2q.475 0 .863-.25t.587-.675l.7-1.425q.375-.775 1.1-1.213T9.625 14h4.75q.85 0 1.575.45t1.125 1.2l.7 1.425q.2.425.588.675t.862.25q.7 0 1.2-.462t.525-1.163q0 .025-.05-.475l-2.1-8.375q-.175-.675-.7-1.1T16.875 6h-9.75q-.7 0-1.237.425t-.688 1.1L3.1 15.9q-.05.15-.05.45q0 .7.513 1.175T4.8 18m8.7-7q.425 0 .713-.287T14.5 10t-.288-.712T13.5 9t-.712.288T12.5 10t.288.713t.712.287m2-2q.425 0 .713-.288T16.5 8t-.288-.712T15.5 7t-.712.288T14.5 8t.288.713T15.5 9m0 4q.425 0 .713-.288T16.5 12t-.288-.712T15.5 11t-.712.288T14.5 12t.288.713t.712.287m2-2q.425 0 .713-.288T18.5 10t-.288-.712T17.5 9t-.712.288T16.5 10t.288.713t.712.287m-9 1.5q.325 0 .538-.213t.212-.537v-1h1q.325 0 .538-.213T11 10t-.213-.537t-.537-.213h-1v-1q0-.325-.213-.537T8.5 7.5t-.537.213t-.213.537v1h-1q-.325 0-.537.213T6 10t.213.538t.537.212h1v1q0 .325.213.538t.537.212M12 12"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M15 1.25h-4.056c-1.838 0-3.294 0-4.433.153c-1.172.158-2.121.49-2.87 1.238c-.748.749-1.08 1.698-1.238 2.87c-.153 1.14-.153 2.595-.153 4.433V16a3.75 3.75 0 0 0 3.166 3.705c.137.764.402 1.416.932 1.947c.602.602 1.36.86 2.26.982c.867.116 1.97.116 3.337.116h3.11c1.367 0 2.47 0 3.337-.116c.9-.122 1.658-.38 2.26-.982s.86-1.36.982-2.26c.116-.867.116-1.97.116-3.337v-5.11c0-1.367 0-2.47-.116-3.337c-.122-.9-.38-1.658-.982-2.26c-.531-.53-1.183-.795-1.947-.932A3.75 3.75 0 0 0 15 1.25m2.13 3.021A2.25 2.25 0 0 0 15 2.75h-4c-1.907 0-3.261.002-4.29.14c-1.005.135-1.585.389-2.008.812S4.025 4.705 3.89 5.71c-.138 1.029-.14 2.383-.14 4.29v6a2.25 2.25 0 0 0 1.521 2.13c-.021-.61-.021-1.3-.021-2.075v-5.11c0-1.367 0-2.47.117-3.337c.12-.9.38-1.658.981-2.26c.602-.602 1.36-.86 2.26-.981c.867-.117 1.97-.117 3.337-.117h3.11c.775 0 1.464 0 2.074.021M7.408 6.41c.277-.277.665-.457 1.4-.556c.754-.101 1.756-.103 3.191-.103h3c1.435 0 2.436.002 3.192.103c.734.099 1.122.28 1.399.556c.277.277.457.665.556 1.4c.101.754.103 1.756.103 3.191v5c0 1.435-.002 2.436-.103 3.192c-.099.734-.28 1.122-.556 1.399c-.277.277-.665.457-1.4.556c-.755.101-1.756.103-3.191.103h-3c-1.435 0-2.437-.002-3.192-.103c-.734-.099-1.122-.28-1.399-.556c-.277-.277-.457-.665-.556-1.4c-.101-.755-.103-1.756-.103-3.191v-5c0-1.435.002-2.437.103-3.192c.099-.734.28-1.122.556-1.399" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 19.875q-.425 0-.825-.187t-.7-.538L2.825 10q-.225-.275-.337-.6t-.113-.675q0-.225.038-.462t.162-.438L4.45 4.1q.275-.5.738-.8T6.225 3h11.55q.575 0 1.038.3t.737.8l1.875 3.725q.125.2.163.437t.037.463q0 .35-.112.675t-.338.6l-7.65 9.15q-.3.35-.7.538t-.825.187M9.625 8h4.75l-1.5-3h-1.75zM11 16.675V10H5.45zm2 0L18.55 10H13zM16.6 8h2.65l-1.5-3H15.1zM4.75 8H7.4l1.5-3H6.25z"/></svg>
|
||||
|
Before Width: | Height: | Size: 488 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path stroke-linejoin="round" d="M20.935 11.009V8.793a2.98 2.98 0 0 0-1.529-2.61l-5.957-3.307a2.98 2.98 0 0 0-2.898 0L4.594 6.182a2.98 2.98 0 0 0-1.529 2.611v6.414a2.98 2.98 0 0 0 1.529 2.61l5.957 3.307a2.98 2.98 0 0 0 2.898 0l2.522-1.4"/><path stroke-linejoin="round" d="M20.33 6.996L12 12L3.67 6.996M12 21.49V12"/><path stroke-miterlimit="10" d="M19.97 19.245v-5"/><path stroke-linejoin="round" d="m17.676 17.14l1.968 1.968a.46.46 0 0 0 .65 0l1.968-1.968"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 631 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 15.248q-.161 0-.298-.053t-.267-.184l-2.62-2.619q-.146-.146-.152-.344t.152-.363q.166-.166.357-.169q.192-.003.357.163L11.5 13.65V5.5q0-.213.143-.357T12 5t.357.143t.143.357v8.15l1.971-1.971q.146-.146.347-.153t.366.159q.16.165.163.354t-.162.353l-2.62 2.62q-.13.13-.267.183q-.136.053-.298.053M6.616 19q-.691 0-1.153-.462T5 17.384v-1.923q0-.213.143-.356t.357-.144t.357.144t.143.356v1.923q0 .231.192.424t.423.192h10.77q.23 0 .423-.192t.192-.424v-1.923q0-.213.143-.356t.357-.144t.357.144t.143.356v1.923q0 .691-.462 1.153T17.384 19z"/></svg>
|
||||
|
Before Width: | Height: | Size: 648 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-1 2q-.425 0-.712-.288T3 20v-2.425q0-.4.15-.763t.425-.637L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.437.65T21 6.4q0 .4-.138.763t-.437.662l-12.6 12.6q-.275.275-.638.425t-.762.15zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z"/></svg>
|
||||
|
Before Width: | Height: | Size: 417 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 17q.425 0 .713-.288T13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17m0-4q.425 0 .713-.288T13 12V8q0-.425-.288-.712T12 7t-.712.288T11 8v4q0 .425.288.713T12 13m0 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8"/></svg>
|
||||
|
Before Width: | Height: | Size: 539 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 17q.425 0 .713-.288T13 16v-4q0-.425-.288-.712T12 11t-.712.288T11 12v4q0 .425.288.713T12 17m0-8q.425 0 .713-.288T13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9m0 13q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8"/></svg>
|
||||
|
Before Width: | Height: | Size: 539 B |