Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c16b63b41 | |||
| 9551eabf2d | |||
| 80395850ba | |||
| ab41ce8bd2 | |||
| 8ace340455 | |||
| 3ff4556f86 | |||
| 01bf6fb261 | |||
| 64b558eb66 | |||
| 97fd4c04ba | |||
| ecc7dc4990 | |||
| 2cacacfa5f | |||
| a0f3a98444 | |||
| 3dd15624de | |||
| 7ba8697cb7 | |||
| 0423bff07a | |||
| 70011b7629 | |||
| d4eb610bb9 | |||
| 16b2cbf32a | |||
| f3ecca6cb0 | |||
| 6f369f7a3b | |||
| e6ee8e7648 | |||
| e2b715dd6b | |||
| e8906d6ec0 | |||
| b5ca91d9a9 | |||
| 86061c72e7 | |||
| f767d4d99e | |||
| fe80af5639 | |||
| 871381f3fa | |||
| a4d4091844 | |||
| a1be636a8c | |||
| 85873379f3 | |||
| 293273af2d | |||
| 550d183ee2 | |||
| 756a6e3d53 | |||
| cbed56ac33 | |||
| fbabff0a34 | |||
| fb6203c4f7 | |||
| 9144e52db1 | |||
| e50f885ba2 | |||
| 433417ade2 | |||
| 6f5d750af9 | |||
| ef7e183312 | |||
| 3b4a17b615 | |||
| 6ff6c06bec | |||
| d5f9d50248 | |||
| d70b174dd4 | |||
| 2164492934 | |||
| 7733d9732e | |||
| f151f5ee4f | |||
| ad910cce0a | |||
| 9621cba58d | |||
| f18cffaa09 | |||
| 17de5172e4 | |||
| 1d4c75bffd | |||
| 2d524de661 | |||
| 832841134a | |||
| b3e92d2165 | |||
| 348b1a5ed0 | |||
| cf7fe20aa6 | |||
| 62a1dca0aa | |||
| 214564d67f | |||
| 9c70530890 | |||
| 568c02495c | |||
| 285256bfb8 | |||
| 42d8618f37 | |||
| 483e58dfd1 | |||
| ae9c012040 | |||
| 220763b389 | |||
| 3d894266a7 | |||
| 33492a6a55 | |||
| 92f4d82e6c | |||
| 5896733fd4 | |||
| 1d72f24afa | |||
| aea8a84335 | |||
| a476819312 | |||
| 1bb62877a3 | |||
| f8ea256637 | |||
| 0480d523e3 | |||
| 1571e9d87d | |||
| f949ecf2f3 | |||
| ecee6529ff | |||
| d9819ef115 | |||
| ff53cc7a46 | |||
| 1a1c7dfb3d | |||
| 769213288e | |||
| 85d670931a | |||
| 487e974274 | |||
| 1b8fdadbf2 | |||
| ecd7b4dceb | |||
| 640eb9a0d5 | |||
| b42086ca27 | |||
| b9beb0d704 | |||
| 09e7bcac6f | |||
| b7f219a25f | |||
| 2b205d8376 | |||
| 4cf1e2caf4 | |||
| 0ee10d07fc | |||
| 365063d30d | |||
| 61ad3f1d54 | |||
| d3a91f5722 | |||
| 9ba307f9f8 | |||
| 1123012737 | |||
| 7a07399946 | |||
| 40b9ec9b01 | |||
| 05e4275962 | |||
| 03cae08df1 | |||
| 6b16ec6168 | |||
| a786530572 | |||
| ef7dfdd6c5 | |||
| 5998e77272 | |||
| fab29f5778 | |||
| bec190691b | |||
| 58217d61d1 | |||
| 0f4db7bbb7 | |||
| 22c8f41f93 | |||
| 5ff51d1174 | |||
| 169b7d5edd |
@@ -53,6 +53,39 @@ jobs:
|
||||
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
|
||||
@@ -88,6 +121,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -128,20 +163,120 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app with updater
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
- 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 }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
projectPath: '.'
|
||||
includeDebug: false
|
||||
includeRelease: true
|
||||
includeUpdaterJson: true
|
||||
tauriScript: 'npm run tauri'
|
||||
args: ${{ matrix.args }}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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
|
||||
@@ -12,9 +12,7 @@ dist
|
||||
dist-ssr
|
||||
docs
|
||||
*.local
|
||||
*.lock
|
||||
.env
|
||||
CHANGELOG.md
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -1,3 +1,101 @@
|
||||
## [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
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
# CreamLinux
|
||||
|
||||
CreamLinux is a GUI application for Linux that simplifies the management of DLC IDs in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
|
||||
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).
|
||||
|
||||
## Watch the demo here:
|
||||
|
||||
[](https://www.youtube.com/watch?v=ZunhZnKFLlg)
|
||||
|
||||
## Beta Status
|
||||
|
||||
⚠️ **IMPORTANT**: CreamLinux is currently in BETA. This means:
|
||||
|
||||
- Some features may be incomplete or subject to change
|
||||
- You might encounter bugs or unexpected behavior
|
||||
- The application is under active development
|
||||
- Your feedback and bug reports are invaluable
|
||||
|
||||
While the core functionality is working, please be aware that this is an early release. Im continuously working to improve stability, add features, and enhance the user experience. Please report any issues you encounter on [GitHub Issues page](https://github.com/Novattz/creamlinux-installer/issues).
|
||||
[](https://www.youtube.com/watch?v=neUDotrqnDM)
|
||||
|
||||
## Features
|
||||
|
||||
- **Auto-discovery**: Automatically finds Steam games installed on your system
|
||||
- **Native support**: Installs CreamLinux for native Linux games
|
||||
- **Proton support**: Installs SmokeAPI for Windows games running through Proton
|
||||
- **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
|
||||
|
||||
@@ -46,6 +36,69 @@ While the core functionality is working, please be aware that this is an early r
|
||||
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):
|
||||
```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
|
||||
@@ -118,6 +171,8 @@ update-desktop-database ~/.local/share/applications
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
pkgs ? import <nixpkgs> { },
|
||||
}:
|
||||
pkgs.callPackage ./package.nix { }
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "creamlinux",
|
||||
"private": true,
|
||||
"version": "1.3.4",
|
||||
"version": "1.7.1",
|
||||
"type": "module",
|
||||
"author": "Tickbase",
|
||||
"repository": "https://github.com/Novattz/creamlinux-installer",
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
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,6 +1,6 @@
|
||||
[package]
|
||||
name = "creamlinux-installer"
|
||||
version = "1.3.4"
|
||||
version = "1.7.1"
|
||||
description = "DLC Manager for Steam games on Linux"
|
||||
authors = ["tickbase"]
|
||||
license = "MIT"
|
||||
@@ -25,16 +25,17 @@ tempfile = "3.8"
|
||||
walkdir = "2.3"
|
||||
parking_lot = "0.12"
|
||||
tauri = { version = "2.5.0", features = [] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.0.0-rc"
|
||||
tauri-plugin-dialog = "2.0.0-rc"
|
||||
tauri-plugin-fs = "2.0.0-rc"
|
||||
num_cpus = "1.16.0"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-process = "2.2.1"
|
||||
async-trait = "0.1.89"
|
||||
sha2 = "0.10.9"
|
||||
rand = "0.9.2"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-updater = "2.7.1"
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "updater:default", "process:default"]
|
||||
"permissions": ["core:default", "updater:default", "process:default", "dialog:default"]
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ mod storage;
|
||||
mod version;
|
||||
|
||||
pub use storage::{
|
||||
get_creamlinux_version_dir, get_smokeapi_version_dir, is_cache_initialized,
|
||||
list_creamlinux_files, list_smokeapi_dlls, read_versions, update_creamlinux_version,
|
||||
update_smokeapi_version,
|
||||
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::{
|
||||
@@ -13,7 +15,7 @@ pub use version::{
|
||||
update_smokeapi_version as update_game_smokeapi_version,
|
||||
};
|
||||
|
||||
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
||||
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;
|
||||
|
||||
@@ -22,39 +24,159 @@ use std::collections::HashMap;
|
||||
pub async fn initialize_cache() -> Result<(), String> {
|
||||
info!("Initializing cache...");
|
||||
|
||||
// Check if cache is already initialized
|
||||
if is_cache_initialized()? {
|
||||
info!("Cache already initialized");
|
||||
return Ok(());
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Cache not initialized, downloading unlockers...");
|
||||
// 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
|
||||
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));
|
||||
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
|
||||
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));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Cache initialization complete");
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -126,6 +248,68 @@ pub async fn check_and_update_cache() -> Result<UpdateResult, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -212,13 +396,20 @@ pub async fn update_outdated_games(
|
||||
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.smokeapi_updated
|
||||
|| self.creamlinux_updated
|
||||
|| self.screamapi_updated
|
||||
|| self.koaloader_updated
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ use std::path::PathBuf;
|
||||
pub struct CacheVersions {
|
||||
pub smokeapi: VersionInfo,
|
||||
pub creamlinux: VersionInfo,
|
||||
pub screamapi: VersionInfo,
|
||||
pub koaloader: VersionInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -18,12 +20,10 @@ pub struct VersionInfo {
|
||||
impl Default for CacheVersions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
smokeapi: VersionInfo {
|
||||
latest: String::new(),
|
||||
},
|
||||
creamlinux: VersionInfo {
|
||||
latest: String::new(),
|
||||
},
|
||||
smokeapi: VersionInfo { latest: String::new() },
|
||||
creamlinux: VersionInfo { latest: String::new() },
|
||||
screamapi: VersionInfo { latest: String::new() },
|
||||
koaloader: VersionInfo { latest: String::new() },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,42 @@ pub fn get_smokeapi_dir() -> Result<PathBuf, String> {
|
||||
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()?;
|
||||
@@ -94,6 +130,24 @@ pub fn get_smokeapi_version_dir(version: &str) -> Result<PathBuf, String> {
|
||||
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()?;
|
||||
@@ -115,23 +169,43 @@ pub fn get_creamlinux_version_dir(version: &str) -> Result<PathBuf, String> {
|
||||
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))?;
|
||||
|
||||
let versions: CacheVersions = serde_json::from_str(&content)
|
||||
|
||||
// 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: {}",
|
||||
versions.smokeapi.latest, versions.creamlinux.latest
|
||||
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
|
||||
versions.smokeapi.latest,
|
||||
versions.creamlinux.latest,
|
||||
versions.screamapi.latest,
|
||||
versions.koaloader.latest,
|
||||
);
|
||||
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
@@ -147,8 +221,11 @@ pub fn write_versions(versions: &CacheVersions) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to write versions.json: {}", e))?;
|
||||
|
||||
info!(
|
||||
"Wrote versions.json - SmokeAPI: {}, CreamLinux: {}",
|
||||
versions.smokeapi.latest, versions.creamlinux.latest
|
||||
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
|
||||
versions.smokeapi.latest,
|
||||
versions.creamlinux.latest,
|
||||
versions.screamapi.latest,
|
||||
versions.koaloader.latest,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -179,6 +256,34 @@ pub fn update_smokeapi_version(new_version: &str) -> Result<(), String> {
|
||||
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()?;
|
||||
@@ -204,12 +309,6 @@ pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Check if the cache is initialized (has both unlockers cached)
|
||||
pub fn is_cache_initialized() -> Result<bool, String> {
|
||||
let versions = read_versions()?;
|
||||
Ok(!versions.smokeapi.latest.is_empty() && !versions.creamlinux.latest.is_empty())
|
||||
}
|
||||
|
||||
// Get the SmokeAPI DLL path for the latest cached version
|
||||
#[allow(dead_code)]
|
||||
pub fn get_smokeapi_dll_path() -> Result<PathBuf, String> {
|
||||
@@ -233,8 +332,8 @@ pub fn get_creamlinux_files_dir() -> Result<PathBuf, String> {
|
||||
get_creamlinux_version_dir(&versions.creamlinux.latest)
|
||||
}
|
||||
|
||||
// List all SmokeAPI DLL files in the cached version directory
|
||||
pub fn list_smokeapi_dlls() -> Result<Vec<PathBuf>, String> {
|
||||
/// 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());
|
||||
@@ -249,17 +348,20 @@ pub fn list_smokeapi_dlls() -> Result<Vec<PathBuf>, String> {
|
||||
let entries = fs::read_dir(&version_dir)
|
||||
.map_err(|e| format!("Failed to read SmokeAPI directory: {}", e))?;
|
||||
|
||||
let mut dlls = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
for entry in entries {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("dll") {
|
||||
dlls.push(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(dlls)
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
// List all CreamLinux files in the cached version directory
|
||||
@@ -289,4 +391,94 @@ pub fn list_creamlinux_files() -> Result<Vec<PathBuf>, String> {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -8,12 +8,21 @@ use log::info;
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,11 +72,50 @@ pub fn load_config() -> Result<Config, String> {
|
||||
// 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 config: Config = serde_json::from_str(&config_str)
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
info!("Loaded config from {:?}", config_path);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -98,6 +146,28 @@ where
|
||||
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::*;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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,44 +0,0 @@
|
||||
// This module contains helper functions for file operations during installation
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
// Copy a file with backup
|
||||
#[allow(dead_code)]
|
||||
pub fn copy_with_backup(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
// If destination exists, create a backup
|
||||
if dest.exists() {
|
||||
let backup = dest.with_extension("bak");
|
||||
fs::copy(dest, &backup)?;
|
||||
}
|
||||
|
||||
fs::copy(src, dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Safely remove a file (doesn't error if it doesn't exist)
|
||||
#[allow(dead_code)]
|
||||
pub fn safe_remove(path: &Path) -> io::Result<()> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Make a file executable (Unix only)
|
||||
#[cfg(unix)]
|
||||
#[allow(dead_code)]
|
||||
pub fn make_executable(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(path, perms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn make_executable(_path: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
mod file_ops;
|
||||
|
||||
use crate::cache::{
|
||||
remove_creamlinux_version, remove_smokeapi_version,
|
||||
update_game_creamlinux_version, update_game_smokeapi_version,
|
||||
};
|
||||
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
||||
use crate::unlockers::{CreamLinux, SmokeAPI, ScreamAPI, Unlocker};
|
||||
use crate::epic_scanner::EpicGame;
|
||||
use crate::AppState;
|
||||
use log::{error, info, warn};
|
||||
use reqwest;
|
||||
@@ -241,8 +240,26 @@ async fn uninstall_creamlinux(game: Game, app_handle: AppHandle) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Install SmokeAPI to a game
|
||||
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());
|
||||
}
|
||||
@@ -286,8 +303,8 @@ async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Uninstall SmokeAPI from a game
|
||||
async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||
// 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());
|
||||
}
|
||||
@@ -329,106 +346,399 @@ async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Fetch DLC details from Steam API (simple version without progress)
|
||||
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = format!(
|
||||
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||
app_id
|
||||
// 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,
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&base_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
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
|
||||
.map_err(|e| format!("Failed to fetch game details: {}", e))?;
|
||||
.ok()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch game details: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
let json: serde_json::Value = resp.json().await.ok()?;
|
||||
|
||||
if json.get("status").and_then(|s| s.as_str()) != Some("success") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
json.get("data").and_then(|d| d.get(app_id)).cloned()
|
||||
}
|
||||
|
||||
let dlc_ids = match data
|
||||
.get(app_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("dlc"))
|
||||
/// 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())
|
||||
{
|
||||
Some(dlc_array) => match dlc_array.as_array() {
|
||||
Some(array) => array
|
||||
.iter()
|
||||
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
||||
.collect::<Vec<String>>(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
|
||||
|
||||
let mut dlc_details = Vec::new();
|
||||
|
||||
for dlc_id in dlc_ids {
|
||||
let dlc_url = format!(
|
||||
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||
dlc_id
|
||||
);
|
||||
|
||||
// Add a small delay to avoid rate limiting
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let dlc_response = client
|
||||
.get(&dlc_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
|
||||
|
||||
if dlc_response.status().is_success() {
|
||||
let dlc_data: serde_json::Value = dlc_response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
|
||||
|
||||
let dlc_name = match dlc_data
|
||||
.get(&dlc_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("name"))
|
||||
{
|
||||
Some(name) => match name.as_str() {
|
||||
Some(s) => s.to_string(),
|
||||
_ => "Unknown DLC".to_string(),
|
||||
},
|
||||
_ => "Unknown DLC".to_string(),
|
||||
};
|
||||
|
||||
info!("Found DLC: {} ({})", dlc_name, dlc_id);
|
||||
dlc_details.push(DlcInfo {
|
||||
appid: dlc_id,
|
||||
name: dlc_name,
|
||||
});
|
||||
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
// If rate limited, wait longer
|
||||
error!("Rate limited by Steam API, waiting 10 seconds");
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
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
|
||||
}
|
||||
|
||||
info!(
|
||||
"Successfully retrieved details for {} DLCs",
|
||||
dlc_details.len()
|
||||
);
|
||||
/// 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 Steam API with progress updates
|
||||
// Fetch DLC details from steamcmd.net with progress updates
|
||||
pub async fn fetch_dlc_details_with_progress(
|
||||
app_id: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -437,80 +747,46 @@ pub async fn fetch_dlc_details_with_progress(
|
||||
"Starting DLC details fetch with progress for game ID: {}",
|
||||
app_id
|
||||
);
|
||||
|
||||
// Get a reference to a cancellation flag from app state
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let should_cancel = state.fetch_cancellation.clone();
|
||||
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = format!(
|
||||
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||
app_id
|
||||
);
|
||||
|
||||
// Emit initial progress
|
||||
|
||||
// Step 1: fetch game info to get DLC ID list
|
||||
emit_dlc_progress(app_handle, "Looking up game details...", 5, None);
|
||||
info!("Emitted initial DLC progress: 5%");
|
||||
|
||||
let response = client
|
||||
.get(&base_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
|
||||
let game_info = fetch_steamcmd_info(&client, app_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch game details: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_msg = format!("Failed to fetch game details: HTTP {}", response.status());
|
||||
error!("{}", error_msg);
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let dlc_ids = match data
|
||||
.get(app_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("dlc"))
|
||||
{
|
||||
Some(dlc_array) => match dlc_array.as_array() {
|
||||
Some(array) => array
|
||||
.iter()
|
||||
.filter_map(|id| id.as_u64().map(|n| n.to_string()))
|
||||
.collect::<Vec<String>>(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
info!("Found {} DLCs for game ID {}", dlc_ids.len(), app_id);
|
||||
.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...", dlc_ids.len()),
|
||||
&format!("Found {} DLCs. Fetching details...", total_dlcs),
|
||||
10,
|
||||
None,
|
||||
);
|
||||
info!("Emitted DLC progress: 10%, found {} DLCs", dlc_ids.len());
|
||||
|
||||
|
||||
// Step 2: fetch each DLC name, emit as we go, skip unknowns
|
||||
let mut dlc_details = Vec::new();
|
||||
let total_dlcs = dlc_ids.len();
|
||||
|
||||
|
||||
for (index, dlc_id) in dlc_ids.iter().enumerate() {
|
||||
// Check if cancellation was requested
|
||||
// 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_percent = 10.0 + (index as f32 / total_dlcs as f32) * 90.0;
|
||||
let progress_rounded = progress_percent as u32;
|
||||
let remaining_dlcs = total_dlcs - index;
|
||||
|
||||
// Estimate time remaining (rough calculation - 300ms per DLC)
|
||||
let est_time_left = if remaining_dlcs > 0 {
|
||||
let seconds = (remaining_dlcs as f32 * 0.3).ceil() as u32;
|
||||
|
||||
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 {
|
||||
@@ -519,12 +795,12 @@ pub async fn fetch_dlc_details_with_progress(
|
||||
} else {
|
||||
"almost done".to_string()
|
||||
};
|
||||
|
||||
|
||||
info!(
|
||||
"Processing DLC {}/{} - Progress: {}%",
|
||||
"Processing DLC {}/{} ({})",
|
||||
index + 1,
|
||||
total_dlcs,
|
||||
progress_rounded
|
||||
dlc_id
|
||||
);
|
||||
emit_dlc_progress(
|
||||
app_handle,
|
||||
@@ -532,73 +808,37 @@ pub async fn fetch_dlc_details_with_progress(
|
||||
progress_rounded,
|
||||
Some(&est_time_left),
|
||||
);
|
||||
|
||||
let dlc_url = format!(
|
||||
"https://store.steampowered.com/api/appdetails?appids={}",
|
||||
dlc_id
|
||||
);
|
||||
|
||||
// Add a small delay to avoid rate limiting
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let dlc_response = client
|
||||
.get(&dlc_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DLC details: {}", e))?;
|
||||
|
||||
if dlc_response.status().is_success() {
|
||||
let dlc_data: serde_json::Value = dlc_response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DLC response: {}", e))?;
|
||||
|
||||
let dlc_name = match dlc_data
|
||||
.get(&dlc_id)
|
||||
.and_then(|app| app.get("data"))
|
||||
.and_then(|data| data.get("name"))
|
||||
{
|
||||
Some(name) => match name.as_str() {
|
||||
Some(s) => s.to_string(),
|
||||
_ => "Unknown DLC".to_string(),
|
||||
},
|
||||
_ => "Unknown DLC".to_string(),
|
||||
};
|
||||
|
||||
info!("Found DLC: {} ({})", dlc_name, dlc_id);
|
||||
let dlc_info = DlcInfo {
|
||||
appid: dlc_id.clone(),
|
||||
name: dlc_name,
|
||||
};
|
||||
|
||||
// Emit each DLC as we find it
|
||||
if let Ok(json) = serde_json::to_string(&dlc_info) {
|
||||
if let Err(e) = app_handle.emit("dlc-found", json) {
|
||||
warn!("Failed to emit dlc-found event: {}", e);
|
||||
} else {
|
||||
info!("Emitted dlc-found event for DLC: {}", dlc_id);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
dlc_details.push(dlc_info);
|
||||
} else if dlc_response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
// If rate limited, wait longer
|
||||
error!("Rate limited by Steam API, waiting 10 seconds");
|
||||
emit_dlc_progress(
|
||||
app_handle,
|
||||
"Rate limited by Steam. Waiting...",
|
||||
progress_rounded,
|
||||
None,
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
|
||||
info!(
|
||||
"Completed DLC fetch. Found {} DLCs in total",
|
||||
dlc_details.len()
|
||||
"Completed DLC fetch. Found {} named DLCs out of {} IDs",
|
||||
dlc_details.len(),
|
||||
total_dlcs
|
||||
);
|
||||
emit_dlc_progress(
|
||||
app_handle,
|
||||
@@ -606,8 +846,7 @@ pub async fn fetch_dlc_details_with_progress(
|
||||
100,
|
||||
None,
|
||||
);
|
||||
info!("Emitted final DLC progress: 100%");
|
||||
|
||||
|
||||
Ok(dlc_details)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,22 @@
|
||||
)]
|
||||
|
||||
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};
|
||||
@@ -33,6 +40,22 @@ pub struct GameAction {
|
||||
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)]
|
||||
@@ -61,12 +84,63 @@ fn update_config(config_data: Config) -> Result<Config, String> {
|
||||
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>,
|
||||
@@ -75,7 +149,18 @@ async fn scan_steam_games(
|
||||
info!("Starting Steam games scan");
|
||||
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
|
||||
|
||||
let paths = searcher::get_default_steam_paths();
|
||||
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);
|
||||
@@ -250,6 +335,70 @@ async fn process_game_action(
|
||||
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,
|
||||
@@ -360,8 +509,8 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_caches() -> Result<(), String> {
|
||||
info!("Data flush requested - cleaning in-memory state only");
|
||||
Ok(())
|
||||
info!("Clearing cached unlocker binaries");
|
||||
cache::clear_unlocker_cache()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -612,6 +761,81 @@ async fn resolve_platform_conflict(
|
||||
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;
|
||||
@@ -675,6 +899,20 @@ fn main() {
|
||||
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");
|
||||
@@ -713,8 +951,9 @@ fn main() {
|
||||
Ok(result) => {
|
||||
if result.any_updated() {
|
||||
info!(
|
||||
"Updates found - SmokeAPI: {:?}, CreamLinux: {:?}",
|
||||
result.new_smokeapi_version, result.new_creamlinux_version
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/// PE import scanner for finding a suitable Koaloader proxy DLL.
|
||||
/// scan ALL PE files (exe + dll) in the executable's directory
|
||||
/// and collect every import that matches a Koaloader proxy variant.
|
||||
use log::{info, warn};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// All DLL names Koaloader can proxy as, ordered by preference.
|
||||
/// Common system DLLs that games almost always load come first.
|
||||
pub const KOA_VARIANTS: &[&str] = &[
|
||||
"version.dll",
|
||||
"winmm.dll",
|
||||
"winhttp.dll",
|
||||
"iphlpapi.dll",
|
||||
"dinput8.dll",
|
||||
"d3d11.dll",
|
||||
"dxgi.dll",
|
||||
"d3d9.dll",
|
||||
"d3d10.dll",
|
||||
"dwmapi.dll",
|
||||
"hid.dll",
|
||||
"msimg32.dll",
|
||||
"mswsock.dll",
|
||||
"opengl32.dll",
|
||||
"profapi.dll",
|
||||
"propsys.dll",
|
||||
"textshaping.dll",
|
||||
"glu32.dll",
|
||||
"audioses.dll",
|
||||
"msasn1.dll",
|
||||
"wldp.dll",
|
||||
"xinput9_1_0.dll",
|
||||
];
|
||||
|
||||
/// Result of a proxy scan. Which proxy was chosen and whether it was a
|
||||
/// direct match or a fallback.
|
||||
pub struct ProxyScanResult {
|
||||
pub proxy_name: String,
|
||||
pub is_fallback: bool,
|
||||
}
|
||||
|
||||
/// Scan all PE files in the exe's directory (both .exe and .dll, exactly like
|
||||
/// the Python script) and return the best Koaloader proxy to use.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Variants imported by the main exe itself
|
||||
/// 2. Variants imported by any other PE file in the same directory
|
||||
/// 3. Fallback to version.dll with is_fallback = true
|
||||
pub fn find_best_proxy(exe_path: &Path) -> ProxyScanResult {
|
||||
let exe_dir = match exe_path.parent() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!("Could not get exe directory, falling back to version.dll");
|
||||
return ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true };
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all PE files in the directory (.exe and .dll)
|
||||
let all_pe_files: Vec<PathBuf> = match fs::read_dir(exe_dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.is_file() && p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("exe") || e.eq_ignore_ascii_case("dll"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter(|p| is_pe_file(p))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!("Could not read exe directory: {}, falling back to version.dll", e);
|
||||
return ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true };
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Scanning {} PE files in: {}",
|
||||
all_pe_files.len(),
|
||||
exe_dir.display()
|
||||
);
|
||||
|
||||
// Build two import sets: main exe and everything else
|
||||
let exe_name = exe_path.file_name().unwrap_or_default();
|
||||
let mut exe_imports: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut other_imports: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for pe_path in &all_pe_files {
|
||||
let imports = get_pe_imports(pe_path);
|
||||
if pe_path.file_name().unwrap_or_default() == exe_name {
|
||||
info!(
|
||||
" {} (main exe): {} imports",
|
||||
pe_path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
imports.len()
|
||||
);
|
||||
for imp in imports { exe_imports.insert(imp); }
|
||||
} else {
|
||||
info!(
|
||||
" {}: {} imports",
|
||||
pe_path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
imports.len()
|
||||
);
|
||||
for imp in imports { other_imports.insert(imp); }
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1: prefer a variant the main exe itself imports
|
||||
for &variant in KOA_VARIANTS {
|
||||
if exe_imports.contains(variant) {
|
||||
info!("Best proxy (main exe imports): {}", variant);
|
||||
return ProxyScanResult { proxy_name: variant.to_string(), is_fallback: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: fall back to a variant imported by any other PE in the directory
|
||||
for &variant in KOA_VARIANTS {
|
||||
if other_imports.contains(variant) {
|
||||
info!("Best proxy (sibling PE imports): {}", variant);
|
||||
return ProxyScanResult { proxy_name: variant.to_string(), is_fallback: false };
|
||||
}
|
||||
}
|
||||
|
||||
// No match at all - use version.dll and flag it so the caller can warn the user
|
||||
warn!(
|
||||
"No Koaloader-compatible import found in {} PE files, falling back to version.dll",
|
||||
all_pe_files.len()
|
||||
);
|
||||
ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true }
|
||||
}
|
||||
|
||||
/// Detect if a Windows PE executable is 64-bit.
|
||||
/// Returns true for AMD64, false for i386. Defaults to true on parse failure.
|
||||
pub fn is_64bit_exe(path: &Path) -> bool {
|
||||
let data = match fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return true,
|
||||
};
|
||||
|
||||
if data.len() < 0x40 || &data[0..2] != b"MZ" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let e_lfanew =
|
||||
u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap_or([0; 4])) as usize;
|
||||
|
||||
if e_lfanew + 6 > data.len() || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 0x8664 = AMD64 (64-bit), 0x014C = i386 (32-bit)
|
||||
let machine = u16::from_le_bytes(
|
||||
data[e_lfanew + 4..e_lfanew + 6].try_into().unwrap_or([0; 2]),
|
||||
);
|
||||
|
||||
machine != 0x014C
|
||||
}
|
||||
|
||||
// Internal helpers
|
||||
|
||||
fn is_pe_file(path: &Path) -> bool {
|
||||
let mut file = match fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut magic = [0u8; 2];
|
||||
file.read_exact(&mut magic).unwrap_or(());
|
||||
magic == [0x4D, 0x5A] // "MZ"
|
||||
}
|
||||
|
||||
pub fn get_pe_imports(path: &Path) -> Vec<String> {
|
||||
match parse_pe_imports(path) {
|
||||
Ok(imports) => imports,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse PE imports for {}: {}", path.display(), e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pe_imports(path: &Path) -> std::io::Result<Vec<String>> {
|
||||
let mut f = fs::File::open(path)?;
|
||||
let mut buf = Vec::new();
|
||||
f.read_to_end(&mut buf)?;
|
||||
|
||||
let data = &buf;
|
||||
|
||||
if data.len() < 0x40 || &data[0..2] != b"MZ" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let e_lfanew =
|
||||
u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap_or([0; 4])) as usize;
|
||||
if e_lfanew + 4 > data.len() || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let coff_offset = e_lfanew + 4;
|
||||
if coff_offset + 20 > data.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let opt_header_size =
|
||||
u16::from_le_bytes(data[coff_offset + 16..coff_offset + 18].try_into().unwrap()) as usize;
|
||||
let opt_offset = coff_offset + 20;
|
||||
if opt_header_size < 4 || opt_offset + opt_header_size > data.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Magic: 0x10B = PE32, 0x20B = PE32+
|
||||
let magic = u16::from_le_bytes(data[opt_offset..opt_offset + 2].try_into().unwrap());
|
||||
let is_pe32_plus = magic == 0x20B;
|
||||
|
||||
let data_dir_offset = if is_pe32_plus { opt_offset + 112 } else { opt_offset + 96 };
|
||||
if data_dir_offset + 8 > data.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let import_rva =
|
||||
u32::from_le_bytes(data[data_dir_offset..data_dir_offset + 4].try_into().unwrap())
|
||||
as usize;
|
||||
let import_size =
|
||||
u32::from_le_bytes(data[data_dir_offset + 4..data_dir_offset + 8].try_into().unwrap())
|
||||
as usize;
|
||||
|
||||
if import_rva == 0 || import_size == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let sections_offset = opt_offset + opt_header_size;
|
||||
let num_sections =
|
||||
u16::from_le_bytes(data[coff_offset + 2..coff_offset + 4].try_into().unwrap()) as usize;
|
||||
|
||||
let rva_to_offset = |rva: usize| -> Option<usize> {
|
||||
for i in 0..num_sections {
|
||||
let sec = sections_offset + i * 40;
|
||||
if sec + 40 > data.len() { break; }
|
||||
let virt_addr =
|
||||
u32::from_le_bytes(data[sec + 12..sec + 16].try_into().unwrap()) as usize;
|
||||
let raw_size =
|
||||
u32::from_le_bytes(data[sec + 16..sec + 20].try_into().unwrap()) as usize;
|
||||
let raw_offset =
|
||||
u32::from_le_bytes(data[sec + 20..sec + 24].try_into().unwrap()) as usize;
|
||||
if rva >= virt_addr && rva < virt_addr + raw_size {
|
||||
return Some(raw_offset + (rva - virt_addr));
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let import_file_offset = match rva_to_offset(import_rva) {
|
||||
Some(o) => o,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut imports = Vec::new();
|
||||
let mut entry_offset = import_file_offset;
|
||||
|
||||
loop {
|
||||
if entry_offset + 20 > data.len() { break; }
|
||||
|
||||
let name_rva =
|
||||
u32::from_le_bytes(data[entry_offset + 12..entry_offset + 16].try_into().unwrap())
|
||||
as usize;
|
||||
|
||||
if name_rva == 0 { break; }
|
||||
|
||||
if let Some(name_offset) = rva_to_offset(name_rva) {
|
||||
let end = data[name_offset..]
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.map(|n| name_offset + n)
|
||||
.unwrap_or(data.len());
|
||||
|
||||
if let Ok(name) = std::str::from_utf8(&data[name_offset..end]) {
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
imports.push(trimmed.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry_offset += 20;
|
||||
}
|
||||
|
||||
Ok(imports)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::cache::get_cache_dir;
|
||||
use crate::config;
|
||||
use log::{info, warn};
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
const API_BASE: &str = "https://api.shibe.fun/v1";
|
||||
const SALT_LENGTH: usize = 32;
|
||||
|
||||
// Report payload
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ReportPayload {
|
||||
pub user_hash: String,
|
||||
pub game_id: String,
|
||||
/// "creamlinux" | "smokeapi"
|
||||
pub unlocker: String,
|
||||
/// true = worked, false = didn't work
|
||||
pub worked: bool,
|
||||
}
|
||||
|
||||
/// Mirrors the JSON returned by GET /v1/votes/:game_id
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VoteResult {
|
||||
pub unlocker: String,
|
||||
pub success: u32,
|
||||
pub fail: u32,
|
||||
}
|
||||
|
||||
// Local report record
|
||||
|
||||
/// One entry in the local reports.json cache.
|
||||
/// Tracks what the user has already voted so we can disable buttons in the UI.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LocalReport {
|
||||
pub game_id: String,
|
||||
pub unlocker: String, // "creamlinux" | "smokeapi"
|
||||
pub worked: bool,
|
||||
}
|
||||
|
||||
// reports.json helpers
|
||||
|
||||
fn reports_cache_path() -> Result<std::path::PathBuf, String> {
|
||||
Ok(get_cache_dir()?.join("reports.json"))
|
||||
}
|
||||
|
||||
/// Load all locally recorded votes.
|
||||
pub fn load_local_reports() -> Vec<LocalReport> {
|
||||
match reports_cache_path() {
|
||||
Ok(path) if path.exists() => {
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a new vote to reports.json (or overwrite an existing one for the same
|
||||
/// game_id + unlocker combo).
|
||||
pub fn save_local_report(report: LocalReport) -> Result<(), String> {
|
||||
let path = reports_cache_path()?;
|
||||
let mut reports = load_local_reports();
|
||||
|
||||
// Upsert: replace existing entry for the same game + unlocker, otherwise push
|
||||
let pos = reports
|
||||
.iter()
|
||||
.position(|r| r.game_id == report.game_id && r.unlocker == report.unlocker);
|
||||
|
||||
match pos {
|
||||
Some(i) => reports[i] = report,
|
||||
None => reports.push(report),
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&reports)
|
||||
.map_err(|e| format!("Failed to serialize reports cache: {}", e))?;
|
||||
fs::write(&path, json)
|
||||
.map_err(|e| format!("Failed to write reports cache: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Salt management
|
||||
|
||||
fn get_or_create_salt() -> Result<String, String> {
|
||||
let salt_path = get_cache_dir()?.join("salt");
|
||||
|
||||
if salt_path.exists() {
|
||||
let salt = fs::read_to_string(&salt_path)
|
||||
.map_err(|e| format!("Failed to read salt file: {}", e))?;
|
||||
let salt = salt.trim().to_string();
|
||||
|
||||
if salt.len() == SALT_LENGTH {
|
||||
return Ok(salt);
|
||||
}
|
||||
|
||||
warn!("Salt file has invalid data, regenerating...");
|
||||
}
|
||||
|
||||
let salt: String = rand::rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(SALT_LENGTH)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
fs::write(&salt_path, &salt)
|
||||
.map_err(|e| format!("Failed to write salt file: {}", e))?;
|
||||
|
||||
info!("Generated new reporting salt");
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
pub fn delete_salt() -> Result<(), String> {
|
||||
let salt_path = get_cache_dir()?.join("salt");
|
||||
|
||||
if salt_path.exists() {
|
||||
fs::remove_file(&salt_path)
|
||||
.map_err(|e| format!("Failed to delete salt: {}", e))?;
|
||||
info!("Deleted reporting salt (user opted out)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Hash generation
|
||||
|
||||
pub fn generate_user_hash(steam_path: &str) -> Result<String, String> {
|
||||
let machine_id = fs::read_to_string("/etc/machine-id")
|
||||
.map_err(|e| format!("Failed to read machine-id: {}", e))?;
|
||||
let machine_id = machine_id.trim();
|
||||
|
||||
let salt = get_or_create_salt()?;
|
||||
let combined = format!("{}{}{}", machine_id, steam_path, salt);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(combined.as_bytes());
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
// HTTP
|
||||
|
||||
pub async fn post_report(payload: ReportPayload) -> Result<(), String> {
|
||||
let cfg = config::load_config()?;
|
||||
|
||||
if !cfg.reporting_opted_in {
|
||||
info!("Reporting disabled - skipping report for game {}", payload.game_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = Client::new();
|
||||
let url = format!("{}/report", API_BASE);
|
||||
|
||||
info!(
|
||||
"Submitting report: game={}, unlocker={}, worked={}",
|
||||
payload.game_id, payload.unlocker, payload.worked
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send report: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
info!("Report submitted successfully");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Report submission failed: HTTP {}", response.status()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ScreamAPIConfig {
|
||||
#[serde(rename = "$schema")]
|
||||
pub schema: String,
|
||||
#[serde(rename = "$version")]
|
||||
pub version: u32,
|
||||
pub logging: bool,
|
||||
pub log_eos: bool,
|
||||
pub block_metrics: bool,
|
||||
pub namespace_id: String,
|
||||
pub default_dlc_status: String,
|
||||
pub override_dlc_status: HashMap<String, String>,
|
||||
pub extra_graphql_endpoints: Vec<String>,
|
||||
pub extra_entitlements: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for ScreamAPIConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: "https://raw.githubusercontent.com/acidicoala/ScreamAPI/master/res/ScreamAPI.schema.json".to_string(),
|
||||
version: 3,
|
||||
logging: false,
|
||||
log_eos: false,
|
||||
block_metrics: false,
|
||||
namespace_id: String::new(),
|
||||
default_dlc_status: "unlocked".to_string(),
|
||||
override_dlc_status: HashMap::new(),
|
||||
extra_graphql_endpoints: Vec::new(),
|
||||
extra_entitlements: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a default ScreamAPI config to a specific directory.
|
||||
/// Called internally by the installer when first setting up ScreamAPI.
|
||||
pub fn write_default_config(dir: &Path) -> Result<(), String> {
|
||||
write_config_to_dir(dir, &ScreamAPIConfig::default())
|
||||
}
|
||||
|
||||
/// Write ScreamAPI config to a specific directory (where the ScreamAPI DLL lives)
|
||||
pub fn write_config_to_dir(dir: &Path, config: &ScreamAPIConfig) -> Result<(), String> {
|
||||
let config_path = dir.join("ScreamAPI.config.json");
|
||||
|
||||
let content = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize ScreamAPI config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write ScreamAPI config: {}", e))?;
|
||||
|
||||
info!("Wrote ScreamAPI config to: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read ScreamAPI config from a game's install path.
|
||||
/// Looks for EOSSDK backup files to find the directory.
|
||||
pub fn read_config(game_path: &str) -> Result<Option<ScreamAPIConfig>, String> {
|
||||
let config_path = match find_screamapi_config_path(game_path) {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read ScreamAPI config: {}", e))?;
|
||||
|
||||
let config: ScreamAPIConfig = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse ScreamAPI config: {}", e))?;
|
||||
|
||||
info!("Read ScreamAPI config from: {}", config_path.display());
|
||||
Ok(Some(config))
|
||||
}
|
||||
|
||||
/// Write ScreamAPI config to the directory where ScreamAPI DLLs are installed.
|
||||
pub fn write_config(game_path: &str, config: &ScreamAPIConfig) -> Result<(), String> {
|
||||
// Find existing config location or fall back to game root
|
||||
let config_path = find_screamapi_config_path(game_path)
|
||||
.unwrap_or_else(|| Path::new(game_path).join("ScreamAPI.config.json"));
|
||||
|
||||
let content = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize ScreamAPI config: {}", e))?;
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.map_err(|e| format!("Failed to write ScreamAPI config: {}", e))?;
|
||||
|
||||
info!("Wrote ScreamAPI config to: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete ScreamAPI config from a game directory
|
||||
pub fn delete_config(game_path: &str) -> Result<(), String> {
|
||||
let config_path = match find_screamapi_config_path(game_path) {
|
||||
Some(p) => p,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if config_path.exists() {
|
||||
fs::remove_file(&config_path)
|
||||
.map_err(|e| format!("Failed to delete ScreamAPI config: {}", e))?;
|
||||
info!("Deleted ScreamAPI config from: {}", config_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find where the ScreamAPI config should live by looking for EOSSDK backup files
|
||||
/// (EOSSDK-Win64-Shipping_o.dll or EOSSDK-Win32-Shipping_o.dll)
|
||||
fn find_screamapi_config_path(game_path: &str) -> Option<PathBuf> {
|
||||
use walkdir::WalkDir;
|
||||
|
||||
for entry in WalkDir::new(game_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let filename = path.file_name()?.to_string_lossy();
|
||||
|
||||
if (filename.starts_with("EOSSDK-Win") && filename.ends_with("_o.dll"))
|
||||
|| filename == "ScreamAPI.config.json"
|
||||
{
|
||||
let dir = path.parent()?;
|
||||
return Some(dir.join("ScreamAPI.config.json"));
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Could not find ScreamAPI install dir in {}, using game root", game_path);
|
||||
None
|
||||
}
|
||||
@@ -72,6 +72,31 @@ pub fn get_default_steam_paths() -> Vec<PathBuf> {
|
||||
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") {
|
||||
@@ -256,18 +281,40 @@ fn check_creamlinux_installed(game_path: &Path) -> bool {
|
||||
|
||||
// Check if a game has SmokeAPI installed
|
||||
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
||||
// First check the provided api_files for backup files
|
||||
for api_file in api_files {
|
||||
let api_path = game_path.join(api_file);
|
||||
let api_dir = api_path.parent().unwrap_or(game_path);
|
||||
let api_filename = api_path.file_name().unwrap_or_default();
|
||||
// For 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);
|
||||
// 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());
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -720,4 +767,41 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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,8 +1,12 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
use super::Unlocker;
|
||||
use async_trait::async_trait;
|
||||
use log::info;
|
||||
use reqwest;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use walkdir::WalkDir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const SCREAMAPI_REPO: &str = "acidicoala/ScreamAPI";
|
||||
|
||||
pub struct ScreamAPI;
|
||||
|
||||
#[async_trait]
|
||||
impl Unlocker for ScreamAPI {
|
||||
async fn get_latest_version() -> Result<String, String> {
|
||||
info!("Fetching latest ScreamAPI version...");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let releases_url = format!(
|
||||
"https://api.github.com/repos/{}/releases/latest",
|
||||
SCREAMAPI_REPO
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&releases_url)
|
||||
.header("User-Agent", "CreamLinux")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch ScreamAPI releases: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to fetch ScreamAPI releases: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let release_info: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let version = release_info
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Failed to extract version from release info".to_string())?
|
||||
.to_string();
|
||||
|
||||
info!("Latest ScreamAPI version: {}", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn download_to_cache() -> Result<String, String> {
|
||||
let version = Self::get_latest_version().await?;
|
||||
info!("Downloading ScreamAPI version {}...", version);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let releases_url = format!(
|
||||
"https://api.github.com/repos/{}/releases/latest",
|
||||
SCREAMAPI_REPO
|
||||
);
|
||||
let release_info: serde_json::Value = client
|
||||
.get(&releases_url)
|
||||
.header("User-Agent", "CreamLinux")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch ScreamAPI release: {}", e))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse release info: {}", e))?;
|
||||
|
||||
let zip_url = release_info
|
||||
.get("assets")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|assets| {
|
||||
assets.iter().find(|asset| {
|
||||
asset
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| n.ends_with(".zip"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.and_then(|asset| asset.get("browser_download_url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.ok_or_else(|| "No zip asset found in ScreamAPI release".to_string())?
|
||||
.to_string();
|
||||
|
||||
info!("Downloading ScreamAPI from: {}", zip_url);
|
||||
|
||||
let response = client
|
||||
.get(&zip_url)
|
||||
.timeout(Duration::from_secs(60))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download ScreamAPI: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download ScreamAPI: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
||||
let zip_path = temp_dir.path().join("screamapi.zip");
|
||||
let content = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
|
||||
fs::write(&zip_path, &content)
|
||||
.map_err(|e| format!("Failed to write zip file: {}", e))?;
|
||||
|
||||
let version_dir = crate::cache::get_screamapi_version_dir(&version)?;
|
||||
let file =
|
||||
fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
|
||||
|
||||
let file_name = file.name().to_string();
|
||||
let base_name = Path::new(&file_name)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let should_extract = base_name.to_lowercase().ends_with(".dll")
|
||||
|| base_name == "ScreamAPI.config.json";
|
||||
|
||||
if should_extract {
|
||||
let output_path = version_dir.join(&base_name);
|
||||
let mut outfile = fs::File::create(&output_path)
|
||||
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||
io::copy(&mut file, &mut outfile)
|
||||
.map_err(|e| format!("Failed to extract file: {}", e))?;
|
||||
info!("Extracted: {}", output_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
info!("ScreamAPI version {} downloaded to cache successfully", version);
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// context = "" -> direct install (replace EOSSDK DLLs)
|
||||
/// context = "koaloader" -> payload install (drop DLL in exe dir)
|
||||
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> {
|
||||
if context == "koaloader" {
|
||||
Self::install_as_koaloader_payload(game_path).await
|
||||
} else {
|
||||
Self::install_direct(game_path).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String> {
|
||||
if context == "koaloader" {
|
||||
Self::uninstall_as_koaloader_payload(game_path).await
|
||||
} else {
|
||||
Self::uninstall_direct(game_path).await
|
||||
}
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"ScreamAPI"
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreamAPI {
|
||||
// Direct install
|
||||
|
||||
async fn install_direct(game_path: &str) -> Result<(), String> {
|
||||
info!("Installing ScreamAPI (direct) to: {}", game_path);
|
||||
|
||||
let install_path = Path::new(game_path);
|
||||
let eos_dlls = Self::find_eossdk_dlls(install_path);
|
||||
|
||||
if eos_dlls.is_empty() {
|
||||
return Err(format!(
|
||||
"No EOSSDK-Win*-Shipping.dll found in {}",
|
||||
game_path
|
||||
));
|
||||
}
|
||||
|
||||
info!("Found {} EOSSDK DLL(s)", eos_dlls.len());
|
||||
|
||||
let versions = crate::cache::read_versions()?;
|
||||
if versions.screamapi.latest.is_empty() {
|
||||
return Err("ScreamAPI is not cached. Please restart the app.".to_string());
|
||||
}
|
||||
let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?;
|
||||
|
||||
for eos_dll in &eos_dlls {
|
||||
let filename = eos_dll.file_name().unwrap_or_default().to_string_lossy();
|
||||
let is_64bit = filename.to_lowercase().contains("64");
|
||||
|
||||
let stem = filename.trim_end_matches(".dll");
|
||||
let backup = eos_dll.with_file_name(format!("{}_o.dll", stem));
|
||||
|
||||
if !backup.exists() && eos_dll.exists() {
|
||||
fs::copy(eos_dll, &backup)
|
||||
.map_err(|e| format!("Failed to backup {}: {}", filename, e))?;
|
||||
info!("Backed up {} -> {}", eos_dll.display(), backup.display());
|
||||
}
|
||||
|
||||
let scream_dll_name = if is_64bit { "ScreamAPI64.dll" } else { "ScreamAPI32.dll" };
|
||||
let src = scream_dir.join(scream_dll_name);
|
||||
if !src.exists() {
|
||||
return Err(format!("ScreamAPI DLL not found in cache: {}", src.display()));
|
||||
}
|
||||
|
||||
fs::copy(&src, eos_dll)
|
||||
.map_err(|e| format!("Failed to install ScreamAPI DLL: {}", e))?;
|
||||
info!("Installed {} as {}", scream_dll_name, eos_dll.display());
|
||||
}
|
||||
|
||||
let config_dir = eos_dlls[0].parent().ok_or("Failed to get parent of EOS DLL")?;
|
||||
crate::screamapi_config::write_default_config(config_dir)?;
|
||||
|
||||
info!("ScreamAPI (direct) installation complete for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_direct(game_path: &str) -> Result<(), String> {
|
||||
info!("Uninstalling ScreamAPI (direct) from: {}", game_path);
|
||||
|
||||
let install_path = Path::new(game_path);
|
||||
|
||||
for entry in WalkDir::new(install_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let lower = filename.to_lowercase();
|
||||
|
||||
if lower.starts_with("eossdk-win") && lower.ends_with("_o.dll") {
|
||||
let original_name = filename.trim_end_matches("_o.dll").to_string() + ".dll";
|
||||
let original = path.parent().unwrap_or(install_path).join(&original_name);
|
||||
|
||||
fs::copy(path, &original)
|
||||
.map_err(|e| format!("Failed to restore {}: {}", original_name, e))?;
|
||||
fs::remove_file(path)
|
||||
.map_err(|e| format!("Failed to remove backup file: {}", e))?;
|
||||
info!("Restored {} from backup", original.display());
|
||||
}
|
||||
}
|
||||
|
||||
crate::screamapi_config::delete_config(game_path)?;
|
||||
info!("ScreamAPI (direct) uninstallation complete for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Koaloader payload
|
||||
|
||||
async fn install_as_koaloader_payload(exe_dir: &str) -> Result<(), String> {
|
||||
info!("Installing ScreamAPI as Koaloader payload in: {}", exe_dir);
|
||||
|
||||
let versions = crate::cache::read_versions()?;
|
||||
if versions.screamapi.latest.is_empty() {
|
||||
return Err("ScreamAPI is not cached. Please restart the app.".to_string());
|
||||
}
|
||||
let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?;
|
||||
let exe_dir_path = Path::new(exe_dir);
|
||||
|
||||
for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] {
|
||||
let src = scream_dir.join(dll_name);
|
||||
if src.exists() {
|
||||
let dest = exe_dir_path.join(dll_name);
|
||||
fs::copy(&src, &dest)
|
||||
.map_err(|e| format!("Failed to copy {}: {}", dll_name, e))?;
|
||||
info!("Placed {} in exe dir", dll_name);
|
||||
}
|
||||
}
|
||||
|
||||
crate::screamapi_config::write_default_config(exe_dir_path)?;
|
||||
info!("ScreamAPI (Koaloader payload) install complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_as_koaloader_payload(exe_dir: &str) -> Result<(), String> {
|
||||
info!("Removing ScreamAPI Koaloader payload from: {}", exe_dir);
|
||||
|
||||
let exe_dir_path = Path::new(exe_dir);
|
||||
for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] {
|
||||
let path = exe_dir_path.join(dll_name);
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to remove {}: {}", dll_name, e))?;
|
||||
info!("Removed {}", dll_name);
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = exe_dir_path.join("ScreamAPI.config.json");
|
||||
if cfg.exists() {
|
||||
fs::remove_file(&cfg).ok();
|
||||
}
|
||||
|
||||
info!("ScreamAPI (Koaloader payload) uninstall complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
pub fn find_eossdk_dlls(root: &Path) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
for entry in WalkDir::new(root)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
let lower = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
|
||||
if lower.starts_with("eossdk-win")
|
||||
&& lower.ends_with("-shipping.dll")
|
||||
&& !lower.contains("_o")
|
||||
{
|
||||
found.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ impl Unlocker for SmokeAPI {
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
// Extract all DLL files
|
||||
// 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)
|
||||
@@ -102,8 +102,11 @@ impl Unlocker for SmokeAPI {
|
||||
|
||||
let file_name = file.name();
|
||||
|
||||
// Only extract DLL files
|
||||
if file_name.to_lowercase().ends_with(".dll") {
|
||||
// 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()
|
||||
@@ -127,17 +130,56 @@ impl Unlocker for SmokeAPI {
|
||||
}
|
||||
|
||||
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 to {} for {} API files",
|
||||
"Installing SmokeAPI (Proton) to {} for {} API files",
|
||||
game_path,
|
||||
api_files.len()
|
||||
);
|
||||
|
||||
// Get the cached SmokeAPI DLLs
|
||||
let cached_dlls = crate::cache::list_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());
|
||||
}
|
||||
@@ -195,15 +237,77 @@ impl Unlocker for SmokeAPI {
|
||||
);
|
||||
}
|
||||
|
||||
info!("SmokeAPI installation completed for: {}", game_path);
|
||||
info!("SmokeAPI (Proton) installation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||
/// 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 from: {}", game_path);
|
||||
info!("Uninstalling SmokeAPI (Proton) from: {}", game_path);
|
||||
|
||||
for api_file in &api_files {
|
||||
let api_path = Path::new(game_path).join(api_file);
|
||||
@@ -250,11 +354,79 @@ impl Unlocker for SmokeAPI {
|
||||
}
|
||||
}
|
||||
|
||||
info!("SmokeAPI uninstallation completed for: {}", game_path);
|
||||
info!("SmokeAPI (Proton) uninstallation completed for: {}", game_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"SmokeAPI"
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use log::{debug, info, warn};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Represents the bitness of a binary
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Bitness {
|
||||
Bit32,
|
||||
Bit64,
|
||||
}
|
||||
|
||||
/// Detect the bitness of a Linux Binary by reading ELF header
|
||||
/// ELF format: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
|
||||
fn detect_binary_bitness(file_path: &Path) -> Option<Bitness> {
|
||||
use std::io::Read;
|
||||
|
||||
// Only read first 5 bytes
|
||||
let mut file = fs::File::open(file_path).ok()?;
|
||||
let mut bytes = [0u8; 5];
|
||||
|
||||
// Read exactly 5 bytes or fail
|
||||
if file.read_exact(&mut bytes).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
||||
if &bytes[0..4] != b"\x7FELF" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Byte 4 (EI_CLASS) indicates 32-bit or 64-bit
|
||||
// 1 = ELFCLASS32 (32-bit)
|
||||
// 2 = ELFCLASS64 (64-bit)
|
||||
match bytes[4] {
|
||||
1 => Some(Bitness::Bit32),
|
||||
2 => Some(Bitness::Bit64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan game directory for Linux binaries and determine bitness
|
||||
/// Returns the detected bitness, prioritizing the main game executable
|
||||
pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
|
||||
info!("Detecting bitness for game at: {}", game_path);
|
||||
|
||||
let game_path_obj = Path::new(game_path);
|
||||
if !game_path_obj.exists() {
|
||||
return Err("Game path does not exist".to_string());
|
||||
}
|
||||
|
||||
// Directories to skip for performance
|
||||
let skip_dirs = [
|
||||
"videos",
|
||||
"video",
|
||||
"movies",
|
||||
"movie",
|
||||
"sound",
|
||||
"sounds",
|
||||
"audio",
|
||||
"textures",
|
||||
"music",
|
||||
"localization",
|
||||
"shaders",
|
||||
"logs",
|
||||
"assets",
|
||||
"_CommonRedist",
|
||||
"data",
|
||||
"Data",
|
||||
"Docs",
|
||||
"docs",
|
||||
"screenshots",
|
||||
"Screenshots",
|
||||
"saves",
|
||||
"Saves",
|
||||
"mods",
|
||||
"Mods",
|
||||
"maps",
|
||||
"Maps",
|
||||
];
|
||||
|
||||
// Limit scan depth to avoid deep recursion
|
||||
const MAX_DEPTH: usize = 3;
|
||||
|
||||
// Stop after finding reasonable confidence (10 binaries)
|
||||
const CONFIDENCE_THRESHOLD: usize = 10;
|
||||
|
||||
let mut bit64_binaries = Vec::new();
|
||||
let mut bit32_binaries = Vec::new();
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// Scan for Linux binaries
|
||||
for entry in WalkDir::new(game_path_obj)
|
||||
.max_depth(MAX_DEPTH)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
if e.file_type().is_dir() {
|
||||
let dir_name = e.file_name().to_string_lossy().to_lowercase();
|
||||
!skip_dirs.iter().any(|&skip| dir_name.contains(skip))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
// Early termination when we have high confidence
|
||||
if bit64_binaries.len() >= CONFIDENCE_THRESHOLD || bit32_binaries.len() >= CONFIDENCE_THRESHOLD {
|
||||
debug!("Reached confidence threshold, stopping scan early");
|
||||
break;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
// Only check files
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-binary files early for performance
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Check for common Linux executable extensions or shared libraries
|
||||
let has_binary_extension = filename.ends_with(".x86")
|
||||
|| filename.ends_with(".x86_64")
|
||||
|| filename.ends_with(".bin")
|
||||
|| filename.ends_with(".so")
|
||||
|| filename.contains(".so.")
|
||||
|| filename.starts_with("lib");
|
||||
|
||||
// Check if file is executable
|
||||
let is_executable = {
|
||||
{
|
||||
// Get metadata once and check both extension and permissions
|
||||
if let Ok(metadata) = fs::metadata(path) {
|
||||
let permissions = metadata.permissions();
|
||||
let executable = permissions.mode() & 0o111 != 0;
|
||||
|
||||
// Skip files that are neither executable nor have binary extensions
|
||||
executable || has_binary_extension
|
||||
} else {
|
||||
// If we can't read metadata, only proceed if it has binary extension
|
||||
has_binary_extension
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !is_executable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect bitness
|
||||
if let Some(bitness) = detect_binary_bitness(path) {
|
||||
debug!("Found {:?} binary: {}", bitness, path.display());
|
||||
|
||||
match bitness {
|
||||
Bitness::Bit64 => {
|
||||
bit64_binaries.push(path.to_path_buf());
|
||||
|
||||
// If we find libsteam_api.so and it's 64-bit, we can be very confident
|
||||
if filename == "libsteam_api.so" {
|
||||
info!("Found 64-bit libsteam_api.so");
|
||||
return Ok(Bitness::Bit64);
|
||||
}
|
||||
},
|
||||
Bitness::Bit32 => {
|
||||
bit32_binaries.push(path.to_path_buf());
|
||||
|
||||
// If we find libsteam_api.so and it's 32-bit, we can be very confident
|
||||
if filename == "libsteam_api.so" {
|
||||
info!("Found 32-bit libsteam_api.so");
|
||||
return Ok(Bitness::Bit32);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decision logic: prioritize finding the main game executable
|
||||
// 1. If we found any 64-bit binaries and no 32-bit, it's 64-bit
|
||||
// 2. If we found any 32-bit binaries and no 64-bit, it's 32-bit
|
||||
// 3. If we found both, prefer 64-bit (modern games are usually 64-bit)
|
||||
// 4. If we found neither, return an error
|
||||
|
||||
if !bit64_binaries.is_empty() && bit32_binaries.is_empty() {
|
||||
info!("Detected 64-bit game (Only 64-bit binaries found)");
|
||||
Ok(Bitness::Bit64)
|
||||
} else if !bit32_binaries.is_empty() && bit64_binaries.is_empty() {
|
||||
info!("Detected 32-bit game (Only 32-bit binaries found)");
|
||||
Ok(Bitness::Bit32)
|
||||
} else if !bit64_binaries.is_empty() && !bit32_binaries.is_empty() {
|
||||
warn!(
|
||||
"Found both 32-bit and 64-bit binaries, defaulting to 64-bit. 32-bit: {}, 64-bit: {}",
|
||||
bit32_binaries.len(),
|
||||
bit64_binaries.len()
|
||||
);
|
||||
info!("Detected 64-bit game (mixed binaries, defaulting to 64-bit)");
|
||||
Ok(Bitness::Bit64)
|
||||
} else {
|
||||
Err("Could not detect game bitness: no Linux binaries found".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod bitness;
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
"version": "1.3.4",
|
||||
"version": "1.7.1",
|
||||
"identifier": "com.creamlinux.dev",
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
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
|
||||
@@ -11,27 +12,30 @@ import {
|
||||
InitialLoadingScreen,
|
||||
ErrorBoundary,
|
||||
UpdateScreen,
|
||||
AnimatedBackground,
|
||||
} from '@/components/layout'
|
||||
|
||||
// Dialog components
|
||||
import {
|
||||
ProgressDialog,
|
||||
DlcSelectionDialog,
|
||||
SettingsDialog,
|
||||
ConflictDialog,
|
||||
ReminderDialog,
|
||||
DisclaimerDialog,
|
||||
UnlockerChoiceDialog,
|
||||
} from '@/components/dialogs'
|
||||
|
||||
// Page components (Overview and Settings)
|
||||
import { OverviewPage, SettingsPage } from '@/components/pages'
|
||||
|
||||
// Game components
|
||||
import { GameList } from '@/components/games'
|
||||
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()
|
||||
|
||||
@@ -47,7 +51,7 @@ function App() {
|
||||
handleRefresh,
|
||||
isLoading,
|
||||
error,
|
||||
} = useAppLogic({ autoLoad: updateComplete })
|
||||
} = useAppLogic()
|
||||
|
||||
// Get action handlers from context
|
||||
const {
|
||||
@@ -60,31 +64,75 @@ function App() {
|
||||
handleDlcConfirm,
|
||||
handleGameEdit,
|
||||
handleUpdateDlcs,
|
||||
settingsDialog,
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleOpenRating,
|
||||
reportingEnabled,
|
||||
showToast,
|
||||
unlockerSelectionDialog,
|
||||
handleSelectCreamLinux,
|
||||
handleSelectSmokeAPI,
|
||||
closeUnlockerDialog,
|
||||
epicGames,
|
||||
epicLoading,
|
||||
epicInstallingId,
|
||||
loadEpicGames,
|
||||
handleEpicInstall,
|
||||
handleEpicUninstallScream,
|
||||
handleEpicUninstallKoaloader,
|
||||
handleEpicSettings,
|
||||
} = useAppContext()
|
||||
|
||||
// Conflict detection
|
||||
const { currentConflict, showReminder, resolveConflict, closeReminder } =
|
||||
useConflictDetection(games)
|
||||
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 () => {
|
||||
const resolution = resolveConflict()
|
||||
if (!resolution) return
|
||||
|
||||
// Always remove files - use the special conflict resolution command
|
||||
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: resolution.gameId,
|
||||
conflictType: resolution.conflictType,
|
||||
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}`, 'error')
|
||||
showToast('Failed to resolve conflict', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,27 +149,29 @@ function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="app-container">
|
||||
{/* Animated background */}
|
||||
<AnimatedBackground />
|
||||
|
||||
{/* Header with search */}
|
||||
<Header
|
||||
onRefresh={handleRefresh}
|
||||
onSearch={handleSearchChange}
|
||||
searchQuery={searchQuery}
|
||||
refreshDisabled={isLoading}
|
||||
/>
|
||||
<Header onSearch={handleSearchChange} searchQuery={searchQuery} />
|
||||
|
||||
<div className="main-content">
|
||||
{/* Sidebar for filtering */}
|
||||
<Sidebar
|
||||
setFilter={setFilter}
|
||||
currentFilter={filter}
|
||||
onSettingsClick={handleSettingsOpen}
|
||||
/>
|
||||
<Sidebar setFilter={handleSetFilter} currentFilter={filter} />
|
||||
|
||||
{/* Show error or game list */}
|
||||
{error ? (
|
||||
{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>
|
||||
@@ -134,6 +184,9 @@ function App() {
|
||||
onAction={handleGameAction}
|
||||
onEdit={handleGameEdit}
|
||||
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
||||
onRate={handleOpenRating}
|
||||
onRefresh={handleRefresh}
|
||||
reportingEnabled={reportingEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -167,21 +220,44 @@ function App() {
|
||||
onUpdate={handleUpdateDlcs}
|
||||
/>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
|
||||
|
||||
{/* Conflict Detection Dialog */}
|
||||
{currentConflict && (
|
||||
<ConflictDialog
|
||||
visible={true}
|
||||
gameTitle={currentConflict.gameTitle}
|
||||
conflictType={currentConflict.type}
|
||||
onConfirm={handleConflictResolve}
|
||||
/>
|
||||
)}
|
||||
<ConflictDialog
|
||||
visible={showDialog}
|
||||
conflicts={conflicts}
|
||||
onResolve={handleConflictResolve}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
|
||||
{/* Steam Launch Options Reminder */}
|
||||
<ReminderDialog visible={showReminder} onClose={closeReminder} />
|
||||
{/* 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} />
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -3,7 +3,7 @@ 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'
|
||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' | 'install_unlocker'
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: ActionType
|
||||
@@ -18,7 +18,6 @@ interface ActionButtonProps {
|
||||
* Specialized button for game installation actions
|
||||
*/
|
||||
const ActionButton: FC<ActionButtonProps> = ({
|
||||
action,
|
||||
isInstalled,
|
||||
isWorking,
|
||||
onClick,
|
||||
@@ -29,10 +28,7 @@ const ActionButton: FC<ActionButtonProps> = ({
|
||||
const getButtonText = () => {
|
||||
if (isWorking) return 'Working...'
|
||||
|
||||
const isCream = action.includes('cream')
|
||||
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
||||
|
||||
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
|
||||
return isInstalled ? 'Uninstall' : 'Install'
|
||||
}
|
||||
|
||||
// Map to button variant
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -58,7 +59,7 @@ const Button: FC<ButtonProps> = ({
|
||||
>
|
||||
{isLoading && (
|
||||
<span className="btn-spinner">
|
||||
<span className="spinner"></span>
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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,25 +1,25 @@
|
||||
import { ReactNode } from 'react'
|
||||
import Spinner from './Spinner'
|
||||
|
||||
export type LoadingType = 'spinner' | 'dots' | 'progress'
|
||||
export type LoadingType = 'spinner' | 'dots'
|
||||
export type LoadingSize = 'small' | 'medium' | 'large'
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
size?: LoadingSize
|
||||
type?: LoadingType
|
||||
message?: string
|
||||
progress?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Versatile loading indicator component
|
||||
* Supports multiple visual styles and sizes
|
||||
* 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,
|
||||
progress = 0,
|
||||
className = '',
|
||||
}: LoadingIndicatorProps) => {
|
||||
// Size class mapping
|
||||
@@ -33,7 +33,7 @@ const LoadingIndicator = ({
|
||||
const renderLoadingIndicator = (): ReactNode => {
|
||||
switch (type) {
|
||||
case 'spinner':
|
||||
return <div className="loading-spinner"></div>
|
||||
return <Spinner />
|
||||
|
||||
case 'dots':
|
||||
return (
|
||||
@@ -44,21 +44,8 @@ const LoadingIndicator = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<div className="loading-progress">
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{progress > 0 && <div className="progress-percentage">{Math.round(progress)}%</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="loading-spinner"></div>
|
||||
return <Spinner />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface GameVotes {
|
||||
unlocker: string
|
||||
success: number
|
||||
fail: number
|
||||
}
|
||||
|
||||
interface VotesDisplayProps {
|
||||
votes: GameVotes | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact vote bar shown inside the unlocker selection dialog.
|
||||
* Shows a green/red progress bar with a label, or "No votes yet" when empty.
|
||||
*/
|
||||
const VotesDisplay: React.FC<VotesDisplayProps> = ({ votes }) => {
|
||||
if (!votes || (votes.success === 0 && votes.fail === 0)) {
|
||||
return (
|
||||
<div className="unlocker-votes">
|
||||
<span className="votes-label votes-label--none">No votes yet</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const total = votes.success + votes.fail
|
||||
const pct = Math.round((votes.success / total) * 100)
|
||||
|
||||
const labelClass =
|
||||
pct >= 70 ? 'votes-label--positive' : pct >= 40 ? '' : 'votes-label--negative'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="unlocker-votes"
|
||||
title={`${votes.success} worked · ${votes.fail} didn't work`}
|
||||
>
|
||||
<div className="votes-bar-wrap">
|
||||
<div className="votes-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`votes-label ${labelClass}`}>
|
||||
{pct}% working ({total})
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VotesDisplay
|
||||
@@ -1,6 +1,11 @@
|
||||
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 { DropdownOption } from './Dropdown'
|
||||
export type { GameVotes } from './VotesDisplay'
|
||||
export type { EntryListProps } from './EntryList'
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Dialog from './Dialog'
|
||||
import DialogHeader from './DialogHeader'
|
||||
import DialogBody from './DialogBody'
|
||||
import DialogFooter from './DialogFooter'
|
||||
import DialogActions from './DialogActions'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { DlcInfo } from '@/types'
|
||||
|
||||
export interface AddDlcDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onAdd: (dlc: DlcInfo) => void
|
||||
existingIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Add DLC Manually dialog
|
||||
* Allows users to manually enter a DLC ID and name when it is
|
||||
* missing from the Steam API and cannot be fetched automatically
|
||||
*/
|
||||
const AddDlcDialog = ({ visible, onClose, onAdd, existingIds }: AddDlcDialogProps) => {
|
||||
const [id, setId] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Reset form state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setId('')
|
||||
setName('')
|
||||
setError('')
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// Validate inputs and add the DLC to the list
|
||||
const handleSubmit = () => {
|
||||
const trimmedId = id.trim()
|
||||
const trimmedName = name.trim()
|
||||
|
||||
if (!trimmedId) return setError('DLC ID is required.')
|
||||
if (!/^\d+$/.test(trimmedId)) return setError('DLC ID must be a number.')
|
||||
if (existingIds.has(trimmedId)) return setError('A DLC with this ID already exists.')
|
||||
if (!trimmedName) return setError('DLC name is required.')
|
||||
|
||||
onAdd({ appid: trimmedId, name: trimmedName, enabled: true })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="small">
|
||||
<DialogHeader onClose={onClose}>
|
||||
<h3>Add DLC Manually</h3>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="add-dlc-form">
|
||||
<div className="add-dlc-field">
|
||||
<label className="add-dlc-label">DLC ID</label>
|
||||
<input
|
||||
type="text"
|
||||
className="add-dlc-input"
|
||||
placeholder="e.g. 1234560"
|
||||
value={id}
|
||||
onChange={(e) => { setId(e.target.value); setError('') }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="add-dlc-field">
|
||||
<label className="add-dlc-label">DLC Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="add-dlc-input"
|
||||
placeholder="e.g. Expansion - My DLC"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError('') }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="add-dlc-error">{error}</p>}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>Add DLC</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddDlcDialog
|
||||
@@ -0,0 +1,194 @@
|
||||
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
|
||||
@@ -7,66 +7,95 @@ import {
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, warning } from '@/components/icons'
|
||||
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
|
||||
gameTitle: string
|
||||
conflictType: 'cream-to-proton' | 'smoke-to-native'
|
||||
onConfirm: () => void
|
||||
conflicts: Conflict[]
|
||||
onResolve: (gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native') => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict Dialog component
|
||||
* Shows when incompatible unlocker files are detected after platform switch
|
||||
* Shows all conflicts at once with individual resolve buttons
|
||||
*/
|
||||
const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
visible,
|
||||
gameTitle,
|
||||
conflictType,
|
||||
onConfirm,
|
||||
conflicts,
|
||||
onResolve,
|
||||
onClose,
|
||||
}) => {
|
||||
const getConflictMessage = () => {
|
||||
if (conflictType === 'cream-to-proton') {
|
||||
return {
|
||||
title: 'CreamLinux unlocker detected, but game is set to Proton',
|
||||
bodyPrefix: 'It looks like you previously installed CreamLinux while ',
|
||||
bodySuffix: ' was running natively. Steam is now configured to run it with Proton, so CreamLinux files will be removed automatically.',
|
||||
}
|
||||
// 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 {
|
||||
title: 'SmokeAPI unlocker detected, but game is set to Native',
|
||||
bodyPrefix: 'It looks like you previously installed SmokeAPI while ',
|
||||
bodySuffix: ' was running with Proton. Steam is now configured to run it natively, so SmokeAPI files will be removed automatically.',
|
||||
}
|
||||
return 'Will remove existing unlocker files and restore the game to a clean state.'
|
||||
}
|
||||
}
|
||||
|
||||
const message = getConflictMessage()
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} size="large" preventBackdropClose={true}>
|
||||
<DialogHeader hideCloseButton={true}>
|
||||
<div className="conflict-dialog-header">
|
||||
<Icon name={warning} variant="solid" size="lg" />
|
||||
<h3>{message.title}</h3>
|
||||
<h3>Unlocker conflicts detected</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="conflict-dialog-body">
|
||||
<p>
|
||||
{message.bodyPrefix}
|
||||
<strong>{gameTitle}</strong>
|
||||
{message.bodySuffix}
|
||||
<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="primary" onClick={onConfirm}>
|
||||
OK
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -4,7 +4,9 @@ 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'
|
||||
|
||||
@@ -51,6 +53,7 @@ const DlcSelectionDialog = ({
|
||||
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(() => {
|
||||
@@ -126,6 +129,11 @@ const DlcSelectionDialog = ({
|
||||
)
|
||||
}, [selectAll])
|
||||
|
||||
// Add a manually-entered DLC to the list
|
||||
const handleAddDlc = useCallback((dlc: DlcInfo) => {
|
||||
setSelectedDlcs((prev) => [...prev, dlc])
|
||||
}, [])
|
||||
|
||||
// Submit selected DLCs to parent component
|
||||
const handleConfirm = useCallback(() => {
|
||||
// Create a deep copy to prevent reference issues
|
||||
@@ -151,124 +159,141 @@ const DlcSelectionDialog = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>{dialogTitle}</h3>
|
||||
<div className="dlc-game-info">
|
||||
<span className="game-title">{gameTitle}</span>
|
||||
<span className="dlc-count">
|
||||
{selectedCount} of {selectedDlcs.length} DLCs selected
|
||||
{getLoadingInfoText()}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>{dialogTitle}</h3>
|
||||
<div className="dlc-game-info">
|
||||
<span className="game-title">{gameTitle}</span>
|
||||
<span className="dlc-count">
|
||||
{selectedCount} of {selectedDlcs.length} DLCs selected
|
||||
{getLoadingInfoText()}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="dlc-dialog-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dlc-search-input"
|
||||
/>
|
||||
<div className="select-all-container">
|
||||
<AnimatedCheckbox
|
||||
checked={selectAll}
|
||||
onChange={handleToggleSelectAll}
|
||||
label="Select All"
|
||||
<div className="dlc-dialog-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search DLCs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dlc-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isLoading || 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 className="select-all-container">
|
||||
<AnimatedCheckbox
|
||||
checked={selectAll}
|
||||
onChange={handleToggleSelectAll}
|
||||
label="Select All"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogBody className="dlc-list-container">
|
||||
{selectedDlcs.length > 0 ? (
|
||||
<ul className="dlc-list">
|
||||
{filteredDlcs.map((dlc) => (
|
||||
<li key={dlc.appid} className="dlc-item">
|
||||
<AnimatedCheckbox
|
||||
checked={dlc.enabled}
|
||||
onChange={() => handleToggleDlc(dlc.appid)}
|
||||
label={dlc.name}
|
||||
sublabel={`ID: ${dlc.appid}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{isLoading && (
|
||||
<li className="dlc-item dlc-item-loading">
|
||||
<div className="loading-pulse"></div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="dlc-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading DLC information...</p>
|
||||
{(isLoading || isUpdating) && loadingProgress > 0 && (
|
||||
<div className="dlc-loading-progress">
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
||||
</div>
|
||||
<div className="loading-details">
|
||||
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
|
||||
{estimatedTimeLeft && (
|
||||
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{/* 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>
|
||||
|
||||
{/* Update button - only show in edit mode */}
|
||||
{isEditMode && onUpdate && (
|
||||
<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="warning"
|
||||
onClick={() => onUpdate(gameId)}
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={(isLoading || isUpdating) && loadingProgress < 10}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowAddDlc(true)}
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? 'Updating...' : 'Update DLC List'}
|
||||
Add DLC Manually
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
|
||||
{/* 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
|
||||
export default DlcSelectionDialog
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
|
||||
interface OptInDialogProps {
|
||||
visible: boolean
|
||||
onAccept: () => void
|
||||
onDecline: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* First-launch opt-in dialog for the compatibility reporting system.
|
||||
* Shown once when the app fully starts. Does not close until the user makes
|
||||
* an explicit choice.
|
||||
*/
|
||||
const OptInDialog: React.FC<OptInDialogProps> = ({ visible, onAccept, onDecline }) => {
|
||||
return (
|
||||
<Dialog visible={visible} onClose={() => {}} size="medium">
|
||||
<DialogHeader onClose={() => {}} hideCloseButton={true}>
|
||||
<h3>Help improve CreamLinux</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="optin-content">
|
||||
|
||||
<p className="optin-intro">
|
||||
CreamLinux can collect anonymous compatibility reports to help users know which
|
||||
games work with CreamLinux and SmokeAPI before they install them.
|
||||
</p>
|
||||
|
||||
<div className="optin-details">
|
||||
<h4>What we collect</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>A one-way anonymous hash</strong> derived from your machine ID, Steam
|
||||
install path, and a locally-stored random salt. <em>This cannot be reversed
|
||||
to identify you</em>, and even we cannot link it to your machine.
|
||||
</li>
|
||||
<li>The Steam App ID of the game you rated.</li>
|
||||
<li>Which unlocker you used (CreamLinux or SmokeAPI).</li>
|
||||
<li>Whether it worked or not.</li>
|
||||
</ul>
|
||||
|
||||
<h4>What we do not collect</h4>
|
||||
<ul>
|
||||
<li>Your username, IP address, or any personally identifiable information.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="optin-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
If you opt out, the local salt will be deleted and no data will ever be sent.
|
||||
You will not be able to submit compatibility votes, but the app works fully
|
||||
without this feature.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onDecline}>
|
||||
No thanks
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onAccept}>
|
||||
Enable reporting
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default OptInDialog
|
||||
@@ -131,7 +131,7 @@ const ProgressDialog = ({
|
||||
<p>{message}</p>
|
||||
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
<div className="progress-dialog-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="progress-percentage">{Math.round(progress)}%</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
|
||||
interface LocalReport {
|
||||
game_id: string
|
||||
unlocker: string
|
||||
worked: boolean
|
||||
}
|
||||
|
||||
export interface RatingDialogProps {
|
||||
visible: boolean
|
||||
gameTitle: string
|
||||
gameId: string
|
||||
/** 'creamlinux' | 'smokeapi' – whichever is currently installed */
|
||||
unlocker: 'creamlinux' | 'smokeapi'
|
||||
onClose: () => void
|
||||
onSubmit: (worked: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const UNLOCKER_LABELS: Record<string, string> = {
|
||||
creamlinux: 'CreamLinux',
|
||||
smokeapi: 'SmokeAPI',
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-game rating dialog. Submits exactly one report for the installed unlocker.
|
||||
*/
|
||||
const RatingDialog: React.FC<RatingDialogProps> = ({
|
||||
visible,
|
||||
gameTitle,
|
||||
gameId,
|
||||
unlocker,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
// Which vote the user has already cast for this game+unlocker, if any
|
||||
const [previousVote, setPreviousVote] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
// Reset submit state each time the dialog opens
|
||||
setSubmitted(false)
|
||||
|
||||
// Load the local reports to see if this game+unlocker has already been started
|
||||
invoke<LocalReport[]>('get_local_reports')
|
||||
.then((reports) => {
|
||||
const existing = reports.find(
|
||||
(r) => r.game_id === gameId && r.unlocker === unlocker
|
||||
)
|
||||
setPreviousVote(existing ? existing.worked : null)
|
||||
})
|
||||
.catch(() => setPreviousVote(null))
|
||||
}, [visible, gameId, unlocker])
|
||||
|
||||
const handleSubmit = async (worked: boolean) => {
|
||||
if (submitting || submitted) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit(worked)
|
||||
setSubmitted(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSubmitted(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const label = UNLOCKER_LABELS[unlocker] ?? unlocker
|
||||
|
||||
// A button is "already chosen" if it matches the previous vote
|
||||
const workedAlreadyChosen = previousVote === true
|
||||
const brokenAlreadyChosen = previousVote === false
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleClose} size="small">
|
||||
<DialogHeader onClose={handleClose} hideCloseButton={true}>
|
||||
<h3>Submit rating</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{submitted ? (
|
||||
<div className="rating-submitted">
|
||||
<p>Thanks for your report! Your vote helps other users.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rating-content">
|
||||
<p>
|
||||
You have <strong>{label}</strong> installed for{' '}
|
||||
<strong>{gameTitle}</strong>. Did it work?
|
||||
</p>
|
||||
|
||||
{previousVote !== null && (
|
||||
<p className="rating-subtext">
|
||||
You previously voted <strong>{previousVote ? 'worked' : "didn't work"}</strong>.
|
||||
You can change your vote below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{previousVote === null && (
|
||||
<p className="rating-subtext">
|
||||
Your rating is anonymous and helps other users know if{' '}
|
||||
{label} works with this game.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rating-buttons">
|
||||
<Button
|
||||
variant="success"
|
||||
className={`rating-btn rating-btn--worked${workedAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={submitting || workedAlreadyChosen}
|
||||
title={workedAlreadyChosen ? 'Already voted' : undefined}
|
||||
leftIcon={<Icon name="Check" variant="solid" size="sm" />}
|
||||
>
|
||||
It worked
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
className={`rating-btn rating-btn--broken${brokenAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={submitting || brokenAlreadyChosen}
|
||||
title={brokenAlreadyChosen ? 'Already voted' : undefined}
|
||||
leftIcon={<Icon name="Close" variant="solid" size="sm" />}
|
||||
>
|
||||
Didn't work
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rating-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>Only the result for {label} will be submitted.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
{submitted ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default RatingDialog
|
||||
@@ -1,56 +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'
|
||||
|
||||
export interface ReminderDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder Dialog component
|
||||
* Reminds users to remove Steam launch options after removing CreamLinux
|
||||
*/
|
||||
const ReminderDialog: React.FC<ReminderDialogProps> = ({ visible, onClose }) => {
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="small">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="reminder-dialog-header">
|
||||
<Icon name={info} variant="solid" size="lg" />
|
||||
<h3>Reminder</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="reminder-dialog-body">
|
||||
<p>
|
||||
If you added a Steam launch option for CreamLinux, remember to remove it in Steam:
|
||||
</p>
|
||||
<ol className="reminder-steps">
|
||||
<li>Right-click the game in Steam</li>
|
||||
<li>Select "Properties"</li>
|
||||
<li>Go to "Launch Options"</li>
|
||||
<li>Remove the CreamLinux command</li>
|
||||
</ol>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Got it
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReminderDialog
|
||||
@@ -1,113 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, settings } from '@/components/icons'
|
||||
|
||||
interface SettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings Dialog component
|
||||
* Contains application settings and configuration options
|
||||
*/
|
||||
const SettingsDialog: React.FC<SettingsDialogProps> = ({ visible, onClose }) => {
|
||||
const [appVersion, setAppVersion] = useState<string>('Loading...')
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch app version when component mounts
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const version = await getVersion()
|
||||
setAppVersion(version)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch app version:', error)
|
||||
setAppVersion('Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
fetchVersion()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
{/*<Icon name={settings} variant="solid" size="md" />*/}
|
||||
<h3>Settings</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="settings-content">
|
||||
<div className="settings-section">
|
||||
<h4>General Settings</h4>
|
||||
<p className="settings-description">
|
||||
Configure your CreamLinux preferences and application behavior.
|
||||
</p>
|
||||
|
||||
<div className="settings-placeholder">
|
||||
<div className="placeholder-icon"> <Icon name={settings} variant="solid" size="xl" /> </div>
|
||||
<div className="placeholder-text">
|
||||
<h5>Settings Coming Soon</h5>
|
||||
<p>
|
||||
Working on adding customizable settings to improve your experience.
|
||||
Future options may include:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Custom Steam library paths</li>
|
||||
<li>Automatic update settings</li>
|
||||
<li>Scan frequency options</li>
|
||||
<li>DLC catalog</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>About CreamLinux</h4>
|
||||
<div className="app-info">
|
||||
<div className="info-row">
|
||||
<span className="info-label">Version:</span>
|
||||
<span className="info-value">{appVersion}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Build:</span>
|
||||
<span className="info-value">Beta</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Repository:</span>
|
||||
<a
|
||||
href="https://github.com/Novattz/creamlinux-installer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="info-link"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsDialog
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||
import { Dropdown, DropdownOption } from '@/components/common'
|
||||
//import { Icon, settings } from '@/components/icons'
|
||||
|
||||
interface SmokeAPIConfig {
|
||||
$schema: string
|
||||
$version: number
|
||||
logging: boolean
|
||||
log_steam_http: boolean
|
||||
default_app_status: 'unlocked' | 'locked' | 'original'
|
||||
override_app_status: Record<string, string>
|
||||
override_dlc_status: Record<string, string>
|
||||
auto_inject_inventory: boolean
|
||||
extra_inventory_items: number[]
|
||||
extra_dlcs: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SmokeAPISettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SmokeAPIConfig = {
|
||||
$schema:
|
||||
'https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json',
|
||||
$version: 4,
|
||||
logging: false,
|
||||
log_steam_http: false,
|
||||
default_app_status: 'unlocked',
|
||||
override_app_status: {},
|
||||
override_dlc_status: {},
|
||||
auto_inject_inventory: true,
|
||||
extra_inventory_items: [],
|
||||
extra_dlcs: {},
|
||||
}
|
||||
|
||||
const APP_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
|
||||
{ value: 'unlocked', label: 'Unlocked' },
|
||||
{ value: 'locked', label: 'Locked' },
|
||||
{ value: 'original', label: 'Original' },
|
||||
]
|
||||
|
||||
/**
|
||||
* SmokeAPI Settings Dialog
|
||||
* Allows configuration of SmokeAPI for a specific game
|
||||
*/
|
||||
const SmokeAPISettingsDialog = ({
|
||||
visible,
|
||||
onClose,
|
||||
gamePath,
|
||||
gameTitle,
|
||||
}: SmokeAPISettingsDialogProps) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [config, setConfig] = useState<SmokeAPIConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// Load existing config when dialog opens
|
||||
const loadConfig = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const existingConfig = await invoke<SmokeAPIConfig | null>('read_smokeapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
|
||||
if (existingConfig) {
|
||||
setConfig(existingConfig)
|
||||
setEnabled(true)
|
||||
} else {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
}
|
||||
setHasChanges(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to load SmokeAPI config:', error)
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [gamePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && gamePath) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [visible, gamePath, loadConfig])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (enabled) {
|
||||
// Save the config
|
||||
await invoke('write_smokeapi_config', {
|
||||
gamePath,
|
||||
config,
|
||||
})
|
||||
} else {
|
||||
// Delete the config
|
||||
await invoke('delete_smokeapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
}
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to save SmokeAPI config:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const updateConfig = <K extends keyof SmokeAPIConfig>(key: K, value: SmokeAPIConfig[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleCancel} size="medium">
|
||||
<DialogHeader onClose={handleCancel} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
{/*<Icon name={settings} variant="solid" size="md" />*/}
|
||||
<h3>SmokeAPI Settings</h3>
|
||||
</div>
|
||||
<p className="dialog-subtitle">{gameTitle}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-settings-content">
|
||||
{/* Enable/Disable Section */}
|
||||
<div className="settings-section">
|
||||
<AnimatedCheckbox
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
setEnabled(!enabled)
|
||||
setHasChanges(true)
|
||||
}}
|
||||
label="Enable SmokeAPI Configuration"
|
||||
sublabel="Enable this to customize SmokeAPI settings for this game"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Options */}
|
||||
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
|
||||
<div className="settings-section">
|
||||
<h4>General Settings</h4>
|
||||
|
||||
<Dropdown
|
||||
label="Default App Status"
|
||||
description="Specifies the default DLC status"
|
||||
value={config.default_app_status}
|
||||
options={APP_STATUS_OPTIONS}
|
||||
onChange={(value) => updateConfig('default_app_status', value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Logging</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.logging}
|
||||
onChange={() => updateConfig('logging', !config.logging)}
|
||||
label="Enable Logging"
|
||||
sublabel="Enables logging to SmokeAPI.log.log file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.log_steam_http}
|
||||
onChange={() => updateConfig('log_steam_http', !config.log_steam_http)}
|
||||
label="Log Steam HTTP"
|
||||
sublabel="Toggles logging of SteamHTTP traffic"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Inventory</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.auto_inject_inventory}
|
||||
onChange={() =>
|
||||
updateConfig('auto_inject_inventory', !config.auto_inject_inventory)
|
||||
}
|
||||
label="Auto Inject Inventory"
|
||||
sublabel="Automatically inject a list of all registered inventory items when the game queries user inventory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={isLoading || !hasChanges}>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmokeAPISettingsDialog
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||
|
||||
export interface SmokeAPIVotesDialogProps {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown before installing SmokeAPI on a Proton game.
|
||||
* Fetches and displays community votes for SmokeAPI specifically,
|
||||
* then lets the user confirm or cancel the installation.
|
||||
*/
|
||||
const SmokeAPIVotesDialog: React.FC<SmokeAPIVotesDialogProps> = ({
|
||||
visible,
|
||||
gameId,
|
||||
gameTitle,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) => {
|
||||
const [votes, setVotes] = useState<GameVotes | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !gameId) {
|
||||
setVotes(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||
.then((results) => {
|
||||
setVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||
})
|
||||
.catch(() => setVotes(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [visible, gameId])
|
||||
|
||||
const hasVotes = votes && (votes.success > 0 || votes.fail > 0)
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="small">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<h3>Install SmokeAPI</h3>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-votes-content">
|
||||
<p className="smokeapi-votes-game">
|
||||
<strong>{gameTitle}</strong>
|
||||
</p>
|
||||
|
||||
<div className="smokeapi-votes-section">
|
||||
<p className="smokeapi-votes-label">Community compatibility</p>
|
||||
{loading ? (
|
||||
<p className="smokeapi-votes-loading">Fetching votes...</p>
|
||||
) : (
|
||||
<VotesDisplay votes={votes} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && !hasVotes && (
|
||||
<div className="smokeapi-votes-notice">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
No one has rated this game yet. You'll be able to submit a rating after
|
||||
installing.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasVotes && (
|
||||
<div className="smokeapi-votes-notice">
|
||||
<Icon name={info} variant="solid" size="sm" />
|
||||
<span>
|
||||
These ratings are from other CreamLinux users. Results may vary.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onConfirm}>
|
||||
Install anyway
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmokeAPIVotesDialog
|
||||
@@ -0,0 +1,95 @@
|
||||
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
|
||||
@@ -0,0 +1,136 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -6,11 +6,15 @@ 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 SettingsDialog } from './SettingsDialog'
|
||||
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
||||
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 ReminderDialog } from './ReminderDialog'
|
||||
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'
|
||||
@@ -20,5 +24,9 @@ export type { DialogFooterProps } from './DialogFooter'
|
||||
export type { DialogActionsProps } from './DialogActions'
|
||||
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
|
||||
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
|
||||
export type { ConflictDialogProps } from './ConflictDialog'
|
||||
export type { ReminderDialogProps } from './ReminderDialog'
|
||||
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'
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { EpicGame } from '@/types/EpicGame'
|
||||
import { ActionButton, Button } from '@/components/buttons'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
interface EpicGameItemProps {
|
||||
game: EpicGame
|
||||
installing?: boolean
|
||||
onInstall: (game: EpicGame) => void
|
||||
onUninstallScream: (game: EpicGame) => void
|
||||
onUninstallKoaloader: (game: EpicGame) => void
|
||||
onSettings: (game: EpicGame) => void
|
||||
}
|
||||
|
||||
const EpicGameItem = ({
|
||||
game,
|
||||
installing,
|
||||
onInstall,
|
||||
onUninstallScream,
|
||||
onUninstallKoaloader,
|
||||
onSettings,
|
||||
}: EpicGameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (game.box_art_url) {
|
||||
setImageUrl(game.box_art_url)
|
||||
}
|
||||
}, [game.box_art_url])
|
||||
|
||||
const backgroundImage =
|
||||
imageUrl && !hasError
|
||||
? `url(${imageUrl})`
|
||||
: 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
|
||||
const anyInstalled = game.scream_installed || game.koaloader_installed
|
||||
const isWorking = !!installing
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-item-card"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{imageUrl && !hasError && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{ display: 'none' }}
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="game-item-overlay">
|
||||
<div className="game-badges">
|
||||
<span className="status-badge epic">Epic</span>
|
||||
{game.scream_installed && <span className="status-badge smoke">ScreamAPI</span>}
|
||||
{game.koaloader_installed && <span className="status-badge smoke">Koaloader</span>}
|
||||
</div>
|
||||
|
||||
<div className="game-title">
|
||||
<h3>{game.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-actions">
|
||||
{/* Nothing installed - install button */}
|
||||
{!anyInstalled && (
|
||||
<ActionButton
|
||||
action="install_unlocker"
|
||||
isInstalled={false}
|
||||
isWorking={isWorking}
|
||||
onClick={() => { if (!isWorking) onInstall(game) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ScreamAPI installed - uninstall + settings */}
|
||||
{game.scream_installed && (
|
||||
<ActionButton
|
||||
action="uninstall_smoke"
|
||||
isInstalled={true}
|
||||
isWorking={isWorking}
|
||||
onClick={() => { if (!isWorking) onUninstallScream(game) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Koaloader installed - uninstall */}
|
||||
{game.koaloader_installed && (
|
||||
<ActionButton
|
||||
action="uninstall_smoke"
|
||||
isInstalled={true}
|
||||
isWorking={isWorking}
|
||||
onClick={() => { if (!isWorking) onUninstallKoaloader(game) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings button - only for direct ScreamAPI (not Koaloader) */}
|
||||
{game.scream_installed && !game.koaloader_installed && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => onSettings(game)}
|
||||
disabled={isWorking}
|
||||
title="Configure ScreamAPI"
|
||||
className="edit-button settings-icon-button"
|
||||
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EpicGameItem
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
@@ -9,16 +9,17 @@ interface GameItemProps {
|
||||
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 }: GameItemProps) => {
|
||||
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Function to fetch the game cover/image
|
||||
@@ -33,13 +34,9 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
|
||||
if (bestImageUrl) {
|
||||
setImageUrl(bestImageUrl)
|
||||
setHasError(false)
|
||||
} else {
|
||||
setHasError(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching game image:', error)
|
||||
setHasError(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -51,11 +48,14 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
}, [game.id, imageUrl])
|
||||
|
||||
// Determine if we should show CreamLinux buttons (only for native games)
|
||||
const shouldShowCream = game.native === true
|
||||
const shouldShowCream = game.native && game.cream_installed // Only show if installed (for uninstall)
|
||||
|
||||
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
||||
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
|
||||
|
||||
// Show generic button if nothing installed
|
||||
const shouldShowUnlocker = game.native && !game.cream_installed && !game.smoke_installed
|
||||
|
||||
// Check if this is a Proton game without API files
|
||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
|
||||
|
||||
@@ -71,6 +71,11 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
onAction(game.id, action)
|
||||
}
|
||||
|
||||
const handleUnlockerAction = () => {
|
||||
if (game.installing) return
|
||||
onAction(game.id, 'install_unlocker')
|
||||
}
|
||||
|
||||
// Handle edit button click
|
||||
const handleEdit = () => {
|
||||
if (onEdit && game.cream_installed) {
|
||||
@@ -85,13 +90,16 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Rating handler
|
||||
const handleRate = () => {
|
||||
if (onRate && (game.cream_installed || game.smoke_installed)) {
|
||||
onRate(game.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine background image
|
||||
const backgroundImage =
|
||||
!isLoading && imageUrl
|
||||
? `url(${imageUrl})`
|
||||
: hasError
|
||||
? 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
: 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
!isLoading && imageUrl ? `url(${imageUrl})` : 'linear-gradient(135deg, #232323, #1A1A1A)'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -116,17 +124,27 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
</div>
|
||||
|
||||
<div className="game-actions">
|
||||
{/* Show CreamLinux button only for native games */}
|
||||
{/* Show generic "Install" button for native games with nothing installed */}
|
||||
{shouldShowUnlocker && (
|
||||
<ActionButton
|
||||
action="install_unlocker"
|
||||
isInstalled={false}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleUnlockerAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show CreamLinux uninstall button if CreamLinux is installed */}
|
||||
{shouldShowCream && (
|
||||
<ActionButton
|
||||
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
|
||||
isInstalled={!!game.cream_installed}
|
||||
action="uninstall_cream"
|
||||
isInstalled={true}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleCreamAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
|
||||
{/* Show SmokeAPI button for Proton games OR native games with SmokeAPI installed */}
|
||||
{shouldShowSmoke && (
|
||||
<ActionButton
|
||||
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
||||
@@ -136,6 +154,16 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show SmokeAPI uninstall for native games if installed */}
|
||||
{game.native && game.smoke_installed && (
|
||||
<ActionButton
|
||||
action="uninstall_smoke"
|
||||
isInstalled={true}
|
||||
isWorking={!!game.installing}
|
||||
onClick={handleSmokeAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show message for Proton games without API files */}
|
||||
{isProtonNoApi && (
|
||||
<div className="api-not-found-message">
|
||||
@@ -151,6 +179,20 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate button */}
|
||||
{(game.cream_installed || game.smoke_installed) && onRate && reportingEnabled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleRate}
|
||||
disabled={!!game.installing}
|
||||
title="Rate compatibility"
|
||||
className="edit-button rate-button"
|
||||
leftIcon={<Icon name="Star" variant="solid" size="md" />}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||
{game.cream_installed && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { GameItem, ImagePreloader } from '@/components/games'
|
||||
import { ActionType } from '@/components/buttons'
|
||||
import { ActionType, Button } from '@/components/buttons'
|
||||
import { Icon, refresh } from '@/components/icons'
|
||||
import { Game } from '@/types'
|
||||
import LoadingIndicator from '../common/LoadingIndicator'
|
||||
|
||||
@@ -10,13 +11,16 @@ interface GameListProps {
|
||||
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 }: GameListProps) => {
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, onRefresh, reportingEnabled }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title
|
||||
@@ -43,7 +47,18 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: Ga
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<h2>Games ({games.length})</h2>
|
||||
<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
|
||||
@@ -57,7 +72,7 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: Ga
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map((game) => (
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} />
|
||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} onRate={onRate} reportingEnabled={reportingEnabled} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
export { default as GameList } from './GameList'
|
||||
export { default as GameItem } from './GameItem'
|
||||
export { default as ImagePreloader } from './ImagePreloader'
|
||||
export { default as EpicGameItem } from './EpicGameItem'
|
||||
export { default as EpicGameList } from './EpicGameList'
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 1a1.5 1.5 0 0 0-1.5 1.5v16a.5.5 0 0 0 .297.457l9 4a.5.5 0 0 0 .406 0l9-4a.5.5 0 0 0 .297-.457v-16A1.5 1.5 0 0 0 20 1zm10.25 11.75h-1.5v-8.5h1.5zM8 18.5l4 2l4-2zM8 4.25H5.25v8.5H8v-1.5H6.75v-2H8v-1.5H6.75v-2H8zm2.5 0H8.75v8.5h1.5v-2.5h.25a1.75 1.75 0 0 0 1.75-1.75V6a1.75 1.75 0 0 0-1.75-1.75m0 4.5h-.25v-3h.25a.25.25 0 0 1 .25.25v2.5a.25.25 0 0 1-.25.25m4.25-3.25c0-.69.56-1.25 1.25-1.25h1.5c.69 0 1.25.56 1.25 1.25v2h-1.5V5.75h-1v5.5h1V9.5h1.5v2c0 .69-.56 1.25-1.25 1.25H16c-.69 0-1.25-.56-1.25-1.25zM5.5 16.25h12v-1.5h-12z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 680 B |
@@ -5,3 +5,4 @@ export { ReactComponent as Windows } from './windows.svg'
|
||||
export { ReactComponent as Github } from './github.svg'
|
||||
export { ReactComponent as Discord } from './discord.svg'
|
||||
export { ReactComponent as Proton } from './proton.svg'
|
||||
export { ReactComponent as Epic } from './epic.svg'
|
||||
@@ -1 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m3.001 5.479l7.377-1.016v7.127H3zm0 13.042l7.377 1.017v-7.04H3zm8.188 1.125L21.001 21v-8.502h-9.812zm0-15.292v7.236h9.812V3z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none">
|
||||
<path d="M10.25 3.66913L4.5479 4.61948C3.22189 4.84048 2.25 5.98775 2.25 7.33206V11.25H10.25V3.66913Z" fill="currentColor"></path>
|
||||
<path d="M2.25 12.75V16.6649C2.25 18.0092 3.22189 19.1565 4.5479 19.3775L10.25 20.3278V12.75H2.25Z" fill="currentColor"></path>
|
||||
<path d="M11.75 20.5778L18.5479 21.7108C20.2241 21.9902 21.75 20.6976 21.75 18.9982V12.75H11.75V20.5778Z" fill="currentColor"></path>
|
||||
<path d="M21.75 11.25V4.99873C21.75 3.29939 20.2241 2.00678 18.5479 2.28614L11.75 3.41913V11.25H21.75Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 664 B |
@@ -20,6 +20,7 @@ export const download = 'Download'
|
||||
export const download1 = 'Download1'
|
||||
export const edit = 'Edit'
|
||||
export const error = 'Error'
|
||||
export const folder = 'Folder'
|
||||
export const info = 'Info'
|
||||
export const layers = 'Layers'
|
||||
export const refresh = 'Refresh'
|
||||
@@ -29,6 +30,7 @@ export const warning = 'Warning'
|
||||
export const wine = 'Wine'
|
||||
export const diamond = 'Diamond'
|
||||
export const settings = 'Settings'
|
||||
export const star = 'Star'
|
||||
|
||||
// Brand icons
|
||||
export const discord = 'Discord'
|
||||
@@ -37,6 +39,7 @@ export const linux = 'Linux'
|
||||
export const proton = 'Proton'
|
||||
export const steam = 'Steam'
|
||||
export const windows = 'Windows'
|
||||
export const epic = 'Epic'
|
||||
|
||||
// Keep the IconNames object for backward compatibility and autocompletion
|
||||
export const IconNames = {
|
||||
@@ -50,6 +53,7 @@ export const IconNames = {
|
||||
Download1: download1,
|
||||
Edit: edit,
|
||||
Error: error,
|
||||
Folder: folder,
|
||||
Info: info,
|
||||
Layers: layers,
|
||||
Refresh: refresh,
|
||||
@@ -59,6 +63,7 @@ export const IconNames = {
|
||||
Wine: wine,
|
||||
Diamond: diamond,
|
||||
Settings: settings,
|
||||
Star: star,
|
||||
|
||||
// Brand icons
|
||||
Discord: discord,
|
||||
@@ -67,6 +72,7 @@ export const IconNames = {
|
||||
Proton: proton,
|
||||
Steam: steam,
|
||||
Windows: windows,
|
||||
Epic: epic,
|
||||
} as const
|
||||
|
||||
// Export direct icon components using createIconComponent from IconFactory
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
|
||||
<path d="M20.25 14.75V10.75C20.25 8.83608 20.2477 7.50125 20.1123 6.49411C19.9808 5.51577 19.7401 4.99789 19.3711 4.62888C19.0021 4.25987 18.4842 4.01921 17.5059 3.88767C16.4987 3.75226 15.1639 3.74997 13.25 3.74997H10.75C8.83611 3.74997 7.50128 3.75226 6.49414 3.88767C5.5158 4.01921 4.99792 4.25987 4.62891 4.62888C4.2599 4.99789 4.01924 5.51577 3.8877 6.49411C3.75229 7.50125 3.75 8.83608 3.75 10.75V14.75C3.75 15.3023 3.30229 15.75 2.75 15.75C2.19772 15.75 1.75 15.3023 1.75 14.75V10.75C1.75 8.89262 1.74779 7.39889 1.90528 6.22751C2.06664 5.02741 2.41231 4.01735 3.21485 3.21481C4.01738 2.41228 5.02745 2.06661 6.22754 1.90524C7.39892 1.74776 8.89265 1.74997 10.75 1.74997H13.25C15.1074 1.74997 16.6011 1.74776 17.7725 1.90524C18.9726 2.06661 19.9826 2.41228 20.7852 3.21481C21.5877 4.01735 21.9334 5.02742 22.0947 6.22751C22.2522 7.39889 22.25 8.89262 22.25 10.75V14.75C22.25 15.3023 21.8023 15.75 21.25 15.75C20.6977 15.75 20.25 15.3023 20.25 14.75Z" fill="currentColor" />
|
||||
<path d="M14.0312 5.74997C15.6419 5.74996 16.9169 5.74997 17.9248 5.86911C18.9557 5.99098 19.8048 6.2463 20.5137 6.82809C20.7541 7.02541 20.9746 7.24589 21.1719 7.4863C21.7537 8.19522 22.009 9.04424 22.1309 10.0752C22.25 11.0831 22.25 12.3581 22.25 13.9687V14.0312C22.25 15.6418 22.25 16.9169 22.1309 17.9248C22.009 18.9557 21.7537 19.8047 21.1719 20.5136C20.9746 20.7541 20.7541 20.9745 20.5137 21.1718C19.8048 21.7536 18.9557 22.009 17.9248 22.1308C16.9169 22.25 15.6419 22.25 14.0312 22.25H9.96875C8.35815 22.25 7.0831 22.25 6.0752 22.1308C5.04427 22.009 4.19525 21.7536 3.48633 21.1718C3.24592 20.9745 3.02544 20.7541 2.82812 20.5136C2.24633 19.8047 1.99101 18.9557 1.86914 17.9248C1.75 16.9169 1.75 15.6418 1.75 14.0312V13.9687C1.75 12.3581 1.75 11.0831 1.86914 10.0752C1.99101 9.04424 2.24633 8.19522 2.82812 7.4863C3.02544 7.24589 3.24592 7.02541 3.48633 6.82809C4.19525 6.2463 5.04427 5.99098 6.0752 5.86911C7.0831 5.74997 8.35815 5.74996 9.96875 5.74997H14.0312ZM12 9.49997C11.4477 9.49997 11 9.94768 11 10.5V15.3906C10.6896 15.0331 10.3585 14.6264 10.1455 14.3535C10.0396 14.2178 9.86489 13.9856 9.80566 13.9072C9.47825 13.4626 8.8519 13.3671 8.40723 13.6943C7.96265 14.0217 7.86716 14.6481 8.19434 15.0927C8.259 15.1784 8.45594 15.4386 8.56934 15.584C8.7953 15.8735 9.10761 16.2629 9.44824 16.6552C9.78455 17.0426 10.1683 17.456 10.5352 17.7802C10.7175 17.9414 10.9198 18.1021 11.1279 18.2275C11.3086 18.3364 11.6228 18.5 12 18.5C12.3772 18.5 12.6914 18.3364 12.8721 18.2275C13.0802 18.1021 13.2825 17.9414 13.4648 17.7802C13.8317 17.4561 14.2154 17.0426 14.5518 16.6552C14.8924 16.2629 15.2047 15.8735 15.4307 15.584C15.5441 15.4386 15.741 15.1784 15.8057 15.0927C16.1328 14.6481 16.0373 14.0227 15.5928 13.6953C15.1481 13.3678 14.5219 13.4635 14.1943 13.9082C14.1351 13.9866 13.9604 14.2178 13.8545 14.3535C13.6415 14.6264 13.3104 15.0331 13 15.3906V10.5C13 9.9477 12.5523 9.49999 12 9.49997Z" fill="currentColor" />
|
||||
<path d="M16.1439 10.8544C15.7604 10.7888 15.2902 10.7658 14.7504 10.7567V4.99991C14.7504 4.5833 14.7563 4.22799 14.6732 3.91788C14.4652 3.1414 13.8589 2.5351 13.0824 2.32706C12.7723 2.24399 12.417 2.24991 12.0004 2.24991C11.5838 2.24991 11.2285 2.244 10.9183 2.32706C10.1419 2.5351 9.53459 3.1414 9.32654 3.91788C9.24355 4.22794 9.25037 4.5834 9.25037 4.99991V10.7567C8.71056 10.7658 8.24038 10.7888 7.85681 10.8544C7.344 10.9421 6.77397 11.1384 6.46033 11.6796L6.40174 11.7929L6.35193 11.9081C6.08178 12.5976 6.3948 13.2355 6.73279 13.7284C7.07715 14.2305 7.6246 14.832 8.28226 15.5546L8.31873 15.5946C9.03427 16.3808 9.62531 17.0262 10.1595 17.4687C10.7074 17.9223 11.2882 18.2426 11.9926 18.2499H12.0082C12.7125 18.2426 13.2934 17.9223 13.8412 17.4687C14.3754 17.0262 14.9665 16.3808 15.682 15.5946L15.7185 15.5546C16.3761 14.832 16.9236 14.2305 17.2679 13.7284C17.6059 13.2355 17.919 12.5976 17.6488 11.9081L17.599 11.7929L17.5404 11.6796C17.2268 11.1384 16.6567 10.9421 16.1439 10.8544Z" fill="currentColor" />
|
||||
<path d="M18.75 19.7499C19.3023 19.7499 19.75 20.1976 19.75 20.7499C19.75 21.3022 19.3023 21.7499 18.75 21.7499H5.25C4.69772 21.7499 4.25 21.3022 4.25 20.7499C4.25 20.1976 4.69772 19.7499 5.25 19.7499H18.75Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none">
|
||||
<path d="M9.89849 2.94507L12.3913 6.24446H20C21.5188 6.24446 22.75 7.47567 22.75 8.99446V19C22.75 20.5188 21.5188 21.75 20 21.75H4C2.48122 21.75 1.25 20.5188 1.25 19V4C1.25 3.0335 2.0335 2.25 3 2.25H8.5022C9.05086 2.25 9.56775 2.50731 9.89849 2.94507Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -8,11 +8,13 @@ export { ReactComponent as Diamond } from './diamond.svg'
|
||||
export { ReactComponent as Download } from './download.svg'
|
||||
export { ReactComponent as Edit } from './edit.svg'
|
||||
export { ReactComponent as Error } from './error.svg'
|
||||
export { ReactComponent as Folder } from './folder.svg'
|
||||
export { ReactComponent as Info } from './info.svg'
|
||||
export { ReactComponent as Layers } from './layers.svg'
|
||||
export { ReactComponent as Refresh } from './refresh.svg'
|
||||
export { ReactComponent as Search } from './search.svg'
|
||||
export { ReactComponent as Settings } from './settings.svg'
|
||||
export { ReactComponent as Star } from './star.svg'
|
||||
export { ReactComponent as Trash } from './trash.svg'
|
||||
export { ReactComponent as Warning } from './warning.svg'
|
||||
export { ReactComponent as Wine } from './wine.svg'
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none">
|
||||
<path d="M11.9961 1.25C13.0454 1.25 13.8719 2.04253 14.3995 3.11191L16.1616 6.66516C16.215 6.77513 16.3417 6.92998 16.5321 7.07164C16.7223 7.21315 16.9086 7.29121 17.0311 7.3118L20.2207 7.84613C21.3729 8.03973 22.3386 8.60449 22.6521 9.5879C22.9653 10.5705 22.5064 11.5916 21.6778 12.4216L21.677 12.4225L19.1991 14.9209C19.1009 15.0199 18.9909 15.2064 18.9219 15.4494C18.8534 15.6908 18.8473 15.9107 18.8784 16.0527L18.8788 16.0547L19.5877 19.1454C19.8818 20.4317 19.7843 21.7073 18.8771 22.3742C17.9667 23.0433 16.7227 22.7467 15.5925 22.0736L12.6026 20.289C12.477 20.214 12.2614 20.1532 12.0011 20.1532C11.7427 20.1532 11.5226 20.2132 11.3888 20.291L11.3869 20.2921L8.40288 22.0732C7.27405 22.7487 6.03154 23.04 5.12111 22.3702C4.21449 21.7032 4.11214 20.43 4.40711 19.1447L5.1159 16.0547L5.11633 16.0527C5.14741 15.9107 5.14133 15.6908 5.0728 15.4494C5.0038 15.2064 4.89379 15.0199 4.79558 14.9209L2.31585 12.4206C1.49265 11.5906 1.03521 10.5704 1.34595 9.58925C1.65759 8.60525 2.62143 8.0398 3.77433 7.84606L6.96132 7.31219L6.96233 7.31202C7.07917 7.29175 7.2627 7.21456 7.45248 7.07268C7.64261 6.93054 7.76959 6.77535 7.82312 6.66516L7.82582 6.65967L9.58562 3.11097L9.58632 3.10957C10.119 2.04108 10.948 1.25 11.9961 1.25Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 21.5H10C6.71252 21.5 5.06878 21.5 3.96243 20.592C3.75989 20.4258 3.57418 20.2401 3.40796 20.0376C2.5 18.9312 2.5 17.2875 2.5 14C2.5 10.7125 2.5 9.06878 3.40796 7.96243C3.57418 7.75989 3.75989 7.57418 3.96243 7.40796C5.06878 6.5 6.71252 6.5 10 6.5H14C17.2875 6.5 18.9312 6.5 20.0376 7.40796C20.2401 7.57418 20.4258 7.75989 20.592 7.96243C21.5 9.06878 21.5 10.7125 21.5 14C21.5 17.2875 21.5 18.9312 20.592 20.0376C20.4258 20.2401 20.2401 20.4258 20.0376 20.592C18.9312 21.5 17.2875 21.5 14 21.5Z" />
|
||||
<path d="M2.5 14.5V10.5C2.5 6.72876 2.5 4.84315 3.67157 3.67157C4.84315 2.5 6.72876 2.5 10.5 2.5H13.5C17.2712 2.5 19.1569 2.5 20.3284 3.67157C21.5 4.84315 21.5 6.72876 21.5 10.5V14.5" />
|
||||
<path d="M15 14.5C15 14.5 12.7905 17.4999 12 17.4999C11.2094 17.5 9 14.4999 9 14.4999M12 17L12 10.5" />
|
||||
<path d="M16.9504 12.1817C17.1981 12.814 16.5076 13.5726 15.1267 15.0899C13.6702 16.6902 12.9201 17.4904 12 17.5C11.0799 17.4904 10.3298 16.6902 8.87331 15.0899C7.49239 13.5726 6.80193 12.814 7.04964 12.1817C7.05868 12.1586 7.06851 12.1359 7.0791 12.1135C7.34928 11.542 8.24477 11.5029 10 11.5002V4.99998C10 4.53501 10 4.30253 10.0511 4.11179C10.1898 3.59414 10.5941 3.1898 11.1118 3.05111C11.3025 3 11.535 3 12 3C12.4649 3 12.6974 3 12.8882 3.05111C13.4058 3.1898 13.8102 3.59414 13.9489 4.11179C14 4.30253 14 4.53501 14 4.99998V11.5002C15.7552 11.5029 16.6507 11.542 16.9209 12.1135C16.9315 12.1359 16.9413 12.1586 16.9504 12.1817Z" />
|
||||
<path d="M5.00006 21H19.0001" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 885 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 6.99446H12.0179M12.0179 6.99446H20C21.1046 6.99446 22 7.88989 22 8.99446V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19V4C2 3.44772 2.44772 3 3 3H8.5022C8.81572 3 9.11109 3.14703 9.30008 3.39718L12.0179 6.99446Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |
@@ -8,11 +8,13 @@ export { ReactComponent as Diamond } from './diamond.svg'
|
||||
export { ReactComponent as Download } from './download.svg'
|
||||
export { ReactComponent as Edit } from './edit.svg'
|
||||
export { ReactComponent as Error } from './error.svg'
|
||||
export { ReactComponent as Folder } from './folder.svg'
|
||||
export { ReactComponent as Info } from './info.svg'
|
||||
export { ReactComponent as Layers } from './layers.svg'
|
||||
export { ReactComponent as Refresh } from './refresh.svg'
|
||||
export { ReactComponent as Search } from './search.svg'
|
||||
export { ReactComponent as Settings } from './settings.svg'
|
||||
export { ReactComponent as Star } from './star.svg'
|
||||
export { ReactComponent as Trash } from './trash.svg'
|
||||
export { ReactComponent as Warning } from './warning.svg'
|
||||
export { ReactComponent as Wine } from './wine.svg'
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.7276 3.44418L15.4874 6.99288C15.7274 7.48687 16.3673 7.9607 16.9073 8.05143L20.0969 8.58575C22.1367 8.92853 22.6167 10.4206 21.1468 11.8925L18.6671 14.3927C18.2471 14.8161 18.0172 15.6327 18.1471 16.2175L18.8571 19.3125C19.417 21.7623 18.1271 22.71 15.9774 21.4296L12.9877 19.6452C12.4478 19.3226 11.5579 19.3226 11.0079 19.6452L8.01827 21.4296C5.8785 22.71 4.57865 21.7522 5.13859 19.3125L5.84851 16.2175C5.97849 15.6327 5.74852 14.8161 5.32856 14.3927L2.84884 11.8925C1.389 10.4206 1.85895 8.92853 3.89872 8.58575L7.08837 8.05143C7.61831 7.9607 8.25824 7.48687 8.49821 6.99288L10.258 3.44418C11.2179 1.51861 12.7777 1.51861 13.7276 3.44418Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Animated background component that draws an interactive particle effect
|
||||
*/
|
||||
const AnimatedBackground = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas size to match window
|
||||
const setCanvasSize = () => {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
}
|
||||
|
||||
setCanvasSize()
|
||||
window.addEventListener('resize', setCanvasSize)
|
||||
|
||||
// Create particles
|
||||
const particles: Particle[] = []
|
||||
const particleCount = 30
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
speedX: number
|
||||
speedY: number
|
||||
opacity: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// Color palette matching theme
|
||||
const colors = [
|
||||
'rgba(74, 118, 196, 0.5)', // primary blue
|
||||
'rgba(155, 125, 255, 0.5)', // purple
|
||||
'rgba(251, 177, 60, 0.5)', // gold
|
||||
]
|
||||
|
||||
// Create initial particles
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: Math.random() * 3 + 1,
|
||||
speedX: Math.random() * 0.2 - 0.1,
|
||||
speedY: Math.random() * 0.2 - 0.1,
|
||||
opacity: Math.random() * 0.07 + 0.03,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
})
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
// Clear canvas with transparent black to create fade effect
|
||||
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Update and draw particles
|
||||
particles.forEach((particle) => {
|
||||
// Update position
|
||||
particle.x += particle.speedX
|
||||
particle.y += particle.speedY
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < 0) particle.x = canvas.width
|
||||
if (particle.x > canvas.width) particle.x = 0
|
||||
if (particle.y < 0) particle.y = canvas.height
|
||||
if (particle.y > canvas.height) particle.y = 0
|
||||
|
||||
// Draw particle
|
||||
ctx.beginPath()
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
|
||||
ctx.fill()
|
||||
|
||||
// Connect particles that are close to each other
|
||||
particles.forEach((otherParticle) => {
|
||||
const dx = particle.x - otherParticle.x
|
||||
const dy = particle.y - otherParticle.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance < 100) {
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`)
|
||||
ctx.lineWidth = 0.2
|
||||
ctx.moveTo(particle.x, particle.y)
|
||||
ctx.lineTo(otherParticle.x, otherParticle.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// Start animation
|
||||
animate()
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', setCanvasSize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas ref={canvasRef} className="animated-background" aria-hidden="true" />
|
||||
}
|
||||
|
||||
export default AnimatedBackground
|
||||
@@ -1,34 +1,23 @@
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, diamond, refresh, search } from '@/components/icons'
|
||||
import { Icon, diamond, search } from '@/components/icons'
|
||||
|
||||
interface HeaderProps {
|
||||
onRefresh: () => void
|
||||
refreshDisabled?: boolean
|
||||
onSearch: (query: string) => void
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Application header component
|
||||
* Contains the app title, search input, and refresh button
|
||||
* Contains the app title and search input
|
||||
*/
|
||||
const Header = ({ onRefresh, refreshDisabled = false, onSearch, searchQuery }: HeaderProps) => {
|
||||
const Header = ({ onSearch, searchQuery }: HeaderProps) => {
|
||||
return (
|
||||
<header className="app-header">
|
||||
<div className="app-title">
|
||||
<Icon name={diamond} variant="solid" size="lg" className="app-logo-icon" />
|
||||
<h1>CreamLinux</h1>
|
||||
</div>
|
||||
|
||||
<div className="header-controls">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshDisabled}
|
||||
className="refresh-button"
|
||||
leftIcon={<Icon name={refresh} variant="solid" size="md" />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Spinner } from '@/components/common'
|
||||
|
||||
interface InitialLoadingScreenProps {
|
||||
message: string
|
||||
@@ -38,8 +39,7 @@ const InitialLoadingScreen = ({ message, progress }: InitialLoadingScreenProps)
|
||||
<h1>CreamLinux</h1>
|
||||
|
||||
<div className="loading-animation">
|
||||
{/* Spinner animation */}
|
||||
<div className="loading-spinner"></div>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<p className="loading-message">{message}</p>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Icon, layers, linux, proton, settings } from '@/components/icons'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { Icon, layers, linux, proton, settings, diamond } from '@/components/icons'
|
||||
import { epic } from '@/components/icons'
|
||||
|
||||
interface SidebarProps {
|
||||
setFilter: (filter: string) => void
|
||||
currentFilter: string
|
||||
onSettingsClick: () => void
|
||||
}
|
||||
|
||||
// Define a type for filter items that makes variant optional
|
||||
type FilterItem = {
|
||||
id: string
|
||||
label: string
|
||||
@@ -15,50 +15,83 @@ type FilterItem = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Application sidebar component
|
||||
* Contains filters for game types
|
||||
*/
|
||||
const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) => {
|
||||
// Available filter options with icons
|
||||
const filters: FilterItem[] = [
|
||||
const Sidebar = ({ setFilter, currentFilter }: SidebarProps) => {
|
||||
const [version, setVersion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getVersion()
|
||||
.then(setVersion)
|
||||
.catch(() => setVersion(''))
|
||||
}, [])
|
||||
|
||||
const generalFilters: FilterItem[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: diamond, variant: 'solid' },
|
||||
{ id: 'settings', label: 'Settings', icon: settings, variant: 'solid' },
|
||||
]
|
||||
|
||||
const steamFilters: FilterItem[] = [
|
||||
{ id: 'all', label: 'All Games', icon: layers, variant: 'solid' },
|
||||
{ id: 'native', label: 'Native', icon: linux, variant: 'brand' },
|
||||
{ id: 'proton', label: 'Proton Required', icon: proton, variant: 'brand' },
|
||||
{ id: 'proton', label: 'Proton', icon: proton, variant: 'brand' },
|
||||
]
|
||||
|
||||
const epicFilters: FilterItem[] = [
|
||||
{ id: 'epic', label: 'All Games', icon: epic, variant: 'brand' },
|
||||
]
|
||||
|
||||
const renderFilter = (filter: FilterItem) => (
|
||||
<li
|
||||
key={filter.id}
|
||||
className={currentFilter === filter.id ? 'active' : ''}
|
||||
onClick={() => setFilter(filter.id)}
|
||||
>
|
||||
<div className="filter-item">
|
||||
<Icon name={filter.icon} variant={filter.variant} size="md" className="filter-icon" />
|
||||
<span>{filter.label}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2>Library</h2>
|
||||
</div>
|
||||
|
||||
<ul className="filter-list">
|
||||
{filters.map((filter) => (
|
||||
<li
|
||||
key={filter.id}
|
||||
className={currentFilter === filter.id ? 'active' : ''}
|
||||
onClick={() => setFilter(filter.id)}
|
||||
>
|
||||
<div className="filter-item">
|
||||
<Icon name={filter.icon} variant={filter.variant} size="md" className="filter-icon" />
|
||||
<span>{filter.label}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="sidebar-section">
|
||||
<ul className="filter-list">
|
||||
{generalFilters.map(renderFilter)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={onSettingsClick}
|
||||
className="settings-button"
|
||||
leftIcon={<Icon name={settings} variant="solid" size="md" className="settings-icon" />}
|
||||
fullWidth
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-section-label">Steam</span>
|
||||
<ul className="filter-list">
|
||||
{steamFilters.map(renderFilter)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-section-label">Epic Games</span>
|
||||
<ul className="filter-list">
|
||||
{epicFilters.map(renderFilter)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-footer-info">
|
||||
{version && <span className="sidebar-footer-version">v{version}</span>}
|
||||
<span className="sidebar-footer-build">Stable</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/Novattz/creamlinux-installer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-footer-link"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import { ProgressBar } from '@/components/common'
|
||||
import { ProgressBar, Spinner } from '@/components/common'
|
||||
|
||||
interface UpdateScreenProps {
|
||||
onComplete: () => void
|
||||
@@ -16,6 +16,8 @@ const UpdateScreen = ({ onComplete }: UpdateScreenProps) => {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [version, setVersion] = useState('')
|
||||
const totalBytesRef = useRef(0)
|
||||
const downloadedBytesRef = useRef(0)
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
try {
|
||||
@@ -29,20 +31,22 @@ const UpdateScreen = ({ onComplete }: UpdateScreenProps) => {
|
||||
setVersion(update.version)
|
||||
|
||||
// Download and install the update
|
||||
totalBytesRef.current = 0
|
||||
downloadedBytesRef.current = 0
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started': {
|
||||
const contentLength = event.data.contentLength
|
||||
console.log(`Started downloading ${contentLength} bytes`)
|
||||
totalBytesRef.current = event.data.contentLength ?? 0
|
||||
console.log(`Started downloading ${totalBytesRef.current} bytes`)
|
||||
break
|
||||
}
|
||||
case 'Progress': {
|
||||
const { chunkLength } = event.data
|
||||
// Calculate cumulative progress
|
||||
setProgress((prev) => {
|
||||
const newProgress = prev + chunkLength
|
||||
return Math.min(newProgress, 100)
|
||||
})
|
||||
downloadedBytesRef.current += event.data.chunkLength
|
||||
// Percentage of total bytes downloaded so far (0 if the server
|
||||
// didn't report a content length)
|
||||
const total = totalBytesRef.current
|
||||
setProgress(total > 0 ? Math.min((downloadedBytesRef.current / total) * 100, 100) : 0)
|
||||
break
|
||||
}
|
||||
case 'Finished':
|
||||
@@ -77,7 +81,7 @@ const UpdateScreen = ({ onComplete }: UpdateScreenProps) => {
|
||||
<h1>CreamLinux</h1>
|
||||
|
||||
<div className="loading-animation">
|
||||
{checking && <div className="loading-spinner"></div>}
|
||||
{checking && <Spinner />}
|
||||
</div>
|
||||
|
||||
<p className="loading-message">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Export all layout components
|
||||
export { default as Header } from './Header'
|
||||
export { default as Sidebar } from './Sidebar'
|
||||
export { default as AnimatedBackground } from './AnimatedBackground'
|
||||
export { default as InitialLoadingScreen } from './InitialLoadingScreen'
|
||||
export { default as ErrorBoundary } from './ErrorBoundary'
|
||||
export { default as UpdateScreen } from './UpdateScreen'
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, ReactNode } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useAppContext } from '@/contexts/useAppContext'
|
||||
import { Icon, IconName } from '@/components/icons'
|
||||
|
||||
interface LocalReport {
|
||||
game_id: string
|
||||
unlocker: string
|
||||
worked: boolean
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
os_name: string
|
||||
cpu_model: string
|
||||
cpu_cores: number
|
||||
gpu_name: string
|
||||
}
|
||||
|
||||
interface StatChipProps {
|
||||
label: string
|
||||
value: ReactNode
|
||||
icon: IconName | string
|
||||
variant?: string
|
||||
}
|
||||
|
||||
const StatChip = ({ label, value, icon, variant }: StatChipProps) => (
|
||||
<div className="stat-chip">
|
||||
<Icon name={icon} variant={variant} size="md" className="stat-chip-icon" />
|
||||
<span className="stat-chip-value">{value}</span>
|
||||
<span className="stat-chip-label">{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Overview page - the default landing view. Leads with the two library
|
||||
* totals as hero numbers, then a Native/Proton composition bar for the
|
||||
* Steam library, then secondary stats as compact chips, plus app info.
|
||||
*/
|
||||
const OverviewPage = () => {
|
||||
const { games, epicGames, epicLoading, loadEpicGames, activityFeed } = useAppContext()
|
||||
const [localReports, setLocalReports] = useState<LocalReport[]>([])
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
invoke<LocalReport[]>('get_local_reports')
|
||||
.then(setLocalReports)
|
||||
.catch(() => setLocalReports([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
invoke<SystemInfo>('get_system_info')
|
||||
.then(setSystemInfo)
|
||||
.catch(() => setSystemInfo(null))
|
||||
}, [])
|
||||
|
||||
// Overview is the default landing page, so kick off the Epic scan here too
|
||||
// otherwise the Epic count would show 0 until the user visits that tab.
|
||||
useEffect(() => {
|
||||
if (epicGames.length === 0 && !epicLoading) {
|
||||
loadEpicGames()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const nativeCount = games.filter((g) => g.native).length
|
||||
const protonCount = games.length - nativeCount
|
||||
const creamCount = games.filter((g) => g.cream_installed).length
|
||||
const smokeCount = games.filter((g) => g.smoke_installed).length
|
||||
const nativePct = games.length ? (nativeCount / games.length) * 100 : 0
|
||||
const protonPct = games.length ? (protonCount / games.length) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="overview-page">
|
||||
<h2>Overview</h2>
|
||||
|
||||
<div className="stat-hero">
|
||||
<div className="stat-hero-item">
|
||||
<span className="stat-hero-value">{games.length}</span>
|
||||
<span className="stat-hero-label">Steam Games</span>
|
||||
</div>
|
||||
<div className="stat-hero-divider" />
|
||||
<div className="stat-hero-item">
|
||||
<span className="stat-hero-value">{epicLoading ? '—' : epicGames.length}</span>
|
||||
<span className="stat-hero-label">Epic Games</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{games.length > 0 && (
|
||||
<div className="library-composition">
|
||||
<span className="overview-section-label">Steam Library Composition</span>
|
||||
<div className="composition-bar">
|
||||
<div className="composition-segment native" style={{ width: `${nativePct}%` }} />
|
||||
<div className="composition-segment proton" style={{ width: `${protonPct}%` }} />
|
||||
</div>
|
||||
<div className="composition-legend">
|
||||
<div className="legend-item">
|
||||
<span className="legend-dot native" />
|
||||
<span className="legend-label">Native</span>
|
||||
<span className="legend-value">{nativeCount}</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<span className="legend-dot proton" />
|
||||
<span className="legend-label">Proton</span>
|
||||
<span className="legend-value">{protonCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stat-chips">
|
||||
<StatChip label="CreamLinux Installed" value={creamCount} icon="Linux" />
|
||||
<StatChip label="SmokeAPI Installed" value={smokeCount} icon="Windows" />
|
||||
<StatChip label="Compatibility Reports" value={localReports.length} icon="Star" />
|
||||
</div>
|
||||
|
||||
<div className="overview-columns">
|
||||
<div className="page-section">
|
||||
<h4>Recent Activity</h4>
|
||||
|
||||
{activityFeed.length === 0 ? (
|
||||
<p className="page-section-description">
|
||||
Nothing yet this session - install or uninstall something and it'll show up here.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="activity-feed">
|
||||
{activityFeed.map((item) => (
|
||||
<li key={item.id} className="activity-item">
|
||||
<span className={`activity-item-dot activity-item-dot--${item.type}`} />
|
||||
<span className="activity-item-message">{item.message}</span>
|
||||
<span className="activity-item-time">
|
||||
{new Date(item.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="page-section">
|
||||
<h4>System</h4>
|
||||
|
||||
{systemInfo ? (
|
||||
<div className="system-specs">
|
||||
<div className="system-spec system-spec--os">
|
||||
<span className="system-spec-label">Operating System</span>
|
||||
<span className="system-spec-value" title={systemInfo.os_name}>
|
||||
{systemInfo.os_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-spec system-spec--cpu">
|
||||
<span className="system-spec-label">Processor</span>
|
||||
<span className="system-spec-value" title={systemInfo.cpu_model}>
|
||||
{systemInfo.cpu_model}
|
||||
</span>
|
||||
<span className="system-spec-sub">{systemInfo.cpu_cores} threads</span>
|
||||
</div>
|
||||
<div className="system-spec system-spec--gpu">
|
||||
<span className="system-spec-label">Graphics</span>
|
||||
<span className="system-spec-value" title={systemInfo.gpu_name}>
|
||||
{systemInfo.gpu_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="page-section-description">Reading system info...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OverviewPage
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { EntryList } from '@/components/common'
|
||||
import { AnimatedCheckbox, Button } from '@/components/buttons'
|
||||
import { useAppContext } from '@/contexts/useAppContext'
|
||||
import { Config } from '@/types/Config'
|
||||
|
||||
/**
|
||||
* Settings page - Steam library paths, the compatibility reporting toggle, and a danger
|
||||
* zone for resetting app data on disk.
|
||||
*/
|
||||
const SettingsPage = () => {
|
||||
const [libraryPaths, setLibraryPaths] = useState<string[]>([])
|
||||
const [reportingOptedIn, setReportingOptedIn] = useState(false)
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const [pathError, setPathError] = useState<string | null>(null)
|
||||
const [isResetting, setIsResetting] = useState(false)
|
||||
const [isClearingCache, setIsClearingCache] = useState(false)
|
||||
|
||||
const { loadGames, showToast } = useAppContext()
|
||||
|
||||
const loadSettingsConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await invoke<Config>('load_config')
|
||||
setLibraryPaths(config.custom_steam_paths)
|
||||
setReportingOptedIn(config.reporting_opted_in)
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSettingsConfig()
|
||||
}, [loadSettingsConfig])
|
||||
|
||||
const addPath = async (path: string) => {
|
||||
setPathError(null)
|
||||
setIsBusy(true)
|
||||
try {
|
||||
const config = await invoke<Config>('add_custom_steam_path', { path })
|
||||
setLibraryPaths(config.custom_steam_paths)
|
||||
loadGames()
|
||||
showToast('Library path added - rescanning for games...', 'info')
|
||||
} catch (error) {
|
||||
setPathError(`${error}`)
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowse = async () => {
|
||||
setPathError(null)
|
||||
|
||||
let selected: string | null
|
||||
try {
|
||||
selected = await open({ directory: true, multiple: false, title: 'Select Steam Library Folder' })
|
||||
} catch (error) {
|
||||
console.error('Failed to open folder picker:', error)
|
||||
return
|
||||
}
|
||||
if (!selected) return
|
||||
|
||||
addPath(selected)
|
||||
}
|
||||
|
||||
const handleRemovePath = async (path: string) => {
|
||||
setIsBusy(true)
|
||||
setPathError(null)
|
||||
try {
|
||||
const config = await invoke<Config>('remove_custom_steam_path', { path })
|
||||
setLibraryPaths(config.custom_steam_paths)
|
||||
loadGames()
|
||||
showToast('Library path removed - rescanning for games...', 'info')
|
||||
} catch (error) {
|
||||
setPathError(`${error}`)
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleReporting = async () => {
|
||||
const next = !reportingOptedIn
|
||||
setReportingOptedIn(next)
|
||||
try {
|
||||
await invoke('set_reporting_opt_in', { optedIn: next })
|
||||
showToast(
|
||||
next ? 'Compatibility reporting enabled' : 'Compatibility reporting disabled',
|
||||
'info'
|
||||
)
|
||||
} catch (error) {
|
||||
setReportingOptedIn(!next)
|
||||
showToast(`Failed to update reporting preference: ${error}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetConfig = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Reset all settings to their defaults? This clears your custom Steam library paths and reporting preference, and brings back the startup disclaimer.'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsResetting(true)
|
||||
try {
|
||||
const config = await invoke<Config>('reset_config')
|
||||
setLibraryPaths(config.custom_steam_paths)
|
||||
setReportingOptedIn(config.reporting_opted_in)
|
||||
loadGames()
|
||||
showToast('Settings reset to defaults', 'success')
|
||||
} catch (error) {
|
||||
showToast(`Failed to reset settings: ${error}`, 'error')
|
||||
} finally {
|
||||
setIsResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Clear cached unlocker downloads? They'll be re-downloaded automatically the next time they're needed."
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsClearingCache(true)
|
||||
try {
|
||||
await invoke('clear_caches')
|
||||
showToast('Unlocker cache cleared', 'success')
|
||||
} catch (error) {
|
||||
showToast(`Failed to clear cache: ${error}`, 'error')
|
||||
} finally {
|
||||
setIsClearingCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenConfigFolder = async () => {
|
||||
try {
|
||||
await invoke('open_config_folder')
|
||||
} catch (error) {
|
||||
showToast(`Failed to open config folder: ${error}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<h2>Settings</h2>
|
||||
|
||||
<div className="page-section">
|
||||
<h4>Steam Library Paths</h4>
|
||||
<p className="page-section-description">
|
||||
Add extra folders to scan for Steam games - useful if a library isn't auto-detected.
|
||||
</p>
|
||||
|
||||
{pathError && <p className="entry-list-error">{pathError}</p>}
|
||||
|
||||
<EntryList
|
||||
items={libraryPaths}
|
||||
onAddManual={addPath}
|
||||
onBrowse={handleBrowse}
|
||||
onRemove={handleRemovePath}
|
||||
placeholder="Type an absolute path and press Enter, or browse..."
|
||||
emptyLabel="No custom library paths added yet."
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="page-section">
|
||||
<h4>Compatibility Reporting</h4>
|
||||
<p className="page-section-description">
|
||||
Anonymously report whether an unlocker worked for a game, and see how it's worked for
|
||||
others before you install.
|
||||
</p>
|
||||
|
||||
<AnimatedCheckbox
|
||||
checked={reportingOptedIn}
|
||||
onChange={handleToggleReporting}
|
||||
label="Enable compatibility reporting"
|
||||
sublabel="You can change this at any time"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="page-section danger-zone">
|
||||
<h4>Danger Zone</h4>
|
||||
<p className="page-section-description">
|
||||
These affect app data on disk - not your games or any unlockers already installed on
|
||||
them.
|
||||
</p>
|
||||
|
||||
<div className="danger-zone-row">
|
||||
<div className="danger-zone-row-text">
|
||||
<span className="danger-zone-row-label">Reset all settings</span>
|
||||
<span className="danger-zone-row-description">
|
||||
Restores defaults, including custom library paths and the startup disclaimer.
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="danger" size="small" onClick={handleResetConfig} disabled={isResetting}>
|
||||
{isResetting ? 'Resetting...' : 'Reset'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="danger-zone-row">
|
||||
<div className="danger-zone-row-text">
|
||||
<span className="danger-zone-row-label">Clear unlocker cache</span>
|
||||
<span className="danger-zone-row-description">
|
||||
Deletes downloaded CreamLinux/SmokeAPI/ScreamAPI/Koaloader files.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearingCache}
|
||||
>
|
||||
{isClearingCache ? 'Clearing...' : 'Clear'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="danger-zone-row">
|
||||
<div className="danger-zone-row-text">
|
||||
<span className="danger-zone-row-label">Open config folder</span>
|
||||
<span className="danger-zone-row-description">~/.config/creamlinux</span>
|
||||
</div>
|
||||
<Button variant="secondary" size="small" onClick={handleOpenConfigFolder}>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as OverviewPage } from './OverviewPage'
|
||||
export { default as SettingsPage } from './SettingsPage'
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext } from 'react'
|
||||
import { Game, DlcInfo } from '@/types'
|
||||
import { Game, DlcInfo, EpicGame } from '@/types'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
import { DlcDialogState } from '@/hooks/useDlcManager'
|
||||
|
||||
@@ -26,11 +26,31 @@ export interface SmokeAPISettingsDialogState {
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
export interface RatingDialogState {
|
||||
visible: boolean
|
||||
gameId: string
|
||||
gameTitle: string
|
||||
unlocker: 'creamlinux' | 'smokeapi'
|
||||
steamPath: string
|
||||
}
|
||||
|
||||
// A single entry in the Overview page's recent-activity feed. Session-only
|
||||
// (not persisted) just a friendlier view of what you've been doing since
|
||||
// the app was launched.
|
||||
export interface ActivityItem {
|
||||
id: string
|
||||
message: string
|
||||
type: 'install' | 'uninstall' | 'update'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// Define the context type
|
||||
export interface AppContextType {
|
||||
// Game state
|
||||
games: Game[]
|
||||
isLoading: boolean
|
||||
isInitialLoad: boolean
|
||||
scanProgress: { message: string; progress: number }
|
||||
error: string | null
|
||||
loadGames: () => Promise<boolean>
|
||||
handleProgressDialogClose: () => void
|
||||
@@ -41,27 +61,61 @@ export interface AppContextType {
|
||||
handleDlcDialogClose: () => void
|
||||
handleUpdateDlcs: (gameId: string) => Promise<void>
|
||||
|
||||
// Epic Games
|
||||
epicGames: EpicGame[]
|
||||
epicLoading: boolean
|
||||
epicInstallingId: string | null
|
||||
loadEpicGames: () => Promise<void>
|
||||
handleEpicInstall: (game: EpicGame) => void
|
||||
handleEpicUninstallScream: (game: EpicGame) => void
|
||||
handleEpicUninstallKoaloader: (game: EpicGame) => void
|
||||
handleEpicSettings: (game: EpicGame) => void
|
||||
|
||||
// Game actions
|
||||
progressDialog: ProgressDialogState
|
||||
handleGameAction: (gameId: string, action: ActionType) => Promise<void>
|
||||
handleDlcConfirm: (selectedDlcs: DlcInfo[]) => void
|
||||
|
||||
// Settings
|
||||
settingsDialog: { visible: boolean }
|
||||
handleSettingsOpen: () => void
|
||||
handleSettingsClose: () => void
|
||||
|
||||
// SmokeAPI settings
|
||||
smokeAPISettingsDialog: SmokeAPISettingsDialogState
|
||||
handleSmokeAPISettingsOpen: (gameId: string) => void
|
||||
handleSmokeAPISettingsClose: () => void
|
||||
|
||||
// SmokeAPI votes dialog
|
||||
smokeAPIVotesDialog: {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
}
|
||||
handleSmokeAPIVotesClose: () => void
|
||||
handleSmokeAPIVotesConfirm: () => void
|
||||
|
||||
// Rating dialog
|
||||
ratingDialog: RatingDialogState
|
||||
handleOpenRating: (gameId: string) => void
|
||||
handleCloseRating: () => void
|
||||
handleSubmitRating: (worked: boolean) => Promise<void>
|
||||
reportingEnabled: boolean
|
||||
|
||||
// Recent activity feed (session-only, shown on Overview)
|
||||
activityFeed: ActivityItem[]
|
||||
|
||||
// Toast notifications
|
||||
showToast: (
|
||||
message: string,
|
||||
type: 'success' | 'error' | 'warning' | 'info',
|
||||
options?: Record<string, unknown>
|
||||
) => void
|
||||
|
||||
// Unlocker selection
|
||||
unlockerSelectionDialog: {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
}
|
||||
handleSelectCreamLinux: () => void
|
||||
handleSelectSmokeAPI: () => void
|
||||
closeUnlockerDialog: () => void
|
||||
}
|
||||
|
||||
// Create the context with a default value
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { AppContext, AppContextType } from './AppContext'
|
||||
import { ReactNode, useState, useEffect, useRef } from 'react'
|
||||
import { AppContext, AppContextType, ActivityItem } from './AppContext'
|
||||
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
||||
import { DlcInfo } from '@/types'
|
||||
import { DlcInfo, Config, EpicGame, Game } from '@/types'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
import { ToastContainer } from '@/components/notifications'
|
||||
import { SmokeAPISettingsDialog } from '@/components/dialogs'
|
||||
import { ApiSettingsDialog, smokeApiSettingsSpec, screamApiSettingsSpec, OptInDialog, RatingDialog, SmokeAPIVotesDialog, UnlockerChoiceDialog } from '@/components/dialogs'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
|
||||
// Context provider component
|
||||
interface AppProviderProps {
|
||||
@@ -17,7 +19,7 @@ interface AppProviderProps {
|
||||
*/
|
||||
export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
// Use our custom hooks
|
||||
const { games, isLoading, error, loadGames, setGames } = useGames()
|
||||
const { games, isLoading, isInitialLoad, scanProgress, error, loadGames, setGames } = useGames()
|
||||
|
||||
const {
|
||||
dlcDialog,
|
||||
@@ -33,12 +35,27 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
handleCloseProgressDialog,
|
||||
handleGameAction: executeGameAction,
|
||||
handleDlcConfirm: executeDlcConfirm,
|
||||
unlockerSelectionDialog,
|
||||
closeUnlockerDialog,
|
||||
} = useGameActions()
|
||||
|
||||
const { toasts, removeToast, success, error: showError, warning, info } = useToasts()
|
||||
|
||||
// Settings dialog state
|
||||
const [settingsDialog, setSettingsDialog] = useState({ visible: false })
|
||||
const [epicGames, setEpicGames] = useState<EpicGame[]>([])
|
||||
const [epicLoading, setEpicLoading] = useState(false)
|
||||
const [epicInstallingId, setEpicInstallingId] = useState<string | null>(null)
|
||||
const epicGamesRef = useRef<EpicGame[]>(epicGames)
|
||||
epicGamesRef.current = epicGames
|
||||
|
||||
const [epicUnlockerDialog, setEpicUnlockerDialog] = useState<{
|
||||
visible: boolean
|
||||
game: EpicGame | null
|
||||
}>({ visible: false, game: null })
|
||||
|
||||
const [screamSettingsDialog, setScreamSettingsDialog] = useState<{
|
||||
visible: boolean
|
||||
game: EpicGame | null
|
||||
}>({ visible: false, game: null })
|
||||
|
||||
// SmokeAPI settings dialog state
|
||||
const [smokeAPISettingsDialog, setSmokeAPISettingsDialog] = useState<{
|
||||
@@ -51,13 +68,148 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
gameTitle: '',
|
||||
})
|
||||
|
||||
// Settings handlers
|
||||
const handleSettingsOpen = () => {
|
||||
setSettingsDialog({ visible: true })
|
||||
// SmokeAPI votes dialog state
|
||||
const [smokeAPIVotesDialog, setSmokeAPIVotesDialog] = useState<{
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
}>({
|
||||
visible: false,
|
||||
gameId: null,
|
||||
gameTitle: null,
|
||||
})
|
||||
|
||||
// Opt-in dialog state
|
||||
const [optInDialog, setOptInDialog] = useState(false)
|
||||
const [reportingEnabled, setReportingEnabled] = useState(false)
|
||||
|
||||
// Recent activity feed (session-only) shown on the Overview page
|
||||
const [activityFeed, setActivityFeed] = useState<ActivityItem[]>([])
|
||||
const pushActivity = (message: string, type: ActivityItem['type']) => {
|
||||
setActivityFeed((prev) =>
|
||||
[{ id: `${Date.now()}-${Math.random()}`, message, type, timestamp: Date.now() }, ...prev].slice(
|
||||
0,
|
||||
20
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleSettingsClose = () => {
|
||||
setSettingsDialog({ visible: false })
|
||||
// Rating dialog state
|
||||
const [ratingDialog, setRatingDialog] = useState<{
|
||||
visible: boolean
|
||||
gameId: string
|
||||
gameTitle: string
|
||||
unlocker: 'creamlinux' | 'smokeapi'
|
||||
steamPath: string
|
||||
}>({
|
||||
visible: false,
|
||||
gameId: '',
|
||||
gameTitle: '',
|
||||
unlocker: 'creamlinux',
|
||||
steamPath: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
invoke<Config>('load_config')
|
||||
.then((cfg) => {
|
||||
setReportingEnabled(cfg.reporting_opted_in)
|
||||
if (!cfg.reporting_has_seen_prompt) {
|
||||
setOptInDialog(true)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load config for reporting check:', err))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined
|
||||
listen<EpicGame>('epic-game-updated', (event) => {
|
||||
const updated = event.payload
|
||||
// Read from the ref (not the epicGames closure) so this effect doesn't
|
||||
// need epicGames as a dependency. That would tear down and re-create
|
||||
// the Tauri listener on every single update instead of registering once.
|
||||
const prev = epicGamesRef.current.find((g) => g.app_name === updated.app_name)
|
||||
|
||||
setEpicGames((games) =>
|
||||
games.map((g) => (g.app_name === updated.app_name ? updated : g))
|
||||
)
|
||||
setEpicInstallingId(null)
|
||||
|
||||
// Determine what changed and show appropriate toast
|
||||
if (prev) {
|
||||
const installedScream = !prev.scream_installed && updated.scream_installed
|
||||
const uninstalledScream = prev.scream_installed && !updated.scream_installed
|
||||
const installedKoa = !prev.koaloader_installed && updated.koaloader_installed
|
||||
const uninstalledKoa = prev.koaloader_installed && !updated.koaloader_installed
|
||||
|
||||
if (installedScream) {
|
||||
success(`ScreamAPI installed for ${updated.title}`)
|
||||
pushActivity(`Installed ScreamAPI for ${updated.title}`, 'install')
|
||||
} else if (uninstalledScream) {
|
||||
info(`ScreamAPI removed from ${updated.title}`)
|
||||
pushActivity(`Removed ScreamAPI from ${updated.title}`, 'uninstall')
|
||||
} else if (installedKoa) {
|
||||
success(`Koaloader installed for ${updated.title}`)
|
||||
pushActivity(`Installed Koaloader for ${updated.title}`, 'install')
|
||||
} else if (uninstalledKoa) {
|
||||
info(`Koaloader removed from ${updated.title}`)
|
||||
pushActivity(`Removed Koaloader from ${updated.title}`, 'uninstall')
|
||||
}
|
||||
|
||||
if (updated.proxy_fallback_used) {
|
||||
warning(
|
||||
'No compatible proxy import found - installed using version.dll as a fallback. ' +
|
||||
'If the game has issues, try the direct ScreamAPI method instead.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}).then((fn) => { unlisten = fn })
|
||||
return () => { unlisten?.() }
|
||||
}, [success, info, warning])
|
||||
|
||||
const loadEpicGames = async () => {
|
||||
setEpicLoading(true)
|
||||
try {
|
||||
const games = await invoke<EpicGame[]>('scan_epic_games')
|
||||
setEpicGames(games)
|
||||
} catch (e) {
|
||||
showError(`Failed to scan Epic games: ${e}`)
|
||||
} finally {
|
||||
setEpicLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runEpicAction = async (game: EpicGame, action: string) => {
|
||||
setEpicInstallingId(game.app_name)
|
||||
try {
|
||||
await invoke('process_epic_action', { epicAction: { game, action } })
|
||||
// state updated via epic-game-updated event listener
|
||||
} catch (e) {
|
||||
showError(`Action failed: ${e}`)
|
||||
setEpicInstallingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEpicInstall = (game: EpicGame) => {
|
||||
setEpicUnlockerDialog({ visible: true, game })
|
||||
}
|
||||
|
||||
const handleEpicUninstallScream = (game: EpicGame) => runEpicAction(game, 'uninstall_scream')
|
||||
const handleEpicUninstallKoaloader = (game: EpicGame) => runEpicAction(game, 'uninstall_koaloader')
|
||||
|
||||
const handleEpicSettings = (game: EpicGame) => {
|
||||
setScreamSettingsDialog({ visible: true, game })
|
||||
}
|
||||
|
||||
const handleSelectScreamAPI = () => {
|
||||
const game = epicUnlockerDialog.game
|
||||
setEpicUnlockerDialog({ visible: false, game: null })
|
||||
if (game) runEpicAction(game, 'install_scream')
|
||||
}
|
||||
|
||||
const handleSelectKoaloader = () => {
|
||||
const game = epicUnlockerDialog.game
|
||||
setEpicUnlockerDialog({ visible: false, game: null })
|
||||
if (game) runEpicAction(game, 'install_koaloader')
|
||||
}
|
||||
|
||||
// SmokeAPI settings handlers
|
||||
@@ -76,7 +228,110 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
}
|
||||
|
||||
const handleSmokeAPISettingsClose = () => {
|
||||
setSmokeAPISettingsDialog((prev) => ({ ...prev, visible: false }))
|
||||
setSmokeAPISettingsDialog({
|
||||
visible: false,
|
||||
gamePath: '',
|
||||
gameTitle: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSmokeAPIVotesClose = () => {
|
||||
setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null })
|
||||
}
|
||||
|
||||
const handleSmokeAPIVotesConfirm = () => {
|
||||
const gameId = smokeAPIVotesDialog.gameId
|
||||
setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null })
|
||||
const game = gameId ? games.find((g) => g.id === gameId) : undefined
|
||||
if (gameId && game) {
|
||||
// Now actually run the install this is what was silently skipping
|
||||
// the success toast and activity feed entry before, since it called
|
||||
// the raw action executor instead of the notifying wrapper.
|
||||
runGameAction(gameId, 'install_smoke', game)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptInAccept = async () => {
|
||||
try {
|
||||
await invoke('set_reporting_opt_in', { optedIn: true })
|
||||
setReportingEnabled(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to save reporting opt-in:', err)
|
||||
}
|
||||
setOptInDialog(false)
|
||||
}
|
||||
|
||||
const handleOptInDecline = async () => {
|
||||
try {
|
||||
await invoke('set_reporting_opt_in', { optedIn: false })
|
||||
setReportingEnabled(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to save reporting opt-out:', err)
|
||||
}
|
||||
setOptInDialog(false)
|
||||
}
|
||||
|
||||
const handleOpenRating = (gameId: string) => {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
if (!game) return
|
||||
|
||||
setRatingDialog({
|
||||
visible: true,
|
||||
gameId,
|
||||
gameTitle: game.title,
|
||||
unlocker: game.cream_installed ? 'creamlinux' : 'smokeapi',
|
||||
steamPath: game.path,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseRating = () => {
|
||||
setRatingDialog((prev) => ({ ...prev, visible: false }))
|
||||
}
|
||||
|
||||
const handleSubmitRating = async (worked: boolean) => {
|
||||
try {
|
||||
await invoke('submit_report', {
|
||||
gameId: ratingDialog.gameId,
|
||||
unlocker: ratingDialog.unlocker,
|
||||
worked,
|
||||
steamPath: ratingDialog.steamPath,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to submit rating:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Runs a game action and reports the result via toast + activity feed.
|
||||
// Used both by handleGameAction's own fallthrough case and by
|
||||
// handleSmokeAPIVotesConfirm, which needs to run the action *after* the
|
||||
// votes dialog closes without re-triggering handleGameAction's own
|
||||
// interception checks (which would just show that dialog again).
|
||||
const runGameAction = async (gameId: string, action: ActionType, game: Game) => {
|
||||
setGames((prevGames) =>
|
||||
prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
|
||||
)
|
||||
|
||||
try {
|
||||
await executeGameAction(gameId, action, games)
|
||||
|
||||
const product = action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'
|
||||
const isUninstall = action.includes('uninstall')
|
||||
const isInstall = action.includes('install') && !isUninstall
|
||||
|
||||
if (isInstall) {
|
||||
success(`Successfully installed ${product} for ${game.title}`)
|
||||
pushActivity(`Installed ${product} for ${game.title}`, 'install')
|
||||
} else if (isUninstall) {
|
||||
info(`${product} uninstalled from ${game.title}`)
|
||||
pushActivity(`Uninstalled ${product} from ${game.title}`, 'uninstall')
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Action failed: ${error}`)
|
||||
} finally {
|
||||
setGames((prevGames) =>
|
||||
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Game action handler with proper error reporting
|
||||
@@ -111,42 +366,45 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// For other actions (uninstall cream, install/uninstall smoke)
|
||||
// Mark game as installing
|
||||
setGames((prevGames) =>
|
||||
prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
|
||||
)
|
||||
|
||||
try {
|
||||
await executeGameAction(gameId, action, games)
|
||||
|
||||
// Show appropriate success message based on action type
|
||||
const product = action.includes('cream') ? 'Creamlinux' : 'SmokeAPI'
|
||||
const isUninstall = action.includes('uninstall')
|
||||
const isInstall = action.includes('install') && !isUninstall
|
||||
|
||||
console.log('DEBUG: Action processed. Product:', product, 'isInstall:', isInstall, 'isUninstall:', isUninstall, 'action:', action)
|
||||
|
||||
if (isInstall) {
|
||||
success(`Successfully installed ${product} for ${game.title}`)
|
||||
} else if (isUninstall) {
|
||||
info(`${product} uninstalled from ${game.title}`)
|
||||
} else {
|
||||
console.log('Unknown action type:', action)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Action failed: ${error}`)
|
||||
} finally {
|
||||
// Reset installing state
|
||||
setGames((prevGames) =>
|
||||
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
|
||||
)
|
||||
// intercept install_smoke for votes dialog
|
||||
if (action === 'install_smoke' && !game.native) {
|
||||
setSmokeAPIVotesDialog({
|
||||
visible: true,
|
||||
gameId: game.id,
|
||||
gameTitle: game.title,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// For install_unlocker action, executeGameAction will handle showing the dialog
|
||||
// We should NOT show any notifications here - they'll be shown after actual installation
|
||||
if (action === 'install_unlocker') {
|
||||
// Mark game as installing while the user makes a selection
|
||||
setGames((prevGames) =>
|
||||
prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
|
||||
)
|
||||
|
||||
try {
|
||||
// This will show the UnlockerChoiceDialog and handle the callback
|
||||
await executeGameAction(gameId, action, games)
|
||||
} catch (error) {
|
||||
showError(`Action failed: ${error}`)
|
||||
} finally {
|
||||
// Reset installing state
|
||||
setGames((prevGames) =>
|
||||
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
|
||||
)
|
||||
}
|
||||
return // Don't show any notifications for install_unlocker
|
||||
}
|
||||
|
||||
// For other actions (uninstall cream, install/uninstall smoke)
|
||||
await runGameAction(gameId, action, game)
|
||||
}
|
||||
|
||||
// DLC confirmation wrapper
|
||||
const handleDlcConfirm = (selectedDlcs: DlcInfo[]) => {
|
||||
const { gameId, isEditMode } = dlcDialog
|
||||
const { gameId, gameTitle, isEditMode } = dlcDialog
|
||||
|
||||
// Create a deep copy to ensure we don't have reference issues
|
||||
const dlcsCopy = selectedDlcs.map((dlc) => ({ ...dlc }))
|
||||
@@ -173,6 +431,12 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
? 'DLC configuration updated successfully'
|
||||
: 'CreamLinux installed with selected DLCs'
|
||||
)
|
||||
pushActivity(
|
||||
isEditMode
|
||||
? `Updated DLC configuration for ${gameTitle}`
|
||||
: `Installed CreamLinux for ${gameTitle}`,
|
||||
isEditMode ? 'update' : 'install'
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
showError(`DLC operation failed: ${error}`)
|
||||
@@ -212,6 +476,8 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
// Game state
|
||||
games,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
scanProgress,
|
||||
error,
|
||||
loadGames,
|
||||
|
||||
@@ -223,24 +489,91 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
handleDlcDialogClose: closeDlcDialog,
|
||||
handleUpdateDlcs: (gameId: string) => handleUpdateDlcs(gameId),
|
||||
|
||||
// Epic games
|
||||
epicGames,
|
||||
epicLoading,
|
||||
epicInstallingId,
|
||||
loadEpicGames,
|
||||
handleEpicInstall,
|
||||
handleEpicUninstallScream,
|
||||
handleEpicUninstallKoaloader,
|
||||
handleEpicSettings,
|
||||
|
||||
// Game actions
|
||||
progressDialog,
|
||||
handleGameAction,
|
||||
handleDlcConfirm,
|
||||
handleProgressDialogClose: handleCloseProgressDialog,
|
||||
|
||||
// Settings
|
||||
settingsDialog,
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
|
||||
// SmokeAPI Settings
|
||||
smokeAPISettingsDialog,
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleSmokeAPISettingsClose,
|
||||
|
||||
// SmokeAPI Votes
|
||||
smokeAPIVotesDialog,
|
||||
handleSmokeAPIVotesClose,
|
||||
handleSmokeAPIVotesConfirm,
|
||||
|
||||
// Rating
|
||||
ratingDialog,
|
||||
handleOpenRating,
|
||||
handleCloseRating,
|
||||
handleSubmitRating,
|
||||
reportingEnabled,
|
||||
|
||||
// Recent activity feed
|
||||
activityFeed,
|
||||
|
||||
// Toast notifications
|
||||
showToast,
|
||||
|
||||
// Unlocker selection - Pass wrapped handlers that also handle the installing state
|
||||
unlockerSelectionDialog,
|
||||
handleSelectCreamLinux: () => {
|
||||
// When CreamLinux is selected, trigger the DLC dialog flow
|
||||
const gameId = unlockerSelectionDialog.gameId
|
||||
if (gameId) {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
if (game) {
|
||||
|
||||
closeUnlockerDialog()
|
||||
|
||||
// Reset installing state before showing DLC dialog
|
||||
setGames((prevGames) =>
|
||||
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
|
||||
)
|
||||
// Show DLC selection dialog directly
|
||||
setDlcDialog({
|
||||
...dlcDialog,
|
||||
visible: true,
|
||||
gameId,
|
||||
gameTitle: game.title,
|
||||
dlcs: [],
|
||||
isLoading: true,
|
||||
isEditMode: false,
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
streamGameDlcs(gameId)
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSelectSmokeAPI: () => {
|
||||
// When SmokeAPI is selected, trigger the actual installation
|
||||
const gameId = unlockerSelectionDialog.gameId
|
||||
if (gameId) {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
if (game) {
|
||||
closeUnlockerDialog()
|
||||
|
||||
setTimeout(() => {
|
||||
handleGameAction(gameId, 'install_smoke')
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
closeUnlockerDialog,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -249,11 +582,76 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
<ToastContainer toasts={toasts} onDismiss={removeToast} />
|
||||
|
||||
{/* SmokeAPI Settings Dialog */}
|
||||
<SmokeAPISettingsDialog
|
||||
<ApiSettingsDialog
|
||||
visible={smokeAPISettingsDialog.visible}
|
||||
onClose={handleSmokeAPISettingsClose}
|
||||
gamePath={smokeAPISettingsDialog.gamePath}
|
||||
gameTitle={smokeAPISettingsDialog.gameTitle}
|
||||
spec={smokeApiSettingsSpec}
|
||||
/>
|
||||
|
||||
{/* Epic Unlocker Selection Dialog */}
|
||||
<UnlockerChoiceDialog
|
||||
visible={epicUnlockerDialog.visible}
|
||||
gameTitle={epicUnlockerDialog.game?.title ?? ''}
|
||||
onClose={() => setEpicUnlockerDialog({ visible: false, game: null })}
|
||||
options={[
|
||||
{
|
||||
key: 'scream',
|
||||
title: 'ScreamAPI',
|
||||
badge: 'recommended',
|
||||
description:
|
||||
'Replaces the EOS SDK DLL directly with ScreamAPI. Works for most Epic games and requires no additional files. DLC unlocking is automatic.',
|
||||
buttonLabel: 'Install ScreamAPI',
|
||||
buttonVariant: 'primary',
|
||||
onSelect: handleSelectScreamAPI,
|
||||
},
|
||||
{
|
||||
key: 'koaloader',
|
||||
title: 'Koaloader + ScreamAPI',
|
||||
badge: 'alternative',
|
||||
description:
|
||||
"Uses a proxy DLL to inject ScreamAPI without modifying the EOS SDK. Try this if the recommended method doesn't work for your game.",
|
||||
buttonLabel: 'Install Koaloader',
|
||||
buttonVariant: 'secondary',
|
||||
onSelect: handleSelectKoaloader,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ScreamAPI Settings Dialog */}
|
||||
<ApiSettingsDialog
|
||||
visible={screamSettingsDialog.visible}
|
||||
onClose={() => setScreamSettingsDialog({ visible: false, game: null })}
|
||||
gamePath={screamSettingsDialog.game?.install_path ?? ''}
|
||||
gameTitle={screamSettingsDialog.game?.title ?? ''}
|
||||
spec={screamApiSettingsSpec}
|
||||
/>
|
||||
|
||||
{/* SmokeAPI Votes Dialog */}
|
||||
<SmokeAPIVotesDialog
|
||||
visible={smokeAPIVotesDialog.visible}
|
||||
gameId={smokeAPIVotesDialog.gameId}
|
||||
gameTitle={smokeAPIVotesDialog.gameTitle}
|
||||
onClose={handleSmokeAPIVotesClose}
|
||||
onConfirm={handleSmokeAPIVotesConfirm}
|
||||
/>
|
||||
|
||||
{/* Opt-in Dialog */}
|
||||
<OptInDialog
|
||||
visible={optInDialog}
|
||||
onAccept={handleOptInAccept}
|
||||
onDecline={handleOptInDecline}
|
||||
/>
|
||||
|
||||
{/* Rating Dialog */}
|
||||
<RatingDialog
|
||||
visible={ratingDialog.visible}
|
||||
gameId={ratingDialog.gameId}
|
||||
gameTitle={ratingDialog.gameTitle}
|
||||
unlocker={ratingDialog.unlocker}
|
||||
onClose={handleCloseRating}
|
||||
onSubmit={handleSubmitRating}
|
||||
/>
|
||||
</AppContext.Provider>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ export { useToasts } from './useToasts'
|
||||
export { useAppLogic } from './useAppLogic'
|
||||
export { useConflictDetection } from './useConflictDetection'
|
||||
export { useDisclaimer } from './useDisclaimer'
|
||||
export { useUnlockerSelection } from './useUnlockerSelection'
|
||||
|
||||
// Export types
|
||||
export type { ToastType, Toast, ToastOptions } from './useToasts'
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useAppContext } from '@/contexts/useAppContext'
|
||||
|
||||
interface UseAppLogicOptions {
|
||||
autoLoad?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Main application logic hook
|
||||
* Combines various aspects of the app's behavior
|
||||
*/
|
||||
export function useAppLogic(options: UseAppLogicOptions = {}) {
|
||||
const { autoLoad = true } = options
|
||||
|
||||
// Get values from app context
|
||||
const { games, loadGames, isLoading, error, showToast } = useAppContext()
|
||||
export function useAppLogic() {
|
||||
// Get values from app context. isInitialLoad/scanProgress are driven by the
|
||||
// real scan_steam_games call and its scan-progress events (see useGames),
|
||||
// not a simulated one, useGames already kicks off the initial scan itself.
|
||||
const { games, loadGames, isLoading, isInitialLoad, scanProgress, error, showToast } =
|
||||
useAppContext()
|
||||
|
||||
// Local state for filtering and UI
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [filter, setFilter] = useState('overview')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||
const isInitializedRef = useRef(false)
|
||||
const [scanProgress, setScanProgress] = useState({
|
||||
message: 'Initializing...',
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
// Filter games based on current filter and search
|
||||
const filteredGames = useCallback(() => {
|
||||
@@ -47,36 +38,6 @@ export function useAppLogic(options: UseAppLogicOptions = {}) {
|
||||
setSearchQuery(query)
|
||||
}, [])
|
||||
|
||||
// Handle initial loading with simulated progress
|
||||
useEffect(() => {
|
||||
if (!autoLoad || !isInitialLoad || isInitializedRef.current) return
|
||||
|
||||
isInitializedRef.current = true
|
||||
console.log('[APP LOGIC #2] Starting initialization')
|
||||
|
||||
const initialLoad = async () => {
|
||||
try {
|
||||
setScanProgress({ message: 'Scanning for games...', progress: 20 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
setScanProgress({ message: 'Loading game information...', progress: 50 })
|
||||
await loadGames()
|
||||
|
||||
setScanProgress({ message: 'Finishing up...', progress: 90 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
setScanProgress({ message: 'Ready!', progress: 100 })
|
||||
setTimeout(() => setIsInitialLoad(false), 500)
|
||||
} catch (error) {
|
||||
setScanProgress({ message: `Error: ${error}`, progress: 100 })
|
||||
showToast(`Failed to load: ${error}`, 'error')
|
||||
setTimeout(() => setIsInitialLoad(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
initialLoad()
|
||||
}, [autoLoad, isInitialLoad, loadGames, showToast])
|
||||
|
||||
// Force a refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -93,7 +54,6 @@ export function useAppLogic(options: UseAppLogicOptions = {}) {
|
||||
searchQuery,
|
||||
handleSearchChange,
|
||||
isInitialLoad,
|
||||
setIsInitialLoad,
|
||||
scanProgress,
|
||||
filteredGames: filteredGames(),
|
||||
handleRefresh,
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface Conflict {
|
||||
|
||||
export interface ConflictResolution {
|
||||
gameId: string
|
||||
removeFiles: boolean
|
||||
conflictType: 'cream-to-proton' | 'smoke-to-native'
|
||||
}
|
||||
|
||||
@@ -19,10 +18,9 @@ export interface ConflictResolution {
|
||||
*/
|
||||
export function useConflictDetection(games: Game[]) {
|
||||
const [conflicts, setConflicts] = useState<Conflict[]>([])
|
||||
const [currentConflict, setCurrentConflict] = useState<Conflict | null>(null)
|
||||
const [showReminder, setShowReminder] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set())
|
||||
const [hasShownThisSession, setHasShownThisSession] = useState(false)
|
||||
|
||||
// Detect conflicts whenever games change
|
||||
useEffect(() => {
|
||||
@@ -43,8 +41,8 @@ export function useConflictDetection(games: Game[]) {
|
||||
})
|
||||
}
|
||||
|
||||
// Conflict 2: SmokeAPI installed but game is now Native
|
||||
if (game.native && game.smoke_installed) {
|
||||
// Conflict 2: Orphaned Proton SmokeAPI DLL files on a native game
|
||||
if (game.native && game.smoke_installed && game.api_files && game.api_files.length > 0) {
|
||||
detectedConflicts.push({
|
||||
gameId: game.id,
|
||||
gameTitle: game.title,
|
||||
@@ -55,69 +53,50 @@ export function useConflictDetection(games: Game[]) {
|
||||
|
||||
setConflicts(detectedConflicts)
|
||||
|
||||
// Show the first conflict if we have any and not currently processing
|
||||
if (detectedConflicts.length > 0 && !currentConflict && !isProcessing) {
|
||||
setCurrentConflict(detectedConflicts[0])
|
||||
// Show dialog only if:
|
||||
// 1. We have conflicts
|
||||
// 2. Dialog isn't already visible
|
||||
// 3. We haven't shown it this session
|
||||
if (detectedConflicts.length > 0 && !showDialog && !hasShownThisSession) {
|
||||
setShowDialog(true)
|
||||
setHasShownThisSession(true)
|
||||
}
|
||||
}, [games, currentConflict, isProcessing, resolvedConflicts])
|
||||
}, [games, resolvedConflicts, showDialog, hasShownThisSession])
|
||||
|
||||
// Handle conflict resolution
|
||||
const resolveConflict = useCallback((): ConflictResolution | null => {
|
||||
if (!currentConflict || isProcessing) return null
|
||||
// Handle resolving a single conflict
|
||||
const resolveConflict = useCallback(
|
||||
(gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native'): ConflictResolution => {
|
||||
// Mark this game as resolved
|
||||
setResolvedConflicts((prev) => new Set(prev).add(gameId))
|
||||
|
||||
setIsProcessing(true)
|
||||
// Remove from conflicts list
|
||||
setConflicts((prev) => prev.filter((c) => c.gameId !== gameId))
|
||||
|
||||
const resolution: ConflictResolution = {
|
||||
gameId: currentConflict.gameId,
|
||||
removeFiles: true, // Always remove files
|
||||
conflictType: currentConflict.type,
|
||||
return {
|
||||
gameId,
|
||||
conflictType,
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Auto-close dialog when all conflicts are resolved
|
||||
useEffect(() => {
|
||||
if (conflicts.length === 0 && showDialog) {
|
||||
setShowDialog(false)
|
||||
}
|
||||
}, [conflicts.length, showDialog])
|
||||
|
||||
// Mark this game as resolved so we don't re-detect the conflict
|
||||
setResolvedConflicts((prev) => new Set(prev).add(currentConflict.gameId))
|
||||
|
||||
// Remove this conflict from the list
|
||||
const remainingConflicts = conflicts.filter((c) => c.gameId !== currentConflict.gameId)
|
||||
setConflicts(remainingConflicts)
|
||||
|
||||
// Close current conflict dialog immediately
|
||||
setCurrentConflict(null)
|
||||
|
||||
// Determine what to show next based on conflict type
|
||||
if (resolution.conflictType === 'cream-to-proton') {
|
||||
// CreamLinux removal - show reminder after delay
|
||||
setTimeout(() => {
|
||||
setShowReminder(true)
|
||||
setIsProcessing(false)
|
||||
}, 100)
|
||||
} else {
|
||||
// SmokeAPI removal - no reminder, just show next conflict or finish
|
||||
setTimeout(() => {
|
||||
if (remainingConflicts.length > 0) {
|
||||
setCurrentConflict(remainingConflicts[0])
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return resolution
|
||||
}, [currentConflict, conflicts, isProcessing])
|
||||
|
||||
// Close reminder dialog
|
||||
const closeReminder = useCallback(() => {
|
||||
setShowReminder(false)
|
||||
|
||||
// After closing reminder, check if there are more conflicts
|
||||
if (conflicts.length > 0) {
|
||||
setCurrentConflict(conflicts[0])
|
||||
}
|
||||
}, [conflicts])
|
||||
// Handle dialog close
|
||||
const closeDialog = useCallback(() => {
|
||||
setShowDialog(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
currentConflict,
|
||||
showReminder,
|
||||
conflicts,
|
||||
showDialog,
|
||||
resolveConflict,
|
||||
closeReminder,
|
||||
closeDialog,
|
||||
hasConflicts: conflicts.length > 0,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { listen } from '@tauri-apps/api/event'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
import { Game, DlcInfo } from '@/types'
|
||||
import { InstallationInstructions } from '@/contexts/AppContext'
|
||||
import { useUnlockerSelection } from './useUnlockerSelection'
|
||||
|
||||
/**
|
||||
* Hook for managing game action operations
|
||||
@@ -79,22 +80,38 @@ export function useGameActions() {
|
||||
setProgressDialog((prev) => ({ ...prev, visible: false }))
|
||||
}, [])
|
||||
|
||||
// Unlocker selection hook for native games
|
||||
const {
|
||||
selectionState,
|
||||
showUnlockerSelection,
|
||||
handleSelectCreamLinux,
|
||||
handleSelectSmokeAPI,
|
||||
closeDialog: closeUnlockerDialog,
|
||||
} = useUnlockerSelection()
|
||||
|
||||
// Unified handler for game actions (install/uninstall)
|
||||
const handleGameAction = useCallback(
|
||||
async (gameId: string, action: ActionType, games: Game[]) => {
|
||||
try {
|
||||
// For CreamLinux installation, we should NOT call process_game_action directly
|
||||
// Instead, we show the DLC selection dialog first, which is handled in AppProvider
|
||||
// Find the game
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
if (!game) return
|
||||
|
||||
// For CreamLinux installation, DLC dialog is handled in AppProvider
|
||||
if (action === 'install_cream') {
|
||||
return
|
||||
}
|
||||
|
||||
// For other actions (uninstall_cream, install_smoke, uninstall_smoke)
|
||||
// Find game to get title
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
if (!game) return
|
||||
// Handle generic "install_unlocker" action for native games
|
||||
if (action === 'install_unlocker') {
|
||||
showUnlockerSelection(game, (chosenAction: ActionType) => {
|
||||
// User chose, now proceed with that action
|
||||
handleGameAction(gameId, chosenAction, games)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get title based on action
|
||||
// For other actions (uninstall_cream, install_smoke, uninstall_smoke)
|
||||
const isCream = action.includes('cream')
|
||||
const isInstall = action.includes('install')
|
||||
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
||||
@@ -138,7 +155,7 @@ export function useGameActions() {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[]
|
||||
[showUnlockerSelection]
|
||||
)
|
||||
|
||||
// Handle DLC selection confirmation
|
||||
@@ -231,5 +248,9 @@ export function useGameActions() {
|
||||
handleCloseProgressDialog,
|
||||
handleGameAction,
|
||||
handleDlcConfirm,
|
||||
unlockerSelectionDialog: selectionState,
|
||||
handleSelectCreamLinux,
|
||||
handleSelectSmokeAPI,
|
||||
closeUnlockerDialog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Game } from '@/types'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
@@ -16,6 +16,7 @@ export function useGames() {
|
||||
progress: 0,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
// LoadGames function outside of the useEffect to make it reusable
|
||||
const loadGames = useCallback(async () => {
|
||||
@@ -91,9 +92,13 @@ export function useGames() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize event listeners and then load games
|
||||
// Initialize event listeners and then load games. Guarded by a ref
|
||||
// (not isInitialLoad state) so this effect only ever runs once putting
|
||||
// isInitialLoad in the dep array would re-subscribe the listeners the
|
||||
// moment it flips to false, needlessly tearing down and recreating them.
|
||||
setupEventListeners().then(() => {
|
||||
if (isInitialLoad) {
|
||||
if (!hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
loadGames().catch(console.error)
|
||||
}
|
||||
})
|
||||
@@ -102,7 +107,7 @@ export function useGames() {
|
||||
return () => {
|
||||
unlisteners.forEach((fn) => fn())
|
||||
}
|
||||
}, [loadGames, isInitialLoad])
|
||||
}, [loadGames])
|
||||
|
||||
// Helper function to update a specific game in state
|
||||
const updateGame = useCallback((updatedGame: Game) => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Game } from '@/types'
|
||||
import { ActionType } from '@/components/buttons'
|
||||
|
||||
export interface UnlockerSelectionState {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing unlocker selection on native games
|
||||
*/
|
||||
export function useUnlockerSelection() {
|
||||
const [selectionState, setSelectionState] = useState<UnlockerSelectionState>({
|
||||
visible: false,
|
||||
gameId: null,
|
||||
gameTitle: null,
|
||||
})
|
||||
|
||||
// Store the callback to call when user makes a selection
|
||||
const [selectionCallback, setSelectionCallback] = useState<((action: ActionType) => void) | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// Show the dialog and store the callback
|
||||
const showUnlockerSelection = useCallback(
|
||||
(game: Game, callback: (action: ActionType) => void) => {
|
||||
setSelectionState({
|
||||
visible: true,
|
||||
gameId: game.id,
|
||||
gameTitle: game.title,
|
||||
})
|
||||
// Wrap in function to avoid stale closure
|
||||
setSelectionCallback(() => callback)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// User selected CreamLinux
|
||||
const handleSelectCreamLinux = useCallback(() => {
|
||||
setSelectionState({ visible: false, gameId: null, gameTitle: null })
|
||||
if (selectionCallback) {
|
||||
selectionCallback('install_cream')
|
||||
}
|
||||
setSelectionCallback(null)
|
||||
}, [selectionCallback])
|
||||
|
||||
// User selected SmokeAPI
|
||||
const handleSelectSmokeAPI = useCallback(() => {
|
||||
setSelectionState({ visible: false, gameId: null, gameTitle: null })
|
||||
if (selectionCallback) {
|
||||
selectionCallback('install_smoke')
|
||||
}
|
||||
setSelectionCallback(null)
|
||||
}, [selectionCallback])
|
||||
|
||||
// Close dialog without selection
|
||||
const closeDialog = useCallback(() => {
|
||||
setSelectionState({ visible: false, gameId: null, gameTitle: null })
|
||||
setSelectionCallback(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectionState,
|
||||
showUnlockerSelection,
|
||||
handleSelectCreamLinux,
|
||||
handleSelectSmokeAPI,
|
||||
closeDialog,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Animations
|
||||
*/
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,8 @@
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src:
|
||||
url('../assets/fonts/Satoshi.ttf') format('ttf'),
|
||||
url('../assets/fonts/Roboto.ttf') format('ttf'),
|
||||
url('../assets/fonts/WorkSans.ttf') format('ttf');
|
||||
font-family: 'Roboto';
|
||||
src: url('../assets/fonts/Roboto.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@forward './animations';
|
||||
@forward './fonts';
|
||||
@forward './layout';
|
||||
@forward './mixins';
|
||||
|
||||
@@ -14,20 +14,6 @@
|
||||
bottom: 0;
|
||||
background-color: var(--primary-bg);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
|
||||
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: var(--z-bg);
|
||||
}
|
||||
}
|
||||
|
||||
// Main content area
|
||||
@@ -50,10 +36,8 @@
|
||||
margin: 2rem auto;
|
||||
max-width: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(var(--danger), 0.05);
|
||||
border: 1px solid rgb(var(--danger), 0.2);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: color-mix(in srgb, var(--danger) 6%, var(--elevated-bg));
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--border-soft));
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@@ -90,12 +74,10 @@
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
transition: background-color var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||
background-color: color-mix(in srgb, black 8%, var(--primary-color));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,10 +92,8 @@
|
||||
margin: 2rem auto;
|
||||
max-width: 600px;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(var(--danger), 0.05);
|
||||
border: 1px solid rgb(var(--danger), 0.2);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: color-mix(in srgb, var(--danger) 6%, var(--elevated-bg));
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--border-soft));
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
@@ -137,12 +117,10 @@
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
transition: background-color var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
|
||||
background-color: color-mix(in srgb, black 8%, var(--primary-color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,42 +20,11 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Glass effect for overlay
|
||||
@mixin glass-overlay($opacity: 0.7) {
|
||||
background-color: rgba(var(--primary-bg), var(--opacity));
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@mixin gradient-bg($start-color, $end-color, $direction: 135deg) {
|
||||
background: linear-gradient($direction, $start-color, $end-color);
|
||||
}
|
||||
|
||||
// Basic transition
|
||||
@mixin transition-standard {
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
}
|
||||
|
||||
@mixin shadow-standard {
|
||||
box-shadow: var(--shadow-standard);
|
||||
}
|
||||
|
||||
@mixin shadow-hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
@mixin text-shadow {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
// Simple animation for hover
|
||||
@mixin hover-lift {
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive mixins
|
||||
@mixin media-sm {
|
||||
@media (min-width: 576px) {
|
||||
@@ -81,13 +50,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Card base styling
|
||||
@mixin card {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
@include shadow-standard;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
// Sets the rendered size of a .spinner-ring SVG (see _loading.scss) the
|
||||
// ring geometry is fixed by its viewBox, so the stroke scales
|
||||
// proportionally with whatever size is set here.
|
||||
@mixin spinner-size($size) {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
// Custom scrollbar
|
||||
@@ -97,7 +65,7 @@
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(var(--primary-bg), 0.5);
|
||||
background: var(--primary-bg);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,16 +34,12 @@
|
||||
--bold: 700;
|
||||
--extrabold: 800;
|
||||
|
||||
--family: 'Satoshi';
|
||||
|
||||
// Shadows
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
|
||||
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
|
||||
|
||||
// Z-index levels
|
||||
--z-bg: 0;
|
||||
@@ -52,11 +48,3 @@
|
||||
--z-modal: 1000;
|
||||
--z-tooltip: 1500;
|
||||
}
|
||||
|
||||
// Color variables for SCSS usage
|
||||
$success-color: #55e07a;
|
||||
$danger-color: #ff5252;
|
||||
$primary-color: #4a76c4;
|
||||
$cream-color: #9b7dff;
|
||||
$smoke-color: #fbb13c;
|
||||
$warning-color: #fbb13c;
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--success-light);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0px 0px 12px rgba(140, 200, 147, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,22 +32,13 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-light);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--disabled);
|
||||
transform: none;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -85,20 +74,11 @@
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
// Settings icon button variant
|
||||
&.settings-icon-button {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
&.checked {
|
||||
background-color: var(--primary-color, #ffc896);
|
||||
border-color: var(--primary-color, #ffc896);
|
||||
box-shadow: 0 0 10px rgba(255, 200, 150, 0.2);
|
||||
}
|
||||
|
||||
.checkbox-icon {
|
||||
|
||||
@@ -14,25 +14,12 @@
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
// Default states
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-standard);
|
||||
}
|
||||
transition: background-color var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: var(--shadow-standard) !important;
|
||||
}
|
||||
|
||||
// Sizing
|
||||
@@ -54,14 +41,30 @@
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
// Variants
|
||||
// Icon-only buttons are square, regardless of size
|
||||
&.btn-icon-only.btn-sm {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
&.btn-icon-only.btn-md {
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
&.btn-icon-only.btn-lg {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
// Variants - flat fill, a plain darken/lighten on hover, no lift or glow
|
||||
&.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.3);
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, black 8%, var(--primary-color));
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: color-mix(in srgb, black 14%, var(--primary-color));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +72,12 @@
|
||||
background-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--border);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: color-mix(in srgb, black 10%, var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +85,8 @@
|
||||
background-color: var(--success);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--success-light);
|
||||
box-shadow: 0 6px 14px rgba(var(--success), 0.3);
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, black 8%, var(--success));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +94,8 @@
|
||||
background-color: var(--danger);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-light);
|
||||
box-shadow: 0 6px 14px rgba(var(--danger), 0.3);
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, black 8%, var(--danger));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +103,8 @@
|
||||
background-color: var(--warning);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--warning-light);
|
||||
box-shadow: 0 6px 14px rgba(var(--warning), 0.3);
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, black 8%, var(--warning));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,13 +118,10 @@
|
||||
position: relative;
|
||||
margin-right: 0.5rem;
|
||||
|
||||
.spinner {
|
||||
.spinner-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,12 +151,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Animation for spinner
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
// Rating button on game card
|
||||
.rate-button {
|
||||
svg {
|
||||
color: var(--elevated-bg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -117,7 +116,7 @@
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(var(--primary-color), 0.2);
|
||||
background-color: color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||