Compare commits
61 Commits
v1.3.3
...
220763b389
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
220763b389 | ||
|
|
3d894266a7 | ||
|
|
33492a6a55 | ||
|
|
92f4d82e6c | ||
|
|
5896733fd4 | ||
|
|
1d72f24afa | ||
|
|
aea8a84335 | ||
|
|
a476819312 | ||
|
|
1bb62877a3 | ||
|
|
f8ea256637 | ||
|
|
0480d523e3 | ||
|
|
1571e9d87d | ||
|
|
f949ecf2f3 | ||
|
|
ecee6529ff | ||
|
|
d9819ef115 | ||
|
|
ff53cc7a46 | ||
|
|
1a1c7dfb3d | ||
|
|
769213288e | ||
|
|
85d670931a | ||
|
|
487e974274 | ||
|
|
1b8fdadbf2 | ||
|
|
ecd7b4dceb | ||
|
|
640eb9a0d5 | ||
|
|
b42086ca27 | ||
|
|
b9beb0d704 | ||
|
|
09e7bcac6f | ||
|
|
b7f219a25f | ||
|
|
2b205d8376 | ||
|
|
4cf1e2caf4 | ||
|
|
0ee10d07fc | ||
|
|
365063d30d | ||
|
|
61ad3f1d54 | ||
|
|
d3a91f5722 | ||
|
|
9ba307f9f8 | ||
|
|
1123012737 | ||
|
|
7a07399946 | ||
|
|
40b9ec9b01 | ||
|
|
05e4275962 | ||
|
|
03cae08df1 | ||
|
|
6b16ec6168 | ||
|
|
a786530572 | ||
|
|
ef7dfdd6c5 | ||
|
|
5998e77272 | ||
|
|
fab29f5778 | ||
|
|
bec190691b | ||
|
|
58217d61d1 | ||
|
|
0f4db7bbb7 | ||
|
|
22c8f41f93 | ||
|
|
5ff51d1174 | ||
|
|
169b7d5edd | ||
|
|
41da6731a7 | ||
|
|
5f8f389687 | ||
|
|
1d8422dc65 | ||
|
|
677e3ef12d | ||
|
|
33266f3781 | ||
|
|
9703f21209 | ||
|
|
3459158d3f | ||
|
|
418b470d4a | ||
|
|
fd606cbc2e | ||
|
|
5845cf9bd8 | ||
|
|
6294b99a14 |
21
.github/workflows/build.yml
vendored
@@ -142,3 +142,24 @@ jobs:
|
|||||||
includeUpdaterJson: true
|
includeUpdaterJson: true
|
||||||
tauriScript: 'npm run tauri'
|
tauriScript: 'npm run tauri'
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
name: Publish release
|
||||||
|
needs: [create-release, build-tauri]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Publish GitHub release (unset draft)
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const release_id = Number("${{ needs.create-release.outputs.release_id }}");
|
||||||
|
|
||||||
|
await github.rest.repos.updateRelease({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
release_id,
|
||||||
|
draft: false
|
||||||
|
});
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -12,9 +12,7 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
docs
|
docs
|
||||||
*.local
|
*.local
|
||||||
*.lock
|
|
||||||
.env
|
.env
|
||||||
CHANGELOG.md
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
50
CHANGELOG.md
@@ -1,3 +1,53 @@
|
|||||||
|
## [1.5.0] - 28-03-2026
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Anonymous reporting system. Vote on whether CreamLinux or SmokeAPI works for a game
|
||||||
|
- Opt-in dialog on first launch explaining what is collected and why
|
||||||
|
- Rating button on game cards (only visible when opted in and an unlocker is installed)
|
||||||
|
- Community vote display in the unlocker selection dialog and before installing SmokeAPI on Proton games
|
||||||
|
- Votes track per-unlocker so CreamLinux and SmokeAPI ratings are independent
|
||||||
|
- Previously submitted votes are stored locally so already-cast buttons are disabled on re-open
|
||||||
|
- Config now automatically migrates missing fields on update without overwriting existing values
|
||||||
|
- API source available at https://github.com/Novattz/Lactose/
|
||||||
|
|
||||||
|
## [1.4.2] - 13-03-2026
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added a dialog so users can manually add DLC's incase they are missing from the steam api
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed an issue where if the libsteam_api.so file is nested too deeply in a game causing the app to not find it.
|
||||||
|
|
||||||
|
## [1.4.1] - 18-01-2026
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Dramatically reduced the time that bitness detection takes to detect game bitness
|
||||||
|
|
||||||
|
## [1.4.0] - 17-01-2026
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Unlocker selection dialog for native games, allowing users to choose between CreamLinux and SmokeAPI
|
||||||
|
- Game bitness detection
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Cache now validates if expected files are missing.
|
||||||
|
|
||||||
|
## [1.3.5] - 09-01-2026
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned conflict detection dialog to show all conflicts at once
|
||||||
|
- Integrated Steam launch option reminder directly into the conflict dialog
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improved UX by allowing users to resolve conflicts in any order or defer to later
|
||||||
|
|
||||||
|
## [1.3.4] - 03-01-2026
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Disclaimer dialog explaining that CreamLinux Installer manages DLC IDs, not actual DLC files
|
||||||
|
- User config stored in `~/.config/creamlinux/config.json`
|
||||||
|
- **"Don't show again" option**: Users can permanently dismiss the disclaimer via checkbox
|
||||||
|
|
||||||
## [1.3.3] - 26-12-2025
|
## [1.3.3] - 26-12-2025
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Tickbase
|
Copyright (c) 2026 Tickbase
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
72
README.md
@@ -1,10 +1,10 @@
|
|||||||
# CreamLinux
|
# CreamLinux
|
||||||
|
|
||||||
CreamLinux is a GUI application for Linux that simplifies the management of DLC in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
|
CreamLinux is a GUI application for Linux that simplifies the management of DLC IDs in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
|
||||||
|
|
||||||
## Watch the demo here:
|
## Watch the demo here:
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=ZunhZnKFLlg)
|
[](https://www.youtube.com/watch?v=neUDotrqnDM)
|
||||||
|
|
||||||
## Beta Status
|
## Beta Status
|
||||||
|
|
||||||
@@ -46,6 +46,70 @@ While the core functionality is working, please be aware that this is an early r
|
|||||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.AppImage
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
You can fetch this repository in your configuration using `pkgs.fetchFromGithub`:
|
||||||
|
```nix
|
||||||
|
let
|
||||||
|
creamlinux = pkgs.callPackage (pkgs.fetchFromGitHub {
|
||||||
|
owner = "Novattz";
|
||||||
|
repo = "creamlinux-installer";
|
||||||
|
rev = "main";
|
||||||
|
hash = ""; # You can use nix-prefetch-url to determine which value to put here, or paste the value returned by the error your rebuild will output
|
||||||
|
}) {};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
environment.systemPackages = [ creamlinux ];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
or, using `builtins.fetchTarball`:
|
||||||
|
```nix
|
||||||
|
let
|
||||||
|
creamlinux-src = builtins.fetchTarball {
|
||||||
|
url = "https://github.com/Novattz/creamlinux-installer/archive/main.tar.gz";
|
||||||
|
sha256 = ""; # See above
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
environment.systemPackages = [
|
||||||
|
(pkgs.callPackage creamlinux-src {})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
alternatively and if you want to pin the package version, using [npins](https://github.com/andir/npins):
|
||||||
|
```bash
|
||||||
|
npins add github Novattz creamlinux-installer --branch main
|
||||||
|
```
|
||||||
|
```nix
|
||||||
|
let
|
||||||
|
sources = import ./npins;
|
||||||
|
creamlinux = pkgs.callPackage sources.creamlinux-installer {};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
environment.systemPackages = [ creamlinux ];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Those are the recommended methods to add creamlinux-installer to your environment. However, you could also add it as an input of your flake, like so:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
creamlinux-installer = {
|
||||||
|
type = "github";
|
||||||
|
owner = "Novattz";
|
||||||
|
repo = "creamlinux-installer";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Then, in your configuration:
|
||||||
|
```nix
|
||||||
|
environment.systemPackages = [
|
||||||
|
(pkgs.callPackage inputs.creamlinux-installer {})
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
@@ -61,7 +125,7 @@ While the core functionality is working, please be aware that this is an early r
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Novattz/creamlinux-installer.git
|
git clone https://github.com/Novattz/creamlinux-installer.git
|
||||||
cd creamlinux
|
cd creamlinux-installer
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
@@ -124,7 +188,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) f
|
|||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support
|
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native support
|
||||||
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
|
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
|
||||||
- [Tauri](https://tauri.app/) - Framework for building the desktop application
|
- [Tauri](https://tauri.app/) - Framework for building the desktop application
|
||||||
- [React](https://reactjs.org/) - UI library
|
- [React](https://reactjs.org/) - UI library
|
||||||
|
|||||||
57
default.nix
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{pkgs ? import <nixpkgs> {}}: let
|
||||||
|
cargoRoot = "src-tauri";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
patchSassEmbedded = pkgs.writeShellScriptBin "patch-sass-embedded" ''
|
||||||
|
NIX_LD="${pkgs.lib.fileContents "${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;
|
||||||
|
}
|
||||||
4683
package-lock.json
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "creamlinux",
|
"name": "creamlinux",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.3",
|
"version": "1.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Tickbase",
|
"author": "Tickbase",
|
||||||
"repository": "https://github.com/Novattz/creamlinux-installer",
|
"repository": "https://github.com/Novattz/creamlinux-installer",
|
||||||
|
|||||||
6607
src-tauri/Cargo.lock
generated
Normal file
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "creamlinux-installer"
|
name = "creamlinux-installer"
|
||||||
version = "1.3.3"
|
version = "1.5.0"
|
||||||
description = "DLC Manager for Steam games on Linux"
|
description = "DLC Manager for Steam games on Linux"
|
||||||
authors = ["tickbase"]
|
authors = ["tickbase"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -30,11 +30,13 @@ tauri-plugin-shell = "2.0.0-rc"
|
|||||||
tauri-plugin-dialog = "2.0.0-rc"
|
tauri-plugin-dialog = "2.0.0-rc"
|
||||||
tauri-plugin-fs = "2.0.0-rc"
|
tauri-plugin-fs = "2.0.0-rc"
|
||||||
num_cpus = "1.16.0"
|
num_cpus = "1.16.0"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2.2.1"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
rand = "0.9.2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2.7.1"
|
||||||
|
|||||||
99
src-tauri/src/cache/mod.rs
vendored
@@ -2,9 +2,10 @@ mod storage;
|
|||||||
mod version;
|
mod version;
|
||||||
|
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
get_creamlinux_version_dir, get_smokeapi_version_dir, is_cache_initialized,
|
get_creamlinux_version_dir, get_smokeapi_version_dir,
|
||||||
list_creamlinux_files, list_smokeapi_dlls, read_versions, update_creamlinux_version,
|
list_creamlinux_files, list_smokeapi_files, read_versions,
|
||||||
update_smokeapi_version,
|
update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache,
|
||||||
|
validate_creamlinux_cache, get_cache_dir,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use version::{
|
pub use version::{
|
||||||
@@ -22,39 +23,87 @@ use std::collections::HashMap;
|
|||||||
pub async fn initialize_cache() -> Result<(), String> {
|
pub async fn initialize_cache() -> Result<(), String> {
|
||||||
info!("Initializing cache...");
|
info!("Initializing cache...");
|
||||||
|
|
||||||
// Check if cache is already initialized
|
let versions = read_versions()?;
|
||||||
if is_cache_initialized()? {
|
let mut needs_smokeapi = false;
|
||||||
info!("Cache already initialized");
|
let mut needs_creamlinux = false;
|
||||||
return Ok(());
|
|
||||||
|
// Check if SmokeAPI is properly cached
|
||||||
|
if versions.smokeapi.latest.is_empty() {
|
||||||
|
info!("No SmokeAPI version in manifest");
|
||||||
|
needs_smokeapi = true
|
||||||
|
} else {
|
||||||
|
// Validate that all files exist
|
||||||
|
match validate_smokeapi_cache(&versions.smokeapi.latest) {
|
||||||
|
Ok(true) => {
|
||||||
|
info!("SmokeAPI cache validated successfully");
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
info!("SmokeAPI cache incomplete, re-downloading");
|
||||||
|
needs_smokeapi = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to validate SmokeAPI cache: {}, re-downloading", e);
|
||||||
|
needs_smokeapi = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Cache not initialized, downloading unlockers...");
|
// Check if CreamLinux is properly cached
|
||||||
|
if versions.creamlinux.latest.is_empty() {
|
||||||
|
info!("No CreamLinux version in manifest");
|
||||||
|
needs_creamlinux = true;
|
||||||
|
} else {
|
||||||
|
match validate_creamlinux_cache(&versions.creamlinux.latest) {
|
||||||
|
Ok(true) => {
|
||||||
|
info!("CreamLinux cache validated successfully");
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
info!("CreamLinux cache incomplete, re-downloading");
|
||||||
|
needs_creamlinux = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to validate CreamLinux cache: {}, re-downloading", e);
|
||||||
|
needs_creamlinux = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Download SmokeAPI
|
// Download SmokeAPI
|
||||||
match SmokeAPI::download_to_cache().await {
|
if needs_smokeapi {
|
||||||
Ok(version) => {
|
info!("Downloading SmokeAPI...");
|
||||||
info!("Downloaded SmokeAPI version: {}", version);
|
match SmokeAPI::download_to_cache().await {
|
||||||
update_smokeapi_version(&version)?;
|
Ok(version) => {
|
||||||
}
|
info!("Downloaded SmokeAPI version: {}", version);
|
||||||
Err(e) => {
|
update_smokeapi_version(&version)?;
|
||||||
error!("Failed to download SmokeAPI: {}", e);
|
}
|
||||||
return Err(format!("Failed to download SmokeAPI: {}", e));
|
Err(e) => {
|
||||||
|
error!("Failed to download SmokeAPI: {}", e);
|
||||||
|
return Err(format!("Failed to download SmokeAPI: {}", e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download CreamLinux
|
// Download CreamLinux
|
||||||
match CreamLinux::download_to_cache().await {
|
if needs_creamlinux {
|
||||||
Ok(version) => {
|
info!("Downloading CreamLinux...");
|
||||||
info!("Downloaded CreamLinux version: {}", version);
|
match CreamLinux::download_to_cache().await {
|
||||||
update_creamlinux_version(&version)?;
|
Ok(version) => {
|
||||||
}
|
info!("Downloaded CreamLinux version: {}", version);
|
||||||
Err(e) => {
|
update_creamlinux_version(&version)?;
|
||||||
error!("Failed to download CreamLinux: {}", e);
|
}
|
||||||
return Err(format!("Failed to download CreamLinux: {}", e));
|
Err(e) => {
|
||||||
|
error!("Failed to download CreamLinux: {}", e);
|
||||||
|
return Err(format!("Failed to download CreamLinux: {}", e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Cache initialization complete");
|
if !needs_smokeapi && !needs_creamlinux {
|
||||||
|
info!("Cache already initialized and validated");
|
||||||
|
} else {
|
||||||
|
info!("Cache initialization complete");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
src-tauri/src/cache/storage.rs
vendored
@@ -204,12 +204,6 @@ pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the cache is initialized (has both unlockers cached)
|
|
||||||
pub fn is_cache_initialized() -> Result<bool, String> {
|
|
||||||
let versions = read_versions()?;
|
|
||||||
Ok(!versions.smokeapi.latest.is_empty() && !versions.creamlinux.latest.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the SmokeAPI DLL path for the latest cached version
|
// Get the SmokeAPI DLL path for the latest cached version
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn get_smokeapi_dll_path() -> Result<PathBuf, String> {
|
pub fn get_smokeapi_dll_path() -> Result<PathBuf, String> {
|
||||||
@@ -233,8 +227,8 @@ pub fn get_creamlinux_files_dir() -> Result<PathBuf, String> {
|
|||||||
get_creamlinux_version_dir(&versions.creamlinux.latest)
|
get_creamlinux_version_dir(&versions.creamlinux.latest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all SmokeAPI DLL files in the cached version directory
|
/// List all SmokeAPI files in the cached version directory
|
||||||
pub fn list_smokeapi_dlls() -> Result<Vec<PathBuf>, String> {
|
pub fn list_smokeapi_files() -> Result<Vec<PathBuf>, String> {
|
||||||
let versions = read_versions()?;
|
let versions = read_versions()?;
|
||||||
if versions.smokeapi.latest.is_empty() {
|
if versions.smokeapi.latest.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -249,17 +243,20 @@ pub fn list_smokeapi_dlls() -> Result<Vec<PathBuf>, String> {
|
|||||||
let entries = fs::read_dir(&version_dir)
|
let entries = fs::read_dir(&version_dir)
|
||||||
.map_err(|e| format!("Failed to read SmokeAPI directory: {}", e))?;
|
.map_err(|e| format!("Failed to read SmokeAPI directory: {}", e))?;
|
||||||
|
|
||||||
let mut dlls = Vec::new();
|
let mut files = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
if let Ok(entry) = entry {
|
if let Ok(entry) = entry {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().and_then(|s| s.to_str()) == Some("dll") {
|
// Get both .dll and .so files
|
||||||
dlls.push(path);
|
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
|
||||||
|
if ext == "dll" || ext == "so" {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(dlls)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all CreamLinux files in the cached version directory
|
// List all CreamLinux files in the cached version directory
|
||||||
@@ -289,4 +286,70 @@ pub fn list_creamlinux_files() -> Result<Vec<PathBuf>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(files)
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that all required files exist for SmokeAPI
|
||||||
|
pub fn validate_smokeapi_cache(version: &str) -> Result<bool, String> {
|
||||||
|
let version_dir = get_smokeapi_version_dir(version)?;
|
||||||
|
|
||||||
|
if !version_dir.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required files for SmokeAPI
|
||||||
|
let required_files = vec![
|
||||||
|
"smoke_api32.dll",
|
||||||
|
"smoke_api64.dll",
|
||||||
|
"libsmoke_api32.so",
|
||||||
|
"libsmoke_api64.so",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut missing_files = Vec::new();
|
||||||
|
|
||||||
|
for file in &required_files {
|
||||||
|
let file_path = version_dir.join(file);
|
||||||
|
if !file_path.exists() {
|
||||||
|
missing_files.push(file.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !missing_files.is_empty() {
|
||||||
|
info!("Missing required files in cache: {:?}", missing_files);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that all required files exist for CreamLinux
|
||||||
|
pub fn validate_creamlinux_cache(version: &str) -> Result<bool, String> {
|
||||||
|
let version_dir = get_creamlinux_version_dir(version)?;
|
||||||
|
|
||||||
|
if !version_dir.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required files for CreamLinux
|
||||||
|
let required_files = vec![
|
||||||
|
"cream.sh",
|
||||||
|
"cream_api.ini",
|
||||||
|
"lib32Creamlinux.so",
|
||||||
|
"lib64Creamlinux.so",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut missing_files = Vec::new();
|
||||||
|
|
||||||
|
for file in &required_files {
|
||||||
|
let file_path = version_dir.join(file);
|
||||||
|
if !file_path.exists() {
|
||||||
|
missing_files.push(file.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !missing_files.is_empty() {
|
||||||
|
info!("Missing required files in cache: {:?}", missing_files);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
}
|
}
|
||||||
162
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use log::info;
|
||||||
|
|
||||||
|
// User configuration structure
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
// Whether to show the disclaimer on startup
|
||||||
|
pub show_disclaimer: bool,
|
||||||
|
// Reporting / compatibility voting
|
||||||
|
pub reporting_opted_in: bool,
|
||||||
|
pub reporting_has_seen_prompt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
show_disclaimer: true,
|
||||||
|
reporting_opted_in: false,
|
||||||
|
reporting_has_seen_prompt: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the config directory path (~/.config/creamlinux)
|
||||||
|
fn get_config_dir() -> Result<PathBuf, String> {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.map_err(|_| "Failed to get HOME directory".to_string())?;
|
||||||
|
|
||||||
|
let config_dir = PathBuf::from(home).join(".config").join("creamlinux");
|
||||||
|
Ok(config_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the config file path
|
||||||
|
fn get_config_path() -> Result<PathBuf, String> {
|
||||||
|
let config_dir = get_config_dir()?;
|
||||||
|
Ok(config_dir.join("config.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the config directory exists
|
||||||
|
fn ensure_config_dir() -> Result<(), String> {
|
||||||
|
let config_dir = get_config_dir()?;
|
||||||
|
|
||||||
|
if !config_dir.exists() {
|
||||||
|
fs::create_dir_all(&config_dir)
|
||||||
|
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||||
|
info!("Created config directory at {:?}", config_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration from disk
|
||||||
|
pub fn load_config() -> Result<Config, String> {
|
||||||
|
ensure_config_dir()?;
|
||||||
|
|
||||||
|
let config_path = get_config_path()?;
|
||||||
|
|
||||||
|
// If config file doesn't exist, create default config
|
||||||
|
if !config_path.exists() {
|
||||||
|
let default_config = Config::default();
|
||||||
|
save_config(&default_config)?;
|
||||||
|
info!("Created default config file at {:?}", config_path);
|
||||||
|
return Ok(default_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse config file
|
||||||
|
let config_str = fs::read_to_string(&config_path)
|
||||||
|
.map_err(|e| format!("Failed to read config file: {}", e))?;
|
||||||
|
|
||||||
|
let mut on_disk: serde_json::Value = serde_json::from_str(&config_str)
|
||||||
|
.map_err(|e| format!("Failed to parse config file: {}", e))?;
|
||||||
|
|
||||||
|
// Serialize the defaults into a Value so we can iterate their keys
|
||||||
|
let defaults = serde_json::to_value(Config::default())
|
||||||
|
.map_err(|e| format!("Failed to serialize default config: {}", e))?;
|
||||||
|
|
||||||
|
// For every key that exists in the current Config but is absent from the
|
||||||
|
// on-disk JSON, inject the default value. Keys that are already present
|
||||||
|
// are left completely untouched.
|
||||||
|
let mut migrated = false;
|
||||||
|
if let Some(default_obj) = defaults.as_object() {
|
||||||
|
let missing: Vec<(String, serde_json::Value)> = default_obj
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| {
|
||||||
|
on_disk
|
||||||
|
.as_object()
|
||||||
|
.map_or(false, |d| !d.contains_key(*key))
|
||||||
|
})
|
||||||
|
.map(|(key, val)| (key.clone(), val.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(disk_obj) = on_disk.as_object_mut() {
|
||||||
|
for (key, value) in missing {
|
||||||
|
info!("Config migration: adding missing field '{}' with default value", key);
|
||||||
|
disk_obj.insert(key, value);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize the (possiblyh augmented) value into Config
|
||||||
|
let config: Config = serde_json::from_value(on_disk)
|
||||||
|
.map_err(|e| format!("Failed to deserialize config: {}", e))?;
|
||||||
|
|
||||||
|
// Persist the migrated file so the next launch doesn't need to do this again
|
||||||
|
if migrated {
|
||||||
|
save_config(&config)?;
|
||||||
|
info!("Config migrated - new fields written to disk");
|
||||||
|
} else {
|
||||||
|
info!("Loaded config from {:?}", config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save configuration to disk
|
||||||
|
pub fn save_config(config: &Config) -> Result<(), String> {
|
||||||
|
ensure_config_dir()?;
|
||||||
|
|
||||||
|
let config_path = get_config_path()?;
|
||||||
|
|
||||||
|
let config_str = serde_json::to_string_pretty(config)
|
||||||
|
.map_err(|e| format!("Failed to serialize config: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(&config_path, config_str)
|
||||||
|
.map_err(|e| format!("Failed to write config file: {}", e))?;
|
||||||
|
|
||||||
|
info!("Saved config to {:?}", config_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a specific config value
|
||||||
|
pub fn update_config<F>(updater: F) -> Result<Config, String>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Config),
|
||||||
|
{
|
||||||
|
let mut config = load_config()?;
|
||||||
|
updater(&mut config);
|
||||||
|
save_config(&config)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert!(config.show_disclaimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization() {
|
||||||
|
let config = Config::default();
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let parsed: Config = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(config.show_disclaimer, parsed.show_disclaimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,8 +241,26 @@ async fn uninstall_creamlinux(game: Game, app_handle: AppHandle) -> Result<(), S
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install SmokeAPI to a game
|
|
||||||
async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
// Check if native or proton and route accordingly
|
||||||
|
if game.native {
|
||||||
|
install_smokeapi_native(game, app_handle).await
|
||||||
|
} else {
|
||||||
|
install_smokeapi_proton(game, app_handle).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
// Check if native or proton and route accordingly
|
||||||
|
if game.native {
|
||||||
|
uninstall_smokeapi_native(game, app_handle).await
|
||||||
|
} else {
|
||||||
|
uninstall_smokeapi_proton(game, app_handle).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install SmokeAPI to a proton game
|
||||||
|
async fn install_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||||
if game.native {
|
if game.native {
|
||||||
return Err("SmokeAPI can only be installed on Proton/Windows games".to_string());
|
return Err("SmokeAPI can only be installed on Proton/Windows games".to_string());
|
||||||
}
|
}
|
||||||
@@ -286,8 +304,8 @@ async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall SmokeAPI from a game
|
// Uninstall SmokeAPI from a proton game
|
||||||
async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
async fn uninstall_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||||
if game.native {
|
if game.native {
|
||||||
return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string());
|
return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string());
|
||||||
}
|
}
|
||||||
@@ -329,6 +347,99 @@ async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), Str
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Install SmokeAPI to a native Linux game
|
||||||
|
async fn install_smokeapi_native(
|
||||||
|
game: Game,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
|
||||||
|
info!("Installing SmokeAPI (native) for game: {}", game.title);
|
||||||
|
let game_title = game.title.clone();
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!("Installing SmokeAPI for {}", game_title),
|
||||||
|
"Detecting game architecture...",
|
||||||
|
20.0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!("Installing SmokeAPI for {}", game_title),
|
||||||
|
"Installing from cache...",
|
||||||
|
50.0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Install SmokeAPI for native Linux (empty string for api_files_str)
|
||||||
|
SmokeAPI::install_to_game(&game.path, "")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to install SmokeAPI: {}", e))?;
|
||||||
|
|
||||||
|
// Update version manifest
|
||||||
|
let cached_versions = crate::cache::read_versions()?;
|
||||||
|
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest)?;
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!("Installation Completed: {}", game_title),
|
||||||
|
"SmokeAPI has been installed successfully!",
|
||||||
|
100.0,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("SmokeAPI (native) installation completed for: {}", game_title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall SmokeAPI from a native Linux game
|
||||||
|
async fn uninstall_smokeapi_native(game: Game, app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
if !game.native {
|
||||||
|
return Err("This function is only for native Linux games".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let game_title = game.title.clone();
|
||||||
|
info!("Uninstalling SmokeAPI (native) from game: {}", game_title);
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!("Uninstalling SmokeAPI from {}", game_title),
|
||||||
|
"Removing SmokeAPI files...",
|
||||||
|
50.0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Uninstall SmokeAPI (empty string for api_files_str)
|
||||||
|
SmokeAPI::uninstall_from_game(&game.path, "")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to uninstall SmokeAPI: {}", e))?;
|
||||||
|
|
||||||
|
// Remove version from manifest
|
||||||
|
remove_smokeapi_version(&game.path)?;
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&format!("Uninstallation Completed: {}", game_title),
|
||||||
|
"SmokeAPI has been removed successfully!",
|
||||||
|
100.0,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("SmokeAPI (native) uninstallation completed for: {}", game_title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch DLC details from Steam API (simple version without progress)
|
// Fetch DLC details from Steam API (simple version without progress)
|
||||||
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
|
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
)]
|
)]
|
||||||
|
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod reporting;
|
||||||
|
mod utils;
|
||||||
mod dlc_manager;
|
mod dlc_manager;
|
||||||
mod installer;
|
mod installer;
|
||||||
mod searcher;
|
mod searcher;
|
||||||
mod unlockers;
|
mod unlockers;
|
||||||
mod smokeapi_config;
|
mod smokeapi_config;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
||||||
use dlc_manager::DlcInfoWithState;
|
use dlc_manager::DlcInfoWithState;
|
||||||
use installer::{Game, InstallerAction, InstallerType};
|
use installer::{Game, InstallerAction, InstallerType};
|
||||||
@@ -46,6 +50,19 @@ pub struct AppState {
|
|||||||
fetch_cancellation: Arc<AtomicBool>,
|
fetch_cancellation: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the current configuration
|
||||||
|
#[tauri::command]
|
||||||
|
fn load_config() -> Result<Config, String> {
|
||||||
|
config::load_config()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_config(config_data: Config) -> Result<Config, String> {
|
||||||
|
config::save_config(&config_data)?;
|
||||||
|
Ok(config_data)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
|
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
|
||||||
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
|
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
|
||||||
@@ -597,6 +614,81 @@ async fn resolve_platform_conflict(
|
|||||||
Ok(updated_game)
|
Ok(updated_game)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_reporting_opt_in(opted_in: bool) -> Result<(), String> {
|
||||||
|
config::update_config(|cfg| {
|
||||||
|
cfg.reporting_opted_in = opted_in;
|
||||||
|
cfg.reporting_has_seen_prompt = true;
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if opted_in {
|
||||||
|
// Ensure a salt exists so future hashes work immediately
|
||||||
|
reporting::delete_salt().ok(); // clear any stale one first
|
||||||
|
// re-create via generate_user_hash is fine; salt is lazy-created there
|
||||||
|
} else {
|
||||||
|
reporting::delete_salt()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Reporting opt-in set to: {}", opted_in);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn submit_report(
|
||||||
|
game_id: String,
|
||||||
|
unlocker: String,
|
||||||
|
worked: bool,
|
||||||
|
steam_path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let user_hash = reporting::generate_user_hash(&steam_path)?;
|
||||||
|
|
||||||
|
reporting::post_report(reporting::ReportPayload {
|
||||||
|
user_hash,
|
||||||
|
game_id: game_id.clone(),
|
||||||
|
unlocker: unlocker.clone(),
|
||||||
|
worked,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Always save locally so the UI can reflect the vote immediately,
|
||||||
|
// regardless of opt-in status (the local file is only used client-side).
|
||||||
|
reporting::save_local_report(reporting::LocalReport {
|
||||||
|
game_id,
|
||||||
|
unlocker,
|
||||||
|
worked,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_local_reports() -> Vec<reporting::LocalReport> {
|
||||||
|
reporting::load_local_reports()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_game_votes(game_id: String) -> Result<Vec<reporting::VoteResult>, String> {
|
||||||
|
let url = format!("https://api.shibe.fun/v1/votes/{}", game_id);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch votes: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
// Non-critical - return empty rather than surfacing an error to the UI
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.json::<Vec<reporting::VoteResult>>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse votes: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use log4rs::append::file::FileAppender;
|
use log4rs::append::file::FileAppender;
|
||||||
@@ -658,6 +750,12 @@ fn main() {
|
|||||||
write_smokeapi_config,
|
write_smokeapi_config,
|
||||||
delete_smokeapi_config,
|
delete_smokeapi_config,
|
||||||
resolve_platform_conflict,
|
resolve_platform_conflict,
|
||||||
|
load_config,
|
||||||
|
update_config,
|
||||||
|
set_reporting_opt_in,
|
||||||
|
submit_report,
|
||||||
|
get_local_reports,
|
||||||
|
get_game_votes,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
info!("Tauri application setup");
|
info!("Tauri application setup");
|
||||||
|
|||||||
177
src-tauri/src/reporting.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
use crate::cache::get_cache_dir;
|
||||||
|
use crate::config;
|
||||||
|
use log::{info, warn};
|
||||||
|
use rand::distr::Alphanumeric;
|
||||||
|
use rand::Rng;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const API_BASE: &str = "https://api.shibe.fun/v1";
|
||||||
|
const SALT_LENGTH: usize = 32;
|
||||||
|
|
||||||
|
// Report payload
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
pub struct ReportPayload {
|
||||||
|
pub user_hash: String,
|
||||||
|
pub game_id: String,
|
||||||
|
/// "creamlinux" | "smokeapi"
|
||||||
|
pub unlocker: String,
|
||||||
|
/// true = worked, false = didn't work
|
||||||
|
pub worked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors the JSON returned by GET /v1/votes/:game_id
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct VoteResult {
|
||||||
|
pub unlocker: String,
|
||||||
|
pub success: u32,
|
||||||
|
pub fail: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local report record
|
||||||
|
|
||||||
|
/// One entry in the local reports.json cache.
|
||||||
|
/// Tracks what the user has already voted so we can disable buttons in the UI.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct LocalReport {
|
||||||
|
pub game_id: String,
|
||||||
|
pub unlocker: String, // "creamlinux" | "smokeapi"
|
||||||
|
pub worked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// reports.json helpers
|
||||||
|
|
||||||
|
fn reports_cache_path() -> Result<std::path::PathBuf, String> {
|
||||||
|
Ok(get_cache_dir()?.join("reports.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all locally recorded votes.
|
||||||
|
pub fn load_local_reports() -> Vec<LocalReport> {
|
||||||
|
match reports_cache_path() {
|
||||||
|
Ok(path) if path.exists() => {
|
||||||
|
fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a new vote to reports.json (or overwrite an existing one for the same
|
||||||
|
/// game_id + unlocker combo).
|
||||||
|
pub fn save_local_report(report: LocalReport) -> Result<(), String> {
|
||||||
|
let path = reports_cache_path()?;
|
||||||
|
let mut reports = load_local_reports();
|
||||||
|
|
||||||
|
// Upsert: replace existing entry for the same game + unlocker, otherwise push
|
||||||
|
let pos = reports
|
||||||
|
.iter()
|
||||||
|
.position(|r| r.game_id == report.game_id && r.unlocker == report.unlocker);
|
||||||
|
|
||||||
|
match pos {
|
||||||
|
Some(i) => reports[i] = report,
|
||||||
|
None => reports.push(report),
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&reports)
|
||||||
|
.map_err(|e| format!("Failed to serialize reports cache: {}", e))?;
|
||||||
|
fs::write(&path, json)
|
||||||
|
.map_err(|e| format!("Failed to write reports cache: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salt management
|
||||||
|
|
||||||
|
fn get_or_create_salt() -> Result<String, String> {
|
||||||
|
let salt_path = get_cache_dir()?.join("salt");
|
||||||
|
|
||||||
|
if salt_path.exists() {
|
||||||
|
let salt = fs::read_to_string(&salt_path)
|
||||||
|
.map_err(|e| format!("Failed to read salt file: {}", e))?;
|
||||||
|
let salt = salt.trim().to_string();
|
||||||
|
|
||||||
|
if salt.len() == SALT_LENGTH {
|
||||||
|
return Ok(salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!("Salt file has invalid data, regenerating...");
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt: String = rand::rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(SALT_LENGTH)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
fs::write(&salt_path, &salt)
|
||||||
|
.map_err(|e| format!("Failed to write salt file: {}", e))?;
|
||||||
|
|
||||||
|
info!("Generated new reporting salt");
|
||||||
|
Ok(salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_salt() -> Result<(), String> {
|
||||||
|
let salt_path = get_cache_dir()?.join("salt");
|
||||||
|
|
||||||
|
if salt_path.exists() {
|
||||||
|
fs::remove_file(&salt_path)
|
||||||
|
.map_err(|e| format!("Failed to delete salt: {}", e))?;
|
||||||
|
info!("Deleted reporting salt (user opted out)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash generation
|
||||||
|
|
||||||
|
pub fn generate_user_hash(steam_path: &str) -> Result<String, String> {
|
||||||
|
let machine_id = fs::read_to_string("/etc/machine-id")
|
||||||
|
.map_err(|e| format!("Failed to read machine-id: {}", e))?;
|
||||||
|
let machine_id = machine_id.trim();
|
||||||
|
|
||||||
|
let salt = get_or_create_salt()?;
|
||||||
|
let combined = format!("{}{}{}", machine_id, steam_path, salt);
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(combined.as_bytes());
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
|
||||||
|
pub async fn post_report(payload: ReportPayload) -> Result<(), String> {
|
||||||
|
let cfg = config::load_config()?;
|
||||||
|
|
||||||
|
if !cfg.reporting_opted_in {
|
||||||
|
info!("Reporting disabled - skipping report for game {}", payload.game_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let url = format!("{}/report", API_BASE);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Submitting report: game={}, unlocker={}, worked={}",
|
||||||
|
payload.game_id, payload.unlocker, payload.worked
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&payload)
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to send report: {}", e))?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("Report submitted successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Report submission failed: HTTP {}", response.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,18 +256,40 @@ fn check_creamlinux_installed(game_path: &Path) -> bool {
|
|||||||
|
|
||||||
// Check if a game has SmokeAPI installed
|
// Check if a game has SmokeAPI installed
|
||||||
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
|
||||||
// First check the provided api_files for backup files
|
// For Proton games: check for backup DLL files
|
||||||
for api_file in api_files {
|
if !api_files.is_empty() {
|
||||||
let api_path = game_path.join(api_file);
|
for api_file in api_files {
|
||||||
let api_dir = api_path.parent().unwrap_or(game_path);
|
let api_path = game_path.join(api_file);
|
||||||
let api_filename = api_path.file_name().unwrap_or_default();
|
let api_dir = api_path.parent().unwrap_or(game_path);
|
||||||
|
let api_filename = api_path.file_name().unwrap_or_default();
|
||||||
|
|
||||||
// Check for backup file (original file renamed with _o.dll suffix)
|
// Check for backup file (original file renamed with _o.dll suffix)
|
||||||
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
|
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
|
||||||
let backup_path = api_dir.join(backup_name);
|
let backup_path = api_dir.join(backup_name);
|
||||||
|
|
||||||
if backup_path.exists() {
|
if backup_path.exists() {
|
||||||
debug!("SmokeAPI backup file found: {}", backup_path.display());
|
debug!("SmokeAPI backup file found: {}", backup_path.display());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Native games: check for lib_steam_api_o.so backup
|
||||||
|
for entry in WalkDir::new(game_path)
|
||||||
|
.max_depth(3)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
{
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
|
||||||
|
// Check for native SmokeAPI backup
|
||||||
|
if filename == "libsteam_api_o.so" {
|
||||||
|
debug!("Found native SmokeAPI backup: {}", path.display());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ impl Unlocker for SmokeAPI {
|
|||||||
let mut archive =
|
let mut archive =
|
||||||
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||||
|
|
||||||
// Extract all DLL files
|
// Extract both DLL files (for Proton) and .so files (for native Linux)
|
||||||
for i in 0..archive.len() {
|
for i in 0..archive.len() {
|
||||||
let mut file = archive
|
let mut file = archive
|
||||||
.by_index(i)
|
.by_index(i)
|
||||||
@@ -102,8 +102,11 @@ impl Unlocker for SmokeAPI {
|
|||||||
|
|
||||||
let file_name = file.name();
|
let file_name = file.name();
|
||||||
|
|
||||||
// Only extract DLL files
|
// Extract DLL files for Proton and .so files for native Linux
|
||||||
if file_name.to_lowercase().ends_with(".dll") {
|
let should_extract = file_name.to_lowercase().ends_with(".dll")
|
||||||
|
|| file_name.to_lowercase().ends_with(".so");
|
||||||
|
|
||||||
|
if should_extract {
|
||||||
let output_path = version_dir.join(
|
let output_path = version_dir.join(
|
||||||
Path::new(file_name)
|
Path::new(file_name)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -127,17 +130,56 @@ impl Unlocker for SmokeAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn install_to_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
async fn install_to_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||||
|
// Check if this is a native Linux game or Proton game
|
||||||
|
// Native games have empty api_files_str, Proton games have DLL paths
|
||||||
|
let is_native = api_files_str.is_empty();
|
||||||
|
|
||||||
|
if is_native {
|
||||||
|
Self::install_to_native_game(game_path).await
|
||||||
|
} else {
|
||||||
|
Self::install_to_proton_game(game_path, api_files_str).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||||
|
// Check if this is a native Linux game or Proton game
|
||||||
|
let is_native = api_files_str.is_empty();
|
||||||
|
|
||||||
|
if is_native {
|
||||||
|
Self::uninstall_from_native_game(game_path).await
|
||||||
|
} else {
|
||||||
|
Self::uninstall_from_proton_game(game_path, api_files_str).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name() -> &'static str {
|
||||||
|
"SmokeAPI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmokeAPI {
|
||||||
|
/// Install SmokeAPI to a Proton/Windows game
|
||||||
|
async fn install_to_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||||
// Parse api_files from the context string (comma-separated)
|
// Parse api_files from the context string (comma-separated)
|
||||||
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
|
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Installing SmokeAPI to {} for {} API files",
|
"Installing SmokeAPI (Proton) to {} for {} API files",
|
||||||
game_path,
|
game_path,
|
||||||
api_files.len()
|
api_files.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the cached SmokeAPI DLLs
|
// Get the cached SmokeAPI DLLs
|
||||||
let cached_dlls = crate::cache::list_smokeapi_dlls()?;
|
let cached_files = crate::cache::list_smokeapi_files()?;
|
||||||
|
if cached_files.is_empty() {
|
||||||
|
return Err("No SmokeAPI files found in cache".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached_dlls: Vec<_> = cached_files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.extension().and_then(|e| e.to_str()) == Some("dll"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
if cached_dlls.is_empty() {
|
if cached_dlls.is_empty() {
|
||||||
return Err("No SmokeAPI DLLs found in cache".to_string());
|
return Err("No SmokeAPI DLLs found in cache".to_string());
|
||||||
}
|
}
|
||||||
@@ -195,15 +237,77 @@ impl Unlocker for SmokeAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("SmokeAPI installation completed for: {}", game_path);
|
info!("SmokeAPI (Proton) installation completed for: {}", game_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
/// Install SmokeAPI to a native Linux game
|
||||||
|
async fn install_to_native_game(game_path: &str) -> Result<(), String> {
|
||||||
|
info!("Installing SmokeAPI (native) to {}", game_path);
|
||||||
|
|
||||||
|
// Detect game bitness
|
||||||
|
let bitness = crate::utils::bitness::detect_game_bitness(game_path)?;
|
||||||
|
info!("Detected game bitness: {:?}", bitness);
|
||||||
|
|
||||||
|
// Get the cached SmokeAPI files
|
||||||
|
let cached_files = crate::cache::list_smokeapi_files()?;
|
||||||
|
if cached_files.is_empty() {
|
||||||
|
return Err("No SmokeAPI files found in cache".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which .so file to use based on bitness
|
||||||
|
let target_so = match bitness {
|
||||||
|
crate::utils::bitness::Bitness::Bit32 => "libsmoke_api32.so",
|
||||||
|
crate::utils::bitness::Bitness::Bit64 => "libsmoke_api64.so",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the matching .so file in cache
|
||||||
|
let matching_so = cached_files
|
||||||
|
.iter()
|
||||||
|
.find(|file| {
|
||||||
|
file.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
== target_so
|
||||||
|
})
|
||||||
|
.ok_or_else(|| format!("No matching {} found in cache", target_so))?;
|
||||||
|
|
||||||
|
let game_path_obj = Path::new(game_path);
|
||||||
|
|
||||||
|
// Look for libsteam_api.so in the game directory (scan up to depth 3)
|
||||||
|
let libsteam_path = Self::find_libsteam_api(game_path_obj)?;
|
||||||
|
|
||||||
|
info!("Found libsteam_api.so at: {}", libsteam_path.display());
|
||||||
|
|
||||||
|
// Create backup of original libsteam_api.so
|
||||||
|
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
|
||||||
|
|
||||||
|
// Only backup if not already backed up
|
||||||
|
if !backup_path.exists() && libsteam_path.exists() {
|
||||||
|
fs::copy(&libsteam_path, &backup_path)
|
||||||
|
.map_err(|e| format!("Failed to backup libsteam_api.so: {}", e))?;
|
||||||
|
info!("Created backup: {}", backup_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace libsteam_api.so with SmokeAPI's libsmoke_api.so
|
||||||
|
fs::copy(matching_so, &libsteam_path)
|
||||||
|
.map_err(|e| format!("Failed to replace libsteam_api.so: {}", e))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Replaced libsteam_api.so with {}",
|
||||||
|
target_so
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("SmokeAPI (native) installation completed for: {}", game_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uninstall SmokeAPI from a Proton/Windows game
|
||||||
|
async fn uninstall_from_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
|
||||||
// Parse api_files from the context string (comma-separated)
|
// Parse api_files from the context string (comma-separated)
|
||||||
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
|
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
info!("Uninstalling SmokeAPI from: {}", game_path);
|
info!("Uninstalling SmokeAPI (Proton) from: {}", game_path);
|
||||||
|
|
||||||
for api_file in &api_files {
|
for api_file in &api_files {
|
||||||
let api_path = Path::new(game_path).join(api_file);
|
let api_path = Path::new(game_path).join(api_file);
|
||||||
@@ -250,11 +354,79 @@ impl Unlocker for SmokeAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("SmokeAPI uninstallation completed for: {}", game_path);
|
info!("SmokeAPI (Proton) uninstallation completed for: {}", game_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name() -> &'static str {
|
/// Uninstall SmokeAPI from a native Linux game
|
||||||
"SmokeAPI"
|
async fn uninstall_from_native_game(game_path: &str) -> Result<(), String> {
|
||||||
|
info!("Uninstalling SmokeAPI (native) from: {}", game_path);
|
||||||
|
|
||||||
|
let game_path_obj = Path::new(game_path);
|
||||||
|
|
||||||
|
// Look for libsteam_api.so (which is actually our SmokeAPI now)
|
||||||
|
let libsteam_path = match Self::find_libsteam_api(game_path_obj) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => {
|
||||||
|
warn!("libsteam_api.so not found, nothing to uninstall");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look for backup
|
||||||
|
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
|
||||||
|
|
||||||
|
if backup_path.exists() {
|
||||||
|
// Remove the SmokeAPI version
|
||||||
|
if libsteam_path.exists() {
|
||||||
|
match fs::remove_file(&libsteam_path) {
|
||||||
|
Ok(_) => info!("Removed SmokeAPI version: {}", libsteam_path.display()),
|
||||||
|
Err(e) => warn!("Failed to remove SmokeAPI file: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the original file
|
||||||
|
match fs::rename(&backup_path, &libsteam_path) {
|
||||||
|
Ok(_) => info!("Restored original libsteam_api.so"),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to restore original file: {}", e);
|
||||||
|
// Try to copy instead if rename fails
|
||||||
|
if let Err(copy_err) = fs::copy(&backup_path, &libsteam_path)
|
||||||
|
.and_then(|_| fs::remove_file(&backup_path))
|
||||||
|
{
|
||||||
|
error!("Failed to copy backup file: {}", copy_err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("No backup found (libsteam_api_o.so), cannot restore original");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("SmokeAPI (native) uninstallation completed for: {}", game_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find libsteam_api.so in the game directory
|
||||||
|
fn find_libsteam_api(game_path: &Path) -> Result<std::path::PathBuf, String> {
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
// Scan for libsteam_api.so (some games place it several subdirectories deep)
|
||||||
|
for entry in WalkDir::new(game_path)
|
||||||
|
.max_depth(8)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
{
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
if filename == "libsteam_api.so" {
|
||||||
|
return Ok(path.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("libsteam_api.so not found in game directory".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
204
src-tauri/src/utils/bitness.rs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
use log::{debug, info, warn};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// Represents the bitness of a binary
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Bitness {
|
||||||
|
Bit32,
|
||||||
|
Bit64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the bitness of a Linux Binary by reading ELF header
|
||||||
|
/// ELF format: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
|
||||||
|
fn detect_binary_bitness(file_path: &Path) -> Option<Bitness> {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
// Only read first 5 bytes
|
||||||
|
let mut file = fs::File::open(file_path).ok()?;
|
||||||
|
let mut bytes = [0u8; 5];
|
||||||
|
|
||||||
|
// Read exactly 5 bytes or fail
|
||||||
|
if file.read_exact(&mut bytes).is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ELF magic number (0x7F 'E' 'L' 'F')
|
||||||
|
if &bytes[0..4] != b"\x7FELF" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte 4 (EI_CLASS) indicates 32-bit or 64-bit
|
||||||
|
// 1 = ELFCLASS32 (32-bit)
|
||||||
|
// 2 = ELFCLASS64 (64-bit)
|
||||||
|
match bytes[4] {
|
||||||
|
1 => Some(Bitness::Bit32),
|
||||||
|
2 => Some(Bitness::Bit64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan game directory for Linux binaries and determine bitness
|
||||||
|
/// Returns the detected bitness, prioritizing the main game executable
|
||||||
|
pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
|
||||||
|
info!("Detecting bitness for game at: {}", game_path);
|
||||||
|
|
||||||
|
let game_path_obj = Path::new(game_path);
|
||||||
|
if !game_path_obj.exists() {
|
||||||
|
return Err("Game path does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directories to skip for performance
|
||||||
|
let skip_dirs = [
|
||||||
|
"videos",
|
||||||
|
"video",
|
||||||
|
"movies",
|
||||||
|
"movie",
|
||||||
|
"sound",
|
||||||
|
"sounds",
|
||||||
|
"audio",
|
||||||
|
"textures",
|
||||||
|
"music",
|
||||||
|
"localization",
|
||||||
|
"shaders",
|
||||||
|
"logs",
|
||||||
|
"assets",
|
||||||
|
"_CommonRedist",
|
||||||
|
"data",
|
||||||
|
"Data",
|
||||||
|
"Docs",
|
||||||
|
"docs",
|
||||||
|
"screenshots",
|
||||||
|
"Screenshots",
|
||||||
|
"saves",
|
||||||
|
"Saves",
|
||||||
|
"mods",
|
||||||
|
"Mods",
|
||||||
|
"maps",
|
||||||
|
"Maps",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Limit scan depth to avoid deep recursion
|
||||||
|
const MAX_DEPTH: usize = 3;
|
||||||
|
|
||||||
|
// Stop after finding reasonable confidence (10 binaries)
|
||||||
|
const CONFIDENCE_THRESHOLD: usize = 10;
|
||||||
|
|
||||||
|
let mut bit64_binaries = Vec::new();
|
||||||
|
let mut bit32_binaries = Vec::new();
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
// Scan for Linux binaries
|
||||||
|
for entry in WalkDir::new(game_path_obj)
|
||||||
|
.max_depth(MAX_DEPTH)
|
||||||
|
.follow_links(false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_entry(|e| {
|
||||||
|
if e.file_type().is_dir() {
|
||||||
|
let dir_name = e.file_name().to_string_lossy().to_lowercase();
|
||||||
|
!skip_dirs.iter().any(|&skip| dir_name.contains(skip))
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
{
|
||||||
|
// Early termination when we have high confidence
|
||||||
|
if bit64_binaries.len() >= CONFIDENCE_THRESHOLD || bit32_binaries.len() >= CONFIDENCE_THRESHOLD {
|
||||||
|
debug!("Reached confidence threshold, stopping scan early");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Only check files
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip non-binary files early for performance
|
||||||
|
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
|
||||||
|
// Check for common Linux executable extensions or shared libraries
|
||||||
|
let has_binary_extension = filename.ends_with(".x86")
|
||||||
|
|| filename.ends_with(".x86_64")
|
||||||
|
|| filename.ends_with(".bin")
|
||||||
|
|| filename.ends_with(".so")
|
||||||
|
|| filename.contains(".so.")
|
||||||
|
|| filename.starts_with("lib");
|
||||||
|
|
||||||
|
// Check if file is executable
|
||||||
|
let is_executable = {
|
||||||
|
{
|
||||||
|
// Get metadata once and check both extension and permissions
|
||||||
|
if let Ok(metadata) = fs::metadata(path) {
|
||||||
|
let permissions = metadata.permissions();
|
||||||
|
let executable = permissions.mode() & 0o111 != 0;
|
||||||
|
|
||||||
|
// Skip files that are neither executable nor have binary extensions
|
||||||
|
executable || has_binary_extension
|
||||||
|
} else {
|
||||||
|
// If we can't read metadata, only proceed if it has binary extension
|
||||||
|
has_binary_extension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_executable {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect bitness
|
||||||
|
if let Some(bitness) = detect_binary_bitness(path) {
|
||||||
|
debug!("Found {:?} binary: {}", bitness, path.display());
|
||||||
|
|
||||||
|
match bitness {
|
||||||
|
Bitness::Bit64 => {
|
||||||
|
bit64_binaries.push(path.to_path_buf());
|
||||||
|
|
||||||
|
// If we find libsteam_api.so and it's 64-bit, we can be very confident
|
||||||
|
if filename == "libsteam_api.so" {
|
||||||
|
info!("Found 64-bit libsteam_api.so");
|
||||||
|
return Ok(Bitness::Bit64);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Bitness::Bit32 => {
|
||||||
|
bit32_binaries.push(path.to_path_buf());
|
||||||
|
|
||||||
|
// If we find libsteam_api.so and it's 32-bit, we can be very confident
|
||||||
|
if filename == "libsteam_api.so" {
|
||||||
|
info!("Found 32-bit libsteam_api.so");
|
||||||
|
return Ok(Bitness::Bit32);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic: prioritize finding the main game executable
|
||||||
|
// 1. If we found any 64-bit binaries and no 32-bit, it's 64-bit
|
||||||
|
// 2. If we found any 32-bit binaries and no 64-bit, it's 32-bit
|
||||||
|
// 3. If we found both, prefer 64-bit (modern games are usually 64-bit)
|
||||||
|
// 4. If we found neither, return an error
|
||||||
|
|
||||||
|
if !bit64_binaries.is_empty() && bit32_binaries.is_empty() {
|
||||||
|
info!("Detected 64-bit game (Only 64-bit binaries found)");
|
||||||
|
Ok(Bitness::Bit64)
|
||||||
|
} else if !bit32_binaries.is_empty() && bit64_binaries.is_empty() {
|
||||||
|
info!("Detected 32-bit game (Only 32-bit binaries found)");
|
||||||
|
Ok(Bitness::Bit32)
|
||||||
|
} else if !bit64_binaries.is_empty() && !bit32_binaries.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Found both 32-bit and 64-bit binaries, defaulting to 64-bit. 32-bit: {}, 64-bit: {}",
|
||||||
|
bit32_binaries.len(),
|
||||||
|
bit64_binaries.len()
|
||||||
|
);
|
||||||
|
info!("Detected 64-bit game (mixed binaries, defaulting to 64-bit)");
|
||||||
|
Ok(Bitness::Bit64)
|
||||||
|
} else {
|
||||||
|
Err("Could not detect game bitness: no Linux binaries found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src-tauri/src/utils/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod bitness;
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
"productName": "Creamlinux",
|
"productName": "Creamlinux",
|
||||||
"mainBinaryName": "creamlinux",
|
"mainBinaryName": "creamlinux",
|
||||||
"version": "1.3.3",
|
"version": "1.5.0",
|
||||||
"identifier": "com.creamlinux.dev",
|
"identifier": "com.creamlinux.dev",
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": false,
|
"withGlobalTauri": false,
|
||||||
|
|||||||
66
src/App.tsx
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { useAppContext } from '@/contexts/useAppContext'
|
import { useAppContext } from '@/contexts/useAppContext'
|
||||||
import { useAppLogic, useConflictDetection } from '@/hooks'
|
import { useAppLogic, useConflictDetection, useDisclaimer } from '@/hooks'
|
||||||
import './styles/main.scss'
|
import './styles/main.scss'
|
||||||
|
|
||||||
// Layout components
|
// Layout components
|
||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
DlcSelectionDialog,
|
DlcSelectionDialog,
|
||||||
SettingsDialog,
|
SettingsDialog,
|
||||||
ConflictDialog,
|
ConflictDialog,
|
||||||
ReminderDialog,
|
DisclaimerDialog,
|
||||||
|
UnlockerSelectionDialog,
|
||||||
} from '@/components/dialogs'
|
} from '@/components/dialogs'
|
||||||
|
|
||||||
// Game components
|
// Game components
|
||||||
@@ -32,6 +33,8 @@ import { GameList } from '@/components/games'
|
|||||||
function App() {
|
function App() {
|
||||||
const [updateComplete, setUpdateComplete] = useState(false)
|
const [updateComplete, setUpdateComplete] = useState(false)
|
||||||
|
|
||||||
|
const { showDisclaimer, handleDisclaimerClose } = useDisclaimer()
|
||||||
|
|
||||||
// Get application logic from hook
|
// Get application logic from hook
|
||||||
const {
|
const {
|
||||||
filter,
|
filter,
|
||||||
@@ -61,27 +64,38 @@ function App() {
|
|||||||
handleSettingsOpen,
|
handleSettingsOpen,
|
||||||
handleSettingsClose,
|
handleSettingsClose,
|
||||||
handleSmokeAPISettingsOpen,
|
handleSmokeAPISettingsOpen,
|
||||||
|
handleOpenRating,
|
||||||
|
reportingEnabled,
|
||||||
showToast,
|
showToast,
|
||||||
|
unlockerSelectionDialog,
|
||||||
|
handleSelectCreamLinux,
|
||||||
|
handleSelectSmokeAPI,
|
||||||
|
closeUnlockerDialog,
|
||||||
} = useAppContext()
|
} = useAppContext()
|
||||||
|
|
||||||
// Conflict detection
|
// Conflict detection
|
||||||
const { currentConflict, showReminder, resolveConflict, closeReminder } =
|
const { conflicts, showDialog, resolveConflict, closeDialog } =
|
||||||
useConflictDetection(games)
|
useConflictDetection(games)
|
||||||
|
|
||||||
// Handle conflict resolution
|
// Handle conflict resolution
|
||||||
const handleConflictResolve = async () => {
|
const handleConflictResolve = async (
|
||||||
const resolution = resolveConflict()
|
gameId: string,
|
||||||
if (!resolution) return
|
conflictType: 'cream-to-proton' | 'smoke-to-native'
|
||||||
|
) => {
|
||||||
// Always remove files - use the special conflict resolution command
|
|
||||||
try {
|
try {
|
||||||
|
// Invoke backend to resolve the conflict
|
||||||
await invoke('resolve_platform_conflict', {
|
await invoke('resolve_platform_conflict', {
|
||||||
gameId: resolution.gameId,
|
gameId,
|
||||||
conflictType: resolution.conflictType,
|
conflictType,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Remove from UI
|
||||||
|
resolveConflict(gameId, conflictType)
|
||||||
|
|
||||||
|
showToast('Conflict resolved successfully', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error resolving conflict:', error)
|
console.error('Error resolving conflict:', error)
|
||||||
showToast(`Failed to resolve conflict: ${error}`, 'error')
|
showToast('Failed to resolve conflict', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +145,8 @@ function App() {
|
|||||||
onAction={handleGameAction}
|
onAction={handleGameAction}
|
||||||
onEdit={handleGameEdit}
|
onEdit={handleGameEdit}
|
||||||
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
onSmokeAPISettings={handleSmokeAPISettingsOpen}
|
||||||
|
onRate={handleOpenRating}
|
||||||
|
reportingEnabled={reportingEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -168,17 +184,25 @@ function App() {
|
|||||||
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
|
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
|
||||||
|
|
||||||
{/* Conflict Detection Dialog */}
|
{/* Conflict Detection Dialog */}
|
||||||
{currentConflict && (
|
<ConflictDialog
|
||||||
<ConflictDialog
|
visible={showDialog}
|
||||||
visible={true}
|
conflicts={conflicts}
|
||||||
gameTitle={currentConflict.gameTitle}
|
onResolve={handleConflictResolve}
|
||||||
conflictType={currentConflict.type}
|
onClose={closeDialog}
|
||||||
onConfirm={handleConflictResolve}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Steam Launch Options Reminder */}
|
{/* Unlocker Selection Dialog */}
|
||||||
<ReminderDialog visible={showReminder} onClose={closeReminder} />
|
<UnlockerSelectionDialog
|
||||||
|
visible={unlockerSelectionDialog.visible}
|
||||||
|
gameId={unlockerSelectionDialog.gameId}
|
||||||
|
gameTitle={unlockerSelectionDialog.gameTitle || ''}
|
||||||
|
onClose={closeUnlockerDialog}
|
||||||
|
onSelectCreamLinux={handleSelectCreamLinux}
|
||||||
|
onSelectSmokeAPI={handleSelectSmokeAPI}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Disclaimer Dialog - Shows AFTER everything is loaded */}
|
||||||
|
<DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} />
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
|
|||||||
BIN
src/assets/screenshot1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -3,7 +3,7 @@ import Button, { ButtonVariant } from '../buttons/Button'
|
|||||||
import { Icon, trash, download } from '@/components/icons'
|
import { Icon, trash, download } from '@/components/icons'
|
||||||
|
|
||||||
// Define available action types
|
// Define available action types
|
||||||
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
|
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' | 'install_unlocker'
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
action: ActionType
|
action: ActionType
|
||||||
@@ -18,7 +18,6 @@ interface ActionButtonProps {
|
|||||||
* Specialized button for game installation actions
|
* Specialized button for game installation actions
|
||||||
*/
|
*/
|
||||||
const ActionButton: FC<ActionButtonProps> = ({
|
const ActionButton: FC<ActionButtonProps> = ({
|
||||||
action,
|
|
||||||
isInstalled,
|
isInstalled,
|
||||||
isWorking,
|
isWorking,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -29,10 +28,7 @@ const ActionButton: FC<ActionButtonProps> = ({
|
|||||||
const getButtonText = () => {
|
const getButtonText = () => {
|
||||||
if (isWorking) return 'Working...'
|
if (isWorking) return 'Working...'
|
||||||
|
|
||||||
const isCream = action.includes('cream')
|
return isInstalled ? 'Uninstall' : 'Install'
|
||||||
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
|
||||||
|
|
||||||
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map to button variant
|
// Map to button variant
|
||||||
|
|||||||
47
src/components/common/VotesDisplay.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface GameVotes {
|
||||||
|
unlocker: string
|
||||||
|
success: number
|
||||||
|
fail: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VotesDisplayProps {
|
||||||
|
votes: GameVotes | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact vote bar shown inside the unlocker selection dialog.
|
||||||
|
* Shows a green/red progress bar with a label, or "No votes yet" when empty.
|
||||||
|
*/
|
||||||
|
const VotesDisplay: React.FC<VotesDisplayProps> = ({ votes }) => {
|
||||||
|
if (!votes || (votes.success === 0 && votes.fail === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="unlocker-votes">
|
||||||
|
<span className="votes-label votes-label--none">No votes yet</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = votes.success + votes.fail
|
||||||
|
const pct = Math.round((votes.success / total) * 100)
|
||||||
|
|
||||||
|
const labelClass =
|
||||||
|
pct >= 70 ? 'votes-label--positive' : pct >= 40 ? '' : 'votes-label--negative'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="unlocker-votes"
|
||||||
|
title={`${votes.success} worked · ${votes.fail} didn't work`}
|
||||||
|
>
|
||||||
|
<div className="votes-bar-wrap">
|
||||||
|
<div className="votes-bar-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`votes-label ${labelClass}`}>
|
||||||
|
{pct}% working ({total})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VotesDisplay
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export { default as LoadingIndicator } from './LoadingIndicator'
|
export { default as LoadingIndicator } from './LoadingIndicator'
|
||||||
export { default as ProgressBar } from './ProgressBar'
|
export { default as ProgressBar } from './ProgressBar'
|
||||||
export { default as Dropdown } from './Dropdown'
|
export { default as Dropdown } from './Dropdown'
|
||||||
|
export { default as VotesDisplay } from './VotesDisplay'
|
||||||
|
|
||||||
export type { LoadingSize, LoadingType } from './LoadingIndicator'
|
export type { LoadingSize, LoadingType } from './LoadingIndicator'
|
||||||
export type { DropdownOption } from './Dropdown'
|
export type { DropdownOption } from './Dropdown'
|
||||||
|
export type { GameVotes } from './VotesDisplay'
|
||||||
93
src/components/dialogs/AddDlcDialog.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Dialog from './Dialog'
|
||||||
|
import DialogHeader from './DialogHeader'
|
||||||
|
import DialogBody from './DialogBody'
|
||||||
|
import DialogFooter from './DialogFooter'
|
||||||
|
import DialogActions from './DialogActions'
|
||||||
|
import { Button } from '@/components/buttons'
|
||||||
|
import { DlcInfo } from '@/types'
|
||||||
|
|
||||||
|
export interface AddDlcDialogProps {
|
||||||
|
visible: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAdd: (dlc: DlcInfo) => void
|
||||||
|
existingIds: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add DLC Manually dialog
|
||||||
|
* Allows users to manually enter a DLC ID and name when it is
|
||||||
|
* missing from the Steam API and cannot be fetched automatically
|
||||||
|
*/
|
||||||
|
const AddDlcDialog = ({ visible, onClose, onAdd, existingIds }: AddDlcDialogProps) => {
|
||||||
|
const [id, setId] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Reset form state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setId('')
|
||||||
|
setName('')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
// Validate inputs and add the DLC to the list
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmedId = id.trim()
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
|
||||||
|
if (!trimmedId) return setError('DLC ID is required.')
|
||||||
|
if (!/^\d+$/.test(trimmedId)) return setError('DLC ID must be a number.')
|
||||||
|
if (existingIds.has(trimmedId)) return setError('A DLC with this ID already exists.')
|
||||||
|
if (!trimmedName) return setError('DLC name is required.')
|
||||||
|
|
||||||
|
onAdd({ appid: trimmedId, name: trimmedName, enabled: true })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog visible={visible} onClose={onClose} size="small">
|
||||||
|
<DialogHeader onClose={onClose}>
|
||||||
|
<h3>Add DLC Manually</h3>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="add-dlc-form">
|
||||||
|
<div className="add-dlc-field">
|
||||||
|
<label className="add-dlc-label">DLC ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="add-dlc-input"
|
||||||
|
placeholder="e.g. 1234560"
|
||||||
|
value={id}
|
||||||
|
onChange={(e) => { setId(e.target.value); setError('') }}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="add-dlc-field">
|
||||||
|
<label className="add-dlc-label">DLC Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="add-dlc-input"
|
||||||
|
placeholder="e.g. Expansion - My DLC"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => { setName(e.target.value); setError('') }}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="add-dlc-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={handleSubmit}>Add DLC</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddDlcDialog
|
||||||
@@ -7,66 +7,95 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
} from '@/components/dialogs'
|
} from '@/components/dialogs'
|
||||||
import { Button } from '@/components/buttons'
|
import { Button } from '@/components/buttons'
|
||||||
import { Icon, warning } from '@/components/icons'
|
import { Icon, warning, info } from '@/components/icons'
|
||||||
|
|
||||||
|
export interface Conflict {
|
||||||
|
gameId: string
|
||||||
|
gameTitle: string
|
||||||
|
type: 'cream-to-proton' | 'smoke-to-native'
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConflictDialogProps {
|
export interface ConflictDialogProps {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
gameTitle: string
|
conflicts: Conflict[]
|
||||||
conflictType: 'cream-to-proton' | 'smoke-to-native'
|
onResolve: (gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native') => void
|
||||||
onConfirm: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conflict Dialog component
|
* Conflict Dialog component
|
||||||
* Shows when incompatible unlocker files are detected after platform switch
|
* Shows all conflicts at once with individual resolve buttons
|
||||||
*/
|
*/
|
||||||
const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||||
visible,
|
visible,
|
||||||
gameTitle,
|
conflicts,
|
||||||
conflictType,
|
onResolve,
|
||||||
onConfirm,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const getConflictMessage = () => {
|
// Check if any CreamLinux conflicts exist
|
||||||
if (conflictType === 'cream-to-proton') {
|
const hasCreamConflicts = conflicts.some((c) => c.type === 'cream-to-proton')
|
||||||
return {
|
|
||||||
title: 'CreamLinux unlocker detected, but game is set to Proton',
|
const getConflictDescription = (type: 'cream-to-proton' | 'smoke-to-native') => {
|
||||||
bodyPrefix: 'It looks like you previously installed CreamLinux while ',
|
if (type === 'cream-to-proton') {
|
||||||
bodySuffix: ' was running natively. Steam is now configured to run it with Proton, so CreamLinux files will be removed automatically.',
|
return 'Will remove existing unlocker files and restore the game to a clean state.'
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return {
|
return 'Will remove existing unlocker files and restore the game to a clean state.'
|
||||||
title: 'SmokeAPI unlocker detected, but game is set to Native',
|
|
||||||
bodyPrefix: 'It looks like you previously installed SmokeAPI while ',
|
|
||||||
bodySuffix: ' was running with Proton. Steam is now configured to run it natively, so SmokeAPI files will be removed automatically.',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = getConflictMessage()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog visible={visible} size="large" preventBackdropClose={true}>
|
<Dialog visible={visible} size="large" preventBackdropClose={true}>
|
||||||
<DialogHeader hideCloseButton={true}>
|
<DialogHeader hideCloseButton={true}>
|
||||||
<div className="conflict-dialog-header">
|
<div className="conflict-dialog-header">
|
||||||
<Icon name={warning} variant="solid" size="lg" />
|
<Icon name={warning} variant="solid" size="lg" />
|
||||||
<h3>{message.title}</h3>
|
<h3>Unlocker conflicts detected</h3>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
<div className="conflict-dialog-body">
|
<div className="conflict-dialog-body">
|
||||||
<p>
|
<p className="conflict-intro">
|
||||||
{message.bodyPrefix}
|
Some games have conflicting unlocker states that need attention.
|
||||||
<strong>{gameTitle}</strong>
|
|
||||||
{message.bodySuffix}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="conflict-list">
|
||||||
|
{conflicts.map((conflict) => (
|
||||||
|
<div key={conflict.gameId} className="conflict-item">
|
||||||
|
<div className="conflict-info">
|
||||||
|
<div className="conflict-icon">
|
||||||
|
<Icon name={warning} variant="solid" size="md" />
|
||||||
|
</div>
|
||||||
|
<div className="conflict-details">
|
||||||
|
<h4>{conflict.gameTitle}</h4>
|
||||||
|
<p>{getConflictDescription(conflict.type)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => onResolve(conflict.gameId, conflict.type)}
|
||||||
|
className="conflict-resolve-btn"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
{hasCreamConflicts && (
|
||||||
|
<div className="conflict-reminder">
|
||||||
|
<Icon name={info} variant="solid" size="md" />
|
||||||
|
<span>
|
||||||
|
Remember to remove <code>sh ./cream.sh %command%</code> from Steam launch options
|
||||||
|
after resolving CreamLinux conflicts.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button variant="primary" onClick={onConfirm}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
OK
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
69
src/components/dialogs/DisclaimerDialog.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogHeader,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActions,
|
||||||
|
} from '@/components/dialogs'
|
||||||
|
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export interface DisclaimerDialogProps {
|
||||||
|
visible: boolean
|
||||||
|
onClose: (dontShowAgain: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disclaimer dialog that appears on app startup
|
||||||
|
* Informs users that CreamLinux manages DLC IDs, not actual DLC files
|
||||||
|
*/
|
||||||
|
const DisclaimerDialog = ({ visible, onClose }: DisclaimerDialogProps) => {
|
||||||
|
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||||
|
|
||||||
|
const handleOkClick = () => {
|
||||||
|
onClose(dontShowAgain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog visible={visible} onClose={() => onClose(false)} size="medium" preventBackdropClose>
|
||||||
|
<DialogHeader hideCloseButton={true}>
|
||||||
|
<div className="disclaimer-header">
|
||||||
|
<h3>Important Notice</h3>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
<div className="disclaimer-content">
|
||||||
|
<p>
|
||||||
|
<strong>CreamLinux Installer</strong> does not install any DLC content files.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This application manages the <strong>DLC IDs</strong> associated with DLCs you want to
|
||||||
|
use. You must obtain the actual DLC files separately.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This tool only configures which DLC IDs are recognized by the game unlockers
|
||||||
|
(CreamLinux and SmokeAPI).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActions>
|
||||||
|
<div className="disclaimer-footer">
|
||||||
|
<AnimatedCheckbox
|
||||||
|
checked={dontShowAgain}
|
||||||
|
onChange={() => setDontShowAgain(!dontShowAgain)}
|
||||||
|
label="Don't show this disclaimer again"
|
||||||
|
/>
|
||||||
|
<Button variant="primary" onClick={handleOkClick}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DisclaimerDialog
|
||||||
@@ -4,6 +4,7 @@ import DialogHeader from './DialogHeader'
|
|||||||
import DialogBody from './DialogBody'
|
import DialogBody from './DialogBody'
|
||||||
import DialogFooter from './DialogFooter'
|
import DialogFooter from './DialogFooter'
|
||||||
import DialogActions from './DialogActions'
|
import DialogActions from './DialogActions'
|
||||||
|
import AddDlcDialog from './AddDlcDialog'
|
||||||
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
import { Button, AnimatedCheckbox } from '@/components/buttons'
|
||||||
import { DlcInfo } from '@/types'
|
import { DlcInfo } from '@/types'
|
||||||
import { Icon, check, info } from '@/components/icons'
|
import { Icon, check, info } from '@/components/icons'
|
||||||
@@ -51,6 +52,7 @@ const DlcSelectionDialog = ({
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectAll, setSelectAll] = useState(true)
|
const [selectAll, setSelectAll] = useState(true)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
const [showAddDlc, setShowAddDlc] = useState(false)
|
||||||
|
|
||||||
// Reset dialog state when it opens or closes
|
// Reset dialog state when it opens or closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,6 +128,11 @@ const DlcSelectionDialog = ({
|
|||||||
)
|
)
|
||||||
}, [selectAll])
|
}, [selectAll])
|
||||||
|
|
||||||
|
// Add a manually-entered DLC to the list
|
||||||
|
const handleAddDlc = useCallback((dlc: DlcInfo) => {
|
||||||
|
setSelectedDlcs((prev) => [...prev, dlc])
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Submit selected DLCs to parent component
|
// Submit selected DLCs to parent component
|
||||||
const handleConfirm = useCallback(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
// Create a deep copy to prevent reference issues
|
// Create a deep copy to prevent reference issues
|
||||||
@@ -151,124 +158,141 @@ const DlcSelectionDialog = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
|
<>
|
||||||
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
|
||||||
<h3>{dialogTitle}</h3>
|
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||||
<div className="dlc-game-info">
|
<h3>{dialogTitle}</h3>
|
||||||
<span className="game-title">{gameTitle}</span>
|
<div className="dlc-game-info">
|
||||||
<span className="dlc-count">
|
<span className="game-title">{gameTitle}</span>
|
||||||
{selectedCount} of {selectedDlcs.length} DLCs selected
|
<span className="dlc-count">
|
||||||
{getLoadingInfoText()}
|
{selectedCount} of {selectedDlcs.length} DLCs selected
|
||||||
</span>
|
{getLoadingInfoText()}
|
||||||
</div>
|
</span>
|
||||||
</DialogHeader>
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="dlc-dialog-search">
|
<div className="dlc-dialog-search">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search DLCs..."
|
placeholder="Search DLCs..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="dlc-search-input"
|
className="dlc-search-input"
|
||||||
/>
|
|
||||||
<div className="select-all-container">
|
|
||||||
<AnimatedCheckbox
|
|
||||||
checked={selectAll}
|
|
||||||
onChange={handleToggleSelectAll}
|
|
||||||
label="Select All"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="select-all-container">
|
||||||
</div>
|
<AnimatedCheckbox
|
||||||
|
checked={selectAll}
|
||||||
{(isLoading || isUpdating) && loadingProgress > 0 && (
|
onChange={handleToggleSelectAll}
|
||||||
<div className="dlc-loading-progress">
|
label="Select All"
|
||||||
<div className="progress-bar-container">
|
/>
|
||||||
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="loading-details">
|
|
||||||
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
|
|
||||||
{estimatedTimeLeft && (
|
|
||||||
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogBody className="dlc-list-container">
|
{(isLoading || isUpdating) && loadingProgress > 0 && (
|
||||||
{selectedDlcs.length > 0 ? (
|
<div className="dlc-loading-progress">
|
||||||
<ul className="dlc-list">
|
<div className="progress-bar-container">
|
||||||
{filteredDlcs.map((dlc) => (
|
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
|
||||||
<li key={dlc.appid} className="dlc-item">
|
</div>
|
||||||
<AnimatedCheckbox
|
<div className="loading-details">
|
||||||
checked={dlc.enabled}
|
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
|
||||||
onChange={() => handleToggleDlc(dlc.appid)}
|
{estimatedTimeLeft && (
|
||||||
label={dlc.name}
|
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
|
||||||
sublabel={`ID: ${dlc.appid}`}
|
)}
|
||||||
/>
|
</div>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{isLoading && (
|
|
||||||
<li className="dlc-item dlc-item-loading">
|
|
||||||
<div className="loading-pulse"></div>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<div className="dlc-loading">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
<p>Loading DLC information...</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogBody>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogBody className="dlc-list-container">
|
||||||
{/* Show update results */}
|
{selectedDlcs.length > 0 ? (
|
||||||
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
|
<ul className="dlc-list">
|
||||||
<>
|
{filteredDlcs.map((dlc) => (
|
||||||
{newDlcsCount > 0 && (
|
<li key={dlc.appid} className="dlc-item">
|
||||||
<div className="dlc-update-results dlc-update-success">
|
<AnimatedCheckbox
|
||||||
<span className="update-message">
|
checked={dlc.enabled}
|
||||||
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
|
onChange={() => handleToggleDlc(dlc.appid)}
|
||||||
</span>
|
label={dlc.name}
|
||||||
</div>
|
sublabel={`ID: ${dlc.appid}`}
|
||||||
)}
|
/>
|
||||||
{newDlcsCount === 0 && (
|
</li>
|
||||||
<div className="dlc-update-results dlc-update-info">
|
))}
|
||||||
<span className="update-message">
|
{isLoading && (
|
||||||
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
|
<li className="dlc-item dlc-item-loading">
|
||||||
</span>
|
<div className="loading-pulse"></div>
|
||||||
</div>
|
</li>
|
||||||
)}
|
)}
|
||||||
</>
|
</ul>
|
||||||
)}
|
) : (
|
||||||
|
<div className="dlc-loading">
|
||||||
<DialogActions>
|
<div className="loading-spinner"></div>
|
||||||
<Button
|
<p>Loading DLC information...</p>
|
||||||
variant="secondary"
|
</div>
|
||||||
onClick={onClose}
|
)}
|
||||||
disabled={(isLoading || isUpdating) && loadingProgress < 10}
|
</DialogBody>
|
||||||
>
|
|
||||||
Cancel
|
<DialogFooter>
|
||||||
</Button>
|
{/* Show update results */}
|
||||||
|
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
|
||||||
{/* Update button - only show in edit mode */}
|
<>
|
||||||
{isEditMode && onUpdate && (
|
{newDlcsCount > 0 && (
|
||||||
|
<div className="dlc-update-results dlc-update-success">
|
||||||
|
<span className="update-message">
|
||||||
|
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{newDlcsCount === 0 && (
|
||||||
|
<div className="dlc-update-results dlc-update-info">
|
||||||
|
<span className="update-message">
|
||||||
|
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
<Button
|
<Button
|
||||||
variant="warning"
|
variant="secondary"
|
||||||
onClick={() => onUpdate(gameId)}
|
onClick={onClose}
|
||||||
|
disabled={(isLoading || isUpdating) && loadingProgress < 10}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowAddDlc(true)}
|
||||||
disabled={isLoading || isUpdating}
|
disabled={isLoading || isUpdating}
|
||||||
>
|
>
|
||||||
{isUpdating ? 'Updating...' : 'Update DLC List'}
|
Add DLC Manually
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
{/* Update button - only show in edit mode */}
|
||||||
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
|
{isEditMode && onUpdate && (
|
||||||
{actionButtonText}
|
<Button
|
||||||
</Button>
|
variant="warning"
|
||||||
</DialogActions>
|
onClick={() => onUpdate(gameId)}
|
||||||
</DialogFooter>
|
disabled={isLoading || isUpdating}
|
||||||
</Dialog>
|
>
|
||||||
|
{isUpdating ? 'Updating...' : 'Update DLC List'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
|
||||||
|
{actionButtonText}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AddDlcDialog
|
||||||
|
visible={showAddDlc}
|
||||||
|
onClose={() => setShowAddDlc(false)}
|
||||||
|
onAdd={handleAddDlc}
|
||||||
|
existingIds={new Set(selectedDlcs.map((d) => d.appid))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DlcSelectionDialog
|
export default DlcSelectionDialog
|
||||||
|
|||||||
82
src/components/dialogs/OptInDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogHeader,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActions,
|
||||||
|
} from '@/components/dialogs'
|
||||||
|
import { Button } from '@/components/buttons'
|
||||||
|
import { Icon, info } from '@/components/icons'
|
||||||
|
|
||||||
|
interface OptInDialogProps {
|
||||||
|
visible: boolean
|
||||||
|
onAccept: () => void
|
||||||
|
onDecline: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-launch opt-in dialog for the compatibility reporting system.
|
||||||
|
* Shown once when the app fully starts. Does not close until the user makes
|
||||||
|
* an explicit choice.
|
||||||
|
*/
|
||||||
|
const OptInDialog: React.FC<OptInDialogProps> = ({ visible, onAccept, onDecline }) => {
|
||||||
|
return (
|
||||||
|
<Dialog visible={visible} onClose={() => {}} size="medium">
|
||||||
|
<DialogHeader onClose={() => {}} hideCloseButton={true}>
|
||||||
|
<h3>Help improve CreamLinux</h3>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
<div className="optin-content">
|
||||||
|
|
||||||
|
<p className="optin-intro">
|
||||||
|
CreamLinux can collect anonymous compatibility reports to help users know which
|
||||||
|
games work with CreamLinux and SmokeAPI before they install them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="optin-details">
|
||||||
|
<h4>What we collect</h4>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>A one-way anonymous hash</strong> derived from your machine ID, Steam
|
||||||
|
install path, and a locally-stored random salt. <em>This cannot be reversed
|
||||||
|
to identify you</em>, and even we cannot link it to your machine.
|
||||||
|
</li>
|
||||||
|
<li>The Steam App ID of the game you rated.</li>
|
||||||
|
<li>Which unlocker you used (CreamLinux or SmokeAPI).</li>
|
||||||
|
<li>Whether it worked or not.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>What we do not collect</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Your username, IP address, or any personally identifiable information.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="optin-notice">
|
||||||
|
<Icon name={info} variant="solid" size="md" />
|
||||||
|
<span>
|
||||||
|
If you opt out, the local salt will be deleted and no data will ever be sent.
|
||||||
|
You will not be able to submit compatibility votes, but the app works fully
|
||||||
|
without this feature.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="secondary" onClick={onDecline}>
|
||||||
|
No thanks
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onAccept}>
|
||||||
|
Enable reporting
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OptInDialog
|
||||||
164
src/components/dialogs/RatingDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogHeader,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActions,
|
||||||
|
} from '@/components/dialogs'
|
||||||
|
import { Button } from '@/components/buttons'
|
||||||
|
import { Icon, info } from '@/components/icons'
|
||||||
|
|
||||||
|
interface LocalReport {
|
||||||
|
game_id: string
|
||||||
|
unlocker: string
|
||||||
|
worked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatingDialogProps {
|
||||||
|
visible: boolean
|
||||||
|
gameTitle: string
|
||||||
|
gameId: string
|
||||||
|
/** 'creamlinux' | 'smokeapi' – whichever is currently installed */
|
||||||
|
unlocker: 'creamlinux' | 'smokeapi'
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (worked: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNLOCKER_LABELS: Record<string, string> = {
|
||||||
|
creamlinux: 'CreamLinux',
|
||||||
|
smokeapi: 'SmokeAPI',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-game rating dialog. Submits exactly one report for the installed unlocker.
|
||||||
|
*/
|
||||||
|
const RatingDialog: React.FC<RatingDialogProps> = ({
|
||||||
|
visible,
|
||||||
|
gameTitle,
|
||||||
|
gameId,
|
||||||
|
unlocker,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
// Which vote the user has already cast for this game+unlocker, if any
|
||||||
|
const [previousVote, setPreviousVote] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
// Reset submit state each time the dialog opens
|
||||||
|
setSubmitted(false)
|
||||||
|
|
||||||
|
// Load the local reports to see if this game+unlocker has already been started
|
||||||
|
invoke<LocalReport[]>('get_local_reports')
|
||||||
|
.then((reports) => {
|
||||||
|
const existing = reports.find(
|
||||||
|
(r) => r.game_id === gameId && r.unlocker === unlocker
|
||||||
|
)
|
||||||
|
setPreviousVote(existing ? existing.worked : null)
|
||||||
|
})
|
||||||
|
.catch(() => setPreviousVote(null))
|
||||||
|
}, [visible, gameId, unlocker])
|
||||||
|
|
||||||
|
const handleSubmit = async (worked: boolean) => {
|
||||||
|
if (submitting || submitted) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await onSubmit(worked)
|
||||||
|
setSubmitted(true)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSubmitted(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = UNLOCKER_LABELS[unlocker] ?? unlocker
|
||||||
|
|
||||||
|
// A button is "already chosen" if it matches the previous vote
|
||||||
|
const workedAlreadyChosen = previousVote === true
|
||||||
|
const brokenAlreadyChosen = previousVote === false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog visible={visible} onClose={handleClose} size="small">
|
||||||
|
<DialogHeader onClose={handleClose} hideCloseButton={true}>
|
||||||
|
<h3>Submit rating</h3>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
{submitted ? (
|
||||||
|
<div className="rating-submitted">
|
||||||
|
<p>Thanks for your report! Your vote helps other users.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rating-content">
|
||||||
|
<p>
|
||||||
|
You have <strong>{label}</strong> installed for{' '}
|
||||||
|
<strong>{gameTitle}</strong>. Did it work?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{previousVote !== null && (
|
||||||
|
<p className="rating-subtext">
|
||||||
|
You previously voted <strong>{previousVote ? 'worked' : "didn't work"}</strong>.
|
||||||
|
You can change your vote below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previousVote === null && (
|
||||||
|
<p className="rating-subtext">
|
||||||
|
Your rating is anonymous and helps other users know if{' '}
|
||||||
|
{label} works with this game.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rating-buttons">
|
||||||
|
<Button
|
||||||
|
variant="success"
|
||||||
|
className={`rating-btn rating-btn--worked${workedAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||||
|
onClick={() => handleSubmit(true)}
|
||||||
|
disabled={submitting || workedAlreadyChosen}
|
||||||
|
title={workedAlreadyChosen ? 'Already voted' : undefined}
|
||||||
|
leftIcon={<Icon name="Check" variant="solid" size="sm" />}
|
||||||
|
>
|
||||||
|
It worked
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className={`rating-btn rating-btn--broken${brokenAlreadyChosen ? ' rating-btn--active' : ''}`}
|
||||||
|
onClick={() => handleSubmit(false)}
|
||||||
|
disabled={submitting || brokenAlreadyChosen}
|
||||||
|
title={brokenAlreadyChosen ? 'Already voted' : undefined}
|
||||||
|
leftIcon={<Icon name="Close" variant="solid" size="sm" />}
|
||||||
|
>
|
||||||
|
Didn't work
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rating-notice">
|
||||||
|
<Icon name={info} variant="solid" size="md" />
|
||||||
|
<span>Only the result for {label} will be submitted.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
{submitted ? 'Close' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RatingDialog
|
||||||
110
src/components/dialogs/SmokeAPIVotesDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogHeader,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActions,
|
||||||
|
} from '@/components/dialogs'
|
||||||
|
import { Button } from '@/components/buttons'
|
||||||
|
import { Icon, info } from '@/components/icons'
|
||||||
|
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||||
|
|
||||||
|
export interface SmokeAPIVotesDialogProps {
|
||||||
|
visible: boolean
|
||||||
|
gameId: string | null
|
||||||
|
gameTitle: string | null
|
||||||
|
onConfirm: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown before installing SmokeAPI on a Proton game.
|
||||||
|
* Fetches and displays community votes for SmokeAPI specifically,
|
||||||
|
* then lets the user confirm or cancel the installation.
|
||||||
|
*/
|
||||||
|
const SmokeAPIVotesDialog: React.FC<SmokeAPIVotesDialogProps> = ({
|
||||||
|
visible,
|
||||||
|
gameId,
|
||||||
|
gameTitle,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [votes, setVotes] = useState<GameVotes | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !gameId) {
|
||||||
|
setVotes(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||||
|
.then((results) => {
|
||||||
|
setVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||||
|
})
|
||||||
|
.catch(() => setVotes(null))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [visible, gameId])
|
||||||
|
|
||||||
|
const hasVotes = votes && (votes.success > 0 || votes.fail > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog visible={visible} onClose={onClose} size="small">
|
||||||
|
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||||
|
<h3>Install SmokeAPI</h3>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
<div className="smokeapi-votes-content">
|
||||||
|
<p className="smokeapi-votes-game">
|
||||||
|
<strong>{gameTitle}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="smokeapi-votes-section">
|
||||||
|
<p className="smokeapi-votes-label">Community compatibility</p>
|
||||||
|
{loading ? (
|
||||||
|
<p className="smokeapi-votes-loading">Fetching votes...</p>
|
||||||
|
) : (
|
||||||
|
<VotesDisplay votes={votes} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !hasVotes && (
|
||||||
|
<div className="smokeapi-votes-notice">
|
||||||
|
<Icon name={info} variant="solid" size="md" />
|
||||||
|
<span>
|
||||||
|
No one has rated this game yet. You'll be able to submit a rating after
|
||||||
|
installing.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && hasVotes && (
|
||||||
|
<div className="smokeapi-votes-notice">
|
||||||
|
<Icon name={info} variant="solid" size="sm" />
|
||||||
|
<span>
|
||||||
|
These ratings are from other CreamLinux users. Results may vary.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onConfirm}>
|
||||||
|
Install anyway
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SmokeAPIVotesDialog
|
||||||
124
src/components/dialogs/UnlockerSelectionDialog.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogHeader,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActions,
|
||||||
|
} from '@/components/dialogs'
|
||||||
|
import { Button } from '@/components/buttons'
|
||||||
|
import { Icon, info } from '@/components/icons'
|
||||||
|
import VotesDisplay, { GameVotes } from '@/components/common/VotesDisplay'
|
||||||
|
|
||||||
|
export interface UnlockerSelectionDialogProps {
|
||||||
|
visible: boolean
|
||||||
|
gameId: string | null
|
||||||
|
gameTitle: string | null
|
||||||
|
onClose: () => void
|
||||||
|
onSelectCreamLinux: () => void
|
||||||
|
onSelectSmokeAPI: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocker Selection Dialog component
|
||||||
|
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games.
|
||||||
|
* Fetches and displays community vote data per unlocker.
|
||||||
|
*/
|
||||||
|
const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
|
||||||
|
visible,
|
||||||
|
gameId,
|
||||||
|
gameTitle,
|
||||||
|
onClose,
|
||||||
|
onSelectCreamLinux,
|
||||||
|
onSelectSmokeAPI,
|
||||||
|
}) => {
|
||||||
|
const [creamVotes, setCreamVotes] = useState<GameVotes | null>(null)
|
||||||
|
const [smokeVotes, setSmokeVotes] = useState<GameVotes | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !gameId) {
|
||||||
|
setCreamVotes(null)
|
||||||
|
setSmokeVotes(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invoke<GameVotes[]>('get_game_votes', { gameId })
|
||||||
|
.then((results) => {
|
||||||
|
setCreamVotes(results.find((v) => v.unlocker === 'creamlinux') ?? null)
|
||||||
|
setSmokeVotes(results.find((v) => v.unlocker === 'smokeapi') ?? null)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Votes are non-critical — silently fall back to "No votes yet"
|
||||||
|
setCreamVotes(null)
|
||||||
|
setSmokeVotes(null)
|
||||||
|
})
|
||||||
|
}, [visible, gameId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog visible={visible} onClose={onClose} size="medium">
|
||||||
|
<DialogHeader onClose={onClose} hideCloseButton={true}>
|
||||||
|
<div className="unlocker-selection-header">
|
||||||
|
<h3>Choose Unlocker</h3>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
<div className="unlocker-selection-content">
|
||||||
|
<p className="game-title-info">
|
||||||
|
Select which unlocker to install for <strong>{gameTitle}</strong>:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="unlocker-options">
|
||||||
|
<div className="unlocker-option recommended">
|
||||||
|
<div className="option-header">
|
||||||
|
<h4>CreamLinux</h4>
|
||||||
|
<span className="recommended-badge">Recommended</span>
|
||||||
|
</div>
|
||||||
|
<p className="option-description">
|
||||||
|
Native Linux DLC unlocker. Works best with most native Linux games and provides
|
||||||
|
better compatibility.
|
||||||
|
</p>
|
||||||
|
<VotesDisplay votes={creamVotes} />
|
||||||
|
<Button variant="primary" onClick={onSelectCreamLinux} fullWidth>
|
||||||
|
Install CreamLinux
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="unlocker-option">
|
||||||
|
<div className="option-header">
|
||||||
|
<h4>SmokeAPI</h4>
|
||||||
|
<span className="alternative-badge">Alternative</span>
|
||||||
|
</div>
|
||||||
|
<p className="option-description">
|
||||||
|
Cross-platform DLC unlocker. Try this if CreamLinux doesn't work for your game.
|
||||||
|
Automatically fetches DLC information.
|
||||||
|
</p>
|
||||||
|
<VotesDisplay votes={smokeVotes} />
|
||||||
|
<Button variant="secondary" onClick={onSelectSmokeAPI} fullWidth>
|
||||||
|
Install SmokeAPI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="selection-info">
|
||||||
|
<Icon name={info} variant="solid" size="md" />
|
||||||
|
<span>
|
||||||
|
You can always uninstall and try the other option if one doesn't work properly.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UnlockerSelectionDialog
|
||||||
@@ -6,10 +6,15 @@ export { default as DialogFooter } from './DialogFooter'
|
|||||||
export { default as DialogActions } from './DialogActions'
|
export { default as DialogActions } from './DialogActions'
|
||||||
export { default as ProgressDialog } from './ProgressDialog'
|
export { default as ProgressDialog } from './ProgressDialog'
|
||||||
export { default as DlcSelectionDialog } from './DlcSelectionDialog'
|
export { default as DlcSelectionDialog } from './DlcSelectionDialog'
|
||||||
|
export { default as AddDlcDialog } from './AddDlcDialog'
|
||||||
export { default as SettingsDialog } from './SettingsDialog'
|
export { default as SettingsDialog } from './SettingsDialog'
|
||||||
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
||||||
export { default as ConflictDialog } from './ConflictDialog'
|
export { default as ConflictDialog } from './ConflictDialog'
|
||||||
export { default as ReminderDialog } from './ReminderDialog'
|
export { default as DisclaimerDialog } from './DisclaimerDialog'
|
||||||
|
export { default as UnlockerSelectionDialog } from './UnlockerSelectionDialog'
|
||||||
|
export { default as OptInDialog } from './OptInDialog'
|
||||||
|
export { default as RatingDialog } from './RatingDialog'
|
||||||
|
export { default as SmokeAPIVotesDialog } from './SmokeAPIVotesDialog'
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { DialogProps } from './Dialog'
|
export type { DialogProps } from './Dialog'
|
||||||
@@ -19,5 +24,8 @@ export type { DialogFooterProps } from './DialogFooter'
|
|||||||
export type { DialogActionsProps } from './DialogActions'
|
export type { DialogActionsProps } from './DialogActions'
|
||||||
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
|
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
|
||||||
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
|
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
|
||||||
export type { ConflictDialogProps } from './ConflictDialog'
|
export type { AddDlcDialogProps } from './AddDlcDialog'
|
||||||
export type { ReminderDialogProps } from './ReminderDialog'
|
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
|
||||||
|
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
|
||||||
|
export type { RatingDialogProps } from './RatingDialog'
|
||||||
|
export type { SmokeAPIVotesDialogProps } from './SmokeAPIVotesDialog'
|
||||||
@@ -9,13 +9,15 @@ interface GameItemProps {
|
|||||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||||
onEdit?: (gameId: string) => void
|
onEdit?: (gameId: string) => void
|
||||||
onSmokeAPISettings?: (gameId: string) => void
|
onSmokeAPISettings?: (gameId: string) => void
|
||||||
|
onRate?: (gameId: string) => void
|
||||||
|
reportingEnabled?: boolean // When false/undefined, rate button is not rendered at all.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual game card component
|
* Individual game card component
|
||||||
* Displays game information and action buttons
|
* Displays game information and action buttons
|
||||||
*/
|
*/
|
||||||
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps) => {
|
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false)
|
||||||
@@ -51,11 +53,14 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
|||||||
}, [game.id, imageUrl])
|
}, [game.id, imageUrl])
|
||||||
|
|
||||||
// Determine if we should show CreamLinux buttons (only for native games)
|
// Determine if we should show CreamLinux buttons (only for native games)
|
||||||
const shouldShowCream = game.native === true
|
const shouldShowCream = game.native && game.cream_installed // Only show if installed (for uninstall)
|
||||||
|
|
||||||
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
||||||
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
|
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
|
||||||
|
|
||||||
|
// Show generic button if nothing installed
|
||||||
|
const shouldShowUnlocker = game.native && !game.cream_installed && !game.smoke_installed
|
||||||
|
|
||||||
// Check if this is a Proton game without API files
|
// Check if this is a Proton game without API files
|
||||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
|
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
|
||||||
|
|
||||||
@@ -71,6 +76,11 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
|||||||
onAction(game.id, action)
|
onAction(game.id, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUnlockerAction = () => {
|
||||||
|
if (game.installing) return
|
||||||
|
onAction(game.id, 'install_unlocker')
|
||||||
|
}
|
||||||
|
|
||||||
// Handle edit button click
|
// Handle edit button click
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
if (onEdit && game.cream_installed) {
|
if (onEdit && game.cream_installed) {
|
||||||
@@ -85,6 +95,13 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rating handler
|
||||||
|
const handleRate = () => {
|
||||||
|
if (onRate && (game.cream_installed || game.smoke_installed)) {
|
||||||
|
onRate(game.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine background image
|
// Determine background image
|
||||||
const backgroundImage =
|
const backgroundImage =
|
||||||
!isLoading && imageUrl
|
!isLoading && imageUrl
|
||||||
@@ -116,17 +133,27 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="game-actions">
|
<div className="game-actions">
|
||||||
{/* Show CreamLinux button only for native games */}
|
{/* Show generic "Install" button for native games with nothing installed */}
|
||||||
|
{shouldShowUnlocker && (
|
||||||
|
<ActionButton
|
||||||
|
action="install_unlocker"
|
||||||
|
isInstalled={false}
|
||||||
|
isWorking={!!game.installing}
|
||||||
|
onClick={handleUnlockerAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show CreamLinux uninstall button if CreamLinux is installed */}
|
||||||
{shouldShowCream && (
|
{shouldShowCream && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
|
action="uninstall_cream"
|
||||||
isInstalled={!!game.cream_installed}
|
isInstalled={true}
|
||||||
isWorking={!!game.installing}
|
isWorking={!!game.installing}
|
||||||
onClick={handleCreamAction}
|
onClick={handleCreamAction}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
|
{/* Show SmokeAPI button for Proton games OR native games with SmokeAPI installed */}
|
||||||
{shouldShowSmoke && (
|
{shouldShowSmoke && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
|
||||||
@@ -136,6 +163,16 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Show SmokeAPI uninstall for native games if installed */}
|
||||||
|
{game.native && game.smoke_installed && (
|
||||||
|
<ActionButton
|
||||||
|
action="uninstall_smoke"
|
||||||
|
isInstalled={true}
|
||||||
|
isWorking={!!game.installing}
|
||||||
|
onClick={handleSmokeAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show message for Proton games without API files */}
|
{/* Show message for Proton games without API files */}
|
||||||
{isProtonNoApi && (
|
{isProtonNoApi && (
|
||||||
<div className="api-not-found-message">
|
<div className="api-not-found-message">
|
||||||
@@ -151,6 +188,20 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Rate button */}
|
||||||
|
{(game.cream_installed || game.smoke_installed) && onRate && reportingEnabled && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleRate}
|
||||||
|
disabled={!!game.installing}
|
||||||
|
title="Rate compatibility"
|
||||||
|
className="edit-button rate-button"
|
||||||
|
leftIcon={<Icon name="Star" variant="solid" size="md" />}
|
||||||
|
iconOnly
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||||
{game.cream_installed && (
|
{game.cream_installed && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ interface GameListProps {
|
|||||||
onAction: (gameId: string, action: ActionType) => Promise<void>
|
onAction: (gameId: string, action: ActionType) => Promise<void>
|
||||||
onEdit?: (gameId: string) => void
|
onEdit?: (gameId: string) => void
|
||||||
onSmokeAPISettings?: (gameId: string) => void
|
onSmokeAPISettings?: (gameId: string) => void
|
||||||
|
onRate?: (gameId: string) => void
|
||||||
|
reportingEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main game list component
|
* Main game list component
|
||||||
* Displays games in a grid with search and filtering applied
|
* Displays games in a grid with search and filtering applied
|
||||||
*/
|
*/
|
||||||
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: GameListProps) => {
|
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameListProps) => {
|
||||||
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
const [imagesPreloaded, setImagesPreloaded] = useState(false)
|
||||||
|
|
||||||
// Sort games alphabetically by title
|
// Sort games alphabetically by title
|
||||||
@@ -57,7 +59,7 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: Ga
|
|||||||
) : (
|
) : (
|
||||||
<div className="game-grid">
|
<div className="game-grid">
|
||||||
{sortedGames.map((game) => (
|
{sortedGames.map((game) => (
|
||||||
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} />
|
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} onRate={onRate} reportingEnabled={reportingEnabled} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const warning = 'Warning'
|
|||||||
export const wine = 'Wine'
|
export const wine = 'Wine'
|
||||||
export const diamond = 'Diamond'
|
export const diamond = 'Diamond'
|
||||||
export const settings = 'Settings'
|
export const settings = 'Settings'
|
||||||
|
export const star = 'Star'
|
||||||
|
|
||||||
// Brand icons
|
// Brand icons
|
||||||
export const discord = 'Discord'
|
export const discord = 'Discord'
|
||||||
@@ -59,6 +60,7 @@ export const IconNames = {
|
|||||||
Wine: wine,
|
Wine: wine,
|
||||||
Diamond: diamond,
|
Diamond: diamond,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
|
Star: star,
|
||||||
|
|
||||||
// Brand icons
|
// Brand icons
|
||||||
Discord: discord,
|
Discord: discord,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
|
||||||
<path d="M20.25 14.75V10.75C20.25 8.83608 20.2477 7.50125 20.1123 6.49411C19.9808 5.51577 19.7401 4.99789 19.3711 4.62888C19.0021 4.25987 18.4842 4.01921 17.5059 3.88767C16.4987 3.75226 15.1639 3.74997 13.25 3.74997H10.75C8.83611 3.74997 7.50128 3.75226 6.49414 3.88767C5.5158 4.01921 4.99792 4.25987 4.62891 4.62888C4.2599 4.99789 4.01924 5.51577 3.8877 6.49411C3.75229 7.50125 3.75 8.83608 3.75 10.75V14.75C3.75 15.3023 3.30229 15.75 2.75 15.75C2.19772 15.75 1.75 15.3023 1.75 14.75V10.75C1.75 8.89262 1.74779 7.39889 1.90528 6.22751C2.06664 5.02741 2.41231 4.01735 3.21485 3.21481C4.01738 2.41228 5.02745 2.06661 6.22754 1.90524C7.39892 1.74776 8.89265 1.74997 10.75 1.74997H13.25C15.1074 1.74997 16.6011 1.74776 17.7725 1.90524C18.9726 2.06661 19.9826 2.41228 20.7852 3.21481C21.5877 4.01735 21.9334 5.02742 22.0947 6.22751C22.2522 7.39889 22.25 8.89262 22.25 10.75V14.75C22.25 15.3023 21.8023 15.75 21.25 15.75C20.6977 15.75 20.25 15.3023 20.25 14.75Z" fill="currentColor" />
|
<path d="M16.1439 10.8544C15.7604 10.7888 15.2902 10.7658 14.7504 10.7567V4.99991C14.7504 4.5833 14.7563 4.22799 14.6732 3.91788C14.4652 3.1414 13.8589 2.5351 13.0824 2.32706C12.7723 2.24399 12.417 2.24991 12.0004 2.24991C11.5838 2.24991 11.2285 2.244 10.9183 2.32706C10.1419 2.5351 9.53459 3.1414 9.32654 3.91788C9.24355 4.22794 9.25037 4.5834 9.25037 4.99991V10.7567C8.71056 10.7658 8.24038 10.7888 7.85681 10.8544C7.344 10.9421 6.77397 11.1384 6.46033 11.6796L6.40174 11.7929L6.35193 11.9081C6.08178 12.5976 6.3948 13.2355 6.73279 13.7284C7.07715 14.2305 7.6246 14.832 8.28226 15.5546L8.31873 15.5946C9.03427 16.3808 9.62531 17.0262 10.1595 17.4687C10.7074 17.9223 11.2882 18.2426 11.9926 18.2499H12.0082C12.7125 18.2426 13.2934 17.9223 13.8412 17.4687C14.3754 17.0262 14.9665 16.3808 15.682 15.5946L15.7185 15.5546C16.3761 14.832 16.9236 14.2305 17.2679 13.7284C17.6059 13.2355 17.919 12.5976 17.6488 11.9081L17.599 11.7929L17.5404 11.6796C17.2268 11.1384 16.6567 10.9421 16.1439 10.8544Z" fill="currentColor" />
|
||||||
<path d="M14.0312 5.74997C15.6419 5.74996 16.9169 5.74997 17.9248 5.86911C18.9557 5.99098 19.8048 6.2463 20.5137 6.82809C20.7541 7.02541 20.9746 7.24589 21.1719 7.4863C21.7537 8.19522 22.009 9.04424 22.1309 10.0752C22.25 11.0831 22.25 12.3581 22.25 13.9687V14.0312C22.25 15.6418 22.25 16.9169 22.1309 17.9248C22.009 18.9557 21.7537 19.8047 21.1719 20.5136C20.9746 20.7541 20.7541 20.9745 20.5137 21.1718C19.8048 21.7536 18.9557 22.009 17.9248 22.1308C16.9169 22.25 15.6419 22.25 14.0312 22.25H9.96875C8.35815 22.25 7.0831 22.25 6.0752 22.1308C5.04427 22.009 4.19525 21.7536 3.48633 21.1718C3.24592 20.9745 3.02544 20.7541 2.82812 20.5136C2.24633 19.8047 1.99101 18.9557 1.86914 17.9248C1.75 16.9169 1.75 15.6418 1.75 14.0312V13.9687C1.75 12.3581 1.75 11.0831 1.86914 10.0752C1.99101 9.04424 2.24633 8.19522 2.82812 7.4863C3.02544 7.24589 3.24592 7.02541 3.48633 6.82809C4.19525 6.2463 5.04427 5.99098 6.0752 5.86911C7.0831 5.74997 8.35815 5.74996 9.96875 5.74997H14.0312ZM12 9.49997C11.4477 9.49997 11 9.94768 11 10.5V15.3906C10.6896 15.0331 10.3585 14.6264 10.1455 14.3535C10.0396 14.2178 9.86489 13.9856 9.80566 13.9072C9.47825 13.4626 8.8519 13.3671 8.40723 13.6943C7.96265 14.0217 7.86716 14.6481 8.19434 15.0927C8.259 15.1784 8.45594 15.4386 8.56934 15.584C8.7953 15.8735 9.10761 16.2629 9.44824 16.6552C9.78455 17.0426 10.1683 17.456 10.5352 17.7802C10.7175 17.9414 10.9198 18.1021 11.1279 18.2275C11.3086 18.3364 11.6228 18.5 12 18.5C12.3772 18.5 12.6914 18.3364 12.8721 18.2275C13.0802 18.1021 13.2825 17.9414 13.4648 17.7802C13.8317 17.4561 14.2154 17.0426 14.5518 16.6552C14.8924 16.2629 15.2047 15.8735 15.4307 15.584C15.5441 15.4386 15.741 15.1784 15.8057 15.0927C16.1328 14.6481 16.0373 14.0227 15.5928 13.6953C15.1481 13.3678 14.5219 13.4635 14.1943 13.9082C14.1351 13.9866 13.9604 14.2178 13.8545 14.3535C13.6415 14.6264 13.3104 15.0331 13 15.3906V10.5C13 9.9477 12.5523 9.49999 12 9.49997Z" fill="currentColor" />
|
<path d="M18.75 19.7499C19.3023 19.7499 19.75 20.1976 19.75 20.7499C19.75 21.3022 19.3023 21.7499 18.75 21.7499H5.25C4.69772 21.7499 4.25 21.3022 4.25 20.7499C4.25 20.1976 4.69772 19.7499 5.25 19.7499H18.75Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -13,6 +13,7 @@ export { ReactComponent as Layers } from './layers.svg'
|
|||||||
export { ReactComponent as Refresh } from './refresh.svg'
|
export { ReactComponent as Refresh } from './refresh.svg'
|
||||||
export { ReactComponent as Search } from './search.svg'
|
export { ReactComponent as Search } from './search.svg'
|
||||||
export { ReactComponent as Settings } from './settings.svg'
|
export { ReactComponent as Settings } from './settings.svg'
|
||||||
|
export { ReactComponent as Star } from './star.svg'
|
||||||
export { ReactComponent as Trash } from './trash.svg'
|
export { ReactComponent as Trash } from './trash.svg'
|
||||||
export { ReactComponent as Warning } from './warning.svg'
|
export { ReactComponent as Warning } from './warning.svg'
|
||||||
export { ReactComponent as Wine } from './wine.svg'
|
export { ReactComponent as Wine } from './wine.svg'
|
||||||
3
src/components/icons/ui/solid/star.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none">
|
||||||
|
<path d="M11.9961 1.25C13.0454 1.25 13.8719 2.04253 14.3995 3.11191L16.1616 6.66516C16.215 6.77513 16.3417 6.92998 16.5321 7.07164C16.7223 7.21315 16.9086 7.29121 17.0311 7.3118L20.2207 7.84613C21.3729 8.03973 22.3386 8.60449 22.6521 9.5879C22.9653 10.5705 22.5064 11.5916 21.6778 12.4216L21.677 12.4225L19.1991 14.9209C19.1009 15.0199 18.9909 15.2064 18.9219 15.4494C18.8534 15.6908 18.8473 15.9107 18.8784 16.0527L18.8788 16.0547L19.5877 19.1454C19.8818 20.4317 19.7843 21.7073 18.8771 22.3742C17.9667 23.0433 16.7227 22.7467 15.5925 22.0736L12.6026 20.289C12.477 20.214 12.2614 20.1532 12.0011 20.1532C11.7427 20.1532 11.5226 20.2132 11.3888 20.291L11.3869 20.2921L8.40288 22.0732C7.27405 22.7487 6.03154 23.04 5.12111 22.3702C4.21449 21.7032 4.11214 20.43 4.40711 19.1447L5.1159 16.0547L5.11633 16.0527C5.14741 15.9107 5.14133 15.6908 5.0728 15.4494C5.0038 15.2064 4.89379 15.0199 4.79558 14.9209L2.31585 12.4206C1.49265 11.5906 1.03521 10.5704 1.34595 9.58925C1.65759 8.60525 2.62143 8.0398 3.77433 7.84606L6.96132 7.31219L6.96233 7.31202C7.07917 7.29175 7.2627 7.21456 7.45248 7.07268C7.64261 6.93054 7.76959 6.77535 7.82312 6.66516L7.82582 6.65967L9.58562 3.11097L9.58632 3.10957C10.119 2.04108 10.948 1.25 11.9961 1.25Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M14 21.5H10C6.71252 21.5 5.06878 21.5 3.96243 20.592C3.75989 20.4258 3.57418 20.2401 3.40796 20.0376C2.5 18.9312 2.5 17.2875 2.5 14C2.5 10.7125 2.5 9.06878 3.40796 7.96243C3.57418 7.75989 3.75989 7.57418 3.96243 7.40796C5.06878 6.5 6.71252 6.5 10 6.5H14C17.2875 6.5 18.9312 6.5 20.0376 7.40796C20.2401 7.57418 20.4258 7.75989 20.592 7.96243C21.5 9.06878 21.5 10.7125 21.5 14C21.5 17.2875 21.5 18.9312 20.592 20.0376C20.4258 20.2401 20.2401 20.4258 20.0376 20.592C18.9312 21.5 17.2875 21.5 14 21.5Z" />
|
<path d="M16.9504 12.1817C17.1981 12.814 16.5076 13.5726 15.1267 15.0899C13.6702 16.6902 12.9201 17.4904 12 17.5C11.0799 17.4904 10.3298 16.6902 8.87331 15.0899C7.49239 13.5726 6.80193 12.814 7.04964 12.1817C7.05868 12.1586 7.06851 12.1359 7.0791 12.1135C7.34928 11.542 8.24477 11.5029 10 11.5002V4.99998C10 4.53501 10 4.30253 10.0511 4.11179C10.1898 3.59414 10.5941 3.1898 11.1118 3.05111C11.3025 3 11.535 3 12 3C12.4649 3 12.6974 3 12.8882 3.05111C13.4058 3.1898 13.8102 3.59414 13.9489 4.11179C14 4.30253 14 4.53501 14 4.99998V11.5002C15.7552 11.5029 16.6507 11.542 16.9209 12.1135C16.9315 12.1359 16.9413 12.1586 16.9504 12.1817Z" />
|
||||||
<path d="M2.5 14.5V10.5C2.5 6.72876 2.5 4.84315 3.67157 3.67157C4.84315 2.5 6.72876 2.5 10.5 2.5H13.5C17.2712 2.5 19.1569 2.5 20.3284 3.67157C21.5 4.84315 21.5 6.72876 21.5 10.5V14.5" />
|
<path d="M5.00006 21H19.0001" />
|
||||||
<path d="M15 14.5C15 14.5 12.7905 17.4999 12 17.4999C11.2094 17.5 9 14.4999 9 14.4999M12 17L12 10.5" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 885 B |
@@ -13,6 +13,7 @@ export { ReactComponent as Layers } from './layers.svg'
|
|||||||
export { ReactComponent as Refresh } from './refresh.svg'
|
export { ReactComponent as Refresh } from './refresh.svg'
|
||||||
export { ReactComponent as Search } from './search.svg'
|
export { ReactComponent as Search } from './search.svg'
|
||||||
export { ReactComponent as Settings } from './settings.svg'
|
export { ReactComponent as Settings } from './settings.svg'
|
||||||
|
export { ReactComponent as Star } from './star.svg'
|
||||||
export { ReactComponent as Trash } from './trash.svg'
|
export { ReactComponent as Trash } from './trash.svg'
|
||||||
export { ReactComponent as Warning } from './warning.svg'
|
export { ReactComponent as Warning } from './warning.svg'
|
||||||
export { ReactComponent as Wine } from './wine.svg'
|
export { ReactComponent as Wine } from './wine.svg'
|
||||||
3
src/components/icons/ui/stroke/star.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="currentColor" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M13.7276 3.44418L15.4874 6.99288C15.7274 7.48687 16.3673 7.9607 16.9073 8.05143L20.0969 8.58575C22.1367 8.92853 22.6167 10.4206 21.1468 11.8925L18.6671 14.3927C18.2471 14.8161 18.0172 15.6327 18.1471 16.2175L18.8571 19.3125C19.417 21.7623 18.1271 22.71 15.9774 21.4296L12.9877 19.6452C12.4478 19.3226 11.5579 19.3226 11.0079 19.6452L8.01827 21.4296C5.8785 22.71 4.57865 21.7522 5.13859 19.3125L5.84851 16.2175C5.97849 15.6327 5.74852 14.8161 5.32856 14.3927L2.84884 11.8925C1.389 10.4206 1.85895 8.92853 3.89872 8.58575L7.08837 8.05143C7.61831 7.9607 8.25824 7.48687 8.49821 6.99288L10.258 3.44418C11.2179 1.51861 12.7777 1.51861 13.7276 3.44418Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 875 B |
@@ -26,6 +26,14 @@ export interface SmokeAPISettingsDialogState {
|
|||||||
gameTitle: string
|
gameTitle: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RatingDialogState {
|
||||||
|
visible: boolean
|
||||||
|
gameId: string
|
||||||
|
gameTitle: string
|
||||||
|
unlocker: 'creamlinux' | 'smokeapi'
|
||||||
|
steamPath: string
|
||||||
|
}
|
||||||
|
|
||||||
// Define the context type
|
// Define the context type
|
||||||
export interface AppContextType {
|
export interface AppContextType {
|
||||||
// Game state
|
// Game state
|
||||||
@@ -56,12 +64,38 @@ export interface AppContextType {
|
|||||||
handleSmokeAPISettingsOpen: (gameId: string) => void
|
handleSmokeAPISettingsOpen: (gameId: string) => void
|
||||||
handleSmokeAPISettingsClose: () => void
|
handleSmokeAPISettingsClose: () => void
|
||||||
|
|
||||||
|
// SmokeAPI votes dialog
|
||||||
|
smokeAPIVotesDialog: {
|
||||||
|
visible: boolean
|
||||||
|
gameId: string | null
|
||||||
|
gameTitle: string | null
|
||||||
|
}
|
||||||
|
handleSmokeAPIVotesClose: () => void
|
||||||
|
handleSmokeAPIVotesConfirm: () => void
|
||||||
|
|
||||||
|
// Rating dialog
|
||||||
|
ratingDialog: RatingDialogState
|
||||||
|
handleOpenRating: (gameId: string) => void
|
||||||
|
handleCloseRating: () => void
|
||||||
|
handleSubmitRating: (worked: boolean) => Promise<void>
|
||||||
|
reportingEnabled: boolean
|
||||||
|
|
||||||
// Toast notifications
|
// Toast notifications
|
||||||
showToast: (
|
showToast: (
|
||||||
message: string,
|
message: string,
|
||||||
type: 'success' | 'error' | 'warning' | 'info',
|
type: 'success' | 'error' | 'warning' | 'info',
|
||||||
options?: Record<string, unknown>
|
options?: Record<string, unknown>
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
|
// Unlocker selection
|
||||||
|
unlockerSelectionDialog: {
|
||||||
|
visible: boolean
|
||||||
|
gameId: string | null
|
||||||
|
gameTitle: string | null
|
||||||
|
}
|
||||||
|
handleSelectCreamLinux: () => void
|
||||||
|
handleSelectSmokeAPI: () => void
|
||||||
|
closeUnlockerDialog: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the context with a default value
|
// Create the context with a default value
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ReactNode, useState } from 'react'
|
import { ReactNode, useState, useEffect } from 'react'
|
||||||
import { AppContext, AppContextType } from './AppContext'
|
import { AppContext, AppContextType } from './AppContext'
|
||||||
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
||||||
import { DlcInfo } from '@/types'
|
import { DlcInfo, Config } from '@/types'
|
||||||
import { ActionType } from '@/components/buttons/ActionButton'
|
import { ActionType } from '@/components/buttons/ActionButton'
|
||||||
import { ToastContainer } from '@/components/notifications'
|
import { ToastContainer } from '@/components/notifications'
|
||||||
import { SmokeAPISettingsDialog } from '@/components/dialogs'
|
import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog } from '@/components/dialogs'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
// Context provider component
|
// Context provider component
|
||||||
interface AppProviderProps {
|
interface AppProviderProps {
|
||||||
@@ -33,6 +34,8 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
handleCloseProgressDialog,
|
handleCloseProgressDialog,
|
||||||
handleGameAction: executeGameAction,
|
handleGameAction: executeGameAction,
|
||||||
handleDlcConfirm: executeDlcConfirm,
|
handleDlcConfirm: executeDlcConfirm,
|
||||||
|
unlockerSelectionDialog,
|
||||||
|
closeUnlockerDialog,
|
||||||
} = useGameActions()
|
} = useGameActions()
|
||||||
|
|
||||||
const { toasts, removeToast, success, error: showError, warning, info } = useToasts()
|
const { toasts, removeToast, success, error: showError, warning, info } = useToasts()
|
||||||
@@ -51,6 +54,47 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
gameTitle: '',
|
gameTitle: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SmokeAPI votes dialog state
|
||||||
|
const [smokeAPIVotesDialog, setSmokeAPIVotesDialog] = useState<{
|
||||||
|
visible: boolean
|
||||||
|
gameId: string | null
|
||||||
|
gameTitle: string | null
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
gameId: null,
|
||||||
|
gameTitle: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Opt-in dialog state
|
||||||
|
const [optInDialog, setOptInDialog] = useState(false)
|
||||||
|
const [reportingEnabled, setReportingEnabled] = useState(false)
|
||||||
|
|
||||||
|
// Rating dialog state
|
||||||
|
const [ratingDialog, setRatingDialog] = useState<{
|
||||||
|
visible: boolean
|
||||||
|
gameId: string
|
||||||
|
gameTitle: string
|
||||||
|
unlocker: 'creamlinux' | 'smokeapi'
|
||||||
|
steamPath: string
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
gameId: '',
|
||||||
|
gameTitle: '',
|
||||||
|
unlocker: 'creamlinux',
|
||||||
|
steamPath: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<Config>('load_config')
|
||||||
|
.then((cfg) => {
|
||||||
|
setReportingEnabled(cfg.reporting_opted_in)
|
||||||
|
if (!cfg.reporting_has_seen_prompt) {
|
||||||
|
setOptInDialog(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to load config for reporting check:', err))
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Settings handlers
|
// Settings handlers
|
||||||
const handleSettingsOpen = () => {
|
const handleSettingsOpen = () => {
|
||||||
setSettingsDialog({ visible: true })
|
setSettingsDialog({ visible: true })
|
||||||
@@ -76,7 +120,74 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSmokeAPISettingsClose = () => {
|
const handleSmokeAPISettingsClose = () => {
|
||||||
setSmokeAPISettingsDialog((prev) => ({ ...prev, visible: false }))
|
setSmokeAPISettingsDialog({
|
||||||
|
visible: false,
|
||||||
|
gamePath: '',
|
||||||
|
gameTitle: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSmokeAPIVotesClose = () => {
|
||||||
|
setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSmokeAPIVotesConfirm = () => {
|
||||||
|
const gameId = smokeAPIVotesDialog.gameId
|
||||||
|
setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null })
|
||||||
|
if (gameId) {
|
||||||
|
// Now actually run the install
|
||||||
|
executeGameAction(gameId, 'install_smoke', games)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOptInAccept = async () => {
|
||||||
|
try {
|
||||||
|
await invoke('set_reporting_opt_in', { optedIn: true })
|
||||||
|
setReportingEnabled(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save reporting opt-in:', err)
|
||||||
|
}
|
||||||
|
setOptInDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOptInDecline = async () => {
|
||||||
|
try {
|
||||||
|
await invoke('set_reporting_opt_in', { optedIn: false })
|
||||||
|
setReportingEnabled(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save reporting opt-out:', err)
|
||||||
|
}
|
||||||
|
setOptInDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenRating = (gameId: string) => {
|
||||||
|
const game = games.find((g) => g.id === gameId)
|
||||||
|
if (!game) return
|
||||||
|
|
||||||
|
setRatingDialog({
|
||||||
|
visible: true,
|
||||||
|
gameId,
|
||||||
|
gameTitle: game.title,
|
||||||
|
unlocker: game.cream_installed ? 'creamlinux' : 'smokeapi',
|
||||||
|
steamPath: game.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseRating = () => {
|
||||||
|
setRatingDialog((prev) => ({ ...prev, visible: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitRating = async (worked: boolean) => {
|
||||||
|
try {
|
||||||
|
await invoke('submit_report', {
|
||||||
|
gameId: ratingDialog.gameId,
|
||||||
|
unlocker: ratingDialog.unlocker,
|
||||||
|
worked,
|
||||||
|
steamPath: ratingDialog.steamPath,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to submit rating:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game action handler with proper error reporting
|
// Game action handler with proper error reporting
|
||||||
@@ -111,6 +222,38 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// intercept install_smoke for votes dialog
|
||||||
|
if (action === 'install_smoke' && !game.native) {
|
||||||
|
setSmokeAPIVotesDialog({
|
||||||
|
visible: true,
|
||||||
|
gameId: game.id,
|
||||||
|
gameTitle: game.title,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For install_unlocker action, executeGameAction will handle showing the dialog
|
||||||
|
// We should NOT show any notifications here - they'll be shown after actual installation
|
||||||
|
if (action === 'install_unlocker') {
|
||||||
|
// Mark game as installing while the user makes a selection
|
||||||
|
setGames((prevGames) =>
|
||||||
|
prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This will show the UnlockerSelectionDialog and handle the callback
|
||||||
|
await executeGameAction(gameId, action, games)
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Action failed: ${error}`)
|
||||||
|
} finally {
|
||||||
|
// Reset installing state
|
||||||
|
setGames((prevGames) =>
|
||||||
|
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return // Don't show any notifications for install_unlocker
|
||||||
|
}
|
||||||
|
|
||||||
// For other actions (uninstall cream, install/uninstall smoke)
|
// For other actions (uninstall cream, install/uninstall smoke)
|
||||||
// Mark game as installing
|
// Mark game as installing
|
||||||
setGames((prevGames) =>
|
setGames((prevGames) =>
|
||||||
@@ -121,7 +264,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
await executeGameAction(gameId, action, games)
|
await executeGameAction(gameId, action, games)
|
||||||
|
|
||||||
// Show appropriate success message based on action type
|
// Show appropriate success message based on action type
|
||||||
const product = action.includes('cream') ? 'Creamlinux' : 'SmokeAPI'
|
const product = action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'
|
||||||
const isUninstall = action.includes('uninstall')
|
const isUninstall = action.includes('uninstall')
|
||||||
const isInstall = action.includes('install') && !isUninstall
|
const isInstall = action.includes('install') && !isUninstall
|
||||||
|
|
||||||
@@ -239,8 +382,67 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
handleSmokeAPISettingsOpen,
|
handleSmokeAPISettingsOpen,
|
||||||
handleSmokeAPISettingsClose,
|
handleSmokeAPISettingsClose,
|
||||||
|
|
||||||
|
// SmokeAPI Votes
|
||||||
|
smokeAPIVotesDialog,
|
||||||
|
handleSmokeAPIVotesClose,
|
||||||
|
handleSmokeAPIVotesConfirm,
|
||||||
|
|
||||||
|
// Rating
|
||||||
|
ratingDialog,
|
||||||
|
handleOpenRating,
|
||||||
|
handleCloseRating,
|
||||||
|
handleSubmitRating,
|
||||||
|
reportingEnabled,
|
||||||
|
|
||||||
// Toast notifications
|
// Toast notifications
|
||||||
showToast,
|
showToast,
|
||||||
|
|
||||||
|
// Unlocker selection - Pass wrapped handlers that also handle the installing state
|
||||||
|
unlockerSelectionDialog,
|
||||||
|
handleSelectCreamLinux: () => {
|
||||||
|
// When CreamLinux is selected, trigger the DLC dialog flow
|
||||||
|
const gameId = unlockerSelectionDialog.gameId
|
||||||
|
if (gameId) {
|
||||||
|
const game = games.find((g) => g.id === gameId)
|
||||||
|
if (game) {
|
||||||
|
|
||||||
|
closeUnlockerDialog()
|
||||||
|
|
||||||
|
// Reset installing state before showing DLC dialog
|
||||||
|
setGames((prevGames) =>
|
||||||
|
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
|
||||||
|
)
|
||||||
|
// Show DLC selection dialog directly
|
||||||
|
setDlcDialog({
|
||||||
|
...dlcDialog,
|
||||||
|
visible: true,
|
||||||
|
gameId,
|
||||||
|
gameTitle: game.title,
|
||||||
|
dlcs: [],
|
||||||
|
isLoading: true,
|
||||||
|
isEditMode: false,
|
||||||
|
progress: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
streamGameDlcs(gameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleSelectSmokeAPI: () => {
|
||||||
|
// When SmokeAPI is selected, trigger the actual installation
|
||||||
|
const gameId = unlockerSelectionDialog.gameId
|
||||||
|
if (gameId) {
|
||||||
|
const game = games.find((g) => g.id === gameId)
|
||||||
|
if (game) {
|
||||||
|
closeUnlockerDialog()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
handleGameAction(gameId, 'install_smoke')
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeUnlockerDialog,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -255,6 +457,32 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
gamePath={smokeAPISettingsDialog.gamePath}
|
gamePath={smokeAPISettingsDialog.gamePath}
|
||||||
gameTitle={smokeAPISettingsDialog.gameTitle}
|
gameTitle={smokeAPISettingsDialog.gameTitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* SmokeAPI Votes Dialog */}
|
||||||
|
<SmokeAPIVotesDialog
|
||||||
|
visible={smokeAPIVotesDialog.visible}
|
||||||
|
gameId={smokeAPIVotesDialog.gameId}
|
||||||
|
gameTitle={smokeAPIVotesDialog.gameTitle}
|
||||||
|
onClose={handleSmokeAPIVotesClose}
|
||||||
|
onConfirm={handleSmokeAPIVotesConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Opt-in Dialog */}
|
||||||
|
<OptInDialog
|
||||||
|
visible={optInDialog}
|
||||||
|
onAccept={handleOptInAccept}
|
||||||
|
onDecline={handleOptInDecline}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rating Dialog */}
|
||||||
|
<RatingDialog
|
||||||
|
visible={ratingDialog.visible}
|
||||||
|
gameId={ratingDialog.gameId}
|
||||||
|
gameTitle={ratingDialog.gameTitle}
|
||||||
|
unlocker={ratingDialog.unlocker}
|
||||||
|
onClose={handleCloseRating}
|
||||||
|
onSubmit={handleSubmitRating}
|
||||||
|
/>
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ export { useGameActions } from './useGameActions'
|
|||||||
export { useToasts } from './useToasts'
|
export { useToasts } from './useToasts'
|
||||||
export { useAppLogic } from './useAppLogic'
|
export { useAppLogic } from './useAppLogic'
|
||||||
export { useConflictDetection } from './useConflictDetection'
|
export { useConflictDetection } from './useConflictDetection'
|
||||||
|
export { useDisclaimer } from './useDisclaimer'
|
||||||
|
export { useUnlockerSelection } from './useUnlockerSelection'
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { ToastType, Toast, ToastOptions } from './useToasts'
|
export type { ToastType, Toast, ToastOptions } from './useToasts'
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface Conflict {
|
|||||||
|
|
||||||
export interface ConflictResolution {
|
export interface ConflictResolution {
|
||||||
gameId: string
|
gameId: string
|
||||||
removeFiles: boolean
|
|
||||||
conflictType: 'cream-to-proton' | 'smoke-to-native'
|
conflictType: 'cream-to-proton' | 'smoke-to-native'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,10 +18,9 @@ export interface ConflictResolution {
|
|||||||
*/
|
*/
|
||||||
export function useConflictDetection(games: Game[]) {
|
export function useConflictDetection(games: Game[]) {
|
||||||
const [conflicts, setConflicts] = useState<Conflict[]>([])
|
const [conflicts, setConflicts] = useState<Conflict[]>([])
|
||||||
const [currentConflict, setCurrentConflict] = useState<Conflict | null>(null)
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
const [showReminder, setShowReminder] = useState(false)
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
|
||||||
const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set())
|
const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set())
|
||||||
|
const [hasShownThisSession, setHasShownThisSession] = useState(false)
|
||||||
|
|
||||||
// Detect conflicts whenever games change
|
// Detect conflicts whenever games change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,8 +41,8 @@ export function useConflictDetection(games: Game[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conflict 2: SmokeAPI installed but game is now Native
|
// Conflict 2: Orphaned Proton SmokeAPI DLL files on a native game
|
||||||
if (game.native && game.smoke_installed) {
|
if (game.native && game.smoke_installed && game.api_files && game.api_files.length > 0) {
|
||||||
detectedConflicts.push({
|
detectedConflicts.push({
|
||||||
gameId: game.id,
|
gameId: game.id,
|
||||||
gameTitle: game.title,
|
gameTitle: game.title,
|
||||||
@@ -55,69 +53,50 @@ export function useConflictDetection(games: Game[]) {
|
|||||||
|
|
||||||
setConflicts(detectedConflicts)
|
setConflicts(detectedConflicts)
|
||||||
|
|
||||||
// Show the first conflict if we have any and not currently processing
|
// Show dialog only if:
|
||||||
if (detectedConflicts.length > 0 && !currentConflict && !isProcessing) {
|
// 1. We have conflicts
|
||||||
setCurrentConflict(detectedConflicts[0])
|
// 2. Dialog isn't already visible
|
||||||
|
// 3. We haven't shown it this session
|
||||||
|
if (detectedConflicts.length > 0 && !showDialog && !hasShownThisSession) {
|
||||||
|
setShowDialog(true)
|
||||||
|
setHasShownThisSession(true)
|
||||||
}
|
}
|
||||||
}, [games, currentConflict, isProcessing, resolvedConflicts])
|
}, [games, resolvedConflicts, showDialog, hasShownThisSession])
|
||||||
|
|
||||||
// Handle conflict resolution
|
// Handle resolving a single conflict
|
||||||
const resolveConflict = useCallback((): ConflictResolution | null => {
|
const resolveConflict = useCallback(
|
||||||
if (!currentConflict || isProcessing) return null
|
(gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native'): ConflictResolution => {
|
||||||
|
// Mark this game as resolved
|
||||||
|
setResolvedConflicts((prev) => new Set(prev).add(gameId))
|
||||||
|
|
||||||
setIsProcessing(true)
|
// Remove from conflicts list
|
||||||
|
setConflicts((prev) => prev.filter((c) => c.gameId !== gameId))
|
||||||
|
|
||||||
const resolution: ConflictResolution = {
|
return {
|
||||||
gameId: currentConflict.gameId,
|
gameId,
|
||||||
removeFiles: true, // Always remove files
|
conflictType,
|
||||||
conflictType: currentConflict.type,
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-close dialog when all conflicts are resolved
|
||||||
|
useEffect(() => {
|
||||||
|
if (conflicts.length === 0 && showDialog) {
|
||||||
|
setShowDialog(false)
|
||||||
}
|
}
|
||||||
|
}, [conflicts.length, showDialog])
|
||||||
|
|
||||||
// Mark this game as resolved so we don't re-detect the conflict
|
// Handle dialog close
|
||||||
setResolvedConflicts((prev) => new Set(prev).add(currentConflict.gameId))
|
const closeDialog = useCallback(() => {
|
||||||
|
setShowDialog(false)
|
||||||
// Remove this conflict from the list
|
}, [])
|
||||||
const remainingConflicts = conflicts.filter((c) => c.gameId !== currentConflict.gameId)
|
|
||||||
setConflicts(remainingConflicts)
|
|
||||||
|
|
||||||
// Close current conflict dialog immediately
|
|
||||||
setCurrentConflict(null)
|
|
||||||
|
|
||||||
// Determine what to show next based on conflict type
|
|
||||||
if (resolution.conflictType === 'cream-to-proton') {
|
|
||||||
// CreamLinux removal - show reminder after delay
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowReminder(true)
|
|
||||||
setIsProcessing(false)
|
|
||||||
}, 100)
|
|
||||||
} else {
|
|
||||||
// SmokeAPI removal - no reminder, just show next conflict or finish
|
|
||||||
setTimeout(() => {
|
|
||||||
if (remainingConflicts.length > 0) {
|
|
||||||
setCurrentConflict(remainingConflicts[0])
|
|
||||||
}
|
|
||||||
setIsProcessing(false)
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolution
|
|
||||||
}, [currentConflict, conflicts, isProcessing])
|
|
||||||
|
|
||||||
// Close reminder dialog
|
|
||||||
const closeReminder = useCallback(() => {
|
|
||||||
setShowReminder(false)
|
|
||||||
|
|
||||||
// After closing reminder, check if there are more conflicts
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
setCurrentConflict(conflicts[0])
|
|
||||||
}
|
|
||||||
}, [conflicts])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentConflict,
|
conflicts,
|
||||||
showReminder,
|
showDialog,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
closeReminder,
|
closeDialog,
|
||||||
hasConflicts: conflicts.length > 0,
|
hasConflicts: conflicts.length > 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
58
src/hooks/useDisclaimer.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { Config } from '@/types/Config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage disclaimer dialog state
|
||||||
|
* Loads config on mount and provides methods to update it
|
||||||
|
*/
|
||||||
|
export function useDisclaimer() {
|
||||||
|
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
// Load config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const config = await invoke<Config>('load_config')
|
||||||
|
setShowDisclaimer(config.show_disclaimer)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error)
|
||||||
|
// Default to showing disclaimer if config load fails
|
||||||
|
setShowDisclaimer(true)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisclaimerClose = async (dontShowAgain: boolean) => {
|
||||||
|
setShowDisclaimer(false)
|
||||||
|
|
||||||
|
if (dontShowAgain) {
|
||||||
|
try {
|
||||||
|
// Load the current config first
|
||||||
|
const currentConfig = await invoke<Config>('load_config')
|
||||||
|
|
||||||
|
// Update the show_disclaimer field
|
||||||
|
const updatedConfig: Config = {
|
||||||
|
...currentConfig,
|
||||||
|
show_disclaimer: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated config
|
||||||
|
await invoke('update_config', { configData: updatedConfig })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update config:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showDisclaimer,
|
||||||
|
isLoading,
|
||||||
|
handleDisclaimerClose,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { listen } from '@tauri-apps/api/event'
|
|||||||
import { ActionType } from '@/components/buttons/ActionButton'
|
import { ActionType } from '@/components/buttons/ActionButton'
|
||||||
import { Game, DlcInfo } from '@/types'
|
import { Game, DlcInfo } from '@/types'
|
||||||
import { InstallationInstructions } from '@/contexts/AppContext'
|
import { InstallationInstructions } from '@/contexts/AppContext'
|
||||||
|
import { useUnlockerSelection } from './useUnlockerSelection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing game action operations
|
* Hook for managing game action operations
|
||||||
@@ -79,22 +80,38 @@ export function useGameActions() {
|
|||||||
setProgressDialog((prev) => ({ ...prev, visible: false }))
|
setProgressDialog((prev) => ({ ...prev, visible: false }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Unlocker selection hook for native games
|
||||||
|
const {
|
||||||
|
selectionState,
|
||||||
|
showUnlockerSelection,
|
||||||
|
handleSelectCreamLinux,
|
||||||
|
handleSelectSmokeAPI,
|
||||||
|
closeDialog: closeUnlockerDialog,
|
||||||
|
} = useUnlockerSelection()
|
||||||
|
|
||||||
// Unified handler for game actions (install/uninstall)
|
// Unified handler for game actions (install/uninstall)
|
||||||
const handleGameAction = useCallback(
|
const handleGameAction = useCallback(
|
||||||
async (gameId: string, action: ActionType, games: Game[]) => {
|
async (gameId: string, action: ActionType, games: Game[]) => {
|
||||||
try {
|
try {
|
||||||
// For CreamLinux installation, we should NOT call process_game_action directly
|
// Find the game
|
||||||
// Instead, we show the DLC selection dialog first, which is handled in AppProvider
|
const game = games.find((g) => g.id === gameId)
|
||||||
|
if (!game) return
|
||||||
|
|
||||||
|
// For CreamLinux installation, DLC dialog is handled in AppProvider
|
||||||
if (action === 'install_cream') {
|
if (action === 'install_cream') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other actions (uninstall_cream, install_smoke, uninstall_smoke)
|
// Handle generic "install_unlocker" action for native games
|
||||||
// Find game to get title
|
if (action === 'install_unlocker') {
|
||||||
const game = games.find((g) => g.id === gameId)
|
showUnlockerSelection(game, (chosenAction: ActionType) => {
|
||||||
if (!game) return
|
// User chose, now proceed with that action
|
||||||
|
handleGameAction(gameId, chosenAction, games)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get title based on action
|
// For other actions (uninstall_cream, install_smoke, uninstall_smoke)
|
||||||
const isCream = action.includes('cream')
|
const isCream = action.includes('cream')
|
||||||
const isInstall = action.includes('install')
|
const isInstall = action.includes('install')
|
||||||
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
|
||||||
@@ -138,7 +155,7 @@ export function useGameActions() {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[showUnlockerSelection]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle DLC selection confirmation
|
// Handle DLC selection confirmation
|
||||||
@@ -231,5 +248,9 @@ export function useGameActions() {
|
|||||||
handleCloseProgressDialog,
|
handleCloseProgressDialog,
|
||||||
handleGameAction,
|
handleGameAction,
|
||||||
handleDlcConfirm,
|
handleDlcConfirm,
|
||||||
|
unlockerSelectionDialog: selectionState,
|
||||||
|
handleSelectCreamLinux,
|
||||||
|
handleSelectSmokeAPI,
|
||||||
|
closeUnlockerDialog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
src/hooks/useUnlockerSelection.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { Game } from '@/types'
|
||||||
|
import { ActionType } from '@/components/buttons'
|
||||||
|
|
||||||
|
export interface UnlockerSelectionState {
|
||||||
|
visible: boolean
|
||||||
|
gameId: string | null
|
||||||
|
gameTitle: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing unlocker selection on native games
|
||||||
|
*/
|
||||||
|
export function useUnlockerSelection() {
|
||||||
|
const [selectionState, setSelectionState] = useState<UnlockerSelectionState>({
|
||||||
|
visible: false,
|
||||||
|
gameId: null,
|
||||||
|
gameTitle: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store the callback to call when user makes a selection
|
||||||
|
const [selectionCallback, setSelectionCallback] = useState<((action: ActionType) => void) | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show the dialog and store the callback
|
||||||
|
const showUnlockerSelection = useCallback(
|
||||||
|
(game: Game, callback: (action: ActionType) => void) => {
|
||||||
|
setSelectionState({
|
||||||
|
visible: true,
|
||||||
|
gameId: game.id,
|
||||||
|
gameTitle: game.title,
|
||||||
|
})
|
||||||
|
// Wrap in function to avoid stale closure
|
||||||
|
setSelectionCallback(() => callback)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// User selected CreamLinux
|
||||||
|
const handleSelectCreamLinux = useCallback(() => {
|
||||||
|
setSelectionState({ visible: false, gameId: null, gameTitle: null })
|
||||||
|
if (selectionCallback) {
|
||||||
|
selectionCallback('install_cream')
|
||||||
|
}
|
||||||
|
setSelectionCallback(null)
|
||||||
|
}, [selectionCallback])
|
||||||
|
|
||||||
|
// User selected SmokeAPI
|
||||||
|
const handleSelectSmokeAPI = useCallback(() => {
|
||||||
|
setSelectionState({ visible: false, gameId: null, gameTitle: null })
|
||||||
|
if (selectionCallback) {
|
||||||
|
selectionCallback('install_smoke')
|
||||||
|
}
|
||||||
|
setSelectionCallback(null)
|
||||||
|
}, [selectionCallback])
|
||||||
|
|
||||||
|
// Close dialog without selection
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setSelectionState({ visible: false, gameId: null, gameTitle: null })
|
||||||
|
setSelectionCallback(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectionState,
|
||||||
|
showUnlockerSelection,
|
||||||
|
handleSelectCreamLinux,
|
||||||
|
handleSelectSmokeAPI,
|
||||||
|
closeDialog,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,3 +160,33 @@
|
|||||||
transform: rotate(360deg);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
@forward './loading';
|
@forward './loading';
|
||||||
@forward './progress_bar';
|
@forward './progress_bar';
|
||||||
@forward './dropdown';
|
@forward './dropdown';
|
||||||
|
@forward './votes_display';
|
||||||
|
|||||||
43
src/styles/components/common/_votes_display.scss
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@use '../../themes/index' as *;
|
||||||
|
@use '../../abstracts/index' as *;
|
||||||
|
|
||||||
|
.unlocker-votes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
|
||||||
|
.votes-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--border-soft);
|
||||||
|
border-radius: 99px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.votes-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--success);
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.votes-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
&.votes-label--positive {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.votes-label--negative {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.votes-label--none {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,64 +25,119 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conflict-dialog-body {
|
.conflict-dialog-body {
|
||||||
p {
|
display: flex;
|
||||||
margin-bottom: 1rem;
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.conflict-intro {
|
||||||
|
margin: 0;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
&:last-of-type {
|
.conflict-list {
|
||||||
margin-bottom: 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
strong {
|
.conflict-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; // Enable text truncation
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: var(--semibold);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: var(--bold);
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-resolve-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
.conflict-reminder {
|
||||||
Reminder Dialog Styles
|
|
||||||
Used for Steam launch option reminders
|
|
||||||
*/
|
|
||||||
|
|
||||||
.reminder-dialog-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
h3 {
|
background: rgba(33, 150, 243, 0.1);
|
||||||
margin: 0;
|
border: 1px solid rgba(33, 150, 243, 0.2);
|
||||||
flex: 1;
|
border-radius: 6px;
|
||||||
font-size: 1.1rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-secondary);
|
||||||
}
|
line-height: 1.4;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: var(--info);
|
color: var(--info);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-dialog-body {
|
span {
|
||||||
p {
|
flex: 1;
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-steps {
|
code {
|
||||||
margin: 1rem 0 0 1.5rem;
|
padding: 0.125rem 0.375rem;
|
||||||
padding: 0;
|
background: rgba(0, 0, 0, 0.3);
|
||||||
color: var(--text-secondary);
|
border-radius: 3px;
|
||||||
line-height: 1.6;
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
li {
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
white-space: nowrap;
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/styles/components/dialogs/_disclaimer_dialog.scss
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
@use '../../themes/index' as *;
|
||||||
|
@use '../../abstracts/index' as *;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Disclaimer Dialog Styles
|
||||||
|
Used for the startup disclaimer dialog
|
||||||
|
*/
|
||||||
|
|
||||||
|
.disclaimer-header {
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disclaimer-content {
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: var(--bold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -209,6 +209,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add DLC manually form
|
||||||
|
.add-dlc-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dlc-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dlc-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dlc-input {
|
||||||
|
background-color: var(--border-dark);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dlc-error {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--error);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Loading animations
|
// Loading animations
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -4,3 +4,8 @@
|
|||||||
@forward './settings_dialog';
|
@forward './settings_dialog';
|
||||||
@forward './smokeapi_settings_dialog';
|
@forward './smokeapi_settings_dialog';
|
||||||
@forward './conflict_dialog';
|
@forward './conflict_dialog';
|
||||||
|
@forward './disclaimer_dialog';
|
||||||
|
@forward './unlocker_selection_dialog';
|
||||||
|
@forward './optin_dialog';
|
||||||
|
@forward './rating_dialog';
|
||||||
|
@forward './smokeapi_votes_dialog';
|
||||||
|
|||||||
84
src/styles/components/dialogs/_optin_dialog.scss
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
@use '../../themes/index' as *;
|
||||||
|
@use '../../abstracts/index' as *;
|
||||||
|
|
||||||
|
.optin-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.optin-icon-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--info);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optin-intro {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optin-details {
|
||||||
|
background-color: var(--border-dark);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0.4rem 0 0.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: var(--bold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optin-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: var(--info-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--info);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/styles/components/dialogs/_rating_dialog.scss
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
@use '../../themes/index' as *;
|
||||||
|
@use '../../abstracts/index' as *;
|
||||||
|
|
||||||
|
.rating-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-subtext {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: var(--bold);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||||
|
color: var(--text-heavy);
|
||||||
|
|
||||||
|
&--worked {
|
||||||
|
background-color: var(--success);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--success-light);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 14px rgba(140, 200, 147, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--broken {
|
||||||
|
background-color: var(--danger);
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-submitted {
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/styles/components/dialogs/_smokeapi_votes_dialog.scss
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@use '../../themes/index' as *;
|
||||||
|
@use '../../abstracts/index' as *;
|
||||||
|
|
||||||
|
.smokeapi-votes-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.smokeapi-votes-game {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.smokeapi-votes-section {
|
||||||
|
background-color: var(--border-dark);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smokeapi-votes-label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: var(--bold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smokeapi-votes-loading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smokeapi-votes-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--info);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/styles/components/dialogs/_unlocker_selection_dialog.scss
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
@use '../../themes/index' as *;
|
||||||
|
@use '../../abstracts/index' as *;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Unlocker Selection Dialog styles
|
||||||
|
For choosing between CreamLinux and SmokeAPI on native games
|
||||||
|
*/
|
||||||
|
|
||||||
|
.unlocker-selection-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlocker-selection-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
.game-title-info {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlocker-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlocker-option {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.recommended {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: rgba(245, 150, 130, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(245, 150, 130, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-badge {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--text-heavy);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alternative-badge {
|
||||||
|
background-color: var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/types/Config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* User configuration structure
|
||||||
|
* Matches the Rust Config struct
|
||||||
|
*/
|
||||||
|
export interface Config {
|
||||||
|
/** Whether to show the disclaimer on startup */
|
||||||
|
show_disclaimer: boolean
|
||||||
|
reporting_opted_in: boolean
|
||||||
|
reporting_has_seen_prompt: boolean
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './Game'
|
export * from './Game'
|
||||||
export * from './DlcInfo'
|
export * from './DlcInfo'
|
||||||
|
export * from './Config'
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"types": ["node"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
|
|||||||