Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: 'Test Build'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-tauri:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'ubuntu-22.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
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
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: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-build
|
||||
path: |
|
||||
src-tauri/target/release/bundle/deb/*.deb
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
if-no-files-found: error
|
||||
@@ -1,3 +1,46 @@
|
||||
## [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
|
||||
|
||||
@@ -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=neUDotrqnDM)
|
||||
|
||||
## 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).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -47,32 +37,30 @@ While the core functionality is working, please be aware that this is an early r
|
||||
```
|
||||
|
||||
### Nix
|
||||
You can fetch this repository in your configuration using `pkgs.fetchFromGithub`:
|
||||
You can add this package to your configuration using `pkgs.fetchFromGitHub`:
|
||||
```nix
|
||||
let
|
||||
creamlinux = pkgs.callPackage (pkgs.fetchFromGitHub {
|
||||
creamlinux = import (pkgs.fetchFromGitHub {
|
||||
owner = "Novattz";
|
||||
repo = "creamlinux-installer";
|
||||
rev = "main";
|
||||
hash = ""; # You can use nix-prefetch-url to determine which value to put here, or paste the value returned by the error your rebuild will output
|
||||
}) {};
|
||||
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-src = builtins.fetchTarball {
|
||||
creamlinux = import (builtins.fetchTarball {
|
||||
url = "https://github.com/Novattz/creamlinux-installer/archive/main.tar.gz";
|
||||
sha256 = ""; # See above
|
||||
};
|
||||
}) { inherit pkgs; };
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage creamlinux-src {})
|
||||
];
|
||||
environment.systemPackages = [ creamlinux ];
|
||||
}
|
||||
```
|
||||
alternatively and if you want to pin the package version, using [npins](https://github.com/andir/npins):
|
||||
@@ -85,12 +73,11 @@ let
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage "${sources.creamlinux-installer}/default.nix" {})
|
||||
(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 = {
|
||||
@@ -107,7 +94,7 @@ Those are the recommended methods to add creamlinux-installer to your environmen
|
||||
Then, in your configuration:
|
||||
```nix
|
||||
environment.systemPackages = [
|
||||
(pkgs.callPackage inputs.creamlinux-installer {})
|
||||
(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.
|
||||
@@ -184,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.
|
||||
|
||||
@@ -1,57 +1,4 @@
|
||||
{pkgs ? import <nixpkgs> {}}: let
|
||||
cargoRoot = "src-tauri";
|
||||
src = ./.;
|
||||
|
||||
patchSassEmbedded = pkgs.writeShellScriptBin "patch-sass-embedded" ''
|
||||
NIX_LD="$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)"
|
||||
for dart_bin in node_modules/sass-embedded-linux-*/dart-sass/src/dart; do
|
||||
if [ -f "$dart_bin" ]; then
|
||||
${pkgs.patchelf}/bin/patchelf --set-interpreter "$NIX_LD" "$dart_bin"
|
||||
fi
|
||||
done
|
||||
'';
|
||||
in
|
||||
pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "creamlinux-installer";
|
||||
version = "1.5.0-unstable-2026-04-23";
|
||||
inherit src;
|
||||
|
||||
cargoLock.lockFile = ./src-tauri/Cargo.lock;
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-anYTERlnfOGDsGW0joy+h7wECJNDy6q+0a2to6t36pg=";
|
||||
};
|
||||
|
||||
nativeBuildInputs =
|
||||
[
|
||||
pkgs.cargo-tauri.hook
|
||||
pkgs.nodejs
|
||||
pkgs.npmHooks.npmConfigHook
|
||||
pkgs.pkg-config
|
||||
]
|
||||
++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.wrapGAppsHook4
|
||||
];
|
||||
|
||||
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.glib-networking
|
||||
pkgs.openssl
|
||||
pkgs.webkitgtk_4_1
|
||||
];
|
||||
|
||||
inherit cargoRoot;
|
||||
|
||||
buildAndTestSubdir = cargoRoot;
|
||||
|
||||
postPatch = ''
|
||||
substituteInPlace src-tauri/tauri.conf.json \
|
||||
--replace-fail '"createUpdaterArtifacts": true' '"createUpdaterArtifacts": false'
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
${patchSassEmbedded}/bin/patch-sass-embedded
|
||||
'';
|
||||
|
||||
env.NO_STRIP = true;
|
||||
}
|
||||
{
|
||||
pkgs ? import <nixpkgs> { },
|
||||
}:
|
||||
pkgs.callPackage ./package.nix { }
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "creamlinux",
|
||||
"version": "1.5.5",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "creamlinux",
|
||||
"version": "1.5.5",
|
||||
"version": "1.7.0",
|
||||
"license": "MIT",
|
||||
"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",
|
||||
@@ -4380,9 +4381,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -4606,6 +4607,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "creamlinux",
|
||||
"private": true,
|
||||
"version": "1.5.5",
|
||||
"version": "1.7.0",
|
||||
"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.0-unstable-2026-07-11";
|
||||
inherit src;
|
||||
|
||||
cargoLock.lockFile = ./src-tauri/Cargo.lock;
|
||||
|
||||
npmDeps = fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-74rTmS0RV8FagUQzH4ympOX/ueHTj15qihcAcCpaEGQ=";
|
||||
};
|
||||
|
||||
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.5.5"
|
||||
version = "1.7.0"
|
||||
description = "DLC Manager for Steam games on Linux"
|
||||
authors = ["tickbase"]
|
||||
license = "MIT"
|
||||
@@ -25,7 +25,6 @@ tempfile = "3.8"
|
||||
walkdir = "2.3"
|
||||
parking_lot = "0.12"
|
||||
tauri = { version = "2.5.0", features = [] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.0.0-rc"
|
||||
tauri-plugin-dialog = "2.0.0-rc"
|
||||
tauri-plugin-fs = "2.0.0-rc"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ mod version;
|
||||
|
||||
pub use storage::{
|
||||
get_creamlinux_version_dir, get_smokeapi_version_dir,
|
||||
list_creamlinux_files, list_smokeapi_files, read_versions,
|
||||
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::{
|
||||
@@ -81,7 +82,7 @@ pub async fn initialize_cache() -> Result<(), String> {
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("ScreamAPI cache incomplete, re-downloading");
|
||||
needs_smokeapi = true;
|
||||
needs_screamapi = true;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to validate ScreamAPI cache: {}, re-downloading", e);
|
||||
@@ -170,7 +171,7 @@ pub async fn initialize_cache() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
if !needs_smokeapi && !needs_creamlinux && !needs_smokeapi && !needs_koaloader {
|
||||
if !needs_smokeapi && !needs_creamlinux && !needs_screamapi && !needs_koaloader {
|
||||
info!("Cache already initialized and validated");
|
||||
} else {
|
||||
info!("Cache initialization complete");
|
||||
@@ -247,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)
|
||||
}
|
||||
|
||||
@@ -333,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,22 @@ pub fn get_koaloader_dir() -> Result<PathBuf, String> {
|
||||
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()?;
|
||||
|
||||
@@ -11,6 +11,9 @@ pub struct Config {
|
||||
// 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 {
|
||||
@@ -19,6 +22,7 @@ impl Default for Config {
|
||||
show_disclaimer: true,
|
||||
reporting_opted_in: false,
|
||||
reporting_has_seen_prompt: false,
|
||||
custom_steam_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,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::*;
|
||||
|
||||
@@ -30,17 +30,32 @@ struct InstalledEntry {
|
||||
|
||||
fn legendary_config_dir() -> Option<PathBuf> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
let path = PathBuf::from(&home)
|
||||
.join(".config")
|
||||
.join("heroic")
|
||||
.join("legendaryConfig")
|
||||
.join("legendary");
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
warn!("Heroic legendary config dir not found at: {}", path.display());
|
||||
None
|
||||
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> {
|
||||
|
||||
@@ -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,5 +1,3 @@
|
||||
mod file_ops;
|
||||
|
||||
use crate::cache::{
|
||||
remove_creamlinux_version, remove_smokeapi_version,
|
||||
update_game_creamlinux_version, update_game_smokeapi_version,
|
||||
@@ -524,65 +522,46 @@ pub async fn install_koaloader(
|
||||
|
||||
let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?;
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path);
|
||||
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Scanning PE imports for best proxy DLL...",
|
||||
30.0, false, false, None,
|
||||
);
|
||||
|
||||
let scan = crate::pe_inspector::find_best_proxy(&exe_path);
|
||||
let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string();
|
||||
|
||||
// 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;
|
||||
|
||||
info!("Selected proxy: {} (fallback={})", scan.proxy_name, is_fallback);
|
||||
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
&format!("Installing proxy DLL ({})...", scan.proxy_name),
|
||||
&format!("Installed proxy DLL ({})", scan.proxy_name),
|
||||
50.0, false, false, None,
|
||||
);
|
||||
|
||||
let proxy_src = crate::unlockers::Koaloader::get_proxy_dll(&proxy_stem, is_64bit)?;
|
||||
std::fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name))
|
||||
.map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?;
|
||||
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Installing ScreamAPI payload...",
|
||||
70.0, false, false, None,
|
||||
);
|
||||
|
||||
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
ScreamAPI::install_to_game(&exe_dir_str, "koaloader")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install ScreamAPI payload: {}", e))?;
|
||||
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installing Koaloader for {}", title),
|
||||
"Writing configuration files...",
|
||||
88.0, false, false, None,
|
||||
);
|
||||
|
||||
let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let koa_config = serde_json::json!({
|
||||
"logging": false,
|
||||
"enabled": true,
|
||||
"auto_load": true,
|
||||
"targets": [exe_name],
|
||||
"modules": []
|
||||
});
|
||||
std::fs::write(
|
||||
exe_dir.join("Koaloader.config.json"),
|
||||
serde_json::to_string_pretty(&koa_config).unwrap(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write Koaloader config: {}", e))?;
|
||||
|
||||
|
||||
crate::unlockers::Koaloader::write_koaloader_config(&exe_path)?;
|
||||
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&format!("Installation Complete: {}", title),
|
||||
@@ -608,26 +587,10 @@ pub async fn uninstall_koaloader(game: EpicGame, app_handle: AppHandle) -> Resul
|
||||
let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?;
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
|
||||
// Remove Koaloader config
|
||||
let koa_config_path = exe_dir.join("Koaloader.config.json");
|
||||
if koa_config_path.exists() {
|
||||
std::fs::remove_file(&koa_config_path)
|
||||
.map_err(|e| format!("Failed to remove Koaloader config: {}", e))?;
|
||||
}
|
||||
|
||||
// Remove any Koaloader proxy DLL
|
||||
if let Ok(entries) = std::fs::read_dir(exe_dir) {
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let name_lower = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
|
||||
if crate::unlockers::koaloader::KOA_VARIANTS.contains(&name_lower.as_str()) {
|
||||
std::fs::remove_file(&path).ok();
|
||||
info!("Removed proxy DLL: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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),
|
||||
@@ -650,106 +613,132 @@ pub async fn uninstall_koaloader(game: EpicGame, app_handle: AppHandle) -> Resul
|
||||
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
|
||||
);
|
||||
// steamcmd helpers
|
||||
|
||||
let response = client
|
||||
.get(&base_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
/// 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,
|
||||
@@ -758,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 {
|
||||
@@ -840,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,
|
||||
@@ -853,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,
|
||||
@@ -927,8 +846,7 @@ pub async fn fetch_dlc_details_with_progress(
|
||||
100,
|
||||
None,
|
||||
);
|
||||
info!("Emitted final DLC progress: 100%");
|
||||
|
||||
|
||||
Ok(dlc_details)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ mod config;
|
||||
mod epic_scanner;
|
||||
mod pe_inspector;
|
||||
mod screamapi_config;
|
||||
mod system_info;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
||||
@@ -83,6 +84,49 @@ 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);
|
||||
@@ -105,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);
|
||||
@@ -454,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]
|
||||
@@ -844,6 +899,11 @@ 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,
|
||||
@@ -891,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
|
||||
|
||||
@@ -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") {
|
||||
@@ -742,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,7 @@ use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const KOALOADER_REPO: &str = "acidicoala/Koaloader";
|
||||
|
||||
pub const KOA_VARIANTS: &[&str] = &[
|
||||
"version.dll", "winmm.dll", "winhttp.dll", "iphlpapi.dll", "dinput8.dll",
|
||||
"d3d11.dll", "dxgi.dll", "d3d9.dll", "d3d10.dll", "dwmapi.dll", "hid.dll",
|
||||
"msimg32.dll", "mswsock.dll", "opengl32.dll", "profapi.dll", "propsys.dll",
|
||||
"textshaping.dll", "glu32.dll", "audioses.dll", "msasn1.dll", "wldp.dll",
|
||||
"xinput9_1_0.dll",
|
||||
];
|
||||
pub use crate::pe_inspector::KOA_VARIANTS;
|
||||
|
||||
pub struct Koaloader;
|
||||
|
||||
@@ -159,35 +152,14 @@ impl Unlocker for Koaloader {
|
||||
/// context = relative executable path (e.g. "en_us/Sources/Bin/SnowRunner.exe")
|
||||
/// Progress events are emitted by installer/mod.rs, not here.
|
||||
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> {
|
||||
// Install without progress called internally (e.g. from installer/mod.rs
|
||||
// after it has already emitted its own progress steps)
|
||||
let exe_path = Self::resolve_exe(game_path, context)?;
|
||||
Self::install_proxy(&exe_path)?;
|
||||
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
|
||||
let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path);
|
||||
let scan = crate::pe_inspector::find_best_proxy(&exe_path);
|
||||
let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string();
|
||||
|
||||
let proxy_src = Self::get_proxy_dll(&proxy_stem, is_64bit)?;
|
||||
fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name))
|
||||
.map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?;
|
||||
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
crate::unlockers::ScreamAPI::install_to_game(&exe_dir_str, "koaloader").await?;
|
||||
|
||||
let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let koa_config = serde_json::json!({
|
||||
"logging": false,
|
||||
"enabled": true,
|
||||
"auto_load": true,
|
||||
"targets": [exe_name],
|
||||
"modules": []
|
||||
});
|
||||
fs::write(
|
||||
exe_dir.join("Koaloader.config.json"),
|
||||
serde_json::to_string_pretty(&koa_config).unwrap(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write Koaloader config: {}", e))?;
|
||||
Self::write_koaloader_config(&exe_path)?;
|
||||
|
||||
info!("Koaloader installation complete for: {}", game_path);
|
||||
Ok(())
|
||||
@@ -198,26 +170,7 @@ impl Unlocker for Koaloader {
|
||||
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
|
||||
let exe_dir_str = exe_dir.to_string_lossy().to_string();
|
||||
|
||||
let koa_config = exe_dir.join("Koaloader.config.json");
|
||||
if koa_config.exists() {
|
||||
fs::remove_file(&koa_config)
|
||||
.map_err(|e| format!("Failed to remove Koaloader config: {}", e))?;
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(exe_dir) {
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
let name_lower = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if KOA_VARIANTS.contains(&name_lower.as_str()) {
|
||||
fs::remove_file(&path).ok();
|
||||
info!("Removed proxy DLL: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::remove_proxy_and_config(exe_dir)?;
|
||||
|
||||
crate::unlockers::ScreamAPI::uninstall_from_game(&exe_dir_str, "koaloader").await?;
|
||||
|
||||
@@ -266,6 +219,71 @@ impl Koaloader {
|
||||
))
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
"version": "1.5.5",
|
||||
"version": "1.7.0",
|
||||
"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,19 +12,20 @@ import {
|
||||
InitialLoadingScreen,
|
||||
ErrorBoundary,
|
||||
UpdateScreen,
|
||||
AnimatedBackground,
|
||||
} from '@/components/layout'
|
||||
|
||||
// Dialog components
|
||||
import {
|
||||
ProgressDialog,
|
||||
DlcSelectionDialog,
|
||||
SettingsDialog,
|
||||
ConflictDialog,
|
||||
DisclaimerDialog,
|
||||
UnlockerSelectionDialog,
|
||||
UnlockerChoiceDialog,
|
||||
} from '@/components/dialogs'
|
||||
|
||||
// Page components (Overview and Settings)
|
||||
import { OverviewPage, SettingsPage } from '@/components/pages'
|
||||
|
||||
// Game components
|
||||
import { GameList, EpicGameList } from '@/components/games'
|
||||
|
||||
@@ -32,6 +34,8 @@ import { GameList, EpicGameList } from '@/components/games'
|
||||
*/
|
||||
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,9 +64,6 @@ function App() {
|
||||
handleDlcConfirm,
|
||||
handleGameEdit,
|
||||
handleUpdateDlcs,
|
||||
settingsDialog,
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
handleSmokeAPISettingsOpen,
|
||||
handleOpenRating,
|
||||
reportingEnabled,
|
||||
@@ -84,6 +85,28 @@ function App() {
|
||||
// Conflict detection
|
||||
const { conflicts, showDialog, resolveConflict, closeDialog } = useConflictDetection(games)
|
||||
|
||||
// Community vote data for the Steam unlocker choice dialog (Epic's
|
||||
// equivalent choice has no vote data, so it skips this entirely)
|
||||
useEffect(() => {
|
||||
const gameId = unlockerSelectionDialog.gameId
|
||||
if (!unlockerSelectionDialog.visible || !gameId) {
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
return
|
||||
}
|
||||
|
||||
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||
.then((results) => {
|
||||
setCreamVotes(results.find((v) => v.unlocker === 'creamlinux') ?? null)
|
||||
setSmokeVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
// Votes are non-critical, silently fall back to "No votes yet"
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
})
|
||||
}, [unlockerSelectionDialog.visible, unlockerSelectionDialog.gameId])
|
||||
|
||||
const handleSetFilter = async (f: string) => {
|
||||
setFilter(f)
|
||||
if (f === 'epic' && epicGames.length === 0 && !epicLoading) {
|
||||
@@ -126,26 +149,18 @@ 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={handleSetFilter}
|
||||
currentFilter={filter}
|
||||
onSettingsClick={handleSettingsOpen}
|
||||
/>
|
||||
<Sidebar setFilter={handleSetFilter} currentFilter={filter} />
|
||||
|
||||
{filter === 'epic' ? (
|
||||
{filter === 'overview' ? (
|
||||
<OverviewPage />
|
||||
) : filter === 'settings' ? (
|
||||
<SettingsPage />
|
||||
) : filter === 'epic' ? (
|
||||
<EpicGameList
|
||||
games={epicGames}
|
||||
isLoading={epicLoading}
|
||||
@@ -154,6 +169,7 @@ function App() {
|
||||
onUninstallScream={handleEpicUninstallScream}
|
||||
onUninstallKoaloader={handleEpicUninstallKoaloader}
|
||||
onSettings={handleEpicSettings}
|
||||
onRefresh={loadEpicGames}
|
||||
/>
|
||||
) : error ? (
|
||||
<div className="error-message">
|
||||
@@ -169,6 +185,7 @@ function App() {
|
||||
onEdit={handleGameEdit}
|
||||
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
||||
onRate={handleOpenRating}
|
||||
onRefresh={handleRefresh}
|
||||
reportingEnabled={reportingEnabled}
|
||||
/>
|
||||
)}
|
||||
@@ -203,9 +220,6 @@ function App() {
|
||||
onUpdate={handleUpdateDlcs}
|
||||
/>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
|
||||
|
||||
{/* Conflict Detection Dialog */}
|
||||
<ConflictDialog
|
||||
visible={showDialog}
|
||||
@@ -215,13 +229,34 @@ function App() {
|
||||
/>
|
||||
|
||||
{/* Unlocker Selection Dialog */}
|
||||
<UnlockerSelectionDialog
|
||||
<UnlockerChoiceDialog
|
||||
visible={unlockerSelectionDialog.visible}
|
||||
gameId={unlockerSelectionDialog.gameId}
|
||||
gameTitle={unlockerSelectionDialog.gameTitle || ''}
|
||||
onClose={closeUnlockerDialog}
|
||||
onSelectCreamLinux={handleSelectCreamLinux}
|
||||
onSelectSmokeAPI={handleSelectSmokeAPI}
|
||||
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 */}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 84 KiB |
@@ -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
|
||||
@@ -2,7 +2,10 @@ export { default as LoadingIndicator } from './LoadingIndicator'
|
||||
export { default as ProgressBar } from './ProgressBar'
|
||||
export { default as Dropdown } from './Dropdown'
|
||||
export { default as VotesDisplay } from './VotesDisplay'
|
||||
export { default as EntryList } from './EntryList'
|
||||
export { default as Spinner } from './Spinner'
|
||||
|
||||
export type { LoadingSize, LoadingType } from './LoadingIndicator'
|
||||
export type { DropdownOption } from './Dropdown'
|
||||
export type { GameVotes } from './VotesDisplay'
|
||||
export type { GameVotes } from './VotesDisplay'
|
||||
export type { EntryListProps } from './EntryList'
|
||||
@@ -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
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
|
||||
@@ -223,7 +224,7 @@ const DlcSelectionDialog = ({
|
||||
</ul>
|
||||
) : (
|
||||
<div className="dlc-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<Spinner />
|
||||
<p>Loading DLC information...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,97 +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'
|
||||
import { EpicGame } from '@/types/EpicGame'
|
||||
|
||||
export interface EpicUnlockerSelectionDialogProps {
|
||||
visible: boolean
|
||||
game: EpicGame | null
|
||||
onClose: () => void
|
||||
onSelectScreamAPI: () => void
|
||||
onSelectKoaloader: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocker selection dialog for Epic games.
|
||||
* Recommended: ScreamAPI (direct EOSSDK replacement).
|
||||
* Alternative: Koaloader + ScreamAPI (proxy DLL injection).
|
||||
*/
|
||||
const EpicUnlockerSelectionDialog: React.FC<EpicUnlockerSelectionDialogProps> = ({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
onSelectScreamAPI,
|
||||
onSelectKoaloader,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="unlocker-selection-header">
|
||||
<h3>Choose Unlocker</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="unlocker-selection-content">
|
||||
<p className="game-title-info">
|
||||
Select which unlocker to install for <strong>{game?.title}</strong>:
|
||||
</p>
|
||||
|
||||
<div className="unlocker-options">
|
||||
<div className="unlocker-option recommended">
|
||||
<div className="option-header">
|
||||
<h4>ScreamAPI</h4>
|
||||
<span className="recommended-badge">Recommended</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Replaces the EOS SDK DLL directly with ScreamAPI. Works for most Epic games and
|
||||
requires no additional files. DLC unlocking is automatic.
|
||||
</p>
|
||||
<Button variant="primary" onClick={onSelectScreamAPI} fullWidth>
|
||||
Install ScreamAPI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="unlocker-option">
|
||||
<div className="option-header">
|
||||
<h4>Koaloader + ScreamAPI</h4>
|
||||
<span className="alternative-badge">Alternative</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Uses a proxy DLL to inject ScreamAPI without modifying the EOS SDK. Try this if the
|
||||
recommended method doesn't work for your game.
|
||||
</p>
|
||||
<Button variant="secondary" onClick={onSelectKoaloader} fullWidth>
|
||||
Install Koaloader
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="selection-info">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
You can always uninstall and try the other option if one doesn't work properly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default EpicUnlockerSelectionDialog
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,209 +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'
|
||||
|
||||
interface ScreamAPIConfig {
|
||||
$schema: string
|
||||
$version: number
|
||||
logging: boolean
|
||||
log_eos: boolean
|
||||
block_metrics: boolean
|
||||
namespace_id: string
|
||||
default_dlc_status: 'unlocked' | 'locked' | 'original'
|
||||
override_dlc_status: Record<string, string>
|
||||
extra_graphql_endpoints: string[]
|
||||
extra_entitlements: Record<string, string>
|
||||
}
|
||||
|
||||
interface ScreamAPISettingsDialogProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
gamePath: string
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ScreamAPIConfig = {
|
||||
$schema:
|
||||
'https://raw.githubusercontent.com/acidicoala/ScreamAPI/master/res/ScreamAPI.schema.json',
|
||||
$version: 3,
|
||||
logging: false,
|
||||
log_eos: false,
|
||||
block_metrics: false,
|
||||
namespace_id: '',
|
||||
default_dlc_status: 'unlocked',
|
||||
override_dlc_status: {},
|
||||
extra_graphql_endpoints: [],
|
||||
extra_entitlements: {},
|
||||
}
|
||||
|
||||
const DLC_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
|
||||
{ value: 'unlocked', label: 'Unlocked' },
|
||||
{ value: 'locked', label: 'Locked' },
|
||||
{ value: 'original', label: 'Original' },
|
||||
]
|
||||
|
||||
const ScreamAPISettingsDialog = ({
|
||||
visible,
|
||||
onClose,
|
||||
gamePath,
|
||||
gameTitle,
|
||||
}: ScreamAPISettingsDialogProps) => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [config, setConfig] = useState<ScreamAPIConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const existingConfig = await invoke<ScreamAPIConfig | null>('read_screamapi_config', {
|
||||
gamePath,
|
||||
})
|
||||
if (existingConfig) {
|
||||
setConfig(existingConfig)
|
||||
setEnabled(true)
|
||||
} else {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
}
|
||||
setHasChanges(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to load ScreamAPI config:', error)
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setEnabled(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [gamePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && gamePath) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [visible, gamePath, loadConfig])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (enabled) {
|
||||
await invoke('write_screamapi_config', { gamePath, config })
|
||||
} else {
|
||||
await invoke('delete_screamapi_config', { gamePath })
|
||||
}
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to save ScreamAPI config:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setHasChanges(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const updateConfig = <K extends keyof ScreamAPIConfig>(key: K, value: ScreamAPIConfig[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={handleCancel} size="medium">
|
||||
<DialogHeader onClose={handleCancel} hideCloseButton={true}>
|
||||
<div className="settings-header">
|
||||
<h3>ScreamAPI Settings</h3>
|
||||
</div>
|
||||
<p className="dialog-subtitle">{gameTitle}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="smokeapi-settings-content">
|
||||
<div className="settings-section">
|
||||
<AnimatedCheckbox
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
setEnabled(!enabled)
|
||||
setHasChanges(true)
|
||||
}}
|
||||
label="Enable ScreamAPI Configuration"
|
||||
sublabel="Enable this to customise ScreamAPI settings for this game"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
|
||||
<div className="settings-section">
|
||||
<h4>General Settings</h4>
|
||||
|
||||
<Dropdown
|
||||
label="Default DLC Status"
|
||||
description="Specifies the default DLC unlock status"
|
||||
value={config.default_dlc_status}
|
||||
options={DLC_STATUS_OPTIONS}
|
||||
onChange={(value) => updateConfig('default_dlc_status', value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Logging</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.logging}
|
||||
onChange={() => updateConfig('logging', !config.logging)}
|
||||
label="Enable Logging"
|
||||
sublabel="Enables logging to ScreamAPI.log.log file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.log_eos}
|
||||
onChange={() => updateConfig('log_eos', !config.log_eos)}
|
||||
label="Log EOS SDK"
|
||||
sublabel="Intercept and log EOS SDK calls (requires logging enabled)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h4>Privacy</h4>
|
||||
|
||||
<div className="checkbox-option">
|
||||
<AnimatedCheckbox
|
||||
checked={config.block_metrics}
|
||||
onChange={() => updateConfig('block_metrics', !config.block_metrics)}
|
||||
label="Block Metrics"
|
||||
sublabel="Block game analytics/usage reporting to Epic Online Services"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={isLoading || !hasChanges}>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScreamAPISettingsDialog
|
||||
@@ -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,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
|
||||
@@ -1,124 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActions,
|
||||
} from '@/components/dialogs'
|
||||
import { Button } from '@/components/buttons'
|
||||
import { Icon, info } from '@/components/icons'
|
||||
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||
|
||||
export interface UnlockerSelectionDialogProps {
|
||||
visible: boolean
|
||||
gameId: string | null
|
||||
gameTitle: string | null
|
||||
onClose: () => void
|
||||
onSelectCreamLinux: () => void
|
||||
onSelectSmokeAPI: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocker Selection Dialog component
|
||||
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games.
|
||||
* Fetches and displays community vote data per unlocker.
|
||||
*/
|
||||
const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
|
||||
visible,
|
||||
gameId,
|
||||
gameTitle,
|
||||
onClose,
|
||||
onSelectCreamLinux,
|
||||
onSelectSmokeAPI,
|
||||
}) => {
|
||||
const [creamVotes, setCreamVotes] = useState<GameVotes | null>(null)
|
||||
const [smokeVotes, setSmokeVotes] = useState<GameVotes | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !gameId) {
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
return
|
||||
}
|
||||
|
||||
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||
.then((results) => {
|
||||
setCreamVotes(results.find((v) => v.unlocker === 'creamlinux') ?? null)
|
||||
setSmokeVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
// Votes are non-critical — silently fall back to "No votes yet"
|
||||
setCreamVotes(null)
|
||||
setSmokeVotes(null)
|
||||
})
|
||||
}, [visible, gameId])
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||
<div className="unlocker-selection-header">
|
||||
<h3>Choose Unlocker</h3>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="unlocker-selection-content">
|
||||
<p className="game-title-info">
|
||||
Select which unlocker to install for <strong>{gameTitle}</strong>:
|
||||
</p>
|
||||
|
||||
<div className="unlocker-options">
|
||||
<div className="unlocker-option recommended">
|
||||
<div className="option-header">
|
||||
<h4>CreamLinux</h4>
|
||||
<span className="recommended-badge">Recommended</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Native Linux DLC unlocker. Works best with most native Linux games and provides
|
||||
better compatibility.
|
||||
</p>
|
||||
<VotesDisplay votes={creamVotes} />
|
||||
<Button variant="primary" onClick={onSelectCreamLinux} fullWidth>
|
||||
Install CreamLinux
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="unlocker-option">
|
||||
<div className="option-header">
|
||||
<h4>SmokeAPI</h4>
|
||||
<span className="alternative-badge">Alternative</span>
|
||||
</div>
|
||||
<p className="option-description">
|
||||
Cross-platform DLC unlocker. Try this if CreamLinux doesn't work for your game.
|
||||
Automatically fetches DLC information.
|
||||
</p>
|
||||
<VotesDisplay votes={smokeVotes} />
|
||||
<Button variant="secondary" onClick={onSelectSmokeAPI} fullWidth>
|
||||
Install SmokeAPI
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="selection-info">
|
||||
<Icon name={info} variant="solid" size="md" />
|
||||
<span>
|
||||
You can always uninstall and try the other option if one doesn't work properly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogActions>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnlockerSelectionDialog
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -7,16 +7,14 @@ export { default as DialogActions } from './DialogActions'
|
||||
export { default as ProgressDialog } from './ProgressDialog'
|
||||
export { default as DlcSelectionDialog } from './DlcSelectionDialog'
|
||||
export { default as AddDlcDialog } from './AddDlcDialog'
|
||||
export { default as SettingsDialog } from './SettingsDialog'
|
||||
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
||||
export { default as ScreamAPISettingsDialog } from './ScreamAPISettingsDialog'
|
||||
export { default as ApiSettingsDialog } from './ApiSettingsDialog'
|
||||
export { smokeApiSettingsSpec, screamApiSettingsSpec } from './apiSettingsSpecs'
|
||||
export { default as ConflictDialog } from './ConflictDialog'
|
||||
export { default as DisclaimerDialog } from './DisclaimerDialog'
|
||||
export { default as UnlockerSelectionDialog } from './UnlockerSelectionDialog'
|
||||
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 { default as EpicUnlockerSelectionDialog } from './EpicUnlockerSelectionDialog'
|
||||
|
||||
// Export types
|
||||
export type { DialogProps } from './Dialog'
|
||||
@@ -28,6 +26,7 @@ export type { ProgressDialogProps, InstallationInstructions } from './ProgressDi
|
||||
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
|
||||
export type { AddDlcDialogProps } from './AddDlcDialog'
|
||||
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
|
||||
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
|
||||
export type { ApiSettingsDialogProps, ApiSettingsSpec, ApiSettingsField } from './ApiSettingsDialog'
|
||||
export type { UnlockerChoiceDialogProps, UnlockerChoiceOption } from './UnlockerChoiceDialog'
|
||||
export type { RatingDialogProps } from './RatingDialog'
|
||||
export type { SmokeAPIVotesDialogProps } from './SmokeAPIVotesDialog'
|
||||
@@ -2,6 +2,8 @@ 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[]
|
||||
@@ -11,6 +13,7 @@ interface EpicGameListProps {
|
||||
onUninstallScream: (game: EpicGame) => void
|
||||
onUninstallKoaloader: (game: EpicGame) => void
|
||||
onSettings: (game: EpicGame) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const EpicGameList = ({
|
||||
@@ -21,6 +24,7 @@ const EpicGameList = ({
|
||||
onUninstallScream,
|
||||
onUninstallKoaloader,
|
||||
onSettings,
|
||||
onRefresh,
|
||||
}: EpicGameListProps) => {
|
||||
const sortedGames = useMemo(
|
||||
() => [...games].sort((a, b) => a.title.localeCompare(b.title)),
|
||||
@@ -37,7 +41,18 @@ const EpicGameList = ({
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<h2>Epic Games ({games.length})</h2>
|
||||
<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">
|
||||
|
||||
@@ -20,7 +20,6 @@ interface 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
|
||||
@@ -35,13 +34,9 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportin
|
||||
|
||||
if (bestImageUrl) {
|
||||
setImageUrl(bestImageUrl)
|
||||
setHasError(false)
|
||||
} else {
|
||||
setHasError(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching game image:', error)
|
||||
setHasError(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -104,11 +99,7 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportin
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -11,6 +12,7 @@ interface GameListProps {
|
||||
onEdit?: (gameId: string) => void
|
||||
onSmokeAPISettings?: (gameId: string) => void
|
||||
onRate?: (gameId: string) => void
|
||||
onRefresh: () => void
|
||||
reportingEnabled?: boolean
|
||||
}
|
||||
|
||||
@@ -18,7 +20,7 @@ interface GameListProps {
|
||||
* Main game list component
|
||||
* Displays games in a grid with search and filtering applied
|
||||
*/
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameListProps) => {
|
||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, onRefresh, reportingEnabled }: GameListProps) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||
|
||||
// Sort games alphabetically by title
|
||||
@@ -45,7 +47,18 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRa
|
||||
|
||||
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
|
||||
|
||||
@@ -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'
|
||||
@@ -52,6 +53,7 @@ export const IconNames = {
|
||||
Download1: download1,
|
||||
Edit: edit,
|
||||
Error: error,
|
||||
Folder: folder,
|
||||
Info: info,
|
||||
Layers: layers,
|
||||
Refresh: refresh,
|
||||
|
||||
@@ -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,6 +8,7 @@ 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'
|
||||
|
||||
@@ -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,6 +8,7 @@ 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'
|
||||
|
||||
@@ -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,11 +1,11 @@
|
||||
import { Icon, layers, linux, proton, settings } from '@/components/icons'
|
||||
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'
|
||||
import { Button } from '@/components/buttons'
|
||||
|
||||
interface SidebarProps {
|
||||
setFilter: (filter: string) => void
|
||||
currentFilter: string
|
||||
onSettingsClick: () => void
|
||||
}
|
||||
|
||||
type FilterItem = {
|
||||
@@ -15,7 +15,20 @@ type FilterItem = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) => {
|
||||
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' },
|
||||
@@ -45,6 +58,12 @@ const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) =>
|
||||
<h2>Library</h2>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<ul className="filter-list">
|
||||
{generalFilters.map(renderFilter)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-section-label">Steam</span>
|
||||
<ul className="filter-list">
|
||||
@@ -59,18 +78,22 @@ const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) =>
|
||||
</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-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>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
export default Sidebar
|
||||
|
||||
@@ -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'
|
||||
@@ -34,11 +34,23 @@ export interface RatingDialogState {
|
||||
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
|
||||
@@ -64,11 +76,6 @@ export interface AppContextType {
|
||||
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
|
||||
@@ -90,6 +97,9 @@ export interface AppContextType {
|
||||
handleSubmitRating: (worked: boolean) => Promise<void>
|
||||
reportingEnabled: boolean
|
||||
|
||||
// Recent activity feed (session-only, shown on Overview)
|
||||
activityFeed: ActivityItem[]
|
||||
|
||||
// Toast notifications
|
||||
showToast: (
|
||||
message: string,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ReactNode, useState, useEffect } 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, Config, EpicGame } from '@/types'
|
||||
import { DlcInfo, Config, EpicGame, Game } from '@/types'
|
||||
import { ActionType } from '@/components/buttons/ActionButton'
|
||||
import { ToastContainer } from '@/components/notifications'
|
||||
import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog, EpicUnlockerSelectionDialog, ScreamAPISettingsDialog } 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'
|
||||
|
||||
@@ -19,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,
|
||||
@@ -41,12 +41,11 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
|
||||
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
|
||||
@@ -84,6 +83,17 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Rating dialog state
|
||||
const [ratingDialog, setRatingDialog] = useState<{
|
||||
visible: boolean
|
||||
@@ -114,13 +124,16 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
let unlisten: (() => void) | undefined
|
||||
listen<EpicGame>('epic-game-updated', (event) => {
|
||||
const updated = event.payload
|
||||
const prev = epicGames.find((g) => g.app_name === updated.app_name)
|
||||
|
||||
// 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
|
||||
@@ -130,12 +143,16 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
|
||||
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) {
|
||||
@@ -147,7 +164,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
}
|
||||
}).then((fn) => { unlisten = fn })
|
||||
return () => { unlisten?.() }
|
||||
}, [epicGames, success, info, warning])
|
||||
}, [success, info, warning])
|
||||
|
||||
const loadEpicGames = async () => {
|
||||
setEpicLoading(true)
|
||||
@@ -195,15 +212,6 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
if (game) runEpicAction(game, 'install_koaloader')
|
||||
}
|
||||
|
||||
// Settings handlers
|
||||
const handleSettingsOpen = () => {
|
||||
setSettingsDialog({ visible: true })
|
||||
}
|
||||
|
||||
const handleSettingsClose = () => {
|
||||
setSettingsDialog({ visible: false })
|
||||
}
|
||||
|
||||
// SmokeAPI settings handlers
|
||||
const handleSmokeAPISettingsOpen = (gameId: string) => {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
@@ -234,9 +242,12 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
const handleSmokeAPIVotesConfirm = () => {
|
||||
const gameId = smokeAPIVotesDialog.gameId
|
||||
setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null })
|
||||
if (gameId) {
|
||||
// Now actually run the install
|
||||
executeGameAction(gameId, 'install_smoke', games)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +301,39 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const handleGameAction = async (gameId: string, action: ActionType) => {
|
||||
const game = games.find((g) => g.id === gameId)
|
||||
@@ -341,7 +385,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
)
|
||||
|
||||
try {
|
||||
// This will show the UnlockerSelectionDialog and handle the callback
|
||||
// This will show the UnlockerChoiceDialog and handle the callback
|
||||
await executeGameAction(gameId, action, games)
|
||||
} catch (error) {
|
||||
showError(`Action failed: ${error}`)
|
||||
@@ -355,41 +399,12 @@ 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))
|
||||
)
|
||||
}
|
||||
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 }))
|
||||
@@ -416,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}`)
|
||||
@@ -455,6 +476,8 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
// Game state
|
||||
games,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
scanProgress,
|
||||
error,
|
||||
loadGames,
|
||||
|
||||
@@ -482,11 +505,6 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
handleDlcConfirm,
|
||||
handleProgressDialogClose: handleCloseProgressDialog,
|
||||
|
||||
// Settings
|
||||
settingsDialog,
|
||||
handleSettingsOpen,
|
||||
handleSettingsClose,
|
||||
|
||||
// SmokeAPI Settings
|
||||
smokeAPISettingsDialog,
|
||||
handleSmokeAPISettingsOpen,
|
||||
@@ -504,6 +522,9 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
||||
handleSubmitRating,
|
||||
reportingEnabled,
|
||||
|
||||
// Recent activity feed
|
||||
activityFeed,
|
||||
|
||||
// Toast notifications
|
||||
showToast,
|
||||
|
||||
@@ -561,28 +582,50 @@ 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 */}
|
||||
<EpicUnlockerSelectionDialog
|
||||
<UnlockerChoiceDialog
|
||||
visible={epicUnlockerDialog.visible}
|
||||
game={epicUnlockerDialog.game}
|
||||
gameTitle={epicUnlockerDialog.game?.title ?? ''}
|
||||
onClose={() => setEpicUnlockerDialog({ visible: false, game: null })}
|
||||
onSelectScreamAPI={handleSelectScreamAPI}
|
||||
onSelectKoaloader={handleSelectKoaloader}
|
||||
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 */}
|
||||
<ScreamAPISettingsDialog
|
||||
<ApiSettingsDialog
|
||||
visible={screamSettingsDialog.visible}
|
||||
onClose={() => setScreamSettingsDialog({ visible: false, game: null })}
|
||||
gamePath={screamSettingsDialog.game?.install_path ?? ''}
|
||||
gameTitle={screamSettingsDialog.game?.title ?? ''}
|
||||
spec={screamApiSettingsSpec}
|
||||
/>
|
||||
|
||||
{/* SmokeAPI Votes Dialog */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,42 +151,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Animation for spinner
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Rating button on game card
|
||||
.rate-button {
|
||||
svg {
|
||||
color: var(--elevated-bg);
|
||||
transition: transform var(--duration-normal) var(--easing-ease-out);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.28);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.25);
|
||||
|
||||
svg {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
EntryList - generic add/remove list of string entries (e.g. file paths).
|
||||
The input row (manual entry + browse button) is pinned below the
|
||||
scrollable entries area, so it's always visible regardless of how many
|
||||
entries there are.
|
||||
*/
|
||||
|
||||
.entry-list-error {
|
||||
color: var(--danger);
|
||||
background-color: var(--danger-soft);
|
||||
border: 1px solid var(--danger-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
// Scrolls once entries exceed a handful of rows the input row below stays
|
||||
// put since it lives outside this scrolling box.
|
||||
.entry-list-scroll-area {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.entry-list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--text-soft);
|
||||
background-color: var(--tertiary-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
.icon {
|
||||
opacity: 0.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-list-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.entry-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--tertiary-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
.entry-list-item-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.entry-list-remove-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate from the START so the end of a long path stays visible the
|
||||
// classic RTL trick: flipping the box's inline direction moves where the
|
||||
// overflow ellipsis lands, while individual LTR characters still render
|
||||
// left-to-right within it.
|
||||
.entry-list-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entry-list-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-list-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background-color: var(--tertiary-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-soft);
|
||||
font-family: sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-list-browse-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -2,3 +2,4 @@
|
||||
@forward './progress_bar';
|
||||
@forward './dropdown';
|
||||
@forward './votes_display';
|
||||
@forward './entry_list';
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
// Size variations
|
||||
&.loading-small {
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-width: 2px;
|
||||
@include spinner-size(20px);
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
@@ -29,18 +27,11 @@
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 6px;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading-medium {
|
||||
.loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-width: 3px;
|
||||
@include spinner-size(30px);
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
@@ -54,18 +45,11 @@
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading-large {
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-width: 4px;
|
||||
@include spinner-size(50px);
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
@@ -79,20 +63,42 @@
|
||||
font-size: 1.1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 10px;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spinner styles
|
||||
.loading-spinner {
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
// Windows/Fluent-style ring spinner - an SVG circle with an animated
|
||||
// stroke-dasharray + rotation, rather than a CSS conic-gradient mask.
|
||||
// Sizing is set per-context (see above); the ring geometry (viewBox +
|
||||
// circle radius) is fixed, so the stroke scales with it automatically.
|
||||
.spinner-ring {
|
||||
overflow: visible;
|
||||
color: var(--primary-color);
|
||||
|
||||
circle {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
transform-origin: 50% 50%;
|
||||
animation: spinner-dash 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-dash {
|
||||
0% {
|
||||
stroke-dasharray: 0.01px, 43.97px;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 21.99px, 21.99px;
|
||||
transform: rotate(450deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 0.01px, 43.97px;
|
||||
transform: rotate(1080deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading dots animation
|
||||
@@ -115,49 +121,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar styles
|
||||
.loading-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Animation
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
|
||||
@@ -21,15 +21,6 @@
|
||||
background: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
SmokeAPI Settings Dialog styles
|
||||
/*
|
||||
API Settings Dialog styles - shared by any unlocker's config dialog
|
||||
(SmokeAPI, ScreamAPI, ...)
|
||||
*/
|
||||
|
||||
.dialog-subtitle {
|
||||
@@ -12,7 +13,7 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.smokeapi-settings-content {
|
||||
.api-settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
@@ -132,13 +132,8 @@
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
.spinner-ring {
|
||||
@include spinner-size(40px);
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -254,16 +249,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Loading animations
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading animation
|
||||
@keyframes loading-pulse {
|
||||
0% {
|
||||
background-position: 200% 50%;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@forward './dialog';
|
||||
@forward './dlc_dialog';
|
||||
@forward './progress_dialog';
|
||||
@forward './settings_dialog';
|
||||
@forward './smokeapi_settings_dialog';
|
||||
@forward './api_settings_dialog';
|
||||
@forward './conflict_dialog';
|
||||
@forward './disclaimer_dialog';
|
||||
@forward './unlocker_selection_dialog';
|
||||
|
||||
@@ -15,13 +15,11 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
.progress-dialog-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--primary-color);
|
||||
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.3);
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
font-size: 0.95rem;
|
||||
font-weight: var(--bold);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
transition: background-color var(--duration-normal) var(--easing-ease-out);
|
||||
color: var(--text-heavy);
|
||||
|
||||
&--worked {
|
||||
@@ -48,8 +48,6 @@
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--success-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(140, 200, 147, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,19 +56,12 @@
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--danger-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(217, 107, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
Settings dialog styles
|
||||
*/
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
h4 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background-color: var(--border-dark);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
h5 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
text-align: left;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
max-width: 300px;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&::before {
|
||||
content: '•';
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-info {
|
||||
background-color: var(--border-dark);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.info-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--secondary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings button in sidebar
|
||||
.settings-button {
|
||||
margin-top: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover .settings-icon {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,12 @@
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
will-change: opacity, transform;
|
||||
box-shadow: var(--shadow-standard);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
transition:
|
||||
transform var(--duration-normal) var(--easing-ease-out),
|
||||
box-shadow var(--duration-normal) var(--easing-ease-out),
|
||||
border-color var(--duration-normal) var(--easing-ease-out);
|
||||
transform-origin: center;
|
||||
|
||||
// Simple image loading animation
|
||||
@@ -20,52 +24,69 @@
|
||||
animation: fadeIn 0.5s forwards;
|
||||
}
|
||||
|
||||
// Hover effects for the card
|
||||
// Hover effects for the card a small lift, plus a soft ambient glow in
|
||||
// whatever status color is most relevant (installed unlocker takes
|
||||
// priority over the base native/proton badge), and each badge itself picks
|
||||
// up a matching glow too.
|
||||
.game-item-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: var(--shadow-hover);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3);
|
||||
border-color: var(--border);
|
||||
z-index: 5;
|
||||
|
||||
&:has(.status-badge.native) {
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.3),
|
||||
0 0 24px rgba(140, 200, 147, 0.25);
|
||||
}
|
||||
|
||||
&:has(.status-badge.proton) {
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.3),
|
||||
0 0 24px rgba(255, 200, 150, 0.25);
|
||||
}
|
||||
|
||||
&:has(.status-badge.cream) {
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.3),
|
||||
0 0 24px rgba(128, 180, 255, 0.3);
|
||||
}
|
||||
|
||||
&:has(.status-badge.smoke) {
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.3),
|
||||
0 0 24px rgba(255, 240, 150, 0.3);
|
||||
}
|
||||
|
||||
// Epic's own badge color is near-black, so it needs a lighter stand-in
|
||||
// to actually read as a glow
|
||||
&:has(.status-badge.epic) {
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.3),
|
||||
0 0 24px rgba(120, 140, 165, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.native {
|
||||
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5);
|
||||
box-shadow: 0 0 10px rgba(140, 200, 147, 0.5);
|
||||
}
|
||||
|
||||
.status-badge.proton {
|
||||
box-shadow: 0 0 10px rgba(255, 201, 150, 0.5);
|
||||
box-shadow: 0 0 10px rgba(255, 200, 150, 0.5);
|
||||
}
|
||||
|
||||
.status-badge.cream {
|
||||
box-shadow: 0 0 10px rgba(128, 181, 255, 0.5);
|
||||
box-shadow: 0 0 10px rgba(128, 180, 255, 0.5);
|
||||
}
|
||||
|
||||
.status-badge.smoke {
|
||||
box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
|
||||
box-shadow: 0 0 10px rgba(255, 240, 150, 0.5);
|
||||
}
|
||||
|
||||
.status-badge.epic {
|
||||
box-shadow: 0 0 10px rgba(15, 25, 35, 0.5);
|
||||
box-shadow: 0 0 10px rgba(120, 140, 165, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Special styling for cards with different statuses
|
||||
.game-item-card:has(.status-badge.cream) {
|
||||
box-shadow:
|
||||
var(--shadow-standard),
|
||||
0 0 15px rgba(128, 181, 255, 0.15);
|
||||
}
|
||||
|
||||
.game-item-card:has(.status-badge.smoke) {
|
||||
box-shadow:
|
||||
var(--shadow-standard),
|
||||
0 0 15px rgba(255, 239, 150, 0.15);
|
||||
}
|
||||
|
||||
.game-item-card:has(.status-badge.epic) {
|
||||
box-shadow:
|
||||
var(--shadow-standard),
|
||||
0 0 15px rgba(15, 25, 35, 0.15);
|
||||
}
|
||||
|
||||
// Game item overlay
|
||||
.game-item-overlay {
|
||||
position: absolute;
|
||||
@@ -85,9 +106,6 @@
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
font-weight: var(--bold);
|
||||
font-family: var(--family);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-heavy);
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -108,13 +126,8 @@
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--bold);
|
||||
font-family: var(--family);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
color: var(--text-heavy);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: box-shadow var(--duration-normal) var(--easing-ease-out);
|
||||
}
|
||||
|
||||
.status-badge.native {
|
||||
@@ -152,8 +165,6 @@
|
||||
font-size: 1.6rem;
|
||||
font-weight: var(--bold);
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
transform: translateZ(0);
|
||||
will-change: opacity, transform;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
|
||||
@@ -17,26 +17,20 @@
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-color), transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
.game-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border-dark);
|
||||
}
|
||||
|
||||
// Game grid
|
||||
.game-grid {
|
||||
display: grid;
|
||||
@@ -63,25 +57,8 @@
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
||||
animation: loading-shimmer 2s infinite;
|
||||
}
|
||||
background-color: var(--tertiary-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@@ -111,20 +88,19 @@
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color),
|
||||
color-mix(in srgb, black 10%, var(--primary-color))
|
||||
);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-heavy);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border-soft);
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
transform: translateY(10px);
|
||||
transition:
|
||||
background-color var(--duration-normal) var(--easing-ease-out),
|
||||
opacity var(--duration-normal) var(--easing-ease-out),
|
||||
transform var(--duration-normal) var(--easing-ease-out);
|
||||
z-index: var(--z-header);
|
||||
|
||||
&.visible {
|
||||
@@ -133,18 +109,6 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading shimmer animation
|
||||
@keyframes loading-shimmer {
|
||||
to {
|
||||
left: 100%;
|
||||
background-color: color-mix(in srgb, black 8%, var(--primary-color));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
Animated background styles
|
||||
*/
|
||||
.animated-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-bg);
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
/*
|
||||
Header component styles
|
||||
*/
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--tertiary-bg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
padding: 0 2rem;
|
||||
background-color: var(--secondary-bg);
|
||||
border-bottom: 1px solid var(--border-dark);
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
@@ -19,47 +18,19 @@
|
||||
.app-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.65rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.app-logo-icon {
|
||||
color: var(--primary-color);
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cream-color),
|
||||
var(--primary-color),
|
||||
var(--smoke-color)
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
@@ -75,30 +46,28 @@
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
left: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background-color: var(--border-dark);
|
||||
background-color: var(--tertiary-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
padding: 0.6rem 1rem 0.6rem 2.5rem;
|
||||
padding: 0.55rem 1rem 0.55rem 2.6rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
min-width: 200px;
|
||||
transition:
|
||||
border-color var(--duration-normal) var(--easing-ease-out),
|
||||
background-color var(--duration-normal) var(--easing-ease-out);
|
||||
min-width: 220px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--secondary-bg);
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(var(--primary-color), 0.3),
|
||||
inset 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
|
||||
& + .search-icon {
|
||||
color: var(--primary-color);
|
||||
@@ -106,6 +75,6 @@
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
@forward './background';
|
||||
@forward './header';
|
||||
@forward './loading_screen';
|
||||
@forward './sidebar';
|
||||
|
||||
@@ -29,20 +29,14 @@
|
||||
margin-bottom: 2rem;
|
||||
font-weight: var(--bold);
|
||||
color: var(--primary-color);
|
||||
text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4);
|
||||
}
|
||||
|
||||
.loading-animation {
|
||||
margin: 1rem 0;
|
||||
|
||||
// Spinner styles - thicker border
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 6px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
// Spinner styles - thicker ring
|
||||
.spinner-ring {
|
||||
@include spinner-size(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +62,7 @@
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.active {
|
||||
background-color: rgba(var(--primary-color-rgb), 0.1);
|
||||
background-color: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
|
||||
.status-step {
|
||||
color: var(--primary-color);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
/*
|
||||
Sidebar component styles
|
||||
*/
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background-color: var(--secondary-bg);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset -5px 0 15px rgba(0, 0, 0, 0.2);
|
||||
border-right: 1px solid var(--border-dark);
|
||||
padding: 1.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,13 +20,13 @@
|
||||
|
||||
.sidebar-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.9;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +36,13 @@
|
||||
|
||||
.sidebar-section-label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
color: var(--text-muted);
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-section .filter-list {
|
||||
@@ -56,34 +54,33 @@
|
||||
margin-bottom: 0;
|
||||
|
||||
li {
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.7rem 1rem;
|
||||
margin-bottom: 0.3rem;
|
||||
transition:
|
||||
background-color var(--duration-normal) var(--easing-ease-out),
|
||||
color var(--duration-normal) var(--easing-ease-out);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.65rem 1rem;
|
||||
margin-bottom: 0.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
background-color: var(--tertiary-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color),
|
||||
color-mix(in srgb, black 10%, var(--primary-color))
|
||||
);
|
||||
box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
|
||||
color: var(--elevated-bg);
|
||||
background-color: color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
color: var(--primary-color);
|
||||
|
||||
.filter-icon {
|
||||
color: var(--elevated-bg);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure icons are white when not active
|
||||
// Ensure icons follow the resting text color when not active
|
||||
&:not(.active) .filter-icon {
|
||||
color: var(--text-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,37 +95,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
margin-top: auto;
|
||||
|
||||
&.btn.btn-secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-soft);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
transition: transform 0.3s ease;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover .settings-icon {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App logo styles
|
||||
.app-logo {
|
||||
display: flex;
|
||||
@@ -139,6 +105,39 @@
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: var(--text-primary);
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned to the bottom of the sidebar via margin-top: auto - version/build
|
||||
// and a repo link, so this doesn't need its own page-section anymore
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 0.85rem 1rem 0.25rem;
|
||||
border-top: 1px solid var(--border-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-footer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-footer-version {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.sidebar-footer-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-normal) var(--easing-ease-out);
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,26 +197,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animations for toast
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@forward './shared';
|
||||
@forward './overview';
|
||||
@@ -0,0 +1,263 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
Overview page - a hero row for the two library totals, a Native/Proton
|
||||
composition bar for the Steam library, and secondary stats as compact
|
||||
chips (rather than one long row of identical boxes).
|
||||
*/
|
||||
|
||||
.overview-section-label {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
// Hero numbers - the two headline totals
|
||||
.stat-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.stat-hero-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.stat-hero-value {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-hero-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-hero-divider {
|
||||
width: 1px;
|
||||
height: 42px;
|
||||
background-color: var(--border-soft);
|
||||
}
|
||||
|
||||
// Native/Proton composition bar for the Steam library
|
||||
.library-composition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.composition-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
background-color: var(--tertiary-bg);
|
||||
}
|
||||
|
||||
.composition-segment {
|
||||
height: 100%;
|
||||
transition: width 0.4s ease;
|
||||
|
||||
&.native {
|
||||
background-color: var(--native);
|
||||
}
|
||||
|
||||
&.proton {
|
||||
background-color: var(--proton);
|
||||
}
|
||||
}
|
||||
|
||||
.composition-legend {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.native {
|
||||
background-color: var(--native);
|
||||
}
|
||||
|
||||
&.proton {
|
||||
background-color: var(--proton);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Secondary stats as compact pill chips
|
||||
.stat-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--elevated-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 99px;
|
||||
|
||||
.stat-chip-icon {
|
||||
color: var(--icon-secondary);
|
||||
}
|
||||
|
||||
.stat-chip-value {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-chip-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Two side-by-side cards (Recent Activity / System) filling out the rest
|
||||
// of the page stacks on narrow windows.
|
||||
.overview-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
|
||||
.page-section {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.activity-item-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--install {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
&--uninstall {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
&--update {
|
||||
background-color: var(--info);
|
||||
}
|
||||
}
|
||||
|
||||
.activity-item-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-item-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// System spec sheet - a colored accent bar per field instead of plain
|
||||
// label/value rows, with the value given real typographic weight
|
||||
.system-specs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.system-spec {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding-left: 0.85rem;
|
||||
border-left: 3px solid var(--border-soft);
|
||||
|
||||
&--os {
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
|
||||
&--cpu {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
&--gpu {
|
||||
border-left-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.system-spec-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.system-spec-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-spec-sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@use '../../themes/index' as *;
|
||||
@use '../../abstracts/index' as *;
|
||||
|
||||
/*
|
||||
Shared layout for top-level pages reached via the sidebar (Overview,
|
||||
Settings) - same container treatment as .game-list so switching between
|
||||
them feels consistent.
|
||||
*/
|
||||
.overview-page,
|
||||
.settings-page {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
@include custom-scrollbar;
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.3px;
|
||||
padding-bottom: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// A titled subsection within a page (distinct from dialogs' .settings-section,
|
||||
// which is sized for a modal rather than a full page). Rendered as a single
|
||||
// unified card, the heading, description, and body content all live inside
|
||||
// the same container instead of the body floating below on its own.
|
||||
.page-section {
|
||||
background-color: var(--elevated-bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
max-width: 640px;
|
||||
|
||||
h4 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.page-section-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// Danger Zone - a page-section with a subtle danger-tinted border instead
|
||||
// of the default neutral one, so it reads as "be careful here" without
|
||||
// resorting to a loud red banner
|
||||
.page-section.danger-zone {
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-soft));
|
||||
|
||||
h4 {
|
||||
color: var(--danger-light);
|
||||
}
|
||||
}
|
||||
|
||||
.danger-zone-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.danger-zone-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.danger-zone-row-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.danger-zone-row-description {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.app-info {
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.info-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--secondary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
/* Dialog components */
|
||||
@use 'components/dialogs/index' as *;
|
||||
|
||||
/* Page components (Overview, Settings) */
|
||||
@use 'components/pages/index' as *;
|
||||
|
||||
/* Notification components */
|
||||
@use 'components/notifications/index' as *;
|
||||
|
||||
@@ -28,6 +31,3 @@
|
||||
|
||||
/* Common components */
|
||||
@use 'components/common/index' as *;
|
||||
|
||||
/* Page-specific styles */
|
||||
//@use 'pages/home';
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
Home page specific styles
|
||||
*/
|
||||
|
||||
// Currently empty since most styles are component-based
|
||||
// Will be used for any specific home page layouts or adjustments
|
||||
|
||||
.home-page {
|
||||
// Page-specific styles can be added here
|
||||
}
|
||||
|
||||
// Page-specific media queries
|
||||
@include media-sm {
|
||||
// Small screen adjustments
|
||||
}
|
||||
|
||||
@include media-md {
|
||||
// Medium screen adjustments
|
||||
}
|
||||
|
||||
@include media-lg {
|
||||
// Large screen adjustments
|
||||
}
|
||||
|
||||
@include media-xl {
|
||||
// Extra large screen adjustments
|
||||
}
|
||||
@@ -7,4 +7,6 @@ export interface Config {
|
||||
show_disclaimer: boolean
|
||||
reporting_opted_in: boolean
|
||||
reporting_has_seen_prompt: boolean
|
||||
/** Extra Steam library folders to scan, beyond the auto-detected ones */
|
||||
custom_steam_paths: string[]
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* General-purpose utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats a timestamp in seconds to a human-readable string
|
||||
* @param seconds Number of seconds
|
||||
* @returns Formatted string (e.g., "5m 30s" or "30s")
|
||||
*/
|
||||
export function formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.round(seconds % 60)
|
||||
|
||||
if (remainingSeconds === 0) {
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
return `${minutes}m ${remainingSeconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a string if it exceeds the specified length
|
||||
* @param str String to truncate
|
||||
* @param maxLength Maximum length before truncation
|
||||
* @param suffix Suffix to append to truncated string (default: "...")
|
||||
* @returns Truncated string
|
||||
*/
|
||||
export function truncateString(str: string, maxLength: number, suffix: string = '...'): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str
|
||||
}
|
||||
|
||||
return str.substring(0, maxLength - suffix.length) + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounces a function to limit how often it's called
|
||||
* @param fn Function to debounce
|
||||
* @param delay Delay in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
|
||||
return function (...args: Parameters<T>) {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled function that only invokes the provided function at most once per specified interval
|
||||
* @param fn Function to throttle
|
||||
* @param limit Interval in milliseconds
|
||||
* @returns Throttled function
|
||||
*/
|
||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastCall = 0
|
||||
|
||||
return function (...args: Parameters<T>) {
|
||||
const now = Date.now()
|
||||
|
||||
if (now - lastCall < limit) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCall = now
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||