Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4648a1ad65 | |||
| fc4466048e | |||
| 917b787a72 | |||
| 9645249b05 | |||
| 979be97e3e | |||
| 01b1404b07 | |||
| 3892c58c5d | |||
| 8190b656f6 | |||
| f2c3c326c2 | |||
| 4d7b888bd5 | |||
| db3ed0e138 | |||
| d91cd08164 | |||
| eb05c35931 | |||
| da59ec9898 |
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a report to help improve CreamLinux
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: 'Novattz'
|
||||
---
|
||||
|
||||
## Bug Description
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## Steps To Reproduce
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## System Information
|
||||
|
||||
- OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
|
||||
- Desktop Environment: [e.g. GNOME, KDE, etc.]
|
||||
- CreamLinux Version: [e.g. 0.1.0]
|
||||
- Steam Version: [e.g. latest]
|
||||
- Graphics card: [e.g. 2060 rtx]
|
||||
|
||||
## Game Information
|
||||
|
||||
- Game name:
|
||||
- Game ID (if known):
|
||||
- Native Linux or Proton:
|
||||
- Steam installation path:
|
||||
|
||||
## Additional Context
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
## Logs
|
||||
|
||||
If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
|
||||
|
||||
```
|
||||
Paste log content here
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for CreamLinux
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: 'Novattz'
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Problem This Feature Solves
|
||||
|
||||
Is your feature request related to a problem? Please describe.
|
||||
Ex. I'm always frustrated when [...]
|
||||
|
||||
## Alternatives You've Considered
|
||||
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
## Implementation Ideas (Optional)
|
||||
|
||||
If you have any ideas on how this feature could be implemented, please share them here.
|
||||
@@ -1,300 +0,0 @@
|
||||
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: install nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: update package.nix version, date, and npm hash
|
||||
env:
|
||||
VERSION: ${{ steps.get-version.outputs.version }}
|
||||
run: |
|
||||
# Get today's date in YYYY-MM-DD format
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
|
||||
# Compute new npm deps hash from package-lock.json
|
||||
HASH=$(nix-shell -p prefetch-npm-deps --run "prefetch-npm-deps package-lock.json" 2>/dev/null)
|
||||
echo "New hash: $HASH"
|
||||
|
||||
# Update version string (e.g. 1.5.5-unstable-2026-05-03)
|
||||
sed -i "s|version = \"[^\"]*\"|version = \"${VERSION}-unstable-${TODAY}\"|" package.nix
|
||||
|
||||
# Update npm deps hash
|
||||
sed -i "s|hash = \"[^\"]*\"|hash = \"${HASH}\"|" package.nix
|
||||
|
||||
echo "Updated package.nix:"
|
||||
grep -E 'version|hash' package.nix
|
||||
|
||||
- name: commit updated package.nix
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.nix
|
||||
if [[ $(git status -s) ]]; then
|
||||
git commit -m "chore: update package.nix for v${{ steps.get-version.outputs.version }}"
|
||||
git push
|
||||
fi
|
||||
|
||||
- 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
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- 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
|
||||
run: npm run tauri build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Strip bundled GTK/WebKit/GLib stack, rely on the host's instead
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src-tauri/target/release/bundle/appimage
|
||||
APPIMAGE=$(ls *.AppImage)
|
||||
chmod +x "$APPIMAGE"
|
||||
./"$APPIMAGE" --appimage-extract >/dev/null
|
||||
|
||||
LDCONFIG_CACHE=$(ldconfig -p)
|
||||
resolve_lib_path() {
|
||||
awk -v lib="$1" '$1==lib {print $NF; exit}' <<< "$LDCONFIG_CACHE"
|
||||
}
|
||||
|
||||
# WebKitGTK bundled by the AppImage tooling is pinned to whatever the
|
||||
# build image ships, and drifts out of sync with newer host systems
|
||||
# (freezes on old bundled WebKit, EGL/DMABUF crashes on some newer
|
||||
# ones). The only configuration that's tested clean is deferring the
|
||||
# whole GTK/WebKit/GLib stack to the host's own matched libraries, so
|
||||
# we compute the full dependency closure from the host side (whatever
|
||||
# is actually installed here) and strip every bundled copy that's
|
||||
# also part of that closure.
|
||||
declare -A EXCLUDE
|
||||
for seed in libwebkit2gtk-4.1.so.0 libglib-2.0.so.0 libgio-2.0.so.0 libgobject-2.0.so.0 libgmodule-2.0.so.0; do
|
||||
EXCLUDE[$seed]=1
|
||||
done
|
||||
|
||||
changed=1
|
||||
while [ "$changed" -eq 1 ]; do
|
||||
changed=0
|
||||
for lib in "${!EXCLUDE[@]}"; do
|
||||
hostpath=$(resolve_lib_path "$lib")
|
||||
[ -n "$hostpath" ] && [ -e "$hostpath" ] || continue
|
||||
deps=$(ldd "$hostpath" 2>/dev/null | grep -oE '[a-zA-Z0-9._+-]+\.so[0-9.]*' | sort -u) || true
|
||||
for d in $deps; do
|
||||
if [ -z "${EXCLUDE[$d]:-}" ]; then
|
||||
if find squashfs-root/usr/lib -maxdepth 1 -name "${d}*" 2>/dev/null | grep -q .; then
|
||||
EXCLUDE[$d]=1
|
||||
changed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
echo "Excluding ${#EXCLUDE[@]} bundled libraries, relying on the host's instead:"
|
||||
for lib in "${!EXCLUDE[@]}"; do echo " $lib"; done | sort
|
||||
|
||||
for lib in "${!EXCLUDE[@]}"; do
|
||||
find squashfs-root/usr/lib -maxdepth 1 -name "${lib%.so*}.so*" -delete
|
||||
done
|
||||
|
||||
curl -sSL -o appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
chmod +x appimagetool
|
||||
rm -f "$APPIMAGE"
|
||||
ARCH=x86_64 ./appimagetool --appimage-extract-and-run squashfs-root "$APPIMAGE"
|
||||
rm -rf squashfs-root appimagetool
|
||||
|
||||
- name: Sign artifacts and upload to release
|
||||
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 }}
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src-tauri/target/release/bundle
|
||||
|
||||
APPIMAGE_PATH=$(ls appimage/*.AppImage)
|
||||
DEB_PATH=$(ls deb/*.deb)
|
||||
RPM_PATH=$(ls rpm/*.rpm)
|
||||
|
||||
for f in "$APPIMAGE_PATH" "$DEB_PATH" "$RPM_PATH"; do
|
||||
npx --prefix "$GITHUB_WORKSPACE" tauri signer sign "$f"
|
||||
done
|
||||
|
||||
APPIMAGE_NAME=$(basename "$APPIMAGE_PATH")
|
||||
DEB_NAME=$(basename "$DEB_PATH")
|
||||
RPM_NAME=$(basename "$RPM_PATH")
|
||||
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg pub_date "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
--arg appimage_sig "$(cat "${APPIMAGE_PATH}.sig")" \
|
||||
--arg appimage_url "https://github.com/$REPO/releases/latest/download/$APPIMAGE_NAME" \
|
||||
--arg deb_sig "$(cat "${DEB_PATH}.sig")" \
|
||||
--arg deb_url "https://github.com/$REPO/releases/latest/download/$DEB_NAME" \
|
||||
--arg rpm_sig "$(cat "${RPM_PATH}.sig")" \
|
||||
--arg rpm_url "https://github.com/$REPO/releases/latest/download/$RPM_NAME" \
|
||||
'{
|
||||
version: $version,
|
||||
notes: "",
|
||||
pub_date: $pub_date,
|
||||
platforms: {
|
||||
"linux-x86_64": { signature: $appimage_sig, url: $appimage_url },
|
||||
"linux-x86_64-appimage": { signature: $appimage_sig, url: $appimage_url },
|
||||
"linux-x86_64-deb": { signature: $deb_sig, url: $deb_url },
|
||||
"linux-x86_64-rpm": { signature: $rpm_sig, url: $rpm_url }
|
||||
}
|
||||
}' > latest.json
|
||||
|
||||
gh release upload "v${VERSION}" \
|
||||
"$APPIMAGE_PATH" "${APPIMAGE_PATH}.sig" \
|
||||
"$DEB_PATH" "${DEB_PATH}.sig" \
|
||||
"$RPM_PATH" "${RPM_PATH}.sig" \
|
||||
latest.json \
|
||||
--clobber \
|
||||
--repo "$REPO"
|
||||
|
||||
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
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
name: 'Test Build'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-tauri:
|
||||
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 \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libjavascriptcoregtk-4.1-0 \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
gir1.2-javascriptcoregtk-4.1 \
|
||||
gir1.2-webkit2-4.1 \
|
||||
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
|
||||
run: npm run tauri build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Strip bundled GTK/WebKit/GLib stack, rely on the host's instead
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src-tauri/target/release/bundle/appimage
|
||||
APPIMAGE=$(ls *.AppImage)
|
||||
chmod +x "$APPIMAGE"
|
||||
./"$APPIMAGE" --appimage-extract >/dev/null
|
||||
|
||||
LDCONFIG_CACHE=$(ldconfig -p)
|
||||
resolve_lib_path() {
|
||||
awk -v lib="$1" '$1==lib {print $NF; exit}' <<< "$LDCONFIG_CACHE"
|
||||
}
|
||||
|
||||
# WebKitGTK bundled by the AppImage tooling is pinned to whatever the
|
||||
# build image ships, and drifts out of sync with newer host systems
|
||||
# (freezes on old bundled WebKit, EGL/DMABUF crashes on some newer
|
||||
# ones). The only configuration that's tested clean is deferring the
|
||||
# whole GTK/WebKit/GLib stack to the host's own matched libraries, so
|
||||
# we compute the full dependency closure from the host side (whatever
|
||||
# is actually installed here) and strip every bundled copy that's
|
||||
# also part of that closure.
|
||||
declare -A EXCLUDE
|
||||
for seed in libwebkit2gtk-4.1.so.0 libglib-2.0.so.0 libgio-2.0.so.0 libgobject-2.0.so.0 libgmodule-2.0.so.0; do
|
||||
EXCLUDE[$seed]=1
|
||||
done
|
||||
|
||||
changed=1
|
||||
while [ "$changed" -eq 1 ]; do
|
||||
changed=0
|
||||
for lib in "${!EXCLUDE[@]}"; do
|
||||
hostpath=$(resolve_lib_path "$lib")
|
||||
[ -n "$hostpath" ] && [ -e "$hostpath" ] || continue
|
||||
deps=$(ldd "$hostpath" 2>/dev/null | grep -oE '[a-zA-Z0-9._+-]+\.so[0-9.]*' | sort -u) || true
|
||||
for d in $deps; do
|
||||
if [ -z "${EXCLUDE[$d]:-}" ]; then
|
||||
if find squashfs-root/usr/lib -maxdepth 1 -name "${d}*" 2>/dev/null | grep -q .; then
|
||||
EXCLUDE[$d]=1
|
||||
changed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
echo "Excluding ${#EXCLUDE[@]} bundled libraries, relying on the host's instead:"
|
||||
for lib in "${!EXCLUDE[@]}"; do echo " $lib"; done | sort
|
||||
|
||||
for lib in "${!EXCLUDE[@]}"; do
|
||||
find squashfs-root/usr/lib -maxdepth 1 -name "${lib%.so*}.so*" -delete
|
||||
done
|
||||
|
||||
curl -sSL -o appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
chmod +x appimagetool
|
||||
rm -f "$APPIMAGE"
|
||||
ARCH=x86_64 ./appimagetool --appimage-extract-and-run squashfs-root "$APPIMAGE"
|
||||
rm -rf squashfs-root
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
if-no-files-found: error
|
||||
@@ -1,26 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
docs
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,3 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
src-tauri/target
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
## [1.7.1] - 12-07-2026
|
||||
|
||||
### Fixed
|
||||
- AppImage freezing on any interaction past the Overview page (KDE, GNOME, and others, mainly on Arch, Fedora, PikaOS, Bazzite, and Steam Deck). Caused by the WebKitGTK/GTK/GLib stack bundled into the AppImage drifting out of sync with the host system's own. The AppImage no longer bundles that stack at all and relies on the system's libraries instead
|
||||
|
||||
## [1.7.0] - 11-07-2026
|
||||
|
||||
> A quick word before the changelog: sorry this update took as long as it did. Life, work, and a few other projects got in the way, but it's finally here. This one was mostly about polish, giving the app some life, cleaning up a lot of rough edges, and generally trying to make it feel nicer to use day to day. Between everything in this release, I'm confident enough in where things stand to move this over to the stable channel.
|
||||
>
|
||||
> This isn't the end of it, either there's still plenty I want to overhaul, and performance is next on the list since I know the AppImage build has been running laggy for some people.
|
||||
>
|
||||
> Thanks for sticking with this project, for the kind words, and for actually using it. I honestly didn't expect to hit 100 stars given how much I still had (and have) left to learn and fix along the way and somehow we're past 300 now. That means a lot.
|
||||
|
||||

|
||||
|
||||
### Added
|
||||
- Custom Steam library paths. Add extra folders to scan for Steam games from Settings, for libraries that aren't auto-detected
|
||||
- Redesigned the whole UI toward a flatter, more minimal look, reworked sidebar, top bar, dialogs, buttons, and game cards to remove gradients, glow shadows, and heavy drop shadows in favor of flat colors and subtle borders
|
||||
- New Windows/Fluent-style ring spinner
|
||||
- Overview page: hero totals for Steam/Epic library size, a Native vs Proton composition bar, compact stat chips, a session Recent Activity feed, and a System card showing host OS/CPU/GPU
|
||||
- Settings page: a Compatibility Reporting toggle (previously only choosable once via the first-launch prompt) and a Danger Zone (reset all settings, clear cached unlocker downloads, open the config folder)
|
||||
- Refresh buttons on both the Steam and Epic game list headers, next to each list's heading, instead of one global refresh button in the top bar that only ever refreshed the Steam list
|
||||
- App version/build/repo link moved into a sidebar footer instead of a top bar pill and an Overview section
|
||||
|
||||
### Fixed
|
||||
- Heroic games installed via Flatpak (e.g. on Steam Deck through Discover) were not being detected, since the Epic scanner only checked the native `~/.config/heroic` path and not Flatpak's sandboxed config location
|
||||
- ScreamAPI's cache would never repair itself when incomplete due to a copy-paste bug that re-downloaded SmokeAPI instead
|
||||
- ScreamAPI and Koaloader were never checked for updates after the initial download
|
||||
- The Steam library was scanned twice on every app launch, doubling startup scan time
|
||||
- The auto-update screen's progress bar jumped straight to 100% instead of showing real download progress
|
||||
- Two event listeners (game scan startup, Epic game updates) were torn down and re-subscribed far more often than needed, adding unnecessary overhead and a small window where events could be missed
|
||||
- Installing SmokeAPI on a Proton game via the community-votes confirmation dialog silently skipped the success toast and activity log entry, since that path called the raw installer directly instead of the wrapper that reports the result
|
||||
- `clear_caches` was a no-op stub that didn't actually clear anything. It now deletes the cached CreamLinux/SmokeAPI/ScreamAPI/Koaloader downloads (report history and the anonymous vote identity are left alone, since those aren't a "cache")
|
||||
- The Epic Games page was missing the heading separator/spacing the Steam page had
|
||||
- Epic game cards weren't getting the hover glow effect Steam game cards got
|
||||
- A dead ternary in the game card's image-fallback logic where both branches were identical
|
||||
|
||||
### Changed
|
||||
- Removed dead code (including an unused utils module), an unused dependency, duplicated Koaloader install logic, and updated Rust packages.
|
||||
- Merged the SmokeAPI and ScreamAPI settings dialogs into one generic, config-driven settings dialog
|
||||
- Merged the Steam and Epic unlocker-choice dialogs into one generic dialog
|
||||
- Removed the unused ReminderDialog component and other dead CSS left over from the old glow/gradient styling
|
||||
|
||||
## [1.5.6] - 06-05-2026
|
||||
|
||||
### Added
|
||||
- DLC fetching now uses steamcmd instead of the Steam store API, which was missing DLCs from many games
|
||||
|
||||
## [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,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,185 +1,36 @@
|
||||
# CreamLinux
|
||||
# Steam DLC Fetcher and installer for Linux
|
||||
- Python script designed for linux to automate fetching of DLC id's for steam games and the installation of creamlinux automatically.
|
||||
|
||||
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), SmokeAPI (for Windows games running through Proton) and ScreamAPI (Epic Games).
|
||||
# Features
|
||||
- Automatically fetches and lists DLC's for selected steam game(s) installed on the computer.
|
||||
- Automatically installs creamlinux and its components into selected steam games, excluding and handling specific config files.
|
||||
- Provides a simple cli to navigate and operate the entire process.
|
||||
|
||||
## Watch the demo here:
|
||||
# Demo
|
||||
https://www.youtube.com/watch?v=22LDDUoBvus&ab_channel=Nova
|
||||
|
||||
[](https://www.youtube.com/watch?v=neUDotrqnDM)
|
||||
# To do
|
||||
- Cross reference dlc files and dlc id's. Incase dlc id's and dlc files differ in terms of quantity it will notify the user.
|
||||
- Possibly add functionality to search for dlc files/automatically installing them.
|
||||
- Add the possibility to install cream/smokeapi for games running proton.
|
||||
- Check if the game already has dlc files installed
|
||||
- Gui?
|
||||
|
||||
## Features
|
||||
# Prerequisites
|
||||
- `python 3.x`
|
||||
- `requests` library
|
||||
- `zipfile` library
|
||||
|
||||
- **Auto-discovery**: Automatically finds Steam games installed on your system
|
||||
- **Native support**: Installs CreamLinux for native Linux games
|
||||
- **Proton support**: Installs SmokeAPI for Windows games running through Proton
|
||||
- **Epic Games support**: Installs ScreamAPI for games running through Heroic/Legendary
|
||||
- **DLC management**: Easily select which DLCs to enable
|
||||
- **Modern UI**: Clean, responsive interface that's easy to use
|
||||
|
||||
## Installation
|
||||
|
||||
### AppImage (Recommended)
|
||||
|
||||
1. Download the latest `creamlinux.AppImage` from the [Releases](https://github.com/Novattz/creamlinux-installer/releases) page
|
||||
2. Make it executable:
|
||||
```bash
|
||||
chmod +x creamlinux.AppImage
|
||||
```
|
||||
3. Run it:
|
||||
|
||||
```bash
|
||||
./creamlinux.AppImage
|
||||
```
|
||||
|
||||
For Nvidia users use this command:
|
||||
|
||||
```
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.AppImage
|
||||
```
|
||||
|
||||
### Nix
|
||||
You can add this package to your configuration using `pkgs.fetchFromGitHub`:
|
||||
```nix
|
||||
let
|
||||
creamlinux = import (pkgs.fetchFromGitHub {
|
||||
owner = "Novattz";
|
||||
repo = "creamlinux-installer";
|
||||
rev = "main"; # replace with a commit hash to pin the version
|
||||
hash = ""; # paste the value returned by the error your rebuild will output
|
||||
}) { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ creamlinux ];
|
||||
}
|
||||
```
|
||||
or, using `builtins.fetchTarball`:
|
||||
```nix
|
||||
let
|
||||
creamlinux = import (builtins.fetchTarball {
|
||||
url = "https://github.com/Novattz/creamlinux-installer/archive/main.tar.gz";
|
||||
sha256 = ""; # See above
|
||||
}) { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ creamlinux ];
|
||||
}
|
||||
```
|
||||
alternatively and if you want to pin the package version, using [npins](https://github.com/andir/npins):
|
||||
# Usage
|
||||
- Clone the repo or download the script.
|
||||
- Navigate to the directory containing the script.
|
||||
- Run the script using python.
|
||||
```bash
|
||||
npins add github Novattz creamlinux-installer --branch main
|
||||
```
|
||||
```nix
|
||||
let
|
||||
sources = import ./npins;
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [
|
||||
(import sources.creamlinux-installer { inherit pkgs; })
|
||||
];
|
||||
}
|
||||
```
|
||||
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 = [
|
||||
(import inputs.creamlinux-installer { inherit pkgs; })
|
||||
];
|
||||
```
|
||||
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
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Novattz/creamlinux-installer.git
|
||||
cd creamlinux-installer
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install # or yarn
|
||||
```
|
||||
|
||||
3. Build the application:
|
||||
|
||||
```bash
|
||||
NO_STRIP=true npm run tauri build
|
||||
```
|
||||
|
||||
4. The compiled binary will be available in `src-tauri/target/release/creamlinux`
|
||||
|
||||
### Desktop Integration
|
||||
|
||||
If you're using the AppImage version, you can integrate it into your desktop environment:
|
||||
|
||||
1. Create a desktop entry file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/share/applications
|
||||
```
|
||||
|
||||
2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
|
||||
|
||||
```
|
||||
[Desktop Entry]
|
||||
Name=Creamlinux
|
||||
Exec=/absolute/path/to/CreamLinux.AppImage
|
||||
Icon=/absolute/path/to/creamlinux-icon.png
|
||||
Type=Application
|
||||
Categories=Game;Utility;
|
||||
Comment=DLC Manager for Steam games on Linux
|
||||
```
|
||||
|
||||
3. Update your desktop database so creamlinux appears in your app launcher:
|
||||
|
||||
```bash
|
||||
update-desktop-database ~/.local/share/applications
|
||||
python dlc_fetcher.py
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
# Issues?
|
||||
- Open a issue and attach all relevant errors/logs.
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **Game doesn't load**: Make sure the launch options are correctly set in Steam
|
||||
- **DLCs not showing up**: Try refreshing the game list and reinstalling
|
||||
- **Cannot find Steam**: Ensure Steam is installed and you've launched it at least once
|
||||
|
||||
### Debug Logs
|
||||
|
||||
Logs are stored at: `~/.cache/creamlinux/creamlinux.log`
|
||||
|
||||
Found a bug? Please report it on the [GitHub Issues page](https://github.com/Novattz/creamlinux-installer/issues).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details.
|
||||
|
||||
## Credits
|
||||
|
||||
- [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
|
||||
# Credits
|
||||
- [All credits for creamlinux go to its original author and contributors.](https://github.com/anticitizn/creamlinux)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
pkgs ? import <nixpkgs> { },
|
||||
}:
|
||||
pkgs.callPackage ./package.nix { }
|
||||
@@ -0,0 +1,237 @@
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import zipfile
|
||||
import time
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
LOG_FILE = 'script.log'
|
||||
|
||||
def clear_screen():
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
def fetch_latest_version():
|
||||
try:
|
||||
response = requests.get("https://api.github.com/repos/Novattz/creamlinux-installer/releases/latest")
|
||||
data = response.json()
|
||||
return data['tag_name']
|
||||
except requests.exceptions.RequestException as e:
|
||||
log_message(f"Failed to fetch latest version: {str(e)}")
|
||||
return "Unknown"
|
||||
|
||||
def show_header(app_version):
|
||||
clear_screen()
|
||||
cyan = '\033[96m'
|
||||
reset = '\033[0m'
|
||||
print(f"{cyan}")
|
||||
print(r"""
|
||||
_ _ _ _ _ ____ _____ _ ________ _____
|
||||
| | | | \ | | | / __ \ / ____| |/ / ____| __ \
|
||||
| | | | \| | | | | | | | | ' /| |__ | |__) |
|
||||
| | | | . ` | | | | | | | | < | __| | _ /
|
||||
| |__| | |\ | |___| |__| | |____| . \| |____| | \ \
|
||||
\____/|_| \_|______\____/ \_____|_|\_\______|_| \_\
|
||||
|
||||
""")
|
||||
print(f"""
|
||||
> Made by Tickbase
|
||||
> GitHub: https://github.com/Novattz/creamlinux-installer
|
||||
> Version: {app_version}
|
||||
{reset}
|
||||
""")
|
||||
|
||||
app_version = fetch_latest_version()
|
||||
|
||||
def log_message(message):
|
||||
with open(LOG_FILE, 'a') as log_file:
|
||||
log_file.write(f"{message}\n")
|
||||
print(message)
|
||||
|
||||
def parse_vdf(file_path):
|
||||
library_paths = []
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
paths = re.findall(r'"path"\s*"(.*?)"', content, re.IGNORECASE)
|
||||
library_paths.extend([os.path.normpath(path) for path in paths])
|
||||
except Exception as e:
|
||||
log_message(f"Failed to read {file_path}: {str(e)}")
|
||||
return library_paths
|
||||
|
||||
def find_steam_binary():
|
||||
try:
|
||||
result = subprocess.run(['which', 'steam'], stdout=subprocess.PIPE)
|
||||
steam_path = result.stdout.decode('utf-8').strip()
|
||||
if steam_path:
|
||||
return os.path.dirname(steam_path)
|
||||
except Exception as e:
|
||||
log_message(f"Failed to locate steam binary: {str(e)}")
|
||||
return None
|
||||
|
||||
def find_steam_library_folders():
|
||||
steam_binary_path = find_steam_binary()
|
||||
base_paths = [
|
||||
os.path.expanduser('~/.steam/steam'),
|
||||
os.path.expanduser('~/.local/share/Steam'),
|
||||
os.path.expanduser('~/home/deck/.steam/steam'),
|
||||
os.path.expanduser('~/home/deck/.local/share/Steam'),
|
||||
'/mnt', '/media',
|
||||
'/run/media/mmcblk0p1/steamapps'
|
||||
]
|
||||
if steam_binary_path:
|
||||
base_paths.append(steam_binary_path)
|
||||
|
||||
library_folders = []
|
||||
try:
|
||||
for base_path in base_paths:
|
||||
if os.path.exists(base_path):
|
||||
for root, dirs, files in os.walk(base_path, topdown=True):
|
||||
if 'steamapps' in dirs:
|
||||
steamapps_path = os.path.join(root, 'steamapps')
|
||||
library_folders.append(steamapps_path)
|
||||
vdf_path = os.path.join(steamapps_path, 'libraryfolders.vdf')
|
||||
if os.path.exists(vdf_path):
|
||||
additional_paths = parse_vdf(vdf_path)
|
||||
for path in additional_paths:
|
||||
new_steamapps_path = os.path.join(path, 'steamapps')
|
||||
if os.path.exists(new_steamapps_path):
|
||||
library_folders.append(new_steamapps_path)
|
||||
dirs[:] = [] # Prevent further scanning into subdirectories
|
||||
if not library_folders:
|
||||
raise FileNotFoundError("No Steam library folders found.")
|
||||
except Exception as e:
|
||||
log_message(f"Error finding Steam library folders: {e}")
|
||||
return library_folders
|
||||
|
||||
def find_steam_apps(library_folders):
|
||||
acf_pattern = re.compile(r'^appmanifest_(\d+)\.acf$')
|
||||
games = {}
|
||||
try:
|
||||
for folder in library_folders:
|
||||
if os.path.exists(folder):
|
||||
for item in os.listdir(folder):
|
||||
if acf_pattern.match(item):
|
||||
try:
|
||||
app_id, game_name, install_dir = parse_acf(os.path.join(folder, item))
|
||||
if app_id and game_name:
|
||||
install_path = os.path.join(folder, 'common', install_dir)
|
||||
if os.path.exists(install_path):
|
||||
cream_installed = 'Cream installed' if 'cream.sh' in os.listdir(install_path) else ''
|
||||
games[app_id] = (game_name, cream_installed, install_path)
|
||||
except Exception as e:
|
||||
log_message(f"Error parsing {item}: {e}")
|
||||
if not games:
|
||||
raise FileNotFoundError("No Steam games found.")
|
||||
except Exception as e:
|
||||
log_message(f"Error finding Steam apps: {e}")
|
||||
return games
|
||||
|
||||
def parse_acf(file_path):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
data = file.read()
|
||||
app_id = re.search(r'"appid"\s+"(\d+)"', data)
|
||||
name = re.search(r'"name"\s+"([^"]+)"', data)
|
||||
install_dir = re.search(r'"installdir"\s+"([^"]+)"', data)
|
||||
return app_id.group(1), name.group(1), install_dir.group(1)
|
||||
except Exception as e:
|
||||
log_message(f"Error reading ACF file {file_path}: {e}")
|
||||
return None, None, None
|
||||
|
||||
def fetch_dlc_details(app_id):
|
||||
base_url = f"https://store.steampowered.com/api/appdetails?appids={app_id}"
|
||||
try:
|
||||
response = requests.get(base_url)
|
||||
data = response.json()
|
||||
if app_id not in data or "data" not in data[app_id]:
|
||||
log_message("Error: Unable to fetch game details.")
|
||||
return []
|
||||
game_data = data[app_id]["data"]
|
||||
dlcs = game_data.get("dlc", [])
|
||||
dlc_details = []
|
||||
for dlc_id in dlcs:
|
||||
try:
|
||||
time.sleep(0.3)
|
||||
dlc_url = f"https://store.steampowered.com/api/appdetails?appids={dlc_id}"
|
||||
dlc_response = requests.get(dlc_url)
|
||||
if dlc_response.status_code == 200:
|
||||
dlc_data = dlc_response.json()
|
||||
if str(dlc_id) in dlc_data and "data" in dlc_data[str(dlc_id)]:
|
||||
dlc_name = dlc_data[str(dlc_id)]["data"].get("name", "Unknown DLC")
|
||||
dlc_details.append({"appid": dlc_id, "name": dlc_name})
|
||||
else:
|
||||
log_message(f"Data missing for DLC {dlc_id}")
|
||||
elif dlc_response.status_code == 429:
|
||||
log_message("Rate limited! Please wait before trying again.")
|
||||
time.sleep(10)
|
||||
else:
|
||||
log_message(f"Failed to fetch details for DLC {dlc_id}, Status Code: {dlc_response.status_code}")
|
||||
except Exception as e:
|
||||
log_message(f"Exception for DLC {dlc_id}: {str(e)}")
|
||||
return dlc_details
|
||||
except requests.exceptions.RequestException as e:
|
||||
log_message(f"Failed to fetch DLC details for {app_id}: {e}")
|
||||
return []
|
||||
|
||||
def install_files(app_id, game_install_dir, dlcs, game_name):
|
||||
zip_url = "https://github.com/anticitizn/creamlinux/releases/latest/download/creamlinux.zip"
|
||||
zip_path = os.path.join(game_install_dir, 'creamlinux.zip')
|
||||
try:
|
||||
response = requests.get(zip_url)
|
||||
if response.status_code == 200:
|
||||
with open(zip_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(game_install_dir)
|
||||
os.remove(zip_path)
|
||||
|
||||
cream_sh_path = os.path.join(game_install_dir, 'cream.sh')
|
||||
os.chmod(cream_sh_path, os.stat(cream_sh_path).st_mode | stat.S_IEXEC)
|
||||
|
||||
dlc_list = "\n".join([f"{dlc['appid']} = {dlc['name']}" for dlc in dlcs])
|
||||
cream_api_path = os.path.join(game_install_dir, 'cream_api.ini')
|
||||
with open(cream_api_path, 'w') as f:
|
||||
f.write(f"APPID = {app_id}\n[config]\nissubscribedapp_on_false_use_real = true\n[methods]\ndisable_steamapps_issubscribedapp = false\n[dlc]\n{dlc_list}")
|
||||
print(f"Custom cream_api.ini has been written to {game_install_dir}.")
|
||||
print(f"Installation complete. Set launch options in Steam: 'sh ./cream.sh %command%' for {game_name}.")
|
||||
else:
|
||||
log_message("Failed to download the files needed for installation.")
|
||||
except Exception as e:
|
||||
log_message(f"Failed to install files for {game_name}: {e}")
|
||||
|
||||
def main():
|
||||
show_header(app_version)
|
||||
try:
|
||||
library_folders = find_steam_library_folders()
|
||||
games = find_steam_apps(library_folders)
|
||||
if games:
|
||||
print("Select the game for which you want to fetch DLCs:")
|
||||
games_list = list(games.items())
|
||||
GREEN = '\033[92m'
|
||||
RESET = '\033[0m'
|
||||
for idx, (app_id, (name, cream_status, _)) in enumerate(games_list, 1):
|
||||
if cream_status:
|
||||
print(f"{idx}. {GREEN}{name} (App ID: {app_id}) - Cream installed{RESET}")
|
||||
else:
|
||||
print(f"{idx}. {name} (App ID: {app_id})")
|
||||
|
||||
choice = int(input("Enter the number of the game: ")) - 1
|
||||
if choice < 0 or choice >= len(games_list):
|
||||
raise ValueError("Invalid selection.")
|
||||
selected_app_id, (selected_game_name, _, selected_install_dir) = games_list[choice]
|
||||
print(f"You selected: {selected_game_name} (App ID: {selected_app_id})")
|
||||
|
||||
dlcs = fetch_dlc_details(selected_app_id)
|
||||
if dlcs:
|
||||
print("DLC IDs found:", [dlc['appid'] for dlc in dlcs]) # Only print app IDs for clarity
|
||||
install_files(selected_app_id, selected_install_dir, dlcs, selected_game_name)
|
||||
else:
|
||||
print("No DLCs found for the selected game.")
|
||||
else:
|
||||
print("No Steam games found on this computer or connected drives.")
|
||||
except Exception as e:
|
||||
log_message(f"An error occurred: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,128 +0,0 @@
|
||||
# Adding New Icons to Creamlinux
|
||||
|
||||
This guide explains how to add new icons to the Creamlinux project.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Basic knowledge of SVG files
|
||||
- Node.js and npm installed
|
||||
- Creamlinux project set up
|
||||
|
||||
## Step 1: Find or Create SVG Icons
|
||||
|
||||
You can:
|
||||
|
||||
- Create your own SVG icons using tools like Figma, Sketch, or Illustrator
|
||||
- Download icons from libraries like Heroicons, Material Icons, or Feather Icons
|
||||
- Use existing SVG files
|
||||
|
||||
Ideally, icons should:
|
||||
|
||||
- Be 24x24px or have a viewBox of "0 0 24 24"
|
||||
- Have a consistent style with existing icons
|
||||
- Use stroke-width of 2 for outline variants
|
||||
- Use solid fills for bold variants
|
||||
|
||||
## Step 2: Optimize SVG Files
|
||||
|
||||
We have a script to optimize SVG files for the icon system:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Optimize a single SVG
|
||||
npm run optimize-svg path/to/icon.svg
|
||||
|
||||
# Optimize all SVGs in a directory
|
||||
npm run optimize-svg src/components/icons/ui/outline
|
||||
```
|
||||
|
||||
The optimizer will:
|
||||
|
||||
- Remove unnecessary attributes
|
||||
- Set the viewBox to "0 0 24 24"
|
||||
- Add currentColor for fills/strokes for proper color inheritance
|
||||
- Remove width and height attributes for flexible sizing
|
||||
|
||||
## Step 3: Add SVG Files to the Project
|
||||
|
||||
1. Decide if your icon is a "bold" (filled) or "outline" (stroked) variant
|
||||
2. Place the file in the appropriate directory:
|
||||
- For outline variants: `src/components/icons/ui/outline/`
|
||||
- For bold variants: `src/components/icons/ui/bold/`
|
||||
3. Use a descriptive name like `download.svg` or `settings.svg`
|
||||
|
||||
## Step 4: Export the Icons
|
||||
|
||||
1. Open the index.ts file in the respective directory:
|
||||
|
||||
- `src/components/icons/ui/outline/index.ts` for outline variants
|
||||
- `src/components/icons/ui/bold/index.ts` for bold variants
|
||||
|
||||
2. Add an export statement for your new icon:
|
||||
|
||||
```typescript
|
||||
// For outline variant
|
||||
export { ReactComponent as NewIconOutlineIcon } from './new-icon.svg'
|
||||
|
||||
// For bold variant
|
||||
export { ReactComponent as NewIconBoldIcon } from './new-icon.svg'
|
||||
```
|
||||
|
||||
Use a consistent naming pattern:
|
||||
|
||||
- CamelCase
|
||||
- Descriptive name
|
||||
- Suffix with BoldIcon or OutlineIcon based on variant
|
||||
|
||||
## Step 5: Use the Icon in Your Components
|
||||
|
||||
Now you can use your new icon in any component:
|
||||
|
||||
```tsx
|
||||
import { Icon } from '@/components/icons'
|
||||
import { NewIconOutlineIcon, NewIconBoldIcon } from '@/components/icons'
|
||||
|
||||
// In your component:
|
||||
<Icon icon={NewIconOutlineIcon} size="md" />
|
||||
<Icon icon={NewIconBoldIcon} size="lg" fillColor="var(--primary-color)" />
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Create both variants**: When possible, create both bold and outline variants for consistency.
|
||||
|
||||
2. **Use semantic names**: Name icons based on their meaning, not appearance (e.g., "success" instead of "checkmark").
|
||||
|
||||
3. **Be consistent**: Follow the existing icon style for visual harmony.
|
||||
|
||||
4. **Test different sizes**: Ensure icons look good at all standard sizes: xs, sm, md, lg, xl.
|
||||
|
||||
5. **Optimize manually if needed**: Sometimes automatic optimization may not work perfectly. You might need to manually edit SVG files.
|
||||
|
||||
6. **Add accessibility**: When using icons, provide proper accessibility:
|
||||
|
||||
```tsx
|
||||
<Icon icon={InfoOutlineIcon} title="Additional information" size="md" />
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Problem**: Icon doesn't change color with CSS
|
||||
**Solution**: Make sure your SVG uses `currentColor` for fill or stroke
|
||||
|
||||
**Problem**: Icon looks pixelated
|
||||
**Solution**: Ensure your SVG has a proper viewBox attribute
|
||||
|
||||
**Problem**: Icon sizing is inconsistent
|
||||
**Solution**: Use the standard size props (xs, sm, md, lg, xl) instead of custom sizes
|
||||
|
||||
**Problem**: SVG has complex gradients or effects that don't render correctly
|
||||
**Solution**: Simplify the SVG design; complex effects aren't ideal for UI icons
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [SVGR documentation](https://react-svgr.com/docs/what-is-svgr/)
|
||||
- [SVGO documentation](https://github.com/svg/svgo)
|
||||
- [SVG MDN documentation](https://developer.mozilla.org/en-US/docs/Web/SVG)
|
||||
@@ -1,160 +0,0 @@
|
||||
# Icon Usage Methods
|
||||
|
||||
There are two ways to use icons in Creamlinux, both fully supported and completely interchangeable.
|
||||
|
||||
## Method 1: Using Icon component with name prop
|
||||
|
||||
This approach uses the `Icon` component with a `name` prop:
|
||||
|
||||
```tsx
|
||||
import { Icon, refresh, check, info, steam } from '@/components/icons'
|
||||
|
||||
<Icon name={refresh} />
|
||||
<Icon name={check} variant="bold" />
|
||||
<Icon name={info} size="lg" fillColor="var(--info)" />
|
||||
<Icon name={steam} /> {/* Brand icons auto-detect the variant */}
|
||||
```
|
||||
|
||||
## Method 2: Using direct icon components
|
||||
|
||||
This approach imports pre-configured icon components directly:
|
||||
|
||||
```tsx
|
||||
import { RefreshIcon, CheckBoldIcon, InfoIcon, SteamIcon } from '@/components/icons'
|
||||
|
||||
<RefreshIcon /> {/* Outline variant */}
|
||||
<CheckBoldIcon /> {/* Bold variant */}
|
||||
<InfoIcon size="lg" fillColor="var(--info)" />
|
||||
<SteamIcon /> {/* Brand icon */}
|
||||
```
|
||||
|
||||
## When to use each method
|
||||
|
||||
### Use Method 1 (Icon + name) when:
|
||||
|
||||
- You have dynamic icon selection based on data or state
|
||||
- You want to keep your imports list shorter
|
||||
- You're working with icons in loops or maps
|
||||
- You want to change variants dynamically
|
||||
|
||||
Example of dynamic icon selection:
|
||||
|
||||
```tsx
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
function StatusIndicator({ status }) {
|
||||
const iconName =
|
||||
status === 'success'
|
||||
? 'Check'
|
||||
: status === 'warning'
|
||||
? 'Warning'
|
||||
: status === 'error'
|
||||
? 'Close'
|
||||
: 'Info'
|
||||
|
||||
return <Icon name={iconName} variant="bold" />
|
||||
}
|
||||
```
|
||||
|
||||
### Use Method 2 (direct components) when:
|
||||
|
||||
- You want the most concise syntax
|
||||
- You're using a fixed set of icons that won't change
|
||||
- You want specific variants (like InfoBoldIcon vs InfoIcon)
|
||||
- You prefer more explicit component names in your JSX
|
||||
|
||||
Example of fixed icon usage:
|
||||
|
||||
```tsx
|
||||
import { InfoIcon, CloseIcon } from '@/components/icons'
|
||||
|
||||
function ModalHeader({ title, onClose }) {
|
||||
return (
|
||||
<div className="modal-header">
|
||||
<div className="title">
|
||||
<InfoIcon size="sm" />
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose}>
|
||||
<CloseIcon size="md" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Available Icon Component Exports
|
||||
|
||||
### UI Icons (Outline variant by default)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
ControllerIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
RefreshIcon,
|
||||
SearchIcon,
|
||||
TrashIcon,
|
||||
WarningIcon,
|
||||
WineIcon,
|
||||
} from '@/components/icons'
|
||||
```
|
||||
|
||||
### Bold Variants
|
||||
|
||||
```tsx
|
||||
import { CheckBoldIcon, InfoBoldIcon, WarningBoldIcon } from '@/components/icons'
|
||||
```
|
||||
|
||||
### Brand Icons
|
||||
|
||||
```tsx
|
||||
import { DiscordIcon, GitHubIcon, LinuxIcon, SteamIcon, WindowsIcon } from '@/components/icons'
|
||||
```
|
||||
|
||||
## Combining Methods
|
||||
|
||||
Both methods work perfectly together and can be mixed in the same component:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Icon,
|
||||
refresh, // Method 1
|
||||
CheckBoldIcon, // Method 2
|
||||
} from '@/components/icons'
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<div>
|
||||
<Icon name={refresh} />
|
||||
<CheckBoldIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Props are Identical
|
||||
|
||||
Both methods accept the same props:
|
||||
|
||||
```tsx
|
||||
// These are equivalent:
|
||||
<InfoIcon size="lg" fillColor="blue" className="my-icon" />
|
||||
<Icon name={info} size="lg" fillColor="blue" className="my-icon" />
|
||||
```
|
||||
|
||||
Available props in both cases:
|
||||
|
||||
- `size`: "xs" | "sm" | "md" | "lg" | "xl" | number
|
||||
- `variant`: "outline" | "bold" | "brand" (only for Icon + name method)
|
||||
- `fillColor`: CSS color string
|
||||
- `strokeColor`: CSS color string
|
||||
- `className`: CSS class string
|
||||
- `title`: Accessibility title
|
||||
- ...plus all standard SVG attributes
|
||||
@@ -1,25 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules', 'src-tauri/target'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Creamlinux</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "creamlinux",
|
||||
"private": true,
|
||||
"version": "1.7.1",
|
||||
"type": "module",
|
||||
"author": "Tickbase",
|
||||
"repository": "https://github.com/Novattz/creamlinux-installer",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"optimize-svg": "node scripts/optimize-svg.js",
|
||||
"set-version": "node scripts/set-version.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sass": "^1.89.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^11.0.2",
|
||||
"@svgr/core": "^8.1.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"glob": "^11.1.0",
|
||||
"globals": "^16.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sass-embedded": "^1.86.3",
|
||||
"semantic-release": "^25.0.2",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
cargo-tauri,
|
||||
writeShellScriptBin,
|
||||
stdenv,
|
||||
patchelf,
|
||||
rustPlatform,
|
||||
fetchNpmDeps,
|
||||
nodejs,
|
||||
npmHooks,
|
||||
pkg-config,
|
||||
lib,
|
||||
wrapGAppsHook4,
|
||||
glib-networking,
|
||||
openssl,
|
||||
webkitgtk_4_1,
|
||||
}:
|
||||
let
|
||||
cargoRoot = "src-tauri";
|
||||
src = ./.;
|
||||
|
||||
patchSassEmbedded = writeShellScriptBin "patch-sass-embedded" ''
|
||||
NIX_LD="$(cat ${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
|
||||
${patchelf}/bin/patchelf --set-interpreter "$NIX_LD" "$dart_bin"
|
||||
fi
|
||||
done
|
||||
'';
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "creamlinux-installer";
|
||||
version = "1.7.1-unstable-2026-07-12";
|
||||
inherit src;
|
||||
|
||||
cargoLock.lockFile = ./src-tauri/Cargo.lock;
|
||||
|
||||
npmDeps = fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-h/PAcOP/sBKHYQipL4yIuRlS+mEDwr1hWy4fSnVT00Y=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
cargo-tauri.hook
|
||||
nodejs
|
||||
npmHooks.npmConfigHook
|
||||
pkg-config
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
wrapGAppsHook4
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.isLinux [
|
||||
glib-networking
|
||||
openssl
|
||||
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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* SVG Optimizer for Creamlinux
|
||||
*
|
||||
* This script optimizes SVG files for use in the icon system.
|
||||
* Run it with `node optimize-svg.js path/to/svg`
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import optimize from 'svgo'
|
||||
|
||||
// Check if a file path is provided
|
||||
if (process.argv.length < 3) {
|
||||
console.error('Please provide a path to an SVG file or directory')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const inputPath = process.argv[2]
|
||||
|
||||
// SVGO configuration
|
||||
const svgoConfig = {
|
||||
plugins: [
|
||||
{
|
||||
name: 'preset-default',
|
||||
params: {
|
||||
overrides: {
|
||||
// Keep viewBox attribute
|
||||
removeViewBox: false,
|
||||
// Don't remove IDs
|
||||
cleanupIDs: false,
|
||||
// Don't minify colors
|
||||
convertColors: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Add currentColor for path fill if not specified
|
||||
{
|
||||
name: 'addAttributesToSVGElement',
|
||||
params: {
|
||||
attributes: [
|
||||
{
|
||||
fill: 'currentColor',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Remove width and height
|
||||
{
|
||||
name: 'removeAttrs',
|
||||
params: {
|
||||
attrs: ['width', 'height'],
|
||||
},
|
||||
},
|
||||
// Make sure viewBox is 0 0 24 24 for consistent sizing
|
||||
{
|
||||
name: 'addAttributesToSVGElement',
|
||||
params: {
|
||||
attributes: [
|
||||
{
|
||||
viewBox: '0 0 24 24',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Function to optimize a single SVG file
|
||||
function optimizeSVG(filePath) {
|
||||
try {
|
||||
const svg = fs.readFileSync(filePath, 'utf8')
|
||||
const result = optimize(svg, svgoConfig)
|
||||
|
||||
// Write the optimized SVG back to the file
|
||||
fs.writeFileSync(filePath, result.data)
|
||||
console.log(`✅ Optimized: ${filePath}`)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`❌ Error optimizing ${filePath}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Function to process a directory of SVG files
|
||||
function processDirectory(dirPath) {
|
||||
try {
|
||||
const files = fs.readdirSync(dirPath)
|
||||
let optimizedCount = 0
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dirPath, file)
|
||||
const stat = fs.statSync(filePath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively process subdirectories
|
||||
optimizedCount += processDirectory(filePath)
|
||||
} else if (path.extname(file).toLowerCase() === '.svg') {
|
||||
// Process SVG files
|
||||
if (optimizeSVG(filePath)) {
|
||||
optimizedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return optimizedCount
|
||||
} catch (error) {
|
||||
console.error(`Error processing directory ${dirPath}:`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
try {
|
||||
const stat = fs.statSync(inputPath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const count = processDirectory(inputPath)
|
||||
console.log(`\nOptimized ${count} SVG files in ${inputPath}`)
|
||||
} else if (path.extname(inputPath).toLowerCase() === '.svg') {
|
||||
optimizeSVG(inputPath)
|
||||
} else {
|
||||
console.error('The provided path is not an SVG file or directory')
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/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,5 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
/resources/
|
||||
@@ -1,41 +0,0 @@
|
||||
[package]
|
||||
name = "creamlinux-installer"
|
||||
version = "1.7.1"
|
||||
description = "DLC Manager for Steam games on Linux"
|
||||
authors = ["tickbase"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Novattz/creamlinux-installer"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
regex = "1"
|
||||
xdg = "2"
|
||||
log = "0.4"
|
||||
log4rs = "1.2"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
zip = "0.6"
|
||||
tempfile = "3.8"
|
||||
walkdir = "2.3"
|
||||
parking_lot = "0.12"
|
||||
tauri = { version = "2.5.0", features = [] }
|
||||
tauri-plugin-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.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.7.1"
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "updater:default", "process:default", "dialog:default"]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 167 KiB |
@@ -1,437 +0,0 @@
|
||||
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,
|
||||
clear_unlocker_cache,
|
||||
};
|
||||
|
||||
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_screamapi = 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_screamapi && !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);
|
||||
}
|
||||
}
|
||||
|
||||
// Check ScreamAPI
|
||||
let current_screamapi = read_versions()?.screamapi.latest;
|
||||
match ScreamAPI::get_latest_version().await {
|
||||
Ok(latest_version) => {
|
||||
if current_screamapi != latest_version {
|
||||
info!(
|
||||
"ScreamAPI update available: {} -> {}",
|
||||
current_screamapi, latest_version
|
||||
);
|
||||
|
||||
match ScreamAPI::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
update_screamapi_version(&version)?;
|
||||
result.screamapi_updated = true;
|
||||
result.new_screamapi_version = Some(version);
|
||||
info!("ScreamAPI updated successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download ScreamAPI update: {}", e);
|
||||
return Err(format!("Failed to download ScreamAPI update: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("ScreamAPI is up to date: {}", current_screamapi);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to check ScreamAPI version: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Koaloader
|
||||
let current_koaloader = read_versions()?.koaloader.latest;
|
||||
match Koaloader::get_latest_version().await {
|
||||
Ok(latest_version) => {
|
||||
if current_koaloader != latest_version {
|
||||
info!(
|
||||
"Koaloader update available: {} -> {}",
|
||||
current_koaloader, latest_version
|
||||
);
|
||||
|
||||
match Koaloader::download_to_cache().await {
|
||||
Ok(version) => {
|
||||
update_koaloader_version(&version)?;
|
||||
result.koaloader_updated = true;
|
||||
result.new_koaloader_version = Some(version);
|
||||
info!("Koaloader updated successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to download Koaloader update: {}", e);
|
||||
return Err(format!("Failed to download Koaloader update: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Koaloader is up to date: {}", current_koaloader);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to check Koaloader 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 screamapi_updated: bool,
|
||||
pub koaloader_updated: bool,
|
||||
pub new_smokeapi_version: Option<String>,
|
||||
pub new_creamlinux_version: Option<String>,
|
||||
pub new_screamapi_version: Option<String>,
|
||||
pub new_koaloader_version: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateResult {
|
||||
pub fn any_updated(&self) -> bool {
|
||||
self.smokeapi_updated
|
||||
|| self.creamlinux_updated
|
||||
|| self.screamapi_updated
|
||||
|| self.koaloader_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
|
||||
}
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Delete all cached unlocker binaries so they're re-downloaded on next
|
||||
// launch. Deliberately leaves reports.json and the anonymous salt file
|
||||
// alone.
|
||||
pub fn clear_unlocker_cache() -> Result<(), String> {
|
||||
let cache_dir = get_cache_dir()?;
|
||||
for name in ["smokeapi", "screamapi", "koaloader", "creamlinux"] {
|
||||
let dir = cache_dir.join(name);
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to clear {} cache: {}", name, e))?;
|
||||
}
|
||||
}
|
||||
info!("Cleared cached unlocker binaries");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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,
|
||||
// Extra Steam library folders to scan, beyond the auto-detected ones
|
||||
#[serde(default)]
|
||||
pub custom_steam_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_disclaimer: true,
|
||||
reporting_opted_in: false,
|
||||
reporting_has_seen_prompt: false,
|
||||
custom_steam_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Reset configuration to defaults
|
||||
pub fn reset_config() -> Result<Config, String> {
|
||||
let default_config = Config::default();
|
||||
save_config(&default_config)?;
|
||||
info!("Config reset to defaults");
|
||||
Ok(default_config)
|
||||
}
|
||||
|
||||
// Open the config directory (~/.config/creamlinux) in the system's file
|
||||
// manager
|
||||
pub fn open_config_folder() -> Result<(), String> {
|
||||
let config_dir = get_config_dir()?;
|
||||
ensure_config_dir()?;
|
||||
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&config_dir)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open config folder: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
use log::{error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tauri::Manager;
|
||||
|
||||
// More detailed DLC information with enabled state
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DlcInfoWithState {
|
||||
pub appid: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
// Parse the cream_api.ini file to extract both enabled and disabled DLCs
|
||||
pub fn get_enabled_dlcs(game_path: &str) -> Result<Vec<String>, String> {
|
||||
info!("Reading enabled DLCs from {}", game_path);
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!(
|
||||
"cream_api.ini not found at {}",
|
||||
cream_api_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||
};
|
||||
|
||||
// Extract DLCs
|
||||
let mut in_dlc_section = false;
|
||||
let mut enabled_dlcs = Vec::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Check if we're in the DLC section
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty lines and non-DLC comments
|
||||
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
|
||||
// Extract the DLC app ID
|
||||
if let Some(appid) = trimmed.split('=').next() {
|
||||
let appid_clean = appid.trim();
|
||||
// Check if the line is commented out (indicating a disabled DLC)
|
||||
if !appid_clean.starts_with("#") {
|
||||
enabled_dlcs.push(appid_clean.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} enabled DLCs", enabled_dlcs.len());
|
||||
Ok(enabled_dlcs)
|
||||
}
|
||||
|
||||
// Get all DLCs (both enabled and disabled) from cream_api.ini
|
||||
pub fn get_all_dlcs(game_path: &str) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Reading all DLCs from {}", game_path);
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!(
|
||||
"cream_api.ini not found at {}",
|
||||
cream_api_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||
};
|
||||
|
||||
// Extract DLCs
|
||||
let mut in_dlc_section = false;
|
||||
let mut all_dlcs = Vec::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Check if we're in the DLC section
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process DLC entries (both enabled and commented/disabled)
|
||||
if in_dlc_section && !trimmed.is_empty() && !trimmed.starts_with(';') {
|
||||
let is_commented = trimmed.starts_with("#");
|
||||
let actual_line = if is_commented {
|
||||
trimmed.trim_start_matches('#').trim()
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let appid = parts[0].trim();
|
||||
let name = parts[1].trim();
|
||||
|
||||
all_dlcs.push(DlcInfoWithState {
|
||||
appid: appid.to_string(),
|
||||
name: name.to_string().trim_matches('"').to_string(),
|
||||
enabled: !is_commented,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Found {} total DLCs ({} enabled, {} disabled)",
|
||||
all_dlcs.len(),
|
||||
all_dlcs.iter().filter(|d| d.enabled).count(),
|
||||
all_dlcs.iter().filter(|d| !d.enabled).count()
|
||||
);
|
||||
|
||||
Ok(all_dlcs)
|
||||
}
|
||||
|
||||
// Update the cream_api.ini file with the user's DLC selections
|
||||
pub fn update_dlc_configuration(
|
||||
game_path: &str,
|
||||
dlcs: Vec<DlcInfoWithState>,
|
||||
) -> Result<(), String> {
|
||||
info!("Updating DLC configuration for {}", game_path);
|
||||
|
||||
let cream_api_path = Path::new(game_path).join("cream_api.ini");
|
||||
if !cream_api_path.exists() {
|
||||
return Err(format!(
|
||||
"cream_api.ini not found at {}",
|
||||
cream_api_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Read the current file contents
|
||||
let current_contents = match fs::read_to_string(&cream_api_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Failed to read cream_api.ini: {}", e)),
|
||||
};
|
||||
|
||||
// Create a mapping of DLC appid to its state for easy lookup
|
||||
let dlc_states: HashMap<String, (bool, String)> = dlcs
|
||||
.iter()
|
||||
.map(|dlc| (dlc.appid.clone(), (dlc.enabled, dlc.name.clone())))
|
||||
.collect();
|
||||
|
||||
// Keep track of processed DLCs to avoid duplicates
|
||||
let mut processed_dlcs = HashSet::new();
|
||||
|
||||
// Process the file line by line to retain most of the original structure
|
||||
let mut new_contents = Vec::new();
|
||||
let mut in_dlc_section = false;
|
||||
|
||||
for line in current_contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Add section markers directly
|
||||
if trimmed == "[dlc]" {
|
||||
in_dlc_section = true;
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're leaving the DLC section
|
||||
if in_dlc_section && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
in_dlc_section = false;
|
||||
|
||||
// Before leaving the DLC section, add any DLCs that weren't processed yet
|
||||
for (appid, (enabled, name)) in &dlc_states {
|
||||
if !processed_dlcs.contains(appid) {
|
||||
if *enabled {
|
||||
new_contents.push(format!("{} = {}", appid, name));
|
||||
} else {
|
||||
new_contents.push(format!("# {} = {}", appid, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the section marker
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_dlc_section && !trimmed.is_empty() {
|
||||
let is_comment_line = trimmed.starts_with(';');
|
||||
|
||||
// If it's a regular comment line (not a DLC), keep it as is
|
||||
if is_comment_line {
|
||||
new_contents.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a commented-out DLC line or a regular DLC line
|
||||
let is_commented = trimmed.starts_with("#");
|
||||
let actual_line = if is_commented {
|
||||
trimmed.trim_start_matches('#').trim()
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
// Extract appid and name
|
||||
let parts: Vec<&str> = actual_line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let appid = parts[0].trim();
|
||||
let name = parts[1].trim();
|
||||
|
||||
// Check if this DLC exists in our updated list
|
||||
if let Some((enabled, _)) = dlc_states.get(appid) {
|
||||
// Add the DLC with its updated state
|
||||
if *enabled {
|
||||
new_contents.push(format!("{} = {}", appid, name));
|
||||
} else {
|
||||
new_contents.push(format!("# {} = {}", appid, name));
|
||||
}
|
||||
processed_dlcs.insert(appid.to_string());
|
||||
} else {
|
||||
// Not in our list, keep the original line
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
} else {
|
||||
// Invalid format or not a DLC line, keep as is
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
} else if !in_dlc_section || trimmed.is_empty() {
|
||||
// Not a DLC line or empty line, keep as is
|
||||
new_contents.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If we never left the DLC section, make sure we add any unprocessed DLCs
|
||||
if in_dlc_section {
|
||||
for (appid, (enabled, name)) in &dlc_states {
|
||||
if !processed_dlcs.contains(appid) {
|
||||
if *enabled {
|
||||
new_contents.push(format!("{} = {}", appid, name));
|
||||
} else {
|
||||
new_contents.push(format!("# {} = {}", appid, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the updated file
|
||||
match fs::write(&cream_api_path, new_contents.join("\n")) {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Successfully updated DLC configuration at {}",
|
||||
cream_api_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to write updated cream_api.ini: {}", e);
|
||||
Err(format!("Failed to write updated cream_api.ini: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a custom installation with selected DLCs
|
||||
pub async fn install_cream_with_dlcs(
|
||||
game_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
selected_dlcs: Vec<DlcInfoWithState>,
|
||||
) -> Result<(), String> {
|
||||
use crate::AppState;
|
||||
|
||||
// Count enabled DLCs for logging
|
||||
let enabled_dlc_count = selected_dlcs.iter().filter(|dlc| dlc.enabled).count();
|
||||
info!(
|
||||
"Starting installation of CreamLinux with {} selected DLCs",
|
||||
enabled_dlc_count
|
||||
);
|
||||
|
||||
// Get the game from state
|
||||
let game = {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let games = state.games.lock();
|
||||
match games.get(&game_id) {
|
||||
Some(g) => g.clone(),
|
||||
None => return Err(format!("Game with ID {} not found", game_id)),
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Installing CreamLinux for game: {} ({})",
|
||||
game.title, game_id
|
||||
);
|
||||
|
||||
// Convert DlcInfoWithState to installer::DlcInfo for those that are enabled
|
||||
let enabled_dlcs = selected_dlcs
|
||||
.iter()
|
||||
.filter(|dlc| dlc.enabled)
|
||||
.map(|dlc| crate::installer::DlcInfo {
|
||||
appid: dlc.appid.clone(),
|
||||
name: dlc.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Install CreamLinux binaries from cache
|
||||
use crate::unlockers::{CreamLinux, Unlocker};
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
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 home = PathBuf::from(&home);
|
||||
|
||||
// Heroic can be installed natively or via Flatpak. Flatpak sandboxes
|
||||
// XDG_CONFIG_HOME to ~/.var/app/<app-id>/config, so ~/.config/heroic
|
||||
// never exists in that case even though the game library itself lives
|
||||
// on the normal host path (e.g. on Steam Deck installs via Discover).
|
||||
let candidates = [
|
||||
home.join(".config").join("heroic"),
|
||||
home.join(".var")
|
||||
.join("app")
|
||||
.join("com.heroicgameslauncher.hgl")
|
||||
.join("config")
|
||||
.join("heroic"),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
let path = candidate.join("legendaryConfig").join("legendary");
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
warn!(
|
||||
"Heroic legendary config dir not found in any known location (checked native and Flatpak paths)"
|
||||
);
|
||||
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
|
||||
}
|
||||
@@ -1,894 +0,0 @@
|
||||
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")?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Scanning PE imports for best proxy DLL...",
|
||||
30.0, false, false, None,
|
||||
);
|
||||
|
||||
// Detects bitness, scans PE imports, and copies the matching proxy DLL
|
||||
let scan = crate::unlockers::Koaloader::install_proxy(&exe_path)?;
|
||||
let is_fallback = scan.is_fallback;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
&format!("Installed proxy DLL ({})", scan.proxy_name),
|
||||
50.0, false, false, None,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
crate::unlockers::Koaloader::write_koaloader_config(&exe_path)?;
|
||||
|
||||
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();
|
||||
|
||||
// Removes Koaloader.config.json and any proxy DLL variant
|
||||
crate::unlockers::Koaloader::remove_proxy_and_config(exe_dir)?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
// steamcmd helpers
|
||||
|
||||
/// Calls `https://api.steamcmd.net/v1/info/{app_id}` and returns the per-app
|
||||
/// JSON object (`data[app_id]`), or `None` on any failure.
|
||||
async fn fetch_steamcmd_info(
|
||||
client: &reqwest::Client,
|
||||
app_id: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let url = format!("https://api.steamcmd.net/v1/info/{}", app_id);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "CreamLinux-Installer")
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let json: serde_json::Value = resp.json().await.ok()?;
|
||||
|
||||
if json.get("status").and_then(|s| s.as_str()) != Some("success") {
|
||||
return None;
|
||||
}
|
||||
|
||||
json.get("data").and_then(|d| d.get(app_id)).cloned()
|
||||
}
|
||||
|
||||
/// Extracts DLC app-IDs from a game's steamcmd info object.
|
||||
/// Merges two sources and deduplicates:
|
||||
/// 1. `extended.listofdlc` - comma-separated string
|
||||
/// 2. `depots[*].dlcappid` - per-depot numeric field
|
||||
fn extract_dlc_ids(info: &serde_json::Value) -> Vec<String> {
|
||||
let mut ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// Source 1 - extended.listofdlc
|
||||
if let Some(list) = info
|
||||
.get("extended")
|
||||
.and_then(|e| e.get("listofdlc"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
for raw in list.split(',') {
|
||||
let id = raw.trim();
|
||||
if !id.is_empty() {
|
||||
ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2 - depots[*].dlcappid
|
||||
if let Some(depots) = info.get("depots").and_then(|d| d.as_object()) {
|
||||
for (_key, depot) in depots {
|
||||
if let Some(dlc_id) = depot.get("dlcappid").and_then(|v| {
|
||||
v.as_u64()
|
||||
.map(|n| n.to_string())
|
||||
.or_else(|| v.as_str().map(|s| s.to_string()))
|
||||
}) {
|
||||
if !dlc_id.is_empty() {
|
||||
ids.insert(dlc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut sorted: Vec<String> = ids.into_iter().collect();
|
||||
sorted.sort();
|
||||
sorted
|
||||
}
|
||||
|
||||
/// Fetches the display name for a single DLC from steamcmd.net.
|
||||
/// Returns `None` if the call fails or the name is empty / "Unknown".
|
||||
async fn fetch_dlc_name(
|
||||
client: &reqwest::Client,
|
||||
dlc_id: &str,
|
||||
) -> Option<String> {
|
||||
let info = fetch_steamcmd_info(client, dlc_id).await?;
|
||||
let name = info
|
||||
.get("common")
|
||||
.and_then(|c| c.get("name"))
|
||||
.and_then(|n| n.as_str())?;
|
||||
|
||||
if name.is_empty() || name.eq_ignore_ascii_case("unknown") {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
// Fetch DLC details from steamcmd.net (simple version without progress)
|
||||
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
|
||||
info!("Fetching DLC list via steamcmd.net for game ID: {}", app_id);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Step 1: get game info → extract DLC IDs
|
||||
let game_info = fetch_steamcmd_info(&client, app_id)
|
||||
.await
|
||||
.ok_or_else(|| format!("steamcmd.net returned no data for app {}", app_id))?;
|
||||
|
||||
let dlc_ids = extract_dlc_ids(&game_info);
|
||||
info!("Found {} DLC IDs for game {}", dlc_ids.len(), app_id);
|
||||
|
||||
// Step 2: fetch name for each ID; skip any that resolve to Unknown
|
||||
let mut dlc_details = Vec::new();
|
||||
|
||||
for dlc_id in &dlc_ids {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
match fetch_dlc_name(&client, dlc_id).await {
|
||||
Some(name) => {
|
||||
info!("Found DLC: {} ({})", name, dlc_id);
|
||||
dlc_details.push(DlcInfo {
|
||||
appid: dlc_id.clone(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
info!("Skipping DLC {} - no name / unknown", dlc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Successfully retrieved {} named DLCs", dlc_details.len());
|
||||
Ok(dlc_details)
|
||||
}
|
||||
|
||||
// Fetch DLC details from steamcmd.net 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
|
||||
);
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let should_cancel = state.fetch_cancellation.clone();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Step 1: fetch game info to get DLC ID list
|
||||
emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
|
||||
|
||||
let game_info = fetch_steamcmd_info(&client, app_id)
|
||||
.await
|
||||
.ok_or_else(|| format!("steamcmd.net returned no data for app {}", app_id))?;
|
||||
|
||||
let dlc_ids = extract_dlc_ids(&game_info);
|
||||
let total_dlcs = dlc_ids.len();
|
||||
|
||||
info!("Found {} DLC IDs for game {}", total_dlcs, app_id);
|
||||
emit_dlc_progress(
|
||||
app_handle,
|
||||
&format!("Found {} DLCs. Fetching details...", total_dlcs),
|
||||
10,
|
||||
None,
|
||||
);
|
||||
|
||||
// Step 2: fetch each DLC name, emit as we go, skip unknowns
|
||||
let mut dlc_details = Vec::new();
|
||||
|
||||
for (index, dlc_id) in dlc_ids.iter().enumerate() {
|
||||
// Check for cancellation
|
||||
if should_cancel.load(Ordering::SeqCst) {
|
||||
info!("DLC fetch cancelled for game {}", app_id);
|
||||
return Err("Operation cancelled by user".to_string());
|
||||
}
|
||||
|
||||
let progress_rounded = (10.0 + (index as f32 / total_dlcs as f32) * 90.0) as u32;
|
||||
let remaining = total_dlcs - index;
|
||||
|
||||
let est_time_left = if remaining > 0 {
|
||||
// steamcmd is ~100 ms/request, much faster than the old 300 ms Steam API
|
||||
let seconds = (remaining as f32 * 0.15).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 {}/{} ({})",
|
||||
index + 1,
|
||||
total_dlcs,
|
||||
dlc_id
|
||||
);
|
||||
emit_dlc_progress(
|
||||
app_handle,
|
||||
&format!("Processing DLC {}/{}", index + 1, total_dlcs),
|
||||
progress_rounded,
|
||||
Some(&est_time_left),
|
||||
);
|
||||
|
||||
// Small delay to be polite to the API
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
match fetch_dlc_name(&client, dlc_id).await {
|
||||
Some(name) => {
|
||||
info!("Found DLC: {} ({})", name, dlc_id);
|
||||
let dlc_info = DlcInfo {
|
||||
appid: dlc_id.clone(),
|
||||
name,
|
||||
};
|
||||
|
||||
// Emit immediately so the UI updates as DLCs arrive
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
dlc_details.push(dlc_info);
|
||||
}
|
||||
None => {
|
||||
info!("Skipping DLC {} - no name / unknown", dlc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Completed DLC fetch. Found {} named DLCs out of {} IDs",
|
||||
dlc_details.len(),
|
||||
total_dlcs
|
||||
);
|
||||
emit_dlc_progress(
|
||||
app_handle,
|
||||
&format!("Completed! Found {} DLCs", dlc_details.len()),
|
||||
100,
|
||||
None,
|
||||
);
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -1,996 +0,0 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
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;
|
||||
mod system_info;
|
||||
|
||||
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};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_updater::Builder as UpdaterBuilder;
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GameAction {
|
||||
game_id: String,
|
||||
action: String,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
data: Vec<DlcInfoWithState>,
|
||||
#[allow(dead_code)]
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
// Structure to hold the state of installed games
|
||||
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)
|
||||
}
|
||||
|
||||
// Add a custom Steam library path (validated to contain a steamapps folder)
|
||||
#[tauri::command]
|
||||
fn add_custom_steam_path(path: String) -> Result<Config, String> {
|
||||
let normalized = searcher::normalize_steam_library_path(std::path::Path::new(&path))?;
|
||||
let normalized_str = normalized.to_string_lossy().to_string();
|
||||
|
||||
info!("Adding custom Steam library path: {}", normalized_str);
|
||||
|
||||
config::update_config(|cfg| {
|
||||
if !cfg.custom_steam_paths.contains(&normalized_str) {
|
||||
cfg.custom_steam_paths.push(normalized_str.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Remove a custom Steam library path
|
||||
#[tauri::command]
|
||||
fn remove_custom_steam_path(path: String) -> Result<Config, String> {
|
||||
info!("Removing custom Steam library path: {}", path);
|
||||
|
||||
config::update_config(|cfg| {
|
||||
cfg.custom_steam_paths.retain(|p| p != &path);
|
||||
})
|
||||
}
|
||||
|
||||
// Reset configuration to defaults
|
||||
#[tauri::command]
|
||||
fn reset_config() -> Result<Config, String> {
|
||||
config::reset_config()
|
||||
}
|
||||
|
||||
// Open the config folder in the system's file manager
|
||||
#[tauri::command]
|
||||
fn open_config_folder() -> Result<(), String> {
|
||||
config::open_config_folder()
|
||||
}
|
||||
|
||||
// Basic host info (OS/CPU/GPU) shown on the Overview page
|
||||
#[tauri::command]
|
||||
fn get_system_info() -> system_info::SystemInfo {
|
||||
system_info::get_system_info()
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[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>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<Game>, String> {
|
||||
info!("Starting Steam games scan");
|
||||
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
|
||||
|
||||
let mut paths = searcher::get_default_steam_paths();
|
||||
|
||||
// Add user-configured custom library paths, in case a library lives
|
||||
// somewhere the auto-detection doesn't know to look.
|
||||
let cfg = config::load_config()?;
|
||||
for custom_path in &cfg.custom_steam_paths {
|
||||
let p = std::path::PathBuf::from(custom_path);
|
||||
if p.exists() && !paths.contains(&p) {
|
||||
info!("Adding custom Steam library path: {}", p.display());
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
|
||||
let libraries = searcher::find_steam_libraries(&paths);
|
||||
|
||||
let mut unique_libraries = std::collections::HashSet::new();
|
||||
for lib in &libraries {
|
||||
unique_libraries.insert(lib.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Found {} Steam library directories:",
|
||||
unique_libraries.len()
|
||||
);
|
||||
for (i, lib) in unique_libraries.iter().enumerate() {
|
||||
info!(" Library {}: {}", i + 1, lib);
|
||||
}
|
||||
|
||||
emit_scan_progress(
|
||||
&app_handle,
|
||||
&format!(
|
||||
"Found {} Steam libraries. Starting game scan...",
|
||||
unique_libraries.len()
|
||||
),
|
||||
20,
|
||||
);
|
||||
|
||||
let games_info = searcher::find_installed_games(&libraries).await;
|
||||
|
||||
emit_scan_progress(
|
||||
&app_handle,
|
||||
&format!("Found {} games. Processing...", games_info.len()),
|
||||
90,
|
||||
);
|
||||
|
||||
info!("Games scan complete - Found {} games", games_info.len());
|
||||
info!(
|
||||
"Native games: {}",
|
||||
games_info.iter().filter(|g| g.native).count()
|
||||
);
|
||||
info!(
|
||||
"Proton games: {}",
|
||||
games_info.iter().filter(|g| !g.native).count()
|
||||
);
|
||||
info!(
|
||||
"Games with CreamLinux: {}",
|
||||
games_info.iter().filter(|g| g.cream_installed).count()
|
||||
);
|
||||
info!(
|
||||
"Games with SmokeAPI: {}",
|
||||
games_info.iter().filter(|g| g.smoke_installed).count()
|
||||
);
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
info!("Processing games into application state...");
|
||||
for game_info in games_info {
|
||||
debug!(
|
||||
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
|
||||
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
|
||||
);
|
||||
|
||||
let game = Game {
|
||||
id: game_info.id,
|
||||
title: game_info.title,
|
||||
path: game_info.path.to_string_lossy().to_string(),
|
||||
native: game_info.native,
|
||||
api_files: game_info.api_files,
|
||||
cream_installed: game_info.cream_installed,
|
||||
smoke_installed: game_info.smoke_installed,
|
||||
installing: false,
|
||||
};
|
||||
|
||||
result.push(game.clone());
|
||||
state.games.lock().insert(game.id.clone(), game);
|
||||
}
|
||||
|
||||
emit_scan_progress(
|
||||
&app_handle,
|
||||
&format!("Scan complete. Found {} games.", result.len()),
|
||||
100,
|
||||
);
|
||||
|
||||
info!("Game scan completed successfully");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
|
||||
info!("Scan progress: {}% - {}", progress, message);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"message": message,
|
||||
"progress": progress
|
||||
});
|
||||
|
||||
if let Err(e) = app_handle.emit("scan-progress", payload) {
|
||||
warn!("Failed to emit scan-progress event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
|
||||
let games = state.games.lock();
|
||||
games
|
||||
.get(&game_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Game with ID {} not found", game_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn process_game_action(
|
||||
game_action: GameAction,
|
||||
state: State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Game, String> {
|
||||
let game = {
|
||||
let games = state.games.lock();
|
||||
games
|
||||
.get(&game_action.game_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
|
||||
};
|
||||
|
||||
let (installer_type, action) = match game_action.action.as_str() {
|
||||
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
|
||||
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
|
||||
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
|
||||
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
|
||||
_ => return Err(format!("Invalid action: {}", game_action.action)),
|
||||
};
|
||||
|
||||
installer::process_action(
|
||||
game_action.game_id.clone(),
|
||||
installer_type,
|
||||
action,
|
||||
game.clone(),
|
||||
app_handle.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let updated_game = {
|
||||
let mut games_map = state.games.lock();
|
||||
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
|
||||
format!(
|
||||
"Game with ID {} not found after action",
|
||||
game_action.game_id
|
||||
)
|
||||
})?;
|
||||
|
||||
match (installer_type, action) {
|
||||
(InstallerType::Cream, InstallerAction::Install) => {
|
||||
game.cream_installed = true;
|
||||
}
|
||||
(InstallerType::Cream, InstallerAction::Uninstall) => {
|
||||
game.cream_installed = false;
|
||||
}
|
||||
(InstallerType::Smoke, InstallerAction::Install) => {
|
||||
game.smoke_installed = true;
|
||||
}
|
||||
(InstallerType::Smoke, InstallerAction::Uninstall) => {
|
||||
game.smoke_installed = false;
|
||||
}
|
||||
}
|
||||
|
||||
game.installing = false;
|
||||
game.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
Ok(updated_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,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<DlcInfoWithState>, String> {
|
||||
info!("Fetching DLC list for game ID: {}", game_id);
|
||||
|
||||
// Fetch DLC data from API
|
||||
match installer::fetch_dlc_details(&game_id).await {
|
||||
Ok(dlcs) => {
|
||||
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 {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 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: tokio::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(dlcs_with_state)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to fetch DLC details: {}", e);
|
||||
Err(format!("Failed to fetch DLC details: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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 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>();
|
||||
state.fetch_cancellation.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
info!("Streaming DLCs for game ID: {}", game_id);
|
||||
|
||||
// Fetch DLC data from API
|
||||
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
|
||||
Ok(dlcs) => {
|
||||
info!(
|
||||
"Successfully streamed {} DLCs for game {}",
|
||||
dlcs.len(),
|
||||
game_id
|
||||
);
|
||||
|
||||
// Convert to DLCInfoWithState for in-memory caching
|
||||
let dlcs_with_state = dlcs
|
||||
.into_iter()
|
||||
.map(|dlc| DlcInfoWithState {
|
||||
appid: dlc.appid,
|
||||
name: dlc.name,
|
||||
enabled: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Update in-memory cache
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut dlc_cache = state.dlc_cache.lock();
|
||||
dlc_cache.insert(
|
||||
game_id.clone(),
|
||||
DlcCache {
|
||||
data: dlcs_with_state,
|
||||
timestamp: tokio::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to stream DLC details: {}", e);
|
||||
// Emit error event
|
||||
let error_payload = serde_json::json!({
|
||||
"error": format!("Failed to fetch DLC details: {}", e)
|
||||
});
|
||||
|
||||
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
|
||||
warn!("Failed to emit dlc-error event: {}", emit_err);
|
||||
}
|
||||
|
||||
Err(format!("Failed to fetch DLC details: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_caches() -> Result<(), String> {
|
||||
info!("Clearing cached unlocker binaries");
|
||||
cache::clear_unlocker_cache()
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_dlc_configuration_command(
|
||||
game_path: String,
|
||||
dlcs: Vec<DlcInfoWithState>,
|
||||
) -> Result<(), String> {
|
||||
info!("Updating DLC configuration for: {}", game_path);
|
||||
dlc_manager::update_dlc_configuration(&game_path, dlcs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_cream_with_dlcs_command(
|
||||
game_id: String,
|
||||
selected_dlcs: Vec<DlcInfoWithState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Game, String> {
|
||||
info!(
|
||||
"Installing CreamLinux with selected DLCs for game: {}",
|
||||
game_id
|
||||
);
|
||||
|
||||
// Clone selected_dlcs for later use
|
||||
let selected_dlcs_clone = selected_dlcs.clone();
|
||||
|
||||
// Install CreamLinux with the selected DLCs
|
||||
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// Return updated game info
|
||||
let state = app_handle.state::<AppState>();
|
||||
|
||||
// Get a mutable reference and update the game
|
||||
let game = {
|
||||
let mut games_map = state.games.lock();
|
||||
let game = games_map.get_mut(&game_id).ok_or_else(|| {
|
||||
format!("Game with ID {} not found after installation", game_id)
|
||||
})?;
|
||||
|
||||
// Update installation status
|
||||
game.cream_installed = true;
|
||||
game.installing = false;
|
||||
|
||||
// Clone the game for returning later
|
||||
game.clone()
|
||||
};
|
||||
|
||||
// Emit an event to update the UI
|
||||
if let Err(e) = app_handle.emit("game-updated", &game) {
|
||||
warn!("Failed to emit game-updated event: {}", e);
|
||||
}
|
||||
|
||||
// Show installation complete dialog with instructions
|
||||
let instructions = installer::InstallationInstructions {
|
||||
type_: "cream_install".to_string(),
|
||||
command: "sh ./cream.sh %command%".to_string(),
|
||||
game_title: game.title.clone(),
|
||||
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
|
||||
};
|
||||
|
||||
installer::emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Completed: {}", game.title),
|
||||
"CreamLinux has been installed successfully!",
|
||||
100.0,
|
||||
true,
|
||||
true,
|
||||
Some(instructions),
|
||||
);
|
||||
|
||||
Ok(game)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to install CreamLinux with selected DLCs: {}", e);
|
||||
Err(format!(
|
||||
"Failed to install CreamLinux with selected DLCs: {}",
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
use log4rs::config::{Appender, Config, Root};
|
||||
use log4rs::encode::pattern::PatternEncoder;
|
||||
use std::fs;
|
||||
|
||||
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
|
||||
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
|
||||
|
||||
if log_path.exists() {
|
||||
if let Err(e) = fs::write(&log_path, "") {
|
||||
eprintln!("Warning: Failed to clear log file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let file = FileAppender::builder()
|
||||
.encoder(Box::new(PatternEncoder::new(
|
||||
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
|
||||
)))
|
||||
.build(log_path)?;
|
||||
|
||||
let config = Config::builder()
|
||||
.appender(Appender::builder().build("file", Box::new(file)))
|
||||
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
|
||||
|
||||
log4rs::init_config(config)?;
|
||||
|
||||
info!("CreamLinux started with a clean log file");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = setup_logging() {
|
||||
eprintln!("Warning: Failed to initialize logging: {}", e);
|
||||
}
|
||||
|
||||
info!("Initializing CreamLinux application");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(UpdaterBuilder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
scan_steam_games,
|
||||
get_game_info,
|
||||
process_game_action,
|
||||
fetch_game_dlcs,
|
||||
stream_game_dlcs,
|
||||
get_enabled_dlcs_command,
|
||||
update_dlc_configuration_command,
|
||||
install_cream_with_dlcs_command,
|
||||
get_all_dlcs_command,
|
||||
clear_caches,
|
||||
abort_dlc_fetch,
|
||||
read_smokeapi_config,
|
||||
write_smokeapi_config,
|
||||
delete_smokeapi_config,
|
||||
resolve_platform_conflict,
|
||||
load_config,
|
||||
update_config,
|
||||
add_custom_steam_path,
|
||||
remove_custom_steam_path,
|
||||
reset_config,
|
||||
open_config_folder,
|
||||
get_system_info,
|
||||
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| {
|
||||
info!("Tauri application setup");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: {:?}, ScreamAPI: {:?}, Koaloader: {:?}",
|
||||
result.new_smokeapi_version, result.new_creamlinux_version,
|
||||
result.new_screamapi_version, result.new_koaloader_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");
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/// 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)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
use log::{debug, error, info, warn};
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// Game information structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub path: PathBuf,
|
||||
pub native: bool,
|
||||
pub api_files: Vec<String>,
|
||||
pub cream_installed: bool,
|
||||
pub smoke_installed: bool,
|
||||
}
|
||||
|
||||
// Find potential Steam installation directories
|
||||
pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Get user's home directory
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
info!("Searching for Steam in home directory: {}", home);
|
||||
|
||||
// Common Steam installation locations on Linux
|
||||
let common_paths = [
|
||||
".steam/steam", // Steam symlink directory
|
||||
".steam/root", // Alternative symlink
|
||||
".local/share/Steam", // Flatpak Steam installation
|
||||
".var/app/com.valvesoftware.Steam/.local/share/Steam", // Flatpak container path
|
||||
".var/app/com.valvesoftware.Steam/data/Steam", // Alternative Flatpak path
|
||||
"/run/media/mmcblk0p1", // Removable Storage path
|
||||
];
|
||||
|
||||
for path in &common_paths {
|
||||
let full_path = PathBuf::from(&home).join(path);
|
||||
if full_path.exists() {
|
||||
debug!("Found Steam directory: {}", full_path.display());
|
||||
paths.push(full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Steam Deck paths if they exist
|
||||
let deck_paths = ["/home/deck/.steam/steam", "/home/deck/.local/share/Steam"];
|
||||
|
||||
for path in &deck_paths {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() && !paths.contains(&p) {
|
||||
debug!("Found Steam Deck path: {}", p.display());
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract paths from Steam registry file
|
||||
if let Some(registry_paths) = read_steam_registry() {
|
||||
for path in registry_paths {
|
||||
if !paths.contains(&path) && path.exists() {
|
||||
debug!("Adding Steam path from registry: {}", path.display());
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} potential Steam directories", paths.len());
|
||||
paths
|
||||
}
|
||||
|
||||
/// Validates a user-picked directory and resolves it to the library root
|
||||
/// `find_steam_libraries` expects (i.e. the folder that directly contains
|
||||
/// `steamapps`, not the `steamapps` folder itself).
|
||||
pub fn normalize_steam_library_path(path: &Path) -> Result<PathBuf, String> {
|
||||
if !path.is_dir() {
|
||||
return Err(format!("{} is not a valid directory", path.display()));
|
||||
}
|
||||
|
||||
if path.join("steamapps").is_dir() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
|
||||
// User picked the steamapps folder itself - use its parent as the root
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("steamapps") {
|
||||
if let Some(parent) = path.parent() {
|
||||
return Ok(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"No Steam library found at {} (expected a 'steamapps' folder inside it)",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
// Try to read the Steam registry file to find installation paths
|
||||
fn read_steam_registry() -> Option<Vec<PathBuf>> {
|
||||
let home = match std::env::var("HOME") {
|
||||
Ok(h) => h,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let registry_paths = [
|
||||
format!("{}/.steam/registry.vdf", home),
|
||||
format!("{}/.steam/steam/registry.vdf", home),
|
||||
format!("{}/.local/share/Steam/registry.vdf", home),
|
||||
];
|
||||
|
||||
for registry_path in registry_paths {
|
||||
let path = Path::new(®istry_path);
|
||||
if path.exists() {
|
||||
debug!("Found Steam registry at: {}", path.display());
|
||||
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Extract Steam installation paths
|
||||
let re_steam_path = Regex::new(r#""SteamPath"\s+"([^"]+)""#).unwrap();
|
||||
if let Some(cap) = re_steam_path.captures(&content) {
|
||||
let steam_path = PathBuf::from(&cap[1]);
|
||||
paths.push(steam_path);
|
||||
}
|
||||
|
||||
// Look for install path
|
||||
let re_install_path = Regex::new(r#""InstallPath"\s+"([^"]+)""#).unwrap();
|
||||
if let Some(cap) = re_install_path.captures(&content) {
|
||||
let install_path = PathBuf::from(&cap[1]);
|
||||
if !paths.contains(&install_path) {
|
||||
paths.push(install_path);
|
||||
}
|
||||
}
|
||||
|
||||
if !paths.is_empty() {
|
||||
return Some(paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// Find all Steam library folders from base Steam installation paths
|
||||
pub fn find_steam_libraries(base_paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut libraries = HashSet::new();
|
||||
|
||||
for base_path in base_paths {
|
||||
debug!("Looking for Steam libraries in: {}", base_path.display());
|
||||
|
||||
// Check if this path contains a steamapps directory
|
||||
let steamapps_path = base_path.join("steamapps");
|
||||
if steamapps_path.exists() && steamapps_path.is_dir() {
|
||||
debug!("Found steamapps directory: {}", steamapps_path.display());
|
||||
libraries.insert(steamapps_path.clone());
|
||||
|
||||
// Check for additional libraries in libraryfolders.vdf
|
||||
parse_library_folders_vdf(&steamapps_path, &mut libraries);
|
||||
}
|
||||
|
||||
// Also check for steamapps in common locations relative to this path
|
||||
let possible_steamapps = [
|
||||
base_path.join("steam/steamapps"),
|
||||
base_path.join("Steam/steamapps"),
|
||||
];
|
||||
|
||||
for path in &possible_steamapps {
|
||||
if path.exists() && path.is_dir() && !libraries.contains(path) {
|
||||
debug!("Found steamapps directory: {}", path.display());
|
||||
libraries.insert(path.clone());
|
||||
|
||||
// Check for additional libraries in libraryfolders.vdf
|
||||
parse_library_folders_vdf(path, &mut libraries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result: Vec<PathBuf> = libraries.into_iter().collect();
|
||||
info!("Found {} Steam library directories", result.len());
|
||||
for (i, lib) in result.iter().enumerate() {
|
||||
info!(" Library {}: {}", i + 1, lib.display());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Parse libraryfolders.vdf to extract additional library paths
|
||||
fn parse_library_folders_vdf(steamapps_path: &Path, libraries: &mut HashSet<PathBuf>) {
|
||||
// Check both possible locations of the VDF file
|
||||
let vdf_paths = [
|
||||
steamapps_path.join("libraryfolders.vdf"),
|
||||
steamapps_path.join("config/libraryfolders.vdf"),
|
||||
];
|
||||
|
||||
for vdf_path in &vdf_paths {
|
||||
if vdf_path.exists() {
|
||||
debug!("Found library folders VDF: {}", vdf_path.display());
|
||||
|
||||
if let Ok(content) = fs::read_to_string(vdf_path) {
|
||||
// Extract library paths using regex for both new and old format VDFs
|
||||
let re_path = Regex::new(r#""path"\s+"([^"]+)""#).unwrap();
|
||||
for cap in re_path.captures_iter(&content) {
|
||||
let path_str = &cap[1];
|
||||
let lib_path = PathBuf::from(path_str).join("steamapps");
|
||||
|
||||
if lib_path.exists() && lib_path.is_dir() && !libraries.contains(&lib_path) {
|
||||
debug!("Found library from VDF: {}", lib_path.display());
|
||||
// Clone lib_path before inserting to avoid ownership issues
|
||||
let lib_path_clone = lib_path.clone();
|
||||
libraries.insert(lib_path_clone);
|
||||
|
||||
// Recursively check this library for more libraries
|
||||
parse_library_folders_vdf(&lib_path, libraries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an appmanifest ACF file to extract game information
|
||||
fn parse_appmanifest(path: &Path) -> Option<(String, String, String)> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
// Use regex to extract the app ID, name, and install directory
|
||||
let re_appid = Regex::new(r#""appid"\s+"(\d+)""#).unwrap();
|
||||
let re_name = Regex::new(r#""name"\s+"([^"]+)""#).unwrap();
|
||||
let re_installdir = Regex::new(r#""installdir"\s+"([^"]+)""#).unwrap();
|
||||
|
||||
if let (Some(app_id_cap), Some(name_cap), Some(dir_cap)) = (
|
||||
re_appid.captures(&content),
|
||||
re_name.captures(&content),
|
||||
re_installdir.captures(&content),
|
||||
) {
|
||||
let app_id = app_id_cap[1].to_string();
|
||||
let name = name_cap[1].to_string();
|
||||
let install_dir = dir_cap[1].to_string();
|
||||
|
||||
return Some((app_id, name, install_dir));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read ACF file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// Check if a file is a Linux ELF binary
|
||||
fn is_elf_binary(path: &Path) -> bool {
|
||||
if let Ok(mut file) = fs::File::open(path) {
|
||||
let mut buffer = [0; 4];
|
||||
if file.read_exact(&mut buffer).is_ok() {
|
||||
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
||||
return buffer[0] == 0x7F
|
||||
&& buffer[1] == b'E'
|
||||
&& buffer[2] == b'L'
|
||||
&& buffer[3] == b'F';
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// Check if a game has CreamLinux installed
|
||||
fn check_creamlinux_installed(game_path: &Path) -> bool {
|
||||
let cream_files = ["cream.sh", "cream_api.ini", "cream_api.so"];
|
||||
|
||||
for file in &cream_files {
|
||||
if game_path.join(file).exists() {
|
||||
debug!("CreamLinux installation detected: {}", file);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// Check if a game has SmokeAPI installed
|
||||
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// Scan a game directory to determine if it's native or needs Proton
|
||||
// Also collect any Steam API DLLs for potential SmokeAPI installation
|
||||
fn scan_game_directory(game_path: &Path) -> (bool, Vec<String>) {
|
||||
let mut found_exe = false;
|
||||
let mut found_linux_binary = false;
|
||||
let mut 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",
|
||||
"video",
|
||||
"movies",
|
||||
"movie",
|
||||
"sound",
|
||||
"sounds",
|
||||
"audio",
|
||||
"textures",
|
||||
"music",
|
||||
"localization",
|
||||
"shaders",
|
||||
"logs",
|
||||
"assets/audio",
|
||||
"assets/video",
|
||||
"assets/textures",
|
||||
];
|
||||
|
||||
// Only scan to a reasonable depth (avoid extreme recursion)
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
// File extensions to check for (executable and Steam API files)
|
||||
let exe_extensions = ["exe", "bat", "cmd", "msi"];
|
||||
let binary_extensions = ["so", "bin", "sh", "x86", "x86_64"];
|
||||
|
||||
// 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
|
||||
.follow_links(false) // Don't follow symlinks to prevent cycles
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
// Skip certain directories for performance
|
||||
if e.file_type().is_dir() {
|
||||
let file_name = e.file_name().to_string_lossy().to_lowercase();
|
||||
if skip_dirs.iter().any(|&dir| file_name == dir) {
|
||||
debug!("Skipping directory: {}", e.path().display());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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" {
|
||||
if filename == "steam_api.dll" || filename == "steam_api64.dll" {
|
||||
if let Ok(rel_path) = path.strip_prefix(game_path) {
|
||||
let rel_path_str = rel_path.to_string_lossy().to_string();
|
||||
debug!("Found Steam API DLL: {}", rel_path_str);
|
||||
steam_api_files.push(rel_path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux binary files
|
||||
if binary_extensions.iter().any(|&e| ext_str == e) {
|
||||
found_linux_binary = true;
|
||||
linux_binary_count += 1;
|
||||
|
||||
// Check if it's actually an ELF binary for more certainty
|
||||
if ext_str == "so" && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Linux executables (no extension)
|
||||
#[cfg(unix)]
|
||||
if !path.extension().is_some() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
|
||||
// Check executable permission and ELF format
|
||||
if is_executable && is_elf_binary(path) {
|
||||
found_linux_binary = true;
|
||||
linux_binary_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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={}, libsteam_api={}, linux_libs={}, linux_binaries={}, exe_files={}, api_dlls={}",
|
||||
is_native,
|
||||
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();
|
||||
let seen_ids = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
||||
|
||||
// IDs to skip (tools, redistributables, etc.)
|
||||
let skip_ids = Arc::new(
|
||||
[
|
||||
"228980", // Steamworks Common Redistributables
|
||||
"1070560", // Steam Linux Runtime
|
||||
"1391110", // Steam Linux Runtime - Soldier
|
||||
"1628350", // Steam Linux Runtime - Sniper
|
||||
"1493710", // Proton Experimental
|
||||
"2180100", // Steam Linux Runtime - Scout
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<HashSet<&str>>(),
|
||||
);
|
||||
|
||||
// Name patterns to skip (case insensitive)
|
||||
let skip_patterns = Arc::new(
|
||||
[
|
||||
r"(?i)steam linux runtime",
|
||||
r"(?i)proton",
|
||||
r"(?i)steamworks common",
|
||||
r"(?i)redistributable",
|
||||
r"(?i)dotnet",
|
||||
r"(?i)vc redist",
|
||||
]
|
||||
.iter()
|
||||
.map(|pat| Regex::new(pat).unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
info!("Scanning for installed games in parallel...");
|
||||
|
||||
// Create a channel to collect results
|
||||
let (tx, mut rx) = mpsc::channel(32);
|
||||
|
||||
// First collect all appmanifest files to process
|
||||
let mut app_manifests = Vec::new();
|
||||
for steamapps_dir in steamapps_paths {
|
||||
if let Ok(entries) = fs::read_dir(steamapps_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Check for appmanifest files
|
||||
if filename.starts_with("appmanifest_") && filename.ends_with(".acf") {
|
||||
app_manifests.push((path, steamapps_dir.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} appmanifest files to process", app_manifests.len());
|
||||
|
||||
// Process appmanifest files
|
||||
let max_concurrent = num_cpus::get().max(1).min(8); // Use between 1 and 8 CPU cores
|
||||
info!("Using {} concurrent scanners", max_concurrent);
|
||||
|
||||
// Use a semaphore to limit concurrency
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
|
||||
|
||||
// Create a Vec to store all our task handles
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Process each manifest file
|
||||
for (manifest_idx, (path, steamapps_dir)) in app_manifests.iter().enumerate() {
|
||||
// Clone what we need for the task
|
||||
let path = path.clone();
|
||||
let steamapps_dir = steamapps_dir.clone();
|
||||
let skip_patterns = Arc::clone(&skip_patterns);
|
||||
let tx = tx.clone();
|
||||
let seen_ids = Arc::clone(&seen_ids);
|
||||
let semaphore = Arc::clone(&semaphore);
|
||||
let skip_ids = Arc::clone(&skip_ids);
|
||||
|
||||
// Create a new task
|
||||
let handle = tokio::spawn(async move {
|
||||
// Acquire a permit from the semaphore
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
// Parse the appmanifest file
|
||||
if let Some((id, name, install_dir)) = parse_appmanifest(&path) {
|
||||
// Skip if in exclusion list
|
||||
if skip_ids.contains(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a guard against duplicates
|
||||
{
|
||||
let mut seen = seen_ids.lock().await;
|
||||
if seen.contains(&id) {
|
||||
return;
|
||||
}
|
||||
seen.insert(id.clone());
|
||||
}
|
||||
|
||||
// Skip if the name matches any exclusion patterns
|
||||
if skip_patterns.iter().any(|re| re.is_match(&name)) {
|
||||
debug!("Skipping runtime/tool: {} ({})", name, id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Full path to the game directory
|
||||
let game_path = steamapps_dir.join("common").join(&install_dir);
|
||||
|
||||
// Skip if game directory doesn't exist
|
||||
if !game_path.exists() {
|
||||
warn!("Game directory not found: {}", game_path.display());
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan the game directory to determine platform and find Steam API DLLs
|
||||
info!("Scanning game: {} at {}", name, game_path.display());
|
||||
|
||||
// Scanning is I/O heavy but not CPU heavy, so we can just do it directly
|
||||
let (is_native, api_files) = scan_game_directory(&game_path);
|
||||
|
||||
// Check for CreamLinux installation
|
||||
let cream_installed = check_creamlinux_installed(&game_path);
|
||||
|
||||
// Check for SmokeAPI installation
|
||||
// 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 {
|
||||
id,
|
||||
title: name,
|
||||
path: game_path,
|
||||
native: is_native,
|
||||
api_files,
|
||||
cream_installed,
|
||||
smoke_installed,
|
||||
};
|
||||
|
||||
// Send the game info through the channel
|
||||
if tx.send(game_info).await.is_err() {
|
||||
error!("Failed to send game info through channel");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
|
||||
// Every 10 files, yield to allow progress updates
|
||||
if manifest_idx % 10 == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the original sender so the receiver knows when we're done
|
||||
drop(tx);
|
||||
|
||||
// Spawn a task to collect all the results
|
||||
let receiver_task = tokio::spawn(async move {
|
||||
let mut results = Vec::new();
|
||||
while let Some(game) = rx.recv().await {
|
||||
info!("Found game: {} ({})", game.title, game.id);
|
||||
info!(" Path: {}", game.path.display());
|
||||
info!(
|
||||
" Status: Native={}, Cream={}, Smoke={}",
|
||||
game.native, game.cream_installed, game.smoke_installed
|
||||
);
|
||||
|
||||
// Log Steam API DLLs if any
|
||||
if !game.api_files.is_empty() {
|
||||
info!(" Steam API files:");
|
||||
for api_file in &game.api_files {
|
||||
info!(" - {}", api_file);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(game);
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
// Wait for all scan tasks to complete but don't wait for the results yet
|
||||
for handle in handles {
|
||||
// Ignore errors the receiver task will just get fewer results
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Now wait for all results to be collected
|
||||
if let Ok(results) = receiver_task.await {
|
||||
games = results;
|
||||
}
|
||||
|
||||
info!("Found {} installed games", games.len());
|
||||
games
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_accepts_folder_containing_steamapps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("steamapps")).unwrap();
|
||||
|
||||
let result = normalize_steam_library_path(dir.path()).unwrap();
|
||||
assert_eq!(result, dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_accepts_steamapps_folder_itself() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let steamapps = dir.path().join("steamapps");
|
||||
fs::create_dir(&steamapps).unwrap();
|
||||
|
||||
let result = normalize_steam_library_path(&steamapps).unwrap();
|
||||
assert_eq!(result, dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_folder_without_steamapps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(normalize_steam_library_path(dir.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_nonexistent_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("does-not-exist");
|
||||
assert!(normalize_steam_library_path(&missing).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
// Basic host info shown on the Overview page, purely informational.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SystemInfo {
|
||||
pub os_name: String,
|
||||
pub cpu_model: String,
|
||||
pub cpu_cores: usize,
|
||||
pub gpu_name: String,
|
||||
}
|
||||
|
||||
fn read_os_name() -> String {
|
||||
if let Ok(contents) = fs::read_to_string("/etc/os-release") {
|
||||
for line in contents.lines() {
|
||||
if let Some(value) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return value.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("{} (unknown distro)", std::env::consts::OS)
|
||||
}
|
||||
|
||||
fn read_cpu_model() -> String {
|
||||
if let Ok(contents) = fs::read_to_string("/proc/cpuinfo") {
|
||||
for line in contents.lines() {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
if key.trim() == "model name" {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Unknown CPU".to_string()
|
||||
}
|
||||
|
||||
// This can't tell *which* SKU in that family you actually have (lspci
|
||||
// doesn't expose that either) so it's a best guess, not a precise model.
|
||||
fn simplify_gpu_name(raw: &str) -> String {
|
||||
let without_rev = raw.split(" (rev").next().unwrap_or(raw).trim();
|
||||
|
||||
// The marketing name is almost always the last bracketed group.
|
||||
let brackets: Vec<&str> = without_rev
|
||||
.split('[')
|
||||
.skip(1)
|
||||
.filter_map(|s| s.split(']').next())
|
||||
.collect();
|
||||
|
||||
let model = brackets
|
||||
.last()
|
||||
.and_then(|group| group.split('/').next())
|
||||
.unwrap_or(without_rev)
|
||||
.trim();
|
||||
|
||||
let vendor = if without_rev.contains("NVIDIA") {
|
||||
Some("NVIDIA")
|
||||
} else if without_rev.contains("AMD") || without_rev.contains("ATI") {
|
||||
Some("AMD")
|
||||
} else if without_rev.contains("Intel") {
|
||||
Some("Intel")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match vendor {
|
||||
Some(v) if !model.to_uppercase().contains(&v.to_uppercase()) => format!("{} {}", v, model),
|
||||
_ => model.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: parse `lspci` for the primary display controller. Not every
|
||||
// system has `lspci` (minimal containers, some distros), so this degrades
|
||||
// to "Unknown GPU" instead of failing the whole system-info fetch.
|
||||
fn read_gpu_name() -> String {
|
||||
let output = match Command::new("lspci").output() {
|
||||
Ok(output) if output.status.success() => output,
|
||||
_ => return "Unknown GPU".to_string(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains("VGA compatible controller") || line.contains("3D controller") {
|
||||
if let Some((_, rest)) = line.split_once(": ") {
|
||||
return simplify_gpu_name(rest.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
"Unknown GPU".to_string()
|
||||
}
|
||||
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
SystemInfo {
|
||||
os_name: read_os_name(),
|
||||
cpu_model: read_cpu_model(),
|
||||
cpu_cores: num_cpus::get(),
|
||||
gpu_name: read_gpu_name(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn simplifies_amd_gpu_names() {
|
||||
assert_eq!(
|
||||
simplify_gpu_name(
|
||||
"Advanced Micro Devices, Inc. [AMD/ATI] Navi 33 [Radeon RX 7600/7600 XT/7600M XT/7600S/7700S / PRO W7600] (rev cf)"
|
||||
),
|
||||
"AMD Radeon RX 7600"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplifies_nvidia_gpu_names() {
|
||||
assert_eq!(
|
||||
simplify_gpu_name("NVIDIA Corporation GA104 [GeForce RTX 3070/3070 Ti] (rev a1)"),
|
||||
"NVIDIA GeForce RTX 3070"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplifies_intel_gpu_names() {
|
||||
assert_eq!(
|
||||
simplify_gpu_name("Intel Corporation TigerLake-LP GT2 [Iris Xe Graphics] (rev 01)"),
|
||||
"Intel Iris Xe Graphics"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
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 use crate::pe_inspector::KOA_VARIANTS;
|
||||
|
||||
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> {
|
||||
let exe_path = Self::resolve_exe(game_path, context)?;
|
||||
Self::install_proxy(&exe_path)?;
|
||||
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
crate::unlockers::ScreamAPI::install_to_game(&exe_dir_str, "koaloader").await?;
|
||||
|
||||
Self::write_koaloader_config(&exe_path)?;
|
||||
|
||||
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();
|
||||
|
||||
Self::remove_proxy_and_config(exe_dir)?;
|
||||
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
/// Detects bitness, scans for the best-match Koaloader proxy, and copies
|
||||
/// it next to `exe_path`. Returns the scan result so callers can report
|
||||
/// which proxy was used and whether it was a version.dll fallback.
|
||||
/// Shared by the Unlocker trait impl above and installer/mod.rs (which
|
||||
/// wraps this with progress events).
|
||||
pub(crate) fn install_proxy(
|
||||
exe_path: &Path,
|
||||
) -> Result<crate::pe_inspector::ProxyScanResult, String> {
|
||||
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))?;
|
||||
|
||||
info!("Selected proxy: {} (fallback={})", scan.proxy_name, scan.is_fallback);
|
||||
Ok(scan)
|
||||
}
|
||||
|
||||
/// Writes Koaloader.config.json targeting the given executable, next to it.
|
||||
pub(crate) fn write_koaloader_config(exe_path: &Path) -> Result<(), String> {
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
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))
|
||||
}
|
||||
|
||||
/// Removes Koaloader.config.json and any installed proxy DLL from `exe_dir`.
|
||||
pub(crate) fn remove_proxy_and_config(exe_dir: &Path) -> Result<(), 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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 +0,0 @@
|
||||
pub mod bitness;
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Utility",
|
||||
"icon": [
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
"version": "1.7.1",
|
||||
"identifier": "com.creamlinux.dev",
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Creamlinux",
|
||||
"width": 1000,
|
||||
"height": 700,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERENzBFNjU0RTBBMUMyNzgKUldSNHdxSGdWT1p3M1liUE0vOGFCRkc2cEQwdWdRR2UyY2VmN3kzckNONCtsaGF0Y1d2WjdOWVEK",
|
||||
"endpoints": [
|
||||
"https://github.com/Novattz/creamlinux-installer/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useAppContext } from '@/contexts/useAppContext'
|
||||
import { useAppLogic, useConflictDetection, useDisclaimer } from '@/hooks'
|
||||
import { GameVotes } from '@/components/common/VotesDisplay'
|
||||
import './styles/main.scss'
|
||||
|
||||
// Layout components
|
||||
import {
|
||||
Header,
|
||||
Sidebar,
|
||||
InitialLoadingScreen,
|
||||
ErrorBoundary,
|
||||
UpdateScreen,
|
||||
} from '@/components/layout'
|
||||
|
||||
// Dialog components
|
||||
import {
|
||||
ProgressDialog,
|
||||
DlcSelectionDialog,
|
||||
ConflictDialog,
|
||||
DisclaimerDialog,
|
||||
UnlockerChoiceDialog,
|
||||
} from '@/components/dialogs'
|
||||
|
||||
// Page components (Overview and Settings)
|
||||
import { OverviewPage, SettingsPage } from '@/components/pages'
|
||||
|
||||
// Game components
|
||||
import { GameList, EpicGameList } from '@/components/games'
|
||||
|
||||
/**
|
||||
* Main application component
|
||||
*/
|
||||
function App() {
|
||||
const [updateComplete, setUpdateComplete] = useState(false)
|
||||
const [creamVotes, setCreamVotes] = useState<GameVotes | null>(null)
|
||||
const [smokeVotes, setSmokeVotes] = useState<GameVotes | null>(null)
|
||||
|
||||
const { showDisclaimer, handleDisclaimerClose } = useDisclaimer()
|
||||
|
||||
// Get application logic from hook
|
||||
const {
|
||||
filter,
|
||||
setFilter,
|
||||
searchQuery,
|
||||
handleSearchChange,
|
||||
isInitialLoad,
|
||||
scanProgress,
|
||||
filteredGames,
|
||||
handleRefresh,
|
||||
isLoading,
|
||||
error,
|
||||
} = useAppLogic()
|
||||
|
||||
// Get action handlers from context
|
||||
const {
|
||||
games,
|
||||
dlcDialog,
|
||||
handleDlcDialogClose,
|
||||
handleProgressDialogClose,
|
||||
progressDialog,
|
||||
handleGameAction,
|
||||
handleDlcConfirm,
|
||||
handleGameEdit,
|
||||
handleUpdateDlcs,
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleOpenRating,
|
||||
reportingEnabled,
|
||||
showToast,
|
||||
unlockerSelectionDialog,
|
||||
handleSelectCreamLinux,
|
||||
handleSelectSmokeAPI,
|
||||
closeUnlockerDialog,
|
||||
epicGames,
|
||||
epicLoading,
|
||||
epicInstallingId,
|
||||
loadEpicGames,
|
||||
handleEpicInstall,
|
||||
handleEpicUninstallScream,
|
||||
handleEpicUninstallKoaloader,
|
||||
handleEpicSettings,
|
||||
} = useAppContext()
|
||||
|
||||
// Conflict detection
|
||||
const { conflicts, showDialog, resolveConflict, closeDialog } = useConflictDetection(games)
|
||||
|
||||
// Community vote data for the Steam unlocker choice dialog (Epic's
|
||||
// equivalent choice has no vote data, so it skips this entirely)
|
||||
useEffect(() => {
|
||||
const gameId = unlockerSelectionDialog.gameId
|
||||
if (!unlockerSelectionDialog.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)
|
||||
})
|
||||
}, [unlockerSelectionDialog.visible, unlockerSelectionDialog.gameId])
|
||||
|
||||
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} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="app-container">
|
||||
{/* Header with search */}
|
||||
<Header onSearch={handleSearchChange} searchQuery={searchQuery} />
|
||||
|
||||
<div className="main-content">
|
||||
{/* Sidebar for filtering */}
|
||||
<Sidebar setFilter={handleSetFilter} currentFilter={filter} />
|
||||
|
||||
{filter === 'overview' ? (
|
||||
<OverviewPage />
|
||||
) : filter === 'settings' ? (
|
||||
<SettingsPage />
|
||||
) : filter === 'epic' ? (
|
||||
<EpicGameList
|
||||
games={epicGames}
|
||||
isLoading={epicLoading}
|
||||
installingId={epicInstallingId}
|
||||
onInstall={handleEpicInstall}
|
||||
onUninstallScream={handleEpicUninstallScream}
|
||||
onUninstallKoaloader={handleEpicUninstallKoaloader}
|
||||
onSettings={handleEpicSettings}
|
||||
onRefresh={loadEpicGames}
|
||||
/>
|
||||
) : error ? (
|
||||
<div className="error-message">
|
||||
<h3>Error Loading Games</h3>
|
||||
<p>{error}</p>
|
||||
<button onClick={handleRefresh}>Retry</button>
|
||||
</div>
|
||||
) : (
|
||||
<GameList
|
||||
games={filteredGames}
|
||||
isLoading={isLoading}
|
||||
onAction={handleGameAction}
|
||||
onEdit={handleGameEdit}
|
||||
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
||||
onRate={handleOpenRating}
|
||||
onRefresh={handleRefresh}
|
||||
reportingEnabled={reportingEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Dialog */}
|
||||
<ProgressDialog
|
||||
visible={progressDialog.visible}
|
||||
title={progressDialog.title}
|
||||
message={progressDialog.message}
|
||||
progress={progressDialog.progress}
|
||||
showInstructions={progressDialog.showInstructions}
|
||||
instructions={progressDialog.instructions}
|
||||
onClose={handleProgressDialogClose}
|
||||
/>
|
||||
|
||||
{/* DLC Selection Dialog */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Conflict Detection Dialog */}
|
||||
<ConflictDialog
|
||||
visible={showDialog}
|
||||
conflicts={conflicts}
|
||||
onResolve={handleConflictResolve}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
|
||||
{/* Unlocker Selection Dialog */}
|
||||
<UnlockerChoiceDialog
|
||||
visible={unlockerSelectionDialog.visible}
|
||||
gameTitle={unlockerSelectionDialog.gameTitle || ''}
|
||||
onClose={closeUnlockerDialog}
|
||||
options={[
|
||||
{
|
||||
key: 'cream',
|
||||
title: 'CreamLinux',
|
||||
badge: 'recommended',
|
||||
description:
|
||||
'Native Linux DLC unlocker. Works best with most native Linux games and provides better compatibility.',
|
||||
votes: creamVotes,
|
||||
buttonLabel: 'Install CreamLinux',
|
||||
buttonVariant: 'primary',
|
||||
onSelect: handleSelectCreamLinux,
|
||||
},
|
||||
{
|
||||
key: 'smoke',
|
||||
title: 'SmokeAPI',
|
||||
badge: 'alternative',
|
||||
description:
|
||||
"Cross-platform DLC unlocker. Try this if CreamLinux doesn't work for your game. Automatically fetches DLC information.",
|
||||
votes: smokeVotes,
|
||||
buttonLabel: 'Install SmokeAPI',
|
||||
buttonVariant: 'secondary',
|
||||
onSelect: handleSelectSmokeAPI,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Disclaimer Dialog - Shows AFTER everything is loaded */}
|
||||
<DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 84 KiB |
@@ -1,72 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import Button, { ButtonVariant } from '../buttons/Button'
|
||||
import { Icon, trash, download } from '@/components/icons'
|
||||
|
||||
// Define available action types
|
||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' | 'install_unlocker'
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: ActionType
|
||||
isInstalled: boolean
|
||||
isWorking: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized button for game installation actions
|
||||
*/
|
||||
const ActionButton: FC<ActionButtonProps> = ({
|
||||
isInstalled,
|
||||
isWorking,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
// Determine button text based on state
|
||||
const getButtonText = () => {
|
||||
if (isWorking) return 'Working...'
|
||||
|
||||
return isInstalled ? 'Uninstall' : 'Install'
|
||||
}
|
||||
|
||||
// Map to button variant
|
||||
const getButtonVariant = (): ButtonVariant => {
|
||||
// For uninstall actions, use danger variant
|
||||
if (isInstalled) return 'danger'
|
||||
// For install actions, use success variant
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// Select appropriate icon based on action type and state
|
||||
const getIconInfo = () => {
|
||||
if (isInstalled) {
|
||||
// Uninstall actions
|
||||
return { name: trash, variant: 'solid' }
|
||||
} else {
|
||||
// Install actions
|
||||
return { name: download, variant: 'solid' }
|
||||
}
|
||||
}
|
||||
|
||||
const iconInfo = getIconInfo()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={getButtonVariant()}
|
||||
isLoading={isWorking}
|
||||
onClick={onClick}
|
||||
disabled={disabled || isWorking}
|
||||
fullWidth
|
||||
className={`action-button ${className}`}
|
||||
leftIcon={
|
||||
isWorking ? undefined : <Icon name={iconInfo.name} variant={iconInfo.variant} size="md" />
|
||||
}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionButton
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Icon, check } from '@/components/icons'
|
||||
|
||||
interface AnimatedCheckboxProps {
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
label?: string
|
||||
sublabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated checkbox component with optional label and sublabel
|
||||
*/
|
||||
const AnimatedCheckbox = ({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
sublabel,
|
||||
className = '',
|
||||
}: AnimatedCheckboxProps) => {
|
||||
return (
|
||||
<label className={`animated-checkbox ${className}`}>
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
|
||||
|
||||
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
|
||||
{checked && <Icon name={check} variant="solid" size="sm" className="checkbox-icon" />}
|
||||
</span>
|
||||
|
||||
{(label || sublabel) && (
|
||||
<div className="checkbox-content">
|
||||
{label && <span className="checkbox-label">{label}</span>}
|
||||
{sublabel && <span className="checkbox-sublabel">{sublabel}</span>}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedCheckbox
|
||||
@@ -1,73 +0,0 @@
|
||||
import { FC, ButtonHTMLAttributes } from 'react'
|
||||
import Spinner from '@/components/common/Spinner'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning'
|
||||
export type ButtonSize = 'small' | 'medium' | 'large'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
isLoading?: boolean
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
fullWidth?: boolean
|
||||
iconOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Button component with different variants, sizes and states
|
||||
*/
|
||||
const Button: FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
iconOnly = false,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
// Size class mapping
|
||||
const sizeClass = {
|
||||
small: 'btn-sm',
|
||||
medium: 'btn-md',
|
||||
large: 'btn-lg',
|
||||
}[size]
|
||||
|
||||
// Variant class mapping
|
||||
const variantClass = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
danger: 'btn-danger',
|
||||
success: 'btn-success',
|
||||
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' : ''
|
||||
} ${isIconOnly ? 'btn-icon-only' : ''} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<span className="btn-spinner">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{leftIcon && !isLoading && <span className="btn-icon btn-icon-left">{leftIcon}</span>}
|
||||
{children && <span className="btn-text">{children}</span>}
|
||||
{rightIcon && !isLoading && <span className="btn-icon btn-icon-right">{rightIcon}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
@@ -1,8 +0,0 @@
|
||||
// Export all button components
|
||||
export { default as Button } from './Button'
|
||||
export { default as ActionButton } from './ActionButton'
|
||||
export { default as AnimatedCheckbox } from './AnimatedCheckbox'
|
||||
|
||||
// Export types
|
||||
export type { ButtonVariant, ButtonSize } from './Button'
|
||||
export type { ActionType } from './ActionButton'
|
||||
@@ -1,97 +0,0 @@
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useState, KeyboardEvent } from 'react'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, IconName } from '@/components/icons'
|
||||
|
||||
export interface EntryListProps {
|
||||
/** The entries to display (e.g. file paths) */
|
||||
items: string[]
|
||||
/** Called with a manually-typed entry when the user presses Enter */
|
||||
onAddManual: (value: string) => void
|
||||
/** Called when the browse button (next to the input) is clicked */
|
||||
onBrowse: () => void
|
||||
/** Called with the entry to remove when its remove button is clicked */
|
||||
onRemove: (item: string) => void
|
||||
/** Placeholder text for the manual-entry input */
|
||||
placeholder: string
|
||||
/** Message shown when there are no entries yet */
|
||||
emptyLabel: string
|
||||
/** Disables all interaction, e.g. while a request is in flight */
|
||||
disabled?: boolean
|
||||
/** Icon shown next to each entry */
|
||||
icon?: IconName | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic list of string entries (paths, URLs, anything long and one-line)
|
||||
* with add/remove controls. The add row (manual entry + browse button) is
|
||||
* always pinned at the bottom it never scrolls out of view, even once
|
||||
* the entries above it start scrolling. Long entries are truncated from the
|
||||
* start so the end, usually the most identifying part stays visible.
|
||||
*/
|
||||
const EntryList = ({
|
||||
items,
|
||||
onAddManual,
|
||||
onBrowse,
|
||||
onRemove,
|
||||
placeholder,
|
||||
emptyLabel,
|
||||
disabled = false,
|
||||
icon = 'Folder',
|
||||
}: EntryListProps) => {
|
||||
const [manualValue, setManualValue] = useState('')
|
||||
|
||||
const submitManualValue = () => {
|
||||
const trimmed = manualValue.trim()
|
||||
if (!trimmed) return
|
||||
onAddManual(trimmed)
|
||||
setManualValue('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') submitManualValue()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="entry-list">
|
||||
<div className="entry-list-scroll-area">
|
||||
{items.length === 0 ? (
|
||||
<div className="entry-list-empty">
|
||||
<Icon name={icon} className="icon-secondary" variant="solid" size="lg" />
|
||||
<p>{emptyLabel}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="entry-list-items">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="entry-list-item">
|
||||
<Icon name={icon} variant="solid" size="md" className="entry-list-item-icon" />
|
||||
<span className="entry-list-item-text" title={item}>
|
||||
{item}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => onRemove(item)}
|
||||
disabled={disabled}
|
||||
title="Remove"
|
||||
className="entry-list-remove-button"
|
||||
leftIcon={<Icon name="Trash" variant="solid" size="sm" />}
|
||||
iconOnly
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="entry-list-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="entry-list-input"
|
||||
placeholder={placeholder}
|
||||
value={manualValue}
|
||||
onChange={(e) => setManualValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onClick={onBrowse}
|
||||
disabled={disabled}
|
||||
title="Browse for a folder"
|
||||
className="entry-list-browse-button"
|
||||
leftIcon={<Icon name="Folder" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryList
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import Spinner from './Spinner'
|
||||
|
||||
export type LoadingType = 'spinner' | 'dots'
|
||||
export type LoadingSize = 'small' | 'medium' | 'large'
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
size?: LoadingSize
|
||||
type?: LoadingType
|
||||
message?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Versatile loading indicator component
|
||||
* Supports multiple visual styles and sizes.
|
||||
* For a progress bar, use ProgressBar or ProgressDialog's progress bar instead.
|
||||
*/
|
||||
const LoadingIndicator = ({
|
||||
size = 'medium',
|
||||
type = 'spinner',
|
||||
message,
|
||||
className = '',
|
||||
}: LoadingIndicatorProps) => {
|
||||
// Size class mapping
|
||||
const sizeClass = {
|
||||
small: 'loading-small',
|
||||
medium: 'loading-medium',
|
||||
large: 'loading-large',
|
||||
}[size]
|
||||
|
||||
// Render loading indicator based on type
|
||||
const renderLoadingIndicator = (): ReactNode => {
|
||||
switch (type) {
|
||||
case 'spinner':
|
||||
return <Spinner />
|
||||
|
||||
case 'dots':
|
||||
return (
|
||||
<div className="loading-dots">
|
||||
<div className="dot dot-1"></div>
|
||||
<div className="dot dot-2"></div>
|
||||
<div className="dot dot-3"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <Spinner />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`loading-indicator ${sizeClass} ${className}`}>
|
||||
{renderLoadingIndicator()}
|
||||
{message && <p className="loading-message">{message}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingIndicator
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,17 +0,0 @@
|
||||
interface SpinnerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows/Fluent-style indeterminate ring spinner an SVG circle whose
|
||||
* stroke-dasharray and rotation are animated (see .spinner-ring in
|
||||
* _loading.scss), rather than a CSS conic-gradient mask. Size and color
|
||||
* come from whatever wrapping className/context is passed in.
|
||||
*/
|
||||
const Spinner = ({ className = '' }: SpinnerProps) => (
|
||||
<svg viewBox="0 0 16 16" className={`spinner-ring ${className}`}>
|
||||
<circle cx="8" cy="8" r="7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default Spinner
|
||||
@@ -1,47 +0,0 @@
|
||||
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,11 +0,0 @@
|
||||
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 { default as EntryList } from './EntryList'
|
||||
export { default as Spinner } from './Spinner'
|
||||
|
||||
export type { LoadingSize, LoadingType } from './LoadingIndicator'
|
||||
export type { DropdownOption } from './Dropdown'
|
||||
export type { GameVotes } from './VotesDisplay'
|
||||
export type { EntryListProps } from './EntryList'
|
||||
@@ -1,93 +0,0 @@
|
||||
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
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import Dialog from './Dialog'
|
||||
import DialogHeader from './DialogHeader'
|
||||
import DialogBody from './DialogBody'
|
||||
import DialogFooter from './DialogFooter'
|
||||
import DialogActions from './DialogActions'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { Dropdown, DropdownOption } from '@/components/common'
|
||||
|
||||
export type ApiSettingsField =
|
||||
| {
|
||||
kind: 'dropdown'
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
options: DropdownOption<string>[]
|
||||
}
|
||||
| {
|
||||
kind: 'checkbox'
|
||||
key: string
|
||||
label: string
|
||||
sublabel: string
|
||||
}
|
||||
|
||||
export interface ApiSettingsSection {
|
||||
title: string
|
||||
fields: ApiSettingsField[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an unlocker's config dialog: what sections/fields to render and
|
||||
* which Tauri commands read/write/delete its config file. One dialog
|
||||
* component renders any config that fits this shape, instead of a
|
||||
* near-identical component per unlocker.
|
||||
*/
|
||||
export interface ApiSettingsSpec {
|
||||
dialogTitle: string
|
||||
enableLabel: string
|
||||
enableSublabel: string
|
||||
defaultConfig: Record<string, unknown>
|
||||
sections: ApiSettingsSection[]
|
||||
readCommand: string
|
||||
writeCommand: string
|
||||
deleteCommand: string
|
||||
}
|
||||
|
||||
export interface ApiSettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
spec: ApiSettingsSpec
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic settings dialog for an unlocker's config file (SmokeAPI,
|
||||
* ScreamAPI, ...). Renders whatever sections/fields the spec describes and
|
||||
* reads/writes/deletes the config through the spec's Tauri commands.
|
||||
*/
|
||||
const ApiSettingsDialog = ({ visible, onClose, gamePath, gameTitle, spec }: ApiSettingsDialogProps) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [config, setConfig] = useState<Record<string, unknown>>(spec.defaultConfig)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const existingConfig = await invoke<Record<string, unknown> | null>(spec.readCommand, {
|
||||
gamePath,
|
||||
})
|
||||
|
||||
if (existingConfig) {
|
||||
setConfig(existingConfig)
|
||||
setEnabled(true)
|
||||
} else {
|
||||
setConfig(spec.defaultConfig)
|
||||
setEnabled(false)
|
||||
}
|
||||
setHasChanges(false)
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${spec.dialogTitle}:`, error)
|
||||
setConfig(spec.defaultConfig)
|
||||
setEnabled(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [gamePath, spec])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && gamePath) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [visible, gamePath, loadConfig])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (enabled) {
|
||||
await invoke(spec.writeCommand, { gamePath, config })
|
||||
} else {
|
||||
await invoke(spec.deleteCommand, { gamePath })
|
||||
}
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error(`Failed to save ${spec.dialogTitle}:`, error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
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>{spec.dialogTitle}</h3>
|
||||
</div>
|
||||
<p className="dialog-subtitle">{gameTitle}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="api-settings-content">
|
||||
<div className="settings-section">
|
||||
<AnimatedCheckbox
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
setEnabled(!enabled)
|
||||
setHasChanges(true)
|
||||
}}
|
||||
label={spec.enableLabel}
|
||||
sublabel={spec.enableSublabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
|
||||
{spec.sections.map((section) => (
|
||||
<div className="settings-section" key={section.title}>
|
||||
<h4>{section.title}</h4>
|
||||
|
||||
{section.fields.map((field) =>
|
||||
field.kind === 'dropdown' ? (
|
||||
<Dropdown
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
value={config[field.key] as string}
|
||||
options={field.options}
|
||||
onChange={(value) => updateConfig(field.key, value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
) : (
|
||||
<div className="checkbox-option" key={field.key}>
|
||||
<AnimatedCheckbox
|
||||
checked={Boolean(config[field.key])}
|
||||
onChange={() => updateConfig(field.key, !config[field.key])}
|
||||
label={field.label}
|
||||
sublabel={field.sublabel}
|
||||
/>
|
||||
</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 ApiSettingsDialog
|
||||
@@ -1,106 +0,0 @@
|
||||
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
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
export interface DialogProps {
|
||||
visible: boolean
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
preventBackdropClose?: boolean
|
||||
children: ReactNode
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
showAnimationOnUnmount?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Base Dialog component that serves as a container for dialog content
|
||||
* Used with DialogHeader, DialogBody, and DialogFooter components
|
||||
*/
|
||||
const Dialog = ({
|
||||
visible,
|
||||
onClose,
|
||||
className = '',
|
||||
preventBackdropClose = false,
|
||||
children,
|
||||
size = 'medium',
|
||||
showAnimationOnUnmount = true,
|
||||
}: DialogProps) => {
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
const [shouldRender, setShouldRender] = useState(visible)
|
||||
|
||||
// Handle visibility changes with animations
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true)
|
||||
// Small delay to trigger entrance animation after component is mounted
|
||||
const timer = setTimeout(() => {
|
||||
setShowContent(true)
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (showAnimationOnUnmount) {
|
||||
// First hide content with animation
|
||||
setShowContent(false)
|
||||
// Then unmount after animation completes
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false)
|
||||
}, 300) // Match this with your CSS transition duration
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
// Immediately unmount without animation
|
||||
setShowContent(false)
|
||||
setShouldRender(false)
|
||||
}
|
||||
}, [visible, showAnimationOnUnmount])
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget && !preventBackdropClose && onClose) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// Don't render anything if dialog shouldn't be shown
|
||||
if (!shouldRender) return null
|
||||
|
||||
const sizeClass = {
|
||||
small: 'dialog-small',
|
||||
medium: 'dialog-medium',
|
||||
large: 'dialog-large',
|
||||
}[size]
|
||||
|
||||
return (
|
||||
<div className={`dialog-overlay ${showContent ? 'visible' : ''}`} onClick={handleBackdropClick}>
|
||||
<div className={`dialog ${sizeClass} ${className} ${showContent ? 'dialog-visible' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dialog
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogActionsProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions container for dialog footers
|
||||
* Provides consistent spacing and alignment for action buttons
|
||||
*/
|
||||
const DialogActions = ({ children, className = '', align = 'end' }: DialogActionsProps) => {
|
||||
const alignClass = {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
}[align]
|
||||
|
||||
return <div className={`dialog-actions ${alignClass} ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
export default DialogActions
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogBodyProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Body component for dialogs
|
||||
* Contains the main content with scrolling capability
|
||||
*/
|
||||
const DialogBody = ({ children, className = '' }: DialogBodyProps) => {
|
||||
return <div className={`dialog-body ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
export default DialogBody
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogFooterProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for dialogs
|
||||
* Contains action buttons and optional status information
|
||||
*/
|
||||
const DialogFooter = ({ children, className = '' }: DialogFooterProps) => {
|
||||
return <div className={`dialog-footer ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
export default DialogFooter
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface DialogHeaderProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClose?: () => void
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component for dialogs
|
||||
* Contains the title and optional close button
|
||||
*/
|
||||
const DialogHeader = ({ children, className = '', onClose, hideCloseButton = false }: DialogHeaderProps) => {
|
||||
return (
|
||||
<div className={`dialog-header ${className}`}>
|
||||
{children}
|
||||
{onClose && !hideCloseButton && (
|
||||
<button className="dialog-close-button" onClick={onClose} aria-label="Close dialog">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogHeader
|
||||
@@ -1,69 +0,0 @@
|
||||
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
|
||||
@@ -1,299 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import Dialog from './Dialog'
|
||||
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 { Spinner } from '@/components/common'
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* DLC Selection Dialog component
|
||||
* Allows users to select which DLCs they want to enable
|
||||
* Works for both initial installation and editing existing configurations
|
||||
*/
|
||||
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(() => {
|
||||
if (!visible) {
|
||||
setInitialized(false)
|
||||
setSelectedDlcs([])
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// Initialize selected DLCs when DLC list changes
|
||||
useEffect(() => {
|
||||
if (dlcs.length > 0) {
|
||||
if (!initialized) {
|
||||
// Create a new array to ensure we don't share references
|
||||
setSelectedDlcs([...dlcs])
|
||||
|
||||
// Determine initial selectAll state based on if all DLCs are enabled
|
||||
const allSelected = dlcs.every((dlc) => dlc.enabled)
|
||||
setSelectAll(allSelected)
|
||||
|
||||
// Mark as initialized to avoid resetting selections on subsequent updates
|
||||
setInitialized(true)
|
||||
} else {
|
||||
// Find new DLCs that aren't in our current selection
|
||||
const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
|
||||
const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
|
||||
|
||||
// If we found new DLCs, add them to our selection
|
||||
if (newDlcs.length > 0) {
|
||||
setSelectedDlcs((prev) => [...prev, ...newDlcs])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [dlcs, selectedDlcs, initialized])
|
||||
|
||||
// Memoize filtered DLCs to avoid unnecessary recalculations
|
||||
const filteredDlcs = React.useMemo(() => {
|
||||
return searchQuery.trim() === ''
|
||||
? selectedDlcs
|
||||
: selectedDlcs.filter(
|
||||
(dlc) =>
|
||||
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
dlc.appid.includes(searchQuery)
|
||||
)
|
||||
}, [selectedDlcs, searchQuery])
|
||||
|
||||
// Update DLC selection status
|
||||
const handleToggleDlc = useCallback((appid: string) => {
|
||||
setSelectedDlcs((prev) =>
|
||||
prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
|
||||
)
|
||||
}, [])
|
||||
|
||||
// Update selectAll state when individual DLC selections change
|
||||
useEffect(() => {
|
||||
if (selectedDlcs.length > 0) {
|
||||
const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
|
||||
setSelectAll(allSelected)
|
||||
}
|
||||
}, [selectedDlcs])
|
||||
|
||||
// Toggle all DLCs at once
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
const newSelectAllState = !selectAll
|
||||
setSelectAll(newSelectAllState)
|
||||
|
||||
setSelectedDlcs((prev) =>
|
||||
prev.map((dlc) => ({
|
||||
...dlc,
|
||||
enabled: newSelectAllState,
|
||||
}))
|
||||
)
|
||||
}, [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
|
||||
const dlcsCopy = JSON.parse(JSON.stringify(selectedDlcs))
|
||||
onConfirm(dlcsCopy)
|
||||
}, [onConfirm, selectedDlcs])
|
||||
|
||||
// Count selected DLCs
|
||||
const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
|
||||
|
||||
// Format dialog title and messages based on mode
|
||||
const dialogTitle = isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'
|
||||
const actionButtonText = isEditMode ? 'Save Changes' : 'Install with Selected DLCs'
|
||||
|
||||
// Format loading message to show total number of DLCs found
|
||||
const getLoadingInfoText = () => {
|
||||
if (isLoading && loadingProgress < 100) {
|
||||
return ` (Loading more DLCs...)`
|
||||
} else if (dlcs.length > 0) {
|
||||
return ` (Total DLCs: ${dlcs.length})`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div className="dlc-dialog-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dlc-search-input"
|
||||
/>
|
||||
<div className="select-all-container">
|
||||
<AnimatedCheckbox
|
||||
checked={selectAll}
|
||||
onChange={handleToggleSelectAll}
|
||||
label="Select All"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isLoading || 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 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">
|
||||
<Spinner />
|
||||
<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))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DlcSelectionDialog
|
||||
@@ -1,82 +0,0 @@
|
||||
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
|
||||
@@ -1,173 +0,0 @@
|
||||
import { useState } 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'
|
||||
|
||||
export interface InstallationInstructions {
|
||||
type: string
|
||||
command: string
|
||||
game_title: string
|
||||
dlc_count?: number
|
||||
}
|
||||
|
||||
export interface ProgressDialogProps {
|
||||
visible: boolean
|
||||
title: string
|
||||
message: string
|
||||
progress: number
|
||||
showInstructions?: boolean
|
||||
instructions?: InstallationInstructions
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressDialog component
|
||||
* Shows installation progress with a progress bar and optional instructions
|
||||
*/
|
||||
const ProgressDialog = ({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
progress,
|
||||
showInstructions = false,
|
||||
instructions,
|
||||
onClose,
|
||||
}: ProgressDialogProps) => {
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
|
||||
const handleCopyCommand = () => {
|
||||
if (instructions?.command) {
|
||||
navigator.clipboard.writeText(instructions.command)
|
||||
setCopySuccess(true)
|
||||
|
||||
// Reset the success message after 2 seconds
|
||||
setTimeout(() => {
|
||||
setCopySuccess(false)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
|
||||
const showCopyButton =
|
||||
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
|
||||
|
||||
// Format instruction message based on type
|
||||
const getInstructionText = () => {
|
||||
if (!instructions) return null
|
||||
|
||||
switch (instructions.type) {
|
||||
case 'cream_install':
|
||||
return (
|
||||
<>
|
||||
<p className="instruction-text">
|
||||
In Steam, set the following launch options for{' '}
|
||||
<strong>{instructions.game_title}</strong>:
|
||||
</p>
|
||||
{instructions.dlc_count !== undefined && (
|
||||
<div className="dlc-count">
|
||||
<strong>{instructions.dlc_count}</strong> DLCs have been enabled!
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case 'cream_uninstall':
|
||||
return (
|
||||
<p className="instruction-text">
|
||||
For <strong>{instructions.game_title}</strong>, open Steam properties and remove the
|
||||
following launch option:
|
||||
</p>
|
||||
)
|
||||
case 'smoke_install':
|
||||
return (
|
||||
<>
|
||||
<p className="instruction-text">
|
||||
SmokeAPI has been installed for <strong>{instructions.game_title}</strong>
|
||||
</p>
|
||||
{instructions.dlc_count !== undefined && (
|
||||
<div className="dlc-count">
|
||||
<strong>{instructions.dlc_count}</strong> Steam API files have been patched.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case 'smoke_uninstall':
|
||||
return (
|
||||
<p className="instruction-text">
|
||||
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
|
||||
</p>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<p className="instruction-text">
|
||||
Done processing <strong>{instructions.game_title}</strong>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the CSS class for the command box based on instruction type
|
||||
const getCommandBoxClass = () => {
|
||||
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'
|
||||
}
|
||||
|
||||
// Determine if close button should be enabled
|
||||
const isCloseButtonEnabled = showInstructions || progress >= 100
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onClose={isCloseButtonEnabled ? onClose : undefined}
|
||||
size="medium"
|
||||
preventBackdropClose={!isCloseButtonEnabled}
|
||||
>
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>{title}</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<p>{message}</p>
|
||||
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-dialog-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||
|
||||
{showInstructions && instructions && (
|
||||
<div className="instruction-container">
|
||||
<h4>
|
||||
{instructions.type.includes('uninstall')
|
||||
? 'Uninstallation Instructions'
|
||||
: 'Installation Instructions'}
|
||||
</h4>
|
||||
{getInstructionText()}
|
||||
|
||||
<div className={getCommandBoxClass()}>
|
||||
<pre className="selectable-text">{instructions.command}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
{showInstructions && showCopyButton && (
|
||||
<Button variant="primary" onClick={handleCopyCommand}>
|
||||
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCloseButtonEnabled && (
|
||||
<Button variant="secondary" onClick={onClose} disabled={!isCloseButtonEnabled}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressDialog
|
||||
@@ -1,164 +0,0 @@
|
||||
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
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
@@ -1,95 +0,0 @@
|
||||
import Dialog from './Dialog'
|
||||
import DialogHeader from './DialogHeader'
|
||||
import DialogBody from './DialogBody'
|
||||
import DialogFooter from './DialogFooter'
|
||||
import DialogActions from './DialogActions'
|
||||
import { Button, ButtonVariant } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||
|
||||
export interface UnlockerChoiceOption {
|
||||
key: string
|
||||
title: string
|
||||
badge: 'recommended' | 'alternative'
|
||||
description: string
|
||||
buttonLabel: string
|
||||
buttonVariant: ButtonVariant
|
||||
onSelect: () => void
|
||||
/** Omit entirely for unlockers that don't have community vote data (e.g. Epic's) */
|
||||
votes?: GameVotes | null
|
||||
}
|
||||
|
||||
export interface UnlockerChoiceDialogProps {
|
||||
visible: boolean
|
||||
gameTitle: string | null
|
||||
onClose: () => void
|
||||
options: UnlockerChoiceOption[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic "choose which unlocker to install" dialog. Used for both the
|
||||
* Steam choice (CreamLinux vs SmokeAPI, with vote data) and the Epic choice
|
||||
* (ScreamAPI vs Koaloader, no votes) - they only differ in copy and options,
|
||||
* not in structure.
|
||||
*/
|
||||
const UnlockerChoiceDialog = ({ visible, gameTitle, onClose, options }: UnlockerChoiceDialogProps) => {
|
||||
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">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.key}
|
||||
className={`unlocker-option ${option.badge === 'recommended' ? 'recommended' : ''}`}
|
||||
>
|
||||
<div className="option-header">
|
||||
<h4>{option.title}</h4>
|
||||
<span
|
||||
className={
|
||||
option.badge === 'recommended' ? 'recommended-badge' : 'alternative-badge'
|
||||
}
|
||||
>
|
||||
{option.badge === 'recommended' ? 'Recommended' : 'Alternative'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="option-description">{option.description}</p>
|
||||
{option.votes !== undefined && <VotesDisplay votes={option.votes} />}
|
||||
<Button variant={option.buttonVariant} onClick={option.onSelect} fullWidth>
|
||||
{option.buttonLabel}
|
||||
</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 UnlockerChoiceDialog
|
||||
@@ -1,136 +0,0 @@
|
||||
import { DropdownOption } from '@/components/common'
|
||||
import { ApiSettingsSpec } from './ApiSettingsDialog'
|
||||
|
||||
const DLC_STATUS_OPTIONS: DropdownOption<string>[] = [
|
||||
{ value: 'unlocked', label: 'Unlocked' },
|
||||
{ value: 'locked', label: 'Locked' },
|
||||
{ value: 'original', label: 'Original' },
|
||||
]
|
||||
|
||||
export const smokeApiSettingsSpec: ApiSettingsSpec = {
|
||||
dialogTitle: 'SmokeAPI Settings',
|
||||
enableLabel: 'Enable SmokeAPI Configuration',
|
||||
enableSublabel: 'Enable this to customize SmokeAPI settings for this game',
|
||||
defaultConfig: {
|
||||
$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: {},
|
||||
},
|
||||
readCommand: 'read_smokeapi_config',
|
||||
writeCommand: 'write_smokeapi_config',
|
||||
deleteCommand: 'delete_smokeapi_config',
|
||||
sections: [
|
||||
{
|
||||
title: 'General Settings',
|
||||
fields: [
|
||||
{
|
||||
kind: 'dropdown',
|
||||
key: 'default_app_status',
|
||||
label: 'Default App Status',
|
||||
description: 'Specifies the default DLC status',
|
||||
options: DLC_STATUS_OPTIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Logging',
|
||||
fields: [
|
||||
{
|
||||
kind: 'checkbox',
|
||||
key: 'logging',
|
||||
label: 'Enable Logging',
|
||||
sublabel: 'Enables logging to SmokeAPI.log.log file',
|
||||
},
|
||||
{
|
||||
kind: 'checkbox',
|
||||
key: 'log_steam_http',
|
||||
label: 'Log Steam HTTP',
|
||||
sublabel: 'Toggles logging of SteamHTTP traffic',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Inventory',
|
||||
fields: [
|
||||
{
|
||||
kind: 'checkbox',
|
||||
key: 'auto_inject_inventory',
|
||||
label: 'Auto Inject Inventory',
|
||||
sublabel:
|
||||
'Automatically inject a list of all registered inventory items when the game queries user inventory',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const screamApiSettingsSpec: ApiSettingsSpec = {
|
||||
dialogTitle: 'ScreamAPI Settings',
|
||||
enableLabel: 'Enable ScreamAPI Configuration',
|
||||
enableSublabel: 'Enable this to customise ScreamAPI settings for this game',
|
||||
defaultConfig: {
|
||||
$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: {},
|
||||
},
|
||||
readCommand: 'read_screamapi_config',
|
||||
writeCommand: 'write_screamapi_config',
|
||||
deleteCommand: 'delete_screamapi_config',
|
||||
sections: [
|
||||
{
|
||||
title: 'General Settings',
|
||||
fields: [
|
||||
{
|
||||
kind: 'dropdown',
|
||||
key: 'default_dlc_status',
|
||||
label: 'Default DLC Status',
|
||||
description: 'Specifies the default DLC unlock status',
|
||||
options: DLC_STATUS_OPTIONS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Logging',
|
||||
fields: [
|
||||
{
|
||||
kind: 'checkbox',
|
||||
key: 'logging',
|
||||
label: 'Enable Logging',
|
||||
sublabel: 'Enables logging to ScreamAPI.log.log file',
|
||||
},
|
||||
{
|
||||
kind: 'checkbox',
|
||||
key: 'log_eos',
|
||||
label: 'Log EOS SDK',
|
||||
sublabel: 'Intercept and log EOS SDK calls (requires logging enabled)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Privacy',
|
||||
fields: [
|
||||
{
|
||||
kind: 'checkbox',
|
||||
key: 'block_metrics',
|
||||
label: 'Block Metrics',
|
||||
sublabel: 'Block game analytics/usage reporting to Epic Online Services',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Export all dialog components
|
||||
export { default as Dialog } from './Dialog'
|
||||
export { default as DialogHeader } from './DialogHeader'
|
||||
export { default as DialogBody } from './DialogBody'
|
||||
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 ApiSettingsDialog } from './ApiSettingsDialog'
|
||||
export { smokeApiSettingsSpec, screamApiSettingsSpec } from './apiSettingsSpecs'
|
||||
export { default as ConflictDialog } from './ConflictDialog'
|
||||
export { default as DisclaimerDialog } from './DisclaimerDialog'
|
||||
export { default as UnlockerChoiceDialog } from './UnlockerChoiceDialog'
|
||||
export { default as OptInDialog } from './OptInDialog'
|
||||
export { default as RatingDialog } from './RatingDialog'
|
||||
export { default as SmokeAPIVotesDialog } from './SmokeAPIVotesDialog'
|
||||
|
||||
// Export types
|
||||
export type { DialogProps } from './Dialog'
|
||||
export type { DialogHeaderProps } from './DialogHeader'
|
||||
export type { DialogBodyProps } from './DialogBody'
|
||||
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 { ApiSettingsDialogProps, ApiSettingsSpec, ApiSettingsField } from './ApiSettingsDialog'
|
||||
export type { UnlockerChoiceDialogProps, UnlockerChoiceOption } from './UnlockerChoiceDialog'
|
||||
export type { RatingDialogProps } from './RatingDialog'
|
||||
export type { SmokeAPIVotesDialogProps } from './SmokeAPIVotesDialog'
|
||||
@@ -1,119 +0,0 @@
|
||||
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
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import EpicGameItem from '@/components/games/EpicGameItem'
|
||||
import { EpicGame } from '@/types/EpicGame'
|
||||
import LoadingIndicator from '../common/LoadingIndicator'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, refresh } from '@/components/icons'
|
||||
|
||||
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
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const EpicGameList = ({
|
||||
games,
|
||||
isLoading,
|
||||
installingId,
|
||||
onInstall,
|
||||
onUninstallScream,
|
||||
onUninstallKoaloader,
|
||||
onSettings,
|
||||
onRefresh,
|
||||
}: 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">
|
||||
<div className="game-list-header">
|
||||
<h2>Epic Games ({games.length})</h2>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={onRefresh}
|
||||
title="Refresh"
|
||||
className="refresh-button"
|
||||
leftIcon={<Icon name={refresh} variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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
|
||||
@@ -1,229 +0,0 @@
|
||||
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, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Function to fetch the game cover/image
|
||||
const fetchGameImage = async () => {
|
||||
// First check if we already have it (to prevent flickering on re-renders)
|
||||
if (imageUrl) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Try to find the best available image for this game
|
||||
const bestImageUrl = await findBestGameImage(game.id)
|
||||
|
||||
if (bestImageUrl) {
|
||||
setImageUrl(bestImageUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching game image:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (game.id) {
|
||||
fetchGameImage()
|
||||
}
|
||||
}, [game.id, imageUrl])
|
||||
|
||||
// Determine if we should show CreamLinux buttons (only for native games)
|
||||
const shouldShowCream = game.native && 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)
|
||||
|
||||
const handleCreamAction = () => {
|
||||
if (game.installing) return
|
||||
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
const handleSmokeAction = () => {
|
||||
if (game.installing) return
|
||||
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
const handleUnlockerAction = () => {
|
||||
if (game.installing) return
|
||||
onAction(game.id, 'install_unlocker')
|
||||
}
|
||||
|
||||
// Handle edit button click
|
||||
const handleEdit = () => {
|
||||
if (onEdit && game.cream_installed) {
|
||||
onEdit(game.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ? `url(${imageUrl})` : 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-item-card"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="game-item-overlay">
|
||||
<div className="game-badges">
|
||||
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
|
||||
{game.native ? 'Native' : 'Proton'}
|
||||
</span>
|
||||
{game.cream_installed && <span className="status-badge cream">CreamLinux</span>}
|
||||
{game.smoke_installed && <span className="status-badge smoke">SmokeAPI</span>}
|
||||
</div>
|
||||
|
||||
<div className="game-title">
|
||||
<h3>{game.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-actions">
|
||||
{/* Show 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="uninstall_cream"
|
||||
isInstalled={true}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleCreamAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI button for Proton games OR native games with SmokeAPI installed */}
|
||||
{shouldShowSmoke && (
|
||||
<ActionButton
|
||||
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
||||
isInstalled={!!game.smoke_installed}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleSmokeAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<span>Steam API DLL not found</span>
|
||||
<Button
|
||||
variant="warning"
|
||||
size="small"
|
||||
onClick={() => onAction(game.id, 'install_smoke')}
|
||||
title="Attempt to scan again"
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
</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
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleEdit}
|
||||
disabled={!game.cream_installed || !!game.installing}
|
||||
title="Manage DLCs"
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GameItem
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { GameItem, ImagePreloader } from '@/components/games'
|
||||
import { ActionType, Button } from '@/components/buttons'
|
||||
import { Icon, refresh } from '@/components/icons'
|
||||
import { Game } from '@/types'
|
||||
import LoadingIndicator from '../common/LoadingIndicator'
|
||||
|
||||
interface GameListProps {
|
||||
games: Game[]
|
||||
isLoading: boolean
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
onRate?: (gameId: string) => void
|
||||
onRefresh: () => void
|
||||
reportingEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game list component
|
||||
* Displays games in a grid with search and filtering applied
|
||||
*/
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, onRefresh, reportingEnabled }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title
|
||||
const sortedGames = useMemo(() => {
|
||||
return [...games].sort((a, b) => a.title.localeCompare(b.title))
|
||||
}, [games])
|
||||
|
||||
// Reset preloaded state when games change
|
||||
useEffect(() => {
|
||||
setImagesPreloaded(false)
|
||||
}, [games])
|
||||
|
||||
const handlePreloadComplete = () => {
|
||||
setImagesPreloaded(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="game-list">
|
||||
<LoadingIndicator type="spinner" size="large" message="Scanning for games..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<div className="game-list-header">
|
||||
<h2>Games ({games.length})</h2>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={onRefresh}
|
||||
title="Refresh"
|
||||
className="refresh-button"
|
||||
leftIcon={<Icon name={refresh} className="icon-secondary" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!imagesPreloaded && games.length > 0 && (
|
||||
<ImagePreloader
|
||||
gameIds={sortedGames.map((game) => game.id)}
|
||||
onComplete={handlePreloadComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{games.length === 0 ? (
|
||||
<div className="no-games-message">No games found</div>
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map((game) => (
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} onRate={onRate} reportingEnabled={reportingEnabled} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GameList
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { findBestGameImage } from '@/services/ImageService'
|
||||
|
||||
interface ImagePreloaderProps {
|
||||
gameIds: string[]
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads game images to prevent flickering
|
||||
* Only used internally by GameList component
|
||||
*/
|
||||
const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
|
||||
useEffect(() => {
|
||||
const preloadImages = async () => {
|
||||
try {
|
||||
// Only preload the first batch for performance (10 images max)
|
||||
const batchToPreload = gameIds.slice(0, 10)
|
||||
|
||||
// Track loading progress
|
||||
let loadedCount = 0
|
||||
const totalImages = batchToPreload.length
|
||||
|
||||
// Load images in parallel
|
||||
await Promise.allSettled(
|
||||
batchToPreload.map(async (id) => {
|
||||
await findBestGameImage(id)
|
||||
loadedCount++
|
||||
|
||||
// If all images are loaded, call onComplete
|
||||
if (loadedCount === totalImages && onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Fallback if Promise.allSettled doesn't trigger onComplete
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preloading images:', error)
|
||||
// Continue even if there's an error
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
preloadImages()
|
||||
} else if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
}, [gameIds, onComplete])
|
||||
|
||||
// Invisible component that just handles preloading
|
||||
return null
|
||||
}
|
||||
|
||||
export default ImagePreloader
|
||||
@@ -1,6 +0,0 @@
|
||||
// Export all game components
|
||||
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'
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Icon component for displaying SVG icons with standardized properties
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
// Import all icon variants
|
||||
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 = '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 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 - solid, stroke, or brand */
|
||||
variant?: IconVariant | string
|
||||
/** Title for accessibility */
|
||||
title?: string
|
||||
/** Fill color (if not specified by the SVG itself) */
|
||||
fillColor?: string
|
||||
/** Stroke color (if not specified by the SVG itself) */
|
||||
strokeColor?: string
|
||||
/** Additional CSS class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert size string to pixel value
|
||||
*/
|
||||
const getSizeValue = (size: IconSize): string => {
|
||||
if (typeof size === 'number') return `${size}px`
|
||||
|
||||
const sizeMap: Record<string, string> = {
|
||||
xs: '12px',
|
||||
sm: '16px',
|
||||
md: '24px',
|
||||
lg: '32px',
|
||||
xl: '48px',
|
||||
}
|
||||
|
||||
return sizeMap[size] || sizeMap.md
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the icon component based on name and variant
|
||||
*/
|
||||
const getIconComponent = (
|
||||
name: string,
|
||||
variant: IconVariant | string
|
||||
): React.ComponentType<React.SVGProps<SVGSVGElement>> | null => {
|
||||
// Normalize variant to ensure it's a valid IconVariant
|
||||
const normalizedVariant =
|
||||
variant === 'solid' || variant === 'stroke' || variant === 'brand'
|
||||
? (variant as IconVariant)
|
||||
: undefined
|
||||
|
||||
// Try to get the icon from the specified variant
|
||||
switch (normalizedVariant) {
|
||||
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 (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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon component
|
||||
* Renders SVG icons with consistent sizing and styling
|
||||
*/
|
||||
const Icon: React.FC<IconProps> = ({
|
||||
name,
|
||||
size = 'md',
|
||||
variant,
|
||||
title,
|
||||
fillColor,
|
||||
strokeColor,
|
||||
className = '',
|
||||
...rest
|
||||
}) => {
|
||||
// Determine default variant based on icon type if no variant provided
|
||||
let defaultVariant: IconVariant | string = variant
|
||||
|
||||
if (defaultVariant === undefined) {
|
||||
if (BRAND_ICON_NAMES.has(name)) {
|
||||
defaultVariant = 'brand'
|
||||
} else {
|
||||
defaultVariant = 'bold' // Default to bold for non-brand icons
|
||||
}
|
||||
}
|
||||
|
||||
// Get the icon component based on name and variant
|
||||
let finalIconComponent = getIconComponent(name, defaultVariant)
|
||||
let finalVariant = defaultVariant
|
||||
|
||||
// Try fallbacks if the icon doesn't exist in the requested variant
|
||||
if (!finalIconComponent && defaultVariant !== 'outline') {
|
||||
finalIconComponent = getIconComponent(name, 'outline')
|
||||
finalVariant = 'outline'
|
||||
}
|
||||
|
||||
if (!finalIconComponent && defaultVariant !== 'bold') {
|
||||
finalIconComponent = getIconComponent(name, 'bold')
|
||||
finalVariant = 'bold'
|
||||
}
|
||||
|
||||
if (!finalIconComponent && defaultVariant !== 'brand') {
|
||||
finalIconComponent = getIconComponent(name, 'brand')
|
||||
finalVariant = 'brand'
|
||||
}
|
||||
|
||||
// If still no icon found, return null
|
||||
if (!finalIconComponent) {
|
||||
console.warn(`Icon not found: ${name} (${defaultVariant})`)
|
||||
return null
|
||||
}
|
||||
|
||||
const sizeValue = getSizeValue(size)
|
||||
const combinedClassName = `icon icon-${size} icon-${finalVariant} ${className}`.trim()
|
||||
|
||||
const IconComponentToRender = finalIconComponent
|
||||
|
||||
return (
|
||||
<IconComponentToRender
|
||||
className={combinedClassName}
|
||||
width={sizeValue}
|
||||
height={sizeValue}
|
||||
fill={fillColor || 'currentColor'}
|
||||
stroke={strokeColor || 'currentColor'}
|
||||
role="img"
|
||||
aria-hidden={!title}
|
||||
aria-label={title}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 680 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 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 679 B |
@@ -1,8 +0,0 @@
|
||||
// Bold variant icons
|
||||
export { ReactComponent as Linux } from './linux.svg'
|
||||
export { ReactComponent as Steam } from './steam.svg'
|
||||
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'
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19.7 17.6c-.1-.2-.2-.4-.2-.6c0-.4-.2-.7-.5-1c-.1-.1-.3-.2-.4-.2c.6-1.8-.3-3.6-1.3-4.9c-.8-1.2-2-2.1-1.9-3.7c0-1.9.2-5.4-3.3-5.1c-3.6.2-2.6 3.9-2.7 5.2c0 1.1-.5 2.2-1.3 3.1c-.2.2-.4.5-.5.7c-1 1.2-1.5 2.8-1.5 4.3c-.2.2-.4.4-.5.6c-.1.1-.2.2-.2.3c-.1.1-.3.2-.5.3c-.4.1-.7.3-.9.7c-.1.3-.2.7-.1 1.1c.1.2.1.4 0 .7c-.2.4-.2.9 0 1.4c.3.4.8.5 1.5.6c.5 0 1.1.2 1.6.4c.5.3 1.1.5 1.7.5c.3 0 .7-.1 1-.2c.3-.2.5-.4.6-.7c.4 0 1-.2 1.7-.2c.6 0 1.2.2 2 .1c0 .1 0 .2.1.3c.2.5.7.9 1.3 1h.2c.8-.1 1.6-.5 2.1-1.1c.4-.4.9-.7 1.4-.9c.6-.3 1-.5 1.1-1c.1-.7-.1-1.1-.5-1.7M12.8 4.8c.6.1 1.1.6 1 1.2q0 .45-.3.9h-.1c-.2-.1-.3-.1-.4-.2c.1-.1.1-.3.2-.5c0-.4-.2-.7-.4-.7c-.3 0-.5.3-.5.7v.1c-.1-.1-.3-.1-.4-.2V6c-.1-.5.3-1.1.9-1.2m-.3 2c.1.1.3.2.4.2s.3.1.4.2c.2.1.4.2.4.5s-.3.6-.9.8c-.2.1-.3.1-.4.2c-.3.2-.6.3-1 .3c-.3 0-.6-.2-.8-.4c-.1-.1-.2-.2-.4-.3c-.1-.1-.3-.3-.4-.6c0-.1.1-.2.2-.3c.3-.2.4-.3.5-.4l.1-.1c.2-.3.6-.5 1-.5c.3.1.6.2.9.4M10.4 5c.4 0 .7.4.8 1.1v.2c-.1 0-.3.1-.4.2v-.2c0-.3-.2-.6-.4-.5c-.2 0-.3.3-.3.6c0 .2.1.3.2.4c0 0-.1.1-.2.1c-.2-.2-.4-.5-.4-.8c0-.6.3-1.1.7-1.1m-1 16.1c-.7.3-1.6.2-2.2-.2c-.6-.3-1.1-.4-1.8-.4c-.5-.1-1-.1-1.1-.3s-.1-.5.1-1q.15-.45 0-.9c-.1-.3-.1-.5 0-.8s.3-.4.6-.5s.5-.2.7-.4c.1-.1.2-.2.3-.4c.3-.4.5-.6.8-.6c.6.1 1.1 1 1.5 1.9c.2.3.4.7.7 1c.4.5.9 1.2.9 1.6c0 .5-.2.8-.5 1m4.9-2.2c0 .1 0 .1-.1.2c-1.2.9-2.8 1-4.1.3l-.6-.9c.9-.1.7-1.3-1.2-2.5c-2-1.3-.6-3.7.1-4.8c.1-.1.1 0-.3.8c-.3.6-.9 2.1-.1 3.2c0-.8.2-1.6.5-2.4c.7-1.3 1.2-2.8 1.5-4.3c.1.1.1.1.2.1c.1.1.2.2.3.2c.2.3.6.4.9.4h.1c.4 0 .8-.1 1.1-.4c.1-.1.2-.2.4-.2q.45-.15.9-.6c.4 1.3.8 2.5 1.4 3.6c.4.8.7 1.6.9 2.5c.3 0 .7.1 1 .3c.8.4 1.1.7 1 1.2H18c0-.3-.2-.6-.9-.9s-1.3-.3-1.5.4c-.1 0-.2.1-.3.1c-.8.4-.8 1.5-.9 2.6c.1.4 0 .7-.1 1.1m4.6.6c-.6.2-1.1.6-1.5 1.1c-.4.6-1.1 1-1.9.9c-.4 0-.8-.3-.9-.7c-.1-.6-.1-1.2.2-1.8c.1-.4.2-.7.3-1.1c.1-1.2.1-1.9.6-2.2c0 .5.3.8.7 1c.5 0 1-.1 1.4-.5h.2c.3 0 .5 0 .7.2s.3.5.3.7c0 .3.2.6.3.9c.5.5.5.8.5.9c-.1.2-.5.4-.9.6m-9-12c-.1 0-.1 0-.1.1c0 0 0 .1.1.1s.1.1.1.1c.3.4.8.6 1.4.7c.5-.1 1-.2 1.5-.6l.6-.3c.1 0 .1-.1.1-.1c0-.1 0-.1-.1-.1c-.2.1-.5.2-.7.3c-.4.3-.9.5-1.4.5s-.9-.3-1.2-.6c-.1 0-.2-.1-.3-.1"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |