48 Commits

Author SHA1 Message Date
Tickbase
d5f9d50248 changelog 2026-04-30 21:04:59 +02:00
Tickbase
d70b174dd4 bump version 2026-04-30 21:02:49 +02:00
Tickbase
2164492934 main #93 2026-04-30 21:02:15 +02:00
Tickbase
7733d9732e screamapi + koaloader cache #93 2026-04-30 21:01:58 +02:00
Tickbase
f151f5ee4f utility #93 2026-04-30 21:01:41 +02:00
Tickbase
ad910cce0a make koaloader publicly available 2026-04-30 21:01:27 +02:00
Tickbase
9621cba58d epic games installer progress #93 2026-04-30 21:01:15 +02:00
Tickbase
f18cffaa09 import scanner for koaloader #93 2026-04-30 21:00:54 +02:00
Tickbase
17de5172e4 screamapi config #93 2026-04-30 21:00:47 +02:00
Tickbase
1d4c75bffd Heroic library scanner #93 2026-04-30 21:00:42 +02:00
Tickbase
2d524de661 koaloader + screamapi #93 2026-04-30 21:00:32 +02:00
Tickbase
832841134a styles 2026-04-30 21:00:21 +02:00
Tickbase
b3e92d2165 types #93 2026-04-30 21:00:15 +02:00
Tickbase
348b1a5ed0 hook up #93 2026-04-30 21:00:09 +02:00
Tickbase
cf7fe20aa6 Add epic games section #93 2026-04-30 20:59:58 +02:00
Tickbase
62a1dca0aa Epic games icon #93 2026-04-30 20:59:52 +02:00
Tickbase
214564d67f imports 2026-04-30 20:59:46 +02:00
Tickbase
9c70530890 Epic games unlocker selector diadlog #93 2026-04-30 20:59:34 +02:00
Tickbase
568c02495c scream api settings dialog #93 2026-04-30 20:59:22 +02:00
Tickbase
285256bfb8 Epic games item list and game card 2026-04-30 20:58:33 +02:00
Tickbase
42d8618f37 Merge pull request #110 from naguiagahnim/main
Fix Nix impure evaluation problem and update README instructions
2026-04-30 10:34:23 +02:00
Agahnim
483e58dfd1 update Readme installation instructions for Nix 2026-04-29 22:40:23 +02:00
Agahnim
ae9c012040 Fix nix eval impurity problem 2026-04-29 22:40:23 +02:00
Tickbase
220763b389 Merge pull request #109 from naguiagahnim/main
Package app for Nix
2026-04-29 14:19:43 +02:00
Agahnim
3d894266a7 Add Nix installation instructions to README.md 2026-04-29 14:13:47 +02:00
Agahnim
33492a6a55 nix: Init package 2026-04-29 13:43:32 +02:00
Agahnim
92f4d82e6c Update package-lock.json 2026-04-29 10:52:33 +02:00
Tickbase
5896733fd4 cargo.lock per request #105 2026-04-23 15:40:18 +02:00
Tickbase
1d72f24afa gitignore 2026-04-23 15:40:09 +02:00
Tickbase
aea8a84335 update demo in README.md 2026-03-28 15:19:13 +00:00
Tickbase
a476819312 Add files via upload 2026-03-28 15:18:29 +00:00
Novattz
1bb62877a3 version bump 2026-03-28 15:08:40 +01:00
Novattz
f8ea256637 changelog 2026-03-28 15:08:36 +01:00
Novattz
0480d523e3 stuff 2026-03-28 15:07:50 +01:00
Novattz
1571e9d87d backend for reporting and commands #22 2026-03-28 15:07:37 +01:00
Novattz
f949ecf2f3 new config options 2026-03-28 15:07:23 +01:00
Novattz
ecee6529ff export get_cache_dir 2026-03-28 15:07:12 +01:00
Novattz
d9819ef115 new packages 2026-03-28 15:06:38 +01:00
Novattz
ff53cc7a46 styling 2026-03-28 15:06:33 +01:00
Novattz
1a1c7dfb3d Vote display #22 2026-03-28 15:06:20 +01:00
Novattz
769213288e reflect votes in dialogs #22 2026-03-28 15:06:09 +01:00
Novattz
85d670931a Rate & opt-in dialog #22 2026-03-28 15:05:57 +01:00
Novattz
487e974274 New icon 2026-03-28 15:05:27 +01:00
Novattz
1b8fdadbf2 version bump 2026-03-13 14:54:52 +00:00
Novattz
ecd7b4dceb changelog 2026-03-13 14:54:15 +00:00
Novattz
640eb9a0d5 add types node to tsconfig 2026-03-13 14:51:50 +00:00
Novattz
b42086ca27 Manually add DLC dialog #99 2026-03-13 14:51:33 +00:00
Novattz
b9beb0d704 Fix steam api being nested too deep #100 2026-03-13 14:26:46 +00:00
65 changed files with 12922 additions and 2774 deletions

1
.gitignore vendored
View File

@@ -12,7 +12,6 @@ dist
dist-ssr
docs
*.local
*.lock
.env
# Editor directories and files

View File

@@ -1,3 +1,30 @@
## [1.5.5] - 30-04-2026
### Added
- Epic Games library scanning via Heroic/Legendary
- ScreamAPI support (Tested and working with SnowRunner)
- Koaloader support (currently not working, fix coming in a future update)
## [1.5.0] - 28-03-2026
### Added
- Anonymous reporting system. Vote on whether CreamLinux or SmokeAPI works for a game
- Opt-in dialog on first launch explaining what is collected and why
- Rating button on game cards (only visible when opted in and an unlocker is installed)
- Community vote display in the unlocker selection dialog and before installing SmokeAPI on Proton games
- Votes track per-unlocker so CreamLinux and SmokeAPI ratings are independent
- Previously submitted votes are stored locally so already-cast buttons are disabled on re-open
- Config now automatically migrates missing fields on update without overwriting existing values
- API source available at https://github.com/Novattz/Lactose/
## [1.4.2] - 13-03-2026
### Added
- Added a dialog so users can manually add DLC's incase they are missing from the steam api
### Fixed
- Fixed an issue where if the libsteam_api.so file is nested too deeply in a game causing the app to not find it.
## [1.4.1] - 18-01-2026
### Added

View File

@@ -4,7 +4,7 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
## Watch the demo here:
[![Watch the demo](./src/assets/screenshot.png)](https://www.youtube.com/watch?v=ZunhZnKFLlg)
[![Watch the demo](./src/assets/screenshot1.png)](https://www.youtube.com/watch?v=neUDotrqnDM)
## Beta Status
@@ -46,6 +46,72 @@ While the core functionality is working, please be aware that this is an early r
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.AppImage
```
### Nix
You can fetch this repository in your configuration using `pkgs.fetchFromGithub`:
```nix
let
creamlinux = pkgs.callPackage (pkgs.fetchFromGitHub {
owner = "Novattz";
repo = "creamlinux-installer";
rev = "main";
hash = ""; # You can use nix-prefetch-url to determine which value to put here, or paste the value returned by the error your rebuild will output
}) {};
in
{
environment.systemPackages = [ creamlinux ];
}
```
or, using `builtins.fetchTarball`:
```nix
let
creamlinux-src = builtins.fetchTarball {
url = "https://github.com/Novattz/creamlinux-installer/archive/main.tar.gz";
sha256 = ""; # See above
};
in
{
environment.systemPackages = [
(pkgs.callPackage creamlinux-src {})
];
}
```
alternatively and if you want to pin the package version, using [npins](https://github.com/andir/npins):
```bash
npins add github Novattz creamlinux-installer --branch main
```
```nix
let
sources = import ./npins;
in
{
environment.systemPackages = [
(pkgs.callPackage "${sources.creamlinux-installer}/default.nix" {})
];
}
```
Those are the recommended methods to add creamlinux-installer to your environment. However, you could also add it as an input of your flake, like so:
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
creamlinux-installer = {
type = "github";
owner = "Novattz";
repo = "creamlinux-installer";
flake = false;
};
};
}
```
Then, in your configuration:
```nix
environment.systemPackages = [
(pkgs.callPackage inputs.creamlinux-installer {})
];
```
Similarly to running the AppImage, you will need to set `WEBKIT_DISABLE_DMABUF_RENDERER=1` if your GPU is from Nvidia in order to run the package.
### Building from Source
#### Prerequisites

57
default.nix Normal file
View File

@@ -0,0 +1,57 @@
{pkgs ? import <nixpkgs> {}}: let
cargoRoot = "src-tauri";
src = ./.;
patchSassEmbedded = pkgs.writeShellScriptBin "patch-sass-embedded" ''
NIX_LD="$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)"
for dart_bin in node_modules/sass-embedded-linux-*/dart-sass/src/dart; do
if [ -f "$dart_bin" ]; then
${pkgs.patchelf}/bin/patchelf --set-interpreter "$NIX_LD" "$dart_bin"
fi
done
'';
in
pkgs.rustPlatform.buildRustPackage {
pname = "creamlinux-installer";
version = "1.5.0-unstable-2026-04-23";
inherit src;
cargoLock.lockFile = ./src-tauri/Cargo.lock;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-anYTERlnfOGDsGW0joy+h7wECJNDy6q+0a2to6t36pg=";
};
nativeBuildInputs =
[
pkgs.cargo-tauri.hook
pkgs.nodejs
pkgs.npmHooks.npmConfigHook
pkgs.pkg-config
]
++ pkgs.lib.optionals pkgs.stdenv.isLinux [
pkgs.wrapGAppsHook4
];
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
pkgs.glib-networking
pkgs.openssl
pkgs.webkitgtk_4_1
];
inherit cargoRoot;
buildAndTestSubdir = cargoRoot;
postPatch = ''
substituteInPlace src-tauri/tauri.conf.json \
--replace-fail '"createUpdaterArtifacts": true' '"createUpdaterArtifacts": false'
'';
preBuild = ''
${patchSassEmbedded}/bin/patch-sass-embedded
'';
env.NO_STRIP = true;
}

4683
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "creamlinux",
"private": true,
"version": "1.4.1",
"version": "1.5.5",
"type": "module",
"author": "Tickbase",
"repository": "https://github.com/Novattz/creamlinux-installer",

6607
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "creamlinux-installer"
version = "1.4.1"
version = "1.5.5"
description = "DLC Manager for Steam games on Linux"
authors = ["tickbase"]
license = "MIT"
@@ -30,11 +30,13 @@ tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-dialog = "2.0.0-rc"
tauri-plugin-fs = "2.0.0-rc"
num_cpus = "1.16.0"
tauri-plugin-process = "2"
tauri-plugin-process = "2.2.1"
async-trait = "0.1.89"
sha2 = "0.10.9"
rand = "0.9.2"
[features]
custom-protocol = ["tauri/custom-protocol"]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-updater = "2.7.1"

View File

@@ -5,7 +5,7 @@ pub use storage::{
get_creamlinux_version_dir, get_smokeapi_version_dir,
list_creamlinux_files, list_smokeapi_files, read_versions,
update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache,
validate_creamlinux_cache,
validate_creamlinux_cache, get_cache_dir, get_koaloader_version_dir, get_screamapi_version_dir,
};
pub use version::{
@@ -14,7 +14,7 @@ pub use version::{
update_smokeapi_version as update_game_smokeapi_version,
};
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
use crate::{cache::storage::{update_koaloader_version, update_screamapi_version, validate_koaloader_cache, validate_screamapi_cache}, unlockers::{CreamLinux, Koaloader, ScreamAPI, SmokeAPI, Unlocker}};
use log::{error, info, warn};
use std::collections::HashMap;
@@ -26,6 +26,8 @@ pub async fn initialize_cache() -> Result<(), String> {
let versions = read_versions()?;
let mut needs_smokeapi = false;
let mut needs_creamlinux = false;
let mut needs_screamapi = false;
let mut needs_koaloader = false;
// Check if SmokeAPI is properly cached
if versions.smokeapi.latest.is_empty() {
@@ -68,6 +70,46 @@ pub async fn initialize_cache() -> Result<(), String> {
}
}
// Check if ScreamAPI is properly cached
if versions.screamapi.latest.is_empty() {
info!("No ScreamAPI version in manifest");
needs_screamapi = true
} else {
match validate_screamapi_cache(&versions.screamapi.latest) {
Ok(true) => {
info!("ScreamAPI cache validated successfully");
}
Ok(false) => {
info!("ScreamAPI cache incomplete, re-downloading");
needs_smokeapi = true;
}
Err(e) => {
warn!("Failed to validate ScreamAPI cache: {}, re-downloading", e);
needs_screamapi = true;
}
}
}
// Check if Koaloader is properly cached
if versions.koaloader.latest.is_empty() {
info!("No Koaloader version in manifest");
needs_koaloader = true
} else {
match validate_koaloader_cache(&versions.koaloader.latest) {
Ok(true) => {
info!("Koaloader cache validated successfully");
}
Ok(false) => {
info!("Koaloader cache incomplete, re-downloading");
needs_koaloader = true;
}
Err(e) => {
warn!("Failed to validate Koaloader cache: {}, re-downloading", e);
needs_koaloader = true;
}
}
}
// Download SmokeAPI
if needs_smokeapi {
info!("Downloading SmokeAPI...");
@@ -98,7 +140,37 @@ pub async fn initialize_cache() -> Result<(), String> {
}
}
if !needs_smokeapi && !needs_creamlinux {
// Download ScreamAPI
if needs_screamapi {
info!("Downloading ScreamAPI...");
match ScreamAPI::download_to_cache().await {
Ok(version) => {
info!("Downloaded ScreamAPI version: {}", version);
update_screamapi_version(&version)?;
}
Err(e) => {
error!("Failed to download SmokeAPI: {}", e);
return Err(format!("Failed to download ScreamAPI: {}", e));
}
}
}
// Download Koaloader
if needs_koaloader {
info!("Downloading Koaloader...");
match Koaloader::download_to_cache().await {
Ok(version) => {
info!("Downloaded Koaloader version: {}", version);
update_koaloader_version(&version)?;
}
Err(e) => {
error!("Failed to download Koaloader: {}", e);
return Err(format!("Failed to download Koaloader: {}", e));
}
}
}
if !needs_smokeapi && !needs_creamlinux && !needs_smokeapi && !needs_koaloader {
info!("Cache already initialized and validated");
} else {
info!("Cache initialization complete");

View File

@@ -8,6 +8,8 @@ use std::path::PathBuf;
pub struct CacheVersions {
pub smokeapi: VersionInfo,
pub creamlinux: VersionInfo,
pub screamapi: VersionInfo,
pub koaloader: VersionInfo,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -18,12 +20,10 @@ pub struct VersionInfo {
impl Default for CacheVersions {
fn default() -> Self {
Self {
smokeapi: VersionInfo {
latest: String::new(),
},
creamlinux: VersionInfo {
latest: String::new(),
},
smokeapi: VersionInfo { latest: String::new() },
creamlinux: VersionInfo { latest: String::new() },
screamapi: VersionInfo { latest: String::new() },
koaloader: VersionInfo { latest: String::new() },
}
}
}
@@ -63,6 +63,26 @@ pub fn get_smokeapi_dir() -> Result<PathBuf, String> {
Ok(smokeapi_dir)
}
pub fn get_screamapi_dir() -> Result<PathBuf, String> {
let cache_dir = get_cache_dir()?;
let dir = cache_dir.join("screamapi");
if !dir.exists() {
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create ScreamAPI directory: {}", e))?;
}
Ok(dir)
}
pub fn get_koaloader_dir() -> Result<PathBuf, String> {
let cache_dir = get_cache_dir()?;
let dir = cache_dir.join("koaloader");
if !dir.exists() {
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create Koaloader directory: {}", e))?;
}
Ok(dir)
}
// Get the CreamLinux cache directory path
pub fn get_creamlinux_dir() -> Result<PathBuf, String> {
let cache_dir = get_cache_dir()?;
@@ -94,6 +114,24 @@ pub fn get_smokeapi_version_dir(version: &str) -> Result<PathBuf, String> {
Ok(version_dir)
}
pub fn get_screamapi_version_dir(version: &str) -> Result<PathBuf, String> {
let dir = get_screamapi_dir()?.join(version);
if !dir.exists() {
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create ScreamAPI version directory: {}", e))?;
}
Ok(dir)
}
pub fn get_koaloader_version_dir(version: &str) -> Result<PathBuf, String> {
let dir = get_koaloader_dir()?.join(version);
if !dir.exists() {
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create Koaloader version directory: {}", e))?;
}
Ok(dir)
}
// Get the path to a versioned CreamLinux directory
pub fn get_creamlinux_version_dir(version: &str) -> Result<PathBuf, String> {
let creamlinux_dir = get_creamlinux_dir()?;
@@ -115,23 +153,43 @@ pub fn get_creamlinux_version_dir(version: &str) -> Result<PathBuf, String> {
pub fn read_versions() -> Result<CacheVersions, String> {
let cache_dir = get_cache_dir()?;
let versions_path = cache_dir.join("versions.json");
if !versions_path.exists() {
info!("versions.json doesn't exist, creating default");
return Ok(CacheVersions::default());
}
let content = fs::read_to_string(&versions_path)
.map_err(|e| format!("Failed to read versions.json: {}", e))?;
let versions: CacheVersions = serde_json::from_str(&content)
// Parse into a raw Value first so we can inject missing fields without
// breaking on older versions.json files that predate new unlockers.
let mut raw: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse versions.json: {}", e))?;
let empty = serde_json::json!({ "latest": "" });
if let Some(obj) = raw.as_object_mut() {
if !obj.contains_key("smokeapi") { obj.insert("smokeapi".into(), empty.clone()); }
if !obj.contains_key("creamlinux") { obj.insert("creamlinux".into(), empty.clone()); }
if !obj.contains_key("screamapi") { obj.insert("screamapi".into(), empty.clone()); }
if !obj.contains_key("koaloader") { obj.insert("koaloader".into(), empty.clone()); }
}
let versions: CacheVersions = serde_json::from_value(raw)
.map_err(|e| format!("Failed to deserialize versions.json: {}", e))?;
// If we injected any missing fields, persist them so the file is up to date
write_versions(&versions)?;
info!(
"Read cached versions - SmokeAPI: {}, CreamLinux: {}",
versions.smokeapi.latest, versions.creamlinux.latest
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
versions.smokeapi.latest,
versions.creamlinux.latest,
versions.screamapi.latest,
versions.koaloader.latest,
);
Ok(versions)
}
@@ -147,8 +205,11 @@ pub fn write_versions(versions: &CacheVersions) -> Result<(), String> {
.map_err(|e| format!("Failed to write versions.json: {}", e))?;
info!(
"Wrote versions.json - SmokeAPI: {}, CreamLinux: {}",
versions.smokeapi.latest, versions.creamlinux.latest
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
versions.smokeapi.latest,
versions.creamlinux.latest,
versions.screamapi.latest,
versions.koaloader.latest,
);
Ok(())
@@ -179,6 +240,34 @@ pub fn update_smokeapi_version(new_version: &str) -> Result<(), String> {
Ok(())
}
pub fn update_screamapi_version(new_version: &str) -> Result<(), String> {
let mut versions = read_versions()?;
let old_version = versions.screamapi.latest.clone();
versions.screamapi.latest = new_version.to_string();
write_versions(&versions)?;
if !old_version.is_empty() && old_version != new_version {
let old_dir = get_screamapi_dir()?.join(&old_version);
if old_dir.exists() {
let _ = fs::remove_dir_all(&old_dir);
}
}
Ok(())
}
pub fn update_koaloader_version(new_version: &str) -> Result<(), String> {
let mut versions = read_versions()?;
let old_version = versions.koaloader.latest.clone();
versions.koaloader.latest = new_version.to_string();
write_versions(&versions)?;
if !old_version.is_empty() && old_version != new_version {
let old_dir = get_koaloader_dir()?.join(&old_version);
if old_dir.exists() {
let _ = fs::remove_dir_all(&old_dir);
}
}
Ok(())
}
// Update the CreamLinux version in versions.json and clean old version directories
pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
let mut versions = read_versions()?;
@@ -321,6 +410,30 @@ pub fn validate_smokeapi_cache(version: &str) -> Result<bool, String> {
Ok(true)
}
pub fn validate_screamapi_cache(version: &str) -> Result<bool, String> {
let version_dir = get_screamapi_version_dir(version)?;
if !version_dir.exists() {
return Ok(false);
}
let required = ["ScreamAPI32.dll", "ScreamAPI64.dll"];
for file in &required {
if !version_dir.join(file).exists() {
return Ok(false);
}
}
Ok(true)
}
pub fn validate_koaloader_cache(version: &str) -> Result<bool, String> {
let version_dir = get_koaloader_version_dir(version)?;
if !version_dir.exists() {
return Ok(false);
}
// Check for at least one proxy folder (version-64 is universally present)
let check = version_dir.join("version-64").join("version.dll");
Ok(check.exists())
}
/// Validate that all required files exist for CreamLinux
pub fn validate_creamlinux_cache(version: &str) -> Result<bool, String> {
let version_dir = get_creamlinux_version_dir(version)?;

View File

@@ -8,12 +8,17 @@ use log::info;
pub struct Config {
// Whether to show the disclaimer on startup
pub show_disclaimer: bool,
// Reporting / compatibility voting
pub reporting_opted_in: bool,
pub reporting_has_seen_prompt: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
show_disclaimer: true,
reporting_opted_in: false,
reporting_has_seen_prompt: false,
}
}
}
@@ -63,11 +68,50 @@ pub fn load_config() -> Result<Config, String> {
// Read and parse config file
let config_str = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
let config: Config = serde_json::from_str(&config_str)
let mut on_disk: serde_json::Value = serde_json::from_str(&config_str)
.map_err(|e| format!("Failed to parse config file: {}", e))?;
// Serialize the defaults into a Value so we can iterate their keys
let defaults = serde_json::to_value(Config::default())
.map_err(|e| format!("Failed to serialize default config: {}", e))?;
// For every key that exists in the current Config but is absent from the
// on-disk JSON, inject the default value. Keys that are already present
// are left completely untouched.
let mut migrated = false;
if let Some(default_obj) = defaults.as_object() {
let missing: Vec<(String, serde_json::Value)> = default_obj
.iter()
.filter(|(key, _)| {
on_disk
.as_object()
.map_or(false, |d| !d.contains_key(*key))
})
.map(|(key, val)| (key.clone(), val.clone()))
.collect();
if let Some(disk_obj) = on_disk.as_object_mut() {
for (key, value) in missing {
info!("Config migration: adding missing field '{}' with default value", key);
disk_obj.insert(key, value);
migrated = true;
}
}
}
// Deserialize the (possiblyh augmented) value into Config
let config: Config = serde_json::from_value(on_disk)
.map_err(|e| format!("Failed to deserialize config: {}", e))?;
// Persist the migrated file so the next launch doesn't need to do this again
if migrated {
save_config(&config)?;
info!("Config migrated - new fields written to disk");
} else {
info!("Loaded config from {:?}", config_path);
}
info!("Loaded config from {:?}", config_path);
Ok(config)
}

View File

@@ -0,0 +1,184 @@
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EpicGame {
pub app_name: String,
pub title: String,
pub install_path: String,
pub executable: String,
pub box_art_url: Option<String>,
pub scream_installed: bool,
pub koaloader_installed: bool,
/// True when Koaloader was installed using version.dll as a fallback
/// because no matching proxy import was detected in the game's PE files.
pub proxy_fallback_used: bool,
}
/// Minimal fields we need from installed.json entries.
#[derive(Debug, Deserialize)]
struct InstalledEntry {
title: String,
install_path: String,
executable: String,
#[serde(default)]
is_dlc: bool,
}
fn legendary_config_dir() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
let path = PathBuf::from(&home)
.join(".config")
.join("heroic")
.join("legendaryConfig")
.join("legendary");
if path.exists() {
Some(path)
} else {
warn!("Heroic legendary config dir not found at: {}", path.display());
None
}
}
pub fn scan_epic_games() -> Vec<EpicGame> {
let legendary_dir = match legendary_config_dir() {
Some(d) => d,
None => return Vec::new(),
};
let installed_path = legendary_dir.join("installed.json");
if !installed_path.exists() {
warn!("installed.json not found at: {}", installed_path.display());
return Vec::new();
}
let content = match fs::read_to_string(&installed_path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read installed.json: {}", e);
return Vec::new();
}
};
let installed: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
warn!("Failed to parse installed.json: {}", e);
return Vec::new();
}
};
let metadata_dir = legendary_dir.join("metadata");
let mut games = Vec::new();
if let Some(obj) = installed.as_object() {
for (app_name, entry_val) in obj {
let entry: InstalledEntry = match serde_json::from_value(entry_val.clone()) {
Ok(e) => e,
Err(e) => {
warn!("Failed to parse installed entry {}: {}", app_name, e);
continue;
}
};
if entry.is_dlc {
continue;
}
let install_path = PathBuf::from(&entry.install_path);
if !install_path.exists() {
warn!(
"Install path does not exist for {}: {}",
app_name, entry.install_path
);
continue;
}
let box_art_url = get_box_art(&metadata_dir, app_name);
let scream_installed = check_screamapi_installed(&install_path);
let koaloader_installed = check_koaloader_installed(&install_path);
info!(
"Found Epic game: {} ({}), ScreamAPI={}, Koaloader={}",
entry.title, app_name, scream_installed, koaloader_installed
);
games.push(EpicGame {
app_name: app_name.clone(),
title: entry.title,
install_path: entry.install_path,
executable: entry.executable,
box_art_url,
scream_installed,
koaloader_installed,
proxy_fallback_used: false,
});
}
}
info!("Found {} Epic games", games.len());
games
}
/// Extract the "DieselGameBox" image URL from a game's metadata JSON.
/// We read the top-level keyImages array directly from the JSON value,
/// which avoids pulling in DLC images from dlcItemList.
fn get_box_art(metadata_dir: &Path, app_name: &str) -> Option<String> {
let meta_path = metadata_dir.join(format!("{}.json", app_name));
if !meta_path.exists() {
return None;
}
let content = fs::read_to_string(&meta_path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
let key_images = val
.get("metadata")
.and_then(|m| m.get("keyImages"))
.and_then(|k| k.as_array())?;
// Prefer landscape (DieselGameBox), fall back to portrait or logo
for preferred in &["DieselGameBox", "DieselGameBoxTall", "DieselGameBoxLogo"] {
if let Some(url) = key_images.iter().find_map(|img| {
if img.get("type").and_then(|t| t.as_str()) == Some(preferred) {
img.get("url").and_then(|u| u.as_str()).map(str::to_owned)
} else {
None
}
}) {
return Some(url);
}
}
None
}
pub fn check_screamapi_installed(install_path: &Path) -> bool {
for entry in WalkDir::new(install_path)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{
let filename = entry.file_name().to_string_lossy().to_lowercase();
if filename.starts_with("eossdk-win") && filename.ends_with("_o.dll") {
return true;
}
}
false
}
pub fn check_koaloader_installed(install_path: &Path) -> bool {
for entry in WalkDir::new(install_path)
.max_depth(4)
.into_iter()
.filter_map(Result::ok)
{
if entry.file_name().to_string_lossy() == "Koaloader.config.json" {
return true;
}
}
false
}

View File

@@ -4,7 +4,8 @@ use crate::cache::{
remove_creamlinux_version, remove_smokeapi_version,
update_game_creamlinux_version, update_game_smokeapi_version,
};
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
use crate::unlockers::{CreamLinux, SmokeAPI, ScreamAPI, Unlocker};
use crate::epic_scanner::EpicGame;
use crate::AppState;
use log::{error, info, warn};
use reqwest;
@@ -440,6 +441,215 @@ async fn uninstall_smokeapi_native(game: Game, app_handle: AppHandle) -> Result<
Ok(())
}
pub async fn install_screamapi(game: EpicGame, app_handle: AppHandle) -> Result<(), String> {
let title = game.title.clone();
info!("Installing ScreamAPI for: {}", title);
emit_progress(
&app_handle,
&format!("Installing ScreamAPI for {}", title),
"Scanning for EOS SDK DLLs...",
15.0, false, false, None,
);
let eos_dlls = crate::unlockers::ScreamAPI::find_eossdk_dlls(
std::path::Path::new(&game.install_path)
);
if eos_dlls.is_empty() {
return Err(format!("No EOSSDK-Win*-Shipping.dll found in {}", game.install_path));
}
emit_progress(
&app_handle,
&format!("Installing ScreamAPI for {}", title),
&format!("Replacing {} EOS SDK DLL(s)...", eos_dlls.len()),
50.0, false, false, None,
);
ScreamAPI::install_to_game(&game.install_path, "")
.await
.map_err(|e| format!("Failed to install ScreamAPI: {}", e))?;
emit_progress(
&app_handle,
&format!("Installation Complete: {}", title),
"ScreamAPI installed successfully!",
100.0, true, false, None,
);
info!("ScreamAPI installation complete for: {}", title);
Ok(())
}
pub async fn uninstall_screamapi(game: EpicGame, app_handle: AppHandle) -> Result<(), String> {
let title = game.title.clone();
info!("Uninstalling ScreamAPI from: {}", title);
emit_progress(
&app_handle,
&format!("Uninstalling ScreamAPI from {}", title),
"Restoring original EOS SDK DLLs...",
30.0, false, false, None,
);
ScreamAPI::uninstall_from_game(&game.install_path, "")
.await
.map_err(|e| format!("Failed to uninstall ScreamAPI: {}", e))?;
emit_progress(
&app_handle,
&format!("Uninstallation Complete: {}", title),
"ScreamAPI removed successfully!",
100.0, true, false, None,
);
info!("ScreamAPI uninstallation complete for: {}", title);
Ok(())
}
/// Returns is_fallback so process_epic_action can set proxy_fallback_used.
pub async fn install_koaloader(
game: EpicGame,
app_handle: AppHandle,
) -> Result<bool, String> {
let title = game.title.clone();
info!("Installing Koaloader for: {}", title);
emit_progress(
&app_handle,
&format!("Installing Koaloader for {}", title),
"Locating game executable...",
10.0, false, false, None,
);
let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?;
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path);
emit_progress(
&app_handle,
&format!("Installing Koaloader for {}", title),
"Scanning PE imports for best proxy DLL...",
30.0, false, false, None,
);
let scan = crate::pe_inspector::find_best_proxy(&exe_path);
let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string();
let is_fallback = scan.is_fallback;
info!("Selected proxy: {} (fallback={})", scan.proxy_name, is_fallback);
emit_progress(
&app_handle,
&format!("Installing Koaloader for {}", title),
&format!("Installing proxy DLL ({})...", scan.proxy_name),
50.0, false, false, None,
);
let proxy_src = crate::unlockers::Koaloader::get_proxy_dll(&proxy_stem, is_64bit)?;
std::fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name))
.map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?;
emit_progress(
&app_handle,
&format!("Installing Koaloader for {}", title),
"Installing ScreamAPI payload...",
70.0, false, false, None,
);
let exe_dir_str = exe_dir.to_string_lossy().to_string();
ScreamAPI::install_to_game(&exe_dir_str, "koaloader")
.await
.map_err(|e| format!("Failed to install ScreamAPI payload: {}", e))?;
emit_progress(
&app_handle,
&format!("Installing Koaloader for {}", title),
"Writing configuration files...",
88.0, false, false, None,
);
let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string();
let koa_config = serde_json::json!({
"logging": false,
"enabled": true,
"auto_load": true,
"targets": [exe_name],
"modules": []
});
std::fs::write(
exe_dir.join("Koaloader.config.json"),
serde_json::to_string_pretty(&koa_config).unwrap(),
)
.map_err(|e| format!("Failed to write Koaloader config: {}", e))?;
emit_progress(
&app_handle,
&format!("Installation Complete: {}", title),
"Koaloader + ScreamAPI installed successfully!",
100.0, true, false, None,
);
info!("Koaloader installation complete for: {}", title);
Ok(is_fallback)
}
pub async fn uninstall_koaloader(game: EpicGame, app_handle: AppHandle) -> Result<(), String> {
let title = game.title.clone();
info!("Uninstalling Koaloader from: {}", title);
emit_progress(
&app_handle,
&format!("Uninstalling Koaloader from {}", title),
"Removing proxy DLL...",
25.0, false, false, None,
);
let exe_path = crate::unlockers::Koaloader::resolve_exe_pub(&game.install_path, &game.executable)?;
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
let exe_dir_str = exe_dir.to_string_lossy().to_string();
// Remove Koaloader config
let koa_config_path = exe_dir.join("Koaloader.config.json");
if koa_config_path.exists() {
std::fs::remove_file(&koa_config_path)
.map_err(|e| format!("Failed to remove Koaloader config: {}", e))?;
}
// Remove any Koaloader proxy DLL
if let Ok(entries) = std::fs::read_dir(exe_dir) {
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
let name_lower = path.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
if crate::unlockers::koaloader::KOA_VARIANTS.contains(&name_lower.as_str()) {
std::fs::remove_file(&path).ok();
info!("Removed proxy DLL: {}", path.display());
}
}
}
emit_progress(
&app_handle,
&format!("Uninstalling Koaloader from {}", title),
"Removing ScreamAPI files...",
65.0, false, false, None,
);
ScreamAPI::uninstall_from_game(&exe_dir_str, "koaloader")
.await
.map_err(|e| format!("Failed to remove ScreamAPI payload: {}", e))?;
emit_progress(
&app_handle,
&format!("Uninstallation Complete: {}", title),
"Koaloader + ScreamAPI removed successfully!",
100.0, true, false, None,
);
info!("Koaloader uninstallation complete for: {}", title);
Ok(())
}
// Fetch DLC details from Steam API (simple version without progress)
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
let client = reqwest::Client::new();

View File

@@ -4,6 +4,7 @@
)]
mod cache;
mod reporting;
mod utils;
mod dlc_manager;
mod installer;
@@ -11,9 +12,13 @@ mod searcher;
mod unlockers;
mod smokeapi_config;
mod config;
mod epic_scanner;
mod pe_inspector;
mod screamapi_config;
use crate::config::Config;
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
use epic_scanner::EpicGame;
use dlc_manager::DlcInfoWithState;
use installer::{Game, InstallerAction, InstallerType};
use log::{debug, error, info, warn};
@@ -34,6 +39,22 @@ pub struct GameAction {
action: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum EpicAction {
InstallScream,
UninstallScream,
InstallKoaloader,
UninstallKoaloader,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EpicGameAction {
pub game: EpicGame,
/// "install_scream" | "uninstall_scream" | "install_koaloader" | "uninstall_koaloader"
pub action: String,
}
#[derive(Debug, Clone)]
struct DlcCache {
#[allow(dead_code)]
@@ -68,6 +89,14 @@ fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, Stri
dlc_manager::get_all_dlcs(&game_path)
}
#[tauri::command]
async fn scan_epic_games() -> Result<Vec<EpicGame>, String> {
info!("Scanning for Epic games via Heroic...");
let games = epic_scanner::scan_epic_games();
info!("Found {} Epic games", games.len());
Ok(games)
}
#[tauri::command]
async fn scan_steam_games(
state: State<'_, AppState>,
@@ -251,6 +280,70 @@ async fn process_game_action(
Ok(updated_game)
}
#[tauri::command]
async fn process_epic_action(
epic_action: EpicGameAction,
app_handle: tauri::AppHandle,
) -> Result<EpicGame, String> {
let mut game = epic_action.game;
let action = epic_action.action.as_str();
info!("Processing epic action '{}' for: {}", action, game.title);
game.proxy_fallback_used = false;
match action {
"install_scream" => {
installer::install_screamapi(game.clone(), app_handle.clone()).await
.map_err(|e| format!("Failed to install ScreamAPI: {}", e))?;
game.scream_installed = true;
}
"uninstall_scream" => {
installer::uninstall_screamapi(game.clone(), app_handle.clone()).await
.map_err(|e| format!("Failed to uninstall ScreamAPI: {}", e))?;
game.scream_installed = false;
}
"install_koaloader" => {
let fallback_used = installer::install_koaloader(game.clone(), app_handle.clone()).await
.map_err(|e| format!("Failed to install Koaloader: {}", e))?;
game.koaloader_installed = true;
game.proxy_fallback_used = fallback_used;
}
"uninstall_koaloader" => {
installer::uninstall_koaloader(game.clone(), app_handle.clone()).await
.map_err(|e| format!("Failed to uninstall Koaloader: {}", e))?;
game.koaloader_installed = false;
}
_ => return Err(format!("Invalid epic action: {}", action)),
}
if let Err(e) = app_handle.emit("epic-game-updated", &game) {
warn!("Failed to emit epic-game-updated event: {}", e);
}
Ok(game)
}
#[tauri::command]
fn read_screamapi_config(
game_path: String,
) -> Result<Option<screamapi_config::ScreamAPIConfig>, String> {
screamapi_config::read_config(&game_path)
}
#[tauri::command]
fn write_screamapi_config(
game_path: String,
config: screamapi_config::ScreamAPIConfig,
) -> Result<(), String> {
screamapi_config::write_config(&game_path, &config)
}
#[tauri::command]
fn delete_screamapi_config(game_path: String) -> Result<(), String> {
screamapi_config::delete_config(&game_path)
}
#[tauri::command]
async fn fetch_game_dlcs(
game_id: String,
@@ -613,6 +706,81 @@ async fn resolve_platform_conflict(
Ok(updated_game)
}
#[tauri::command]
fn set_reporting_opt_in(opted_in: bool) -> Result<(), String> {
config::update_config(|cfg| {
cfg.reporting_opted_in = opted_in;
cfg.reporting_has_seen_prompt = true;
})?;
if opted_in {
// Ensure a salt exists so future hashes work immediately
reporting::delete_salt().ok(); // clear any stale one first
// re-create via generate_user_hash is fine; salt is lazy-created there
} else {
reporting::delete_salt()?;
}
info!("Reporting opt-in set to: {}", opted_in);
Ok(())
}
#[tauri::command]
async fn submit_report(
game_id: String,
unlocker: String,
worked: bool,
steam_path: String,
) -> Result<(), String> {
let user_hash = reporting::generate_user_hash(&steam_path)?;
reporting::post_report(reporting::ReportPayload {
user_hash,
game_id: game_id.clone(),
unlocker: unlocker.clone(),
worked,
})
.await?;
// Always save locally so the UI can reflect the vote immediately,
// regardless of opt-in status (the local file is only used client-side).
reporting::save_local_report(reporting::LocalReport {
game_id,
unlocker,
worked,
})?;
Ok(())
}
#[tauri::command]
fn get_local_reports() -> Vec<reporting::LocalReport> {
reporting::load_local_reports()
}
#[tauri::command]
async fn get_game_votes(game_id: String) -> Result<Vec<reporting::VoteResult>, String> {
let url = format!("https://api.shibe.fun/v1/votes/{}", game_id);
let client = reqwest::Client::new();
let response = client
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.map_err(|e| format!("Failed to fetch votes: {}", e))?;
if !response.status().is_success() {
// Non-critical - return empty rather than surfacing an error to the UI
return Ok(Vec::new());
}
response
.json::<Vec<reporting::VoteResult>>()
.await
.map_err(|e| format!("Failed to parse votes: {}", e))
}
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
use log::LevelFilter;
use log4rs::append::file::FileAppender;
@@ -676,6 +844,15 @@ fn main() {
resolve_platform_conflict,
load_config,
update_config,
set_reporting_opt_in,
submit_report,
get_local_reports,
get_game_votes,
scan_epic_games,
process_epic_action,
read_screamapi_config,
write_screamapi_config,
delete_screamapi_config,
])
.setup(|app| {
info!("Tauri application setup");

View File

@@ -0,0 +1,287 @@
/// PE import scanner for finding a suitable Koaloader proxy DLL.
/// scan ALL PE files (exe + dll) in the executable's directory
/// and collect every import that matches a Koaloader proxy variant.
use log::{info, warn};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
/// All DLL names Koaloader can proxy as, ordered by preference.
/// Common system DLLs that games almost always load come first.
pub const KOA_VARIANTS: &[&str] = &[
"version.dll",
"winmm.dll",
"winhttp.dll",
"iphlpapi.dll",
"dinput8.dll",
"d3d11.dll",
"dxgi.dll",
"d3d9.dll",
"d3d10.dll",
"dwmapi.dll",
"hid.dll",
"msimg32.dll",
"mswsock.dll",
"opengl32.dll",
"profapi.dll",
"propsys.dll",
"textshaping.dll",
"glu32.dll",
"audioses.dll",
"msasn1.dll",
"wldp.dll",
"xinput9_1_0.dll",
];
/// Result of a proxy scan. Which proxy was chosen and whether it was a
/// direct match or a fallback.
pub struct ProxyScanResult {
pub proxy_name: String,
pub is_fallback: bool,
}
/// Scan all PE files in the exe's directory (both .exe and .dll, exactly like
/// the Python script) and return the best Koaloader proxy to use.
///
/// Priority:
/// 1. Variants imported by the main exe itself
/// 2. Variants imported by any other PE file in the same directory
/// 3. Fallback to version.dll with is_fallback = true
pub fn find_best_proxy(exe_path: &Path) -> ProxyScanResult {
let exe_dir = match exe_path.parent() {
Some(d) => d,
None => {
warn!("Could not get exe directory, falling back to version.dll");
return ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true };
}
};
// Collect all PE files in the directory (.exe and .dll)
let all_pe_files: Vec<PathBuf> = match fs::read_dir(exe_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.is_file() && p.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("exe") || e.eq_ignore_ascii_case("dll"))
.unwrap_or(false)
})
.filter(|p| is_pe_file(p))
.collect(),
Err(e) => {
warn!("Could not read exe directory: {}, falling back to version.dll", e);
return ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true };
}
};
info!(
"Scanning {} PE files in: {}",
all_pe_files.len(),
exe_dir.display()
);
// Build two import sets: main exe and everything else
let exe_name = exe_path.file_name().unwrap_or_default();
let mut exe_imports: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut other_imports: std::collections::HashSet<String> = std::collections::HashSet::new();
for pe_path in &all_pe_files {
let imports = get_pe_imports(pe_path);
if pe_path.file_name().unwrap_or_default() == exe_name {
info!(
" {} (main exe): {} imports",
pe_path.file_name().unwrap_or_default().to_string_lossy(),
imports.len()
);
for imp in imports { exe_imports.insert(imp); }
} else {
info!(
" {}: {} imports",
pe_path.file_name().unwrap_or_default().to_string_lossy(),
imports.len()
);
for imp in imports { other_imports.insert(imp); }
}
}
// Pass 1: prefer a variant the main exe itself imports
for &variant in KOA_VARIANTS {
if exe_imports.contains(variant) {
info!("Best proxy (main exe imports): {}", variant);
return ProxyScanResult { proxy_name: variant.to_string(), is_fallback: false };
}
}
// Pass 2: fall back to a variant imported by any other PE in the directory
for &variant in KOA_VARIANTS {
if other_imports.contains(variant) {
info!("Best proxy (sibling PE imports): {}", variant);
return ProxyScanResult { proxy_name: variant.to_string(), is_fallback: false };
}
}
// No match at all - use version.dll and flag it so the caller can warn the user
warn!(
"No Koaloader-compatible import found in {} PE files, falling back to version.dll",
all_pe_files.len()
);
ProxyScanResult { proxy_name: "version.dll".to_string(), is_fallback: true }
}
/// Detect if a Windows PE executable is 64-bit.
/// Returns true for AMD64, false for i386. Defaults to true on parse failure.
pub fn is_64bit_exe(path: &Path) -> bool {
let data = match fs::read(path) {
Ok(d) => d,
Err(_) => return true,
};
if data.len() < 0x40 || &data[0..2] != b"MZ" {
return true;
}
let e_lfanew =
u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap_or([0; 4])) as usize;
if e_lfanew + 6 > data.len() || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
return true;
}
// 0x8664 = AMD64 (64-bit), 0x014C = i386 (32-bit)
let machine = u16::from_le_bytes(
data[e_lfanew + 4..e_lfanew + 6].try_into().unwrap_or([0; 2]),
);
machine != 0x014C
}
// Internal helpers
fn is_pe_file(path: &Path) -> bool {
let mut file = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut magic = [0u8; 2];
file.read_exact(&mut magic).unwrap_or(());
magic == [0x4D, 0x5A] // "MZ"
}
pub fn get_pe_imports(path: &Path) -> Vec<String> {
match parse_pe_imports(path) {
Ok(imports) => imports,
Err(e) => {
warn!("Failed to parse PE imports for {}: {}", path.display(), e);
Vec::new()
}
}
}
fn parse_pe_imports(path: &Path) -> std::io::Result<Vec<String>> {
let mut f = fs::File::open(path)?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
let data = &buf;
if data.len() < 0x40 || &data[0..2] != b"MZ" {
return Ok(Vec::new());
}
let e_lfanew =
u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap_or([0; 4])) as usize;
if e_lfanew + 4 > data.len() || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
return Ok(Vec::new());
}
let coff_offset = e_lfanew + 4;
if coff_offset + 20 > data.len() {
return Ok(Vec::new());
}
let opt_header_size =
u16::from_le_bytes(data[coff_offset + 16..coff_offset + 18].try_into().unwrap()) as usize;
let opt_offset = coff_offset + 20;
if opt_header_size < 4 || opt_offset + opt_header_size > data.len() {
return Ok(Vec::new());
}
// Magic: 0x10B = PE32, 0x20B = PE32+
let magic = u16::from_le_bytes(data[opt_offset..opt_offset + 2].try_into().unwrap());
let is_pe32_plus = magic == 0x20B;
let data_dir_offset = if is_pe32_plus { opt_offset + 112 } else { opt_offset + 96 };
if data_dir_offset + 8 > data.len() {
return Ok(Vec::new());
}
let import_rva =
u32::from_le_bytes(data[data_dir_offset..data_dir_offset + 4].try_into().unwrap())
as usize;
let import_size =
u32::from_le_bytes(data[data_dir_offset + 4..data_dir_offset + 8].try_into().unwrap())
as usize;
if import_rva == 0 || import_size == 0 {
return Ok(Vec::new());
}
let sections_offset = opt_offset + opt_header_size;
let num_sections =
u16::from_le_bytes(data[coff_offset + 2..coff_offset + 4].try_into().unwrap()) as usize;
let rva_to_offset = |rva: usize| -> Option<usize> {
for i in 0..num_sections {
let sec = sections_offset + i * 40;
if sec + 40 > data.len() { break; }
let virt_addr =
u32::from_le_bytes(data[sec + 12..sec + 16].try_into().unwrap()) as usize;
let raw_size =
u32::from_le_bytes(data[sec + 16..sec + 20].try_into().unwrap()) as usize;
let raw_offset =
u32::from_le_bytes(data[sec + 20..sec + 24].try_into().unwrap()) as usize;
if rva >= virt_addr && rva < virt_addr + raw_size {
return Some(raw_offset + (rva - virt_addr));
}
}
None
};
let import_file_offset = match rva_to_offset(import_rva) {
Some(o) => o,
None => return Ok(Vec::new()),
};
let mut imports = Vec::new();
let mut entry_offset = import_file_offset;
loop {
if entry_offset + 20 > data.len() { break; }
let name_rva =
u32::from_le_bytes(data[entry_offset + 12..entry_offset + 16].try_into().unwrap())
as usize;
if name_rva == 0 { break; }
if let Some(name_offset) = rva_to_offset(name_rva) {
let end = data[name_offset..]
.iter()
.position(|&b| b == 0)
.map(|n| name_offset + n)
.unwrap_or(data.len());
if let Ok(name) = std::str::from_utf8(&data[name_offset..end]) {
let trimmed = name.trim();
if !trimmed.is_empty() {
imports.push(trimmed.to_lowercase());
}
}
}
entry_offset += 20;
}
Ok(imports)
}

177
src-tauri/src/reporting.rs Normal file
View 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()))
}
}

View File

@@ -0,0 +1,137 @@
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ScreamAPIConfig {
#[serde(rename = "$schema")]
pub schema: String,
#[serde(rename = "$version")]
pub version: u32,
pub logging: bool,
pub log_eos: bool,
pub block_metrics: bool,
pub namespace_id: String,
pub default_dlc_status: String,
pub override_dlc_status: HashMap<String, String>,
pub extra_graphql_endpoints: Vec<String>,
pub extra_entitlements: HashMap<String, String>,
}
impl Default for ScreamAPIConfig {
fn default() -> Self {
Self {
schema: "https://raw.githubusercontent.com/acidicoala/ScreamAPI/master/res/ScreamAPI.schema.json".to_string(),
version: 3,
logging: false,
log_eos: false,
block_metrics: false,
namespace_id: String::new(),
default_dlc_status: "unlocked".to_string(),
override_dlc_status: HashMap::new(),
extra_graphql_endpoints: Vec::new(),
extra_entitlements: HashMap::new(),
}
}
}
/// Write a default ScreamAPI config to a specific directory.
/// Called internally by the installer when first setting up ScreamAPI.
pub fn write_default_config(dir: &Path) -> Result<(), String> {
write_config_to_dir(dir, &ScreamAPIConfig::default())
}
/// Write ScreamAPI config to a specific directory (where the ScreamAPI DLL lives)
pub fn write_config_to_dir(dir: &Path, config: &ScreamAPIConfig) -> Result<(), String> {
let config_path = dir.join("ScreamAPI.config.json");
let content = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize ScreamAPI config: {}", e))?;
fs::write(&config_path, content)
.map_err(|e| format!("Failed to write ScreamAPI config: {}", e))?;
info!("Wrote ScreamAPI config to: {}", config_path.display());
Ok(())
}
/// Read ScreamAPI config from a game's install path.
/// Looks for EOSSDK backup files to find the directory.
pub fn read_config(game_path: &str) -> Result<Option<ScreamAPIConfig>, String> {
let config_path = match find_screamapi_config_path(game_path) {
Some(p) => p,
None => return Ok(None),
};
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read ScreamAPI config: {}", e))?;
let config: ScreamAPIConfig = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse ScreamAPI config: {}", e))?;
info!("Read ScreamAPI config from: {}", config_path.display());
Ok(Some(config))
}
/// Write ScreamAPI config to the directory where ScreamAPI DLLs are installed.
pub fn write_config(game_path: &str, config: &ScreamAPIConfig) -> Result<(), String> {
// Find existing config location or fall back to game root
let config_path = find_screamapi_config_path(game_path)
.unwrap_or_else(|| Path::new(game_path).join("ScreamAPI.config.json"));
let content = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize ScreamAPI config: {}", e))?;
fs::write(&config_path, content)
.map_err(|e| format!("Failed to write ScreamAPI config: {}", e))?;
info!("Wrote ScreamAPI config to: {}", config_path.display());
Ok(())
}
/// Delete ScreamAPI config from a game directory
pub fn delete_config(game_path: &str) -> Result<(), String> {
let config_path = match find_screamapi_config_path(game_path) {
Some(p) => p,
None => return Ok(()),
};
if config_path.exists() {
fs::remove_file(&config_path)
.map_err(|e| format!("Failed to delete ScreamAPI config: {}", e))?;
info!("Deleted ScreamAPI config from: {}", config_path.display());
}
Ok(())
}
/// Find where the ScreamAPI config should live by looking for EOSSDK backup files
/// (EOSSDK-Win64-Shipping_o.dll or EOSSDK-Win32-Shipping_o.dll)
fn find_screamapi_config_path(game_path: &str) -> Option<PathBuf> {
use walkdir::WalkDir;
for entry in WalkDir::new(game_path)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
let filename = path.file_name()?.to_string_lossy();
if (filename.starts_with("EOSSDK-Win") && filename.ends_with("_o.dll"))
|| filename == "ScreamAPI.config.json"
{
let dir = path.parent()?;
return Some(dir.join("ScreamAPI.config.json"));
}
}
warn!("Could not find ScreamAPI install dir in {}, using game root", game_path);
None
}

View File

@@ -0,0 +1,289 @@
use super::Unlocker;
use async_trait::async_trait;
use log::info;
use reqwest;
use std::fs;
use std::io;
use std::path::Path;
use std::time::Duration;
use tempfile::tempdir;
use zip::ZipArchive;
const KOALOADER_REPO: &str = "acidicoala/Koaloader";
pub const KOA_VARIANTS: &[&str] = &[
"version.dll", "winmm.dll", "winhttp.dll", "iphlpapi.dll", "dinput8.dll",
"d3d11.dll", "dxgi.dll", "d3d9.dll", "d3d10.dll", "dwmapi.dll", "hid.dll",
"msimg32.dll", "mswsock.dll", "opengl32.dll", "profapi.dll", "propsys.dll",
"textshaping.dll", "glu32.dll", "audioses.dll", "msasn1.dll", "wldp.dll",
"xinput9_1_0.dll",
];
pub struct Koaloader;
#[async_trait]
impl Unlocker for Koaloader {
async fn get_latest_version() -> Result<String, String> {
info!("Fetching latest Koaloader version...");
let client = reqwest::Client::new();
let releases_url = format!(
"https://api.github.com/repos/{}/releases/latest",
KOALOADER_REPO
);
let response = client
.get(&releases_url)
.header("User-Agent", "CreamLinux")
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch Koaloader releases: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch Koaloader releases: HTTP {}",
response.status()
));
}
let release_info: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse release info: {}", e))?;
let version = release_info
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Failed to extract version from release info".to_string())?
.to_string();
info!("Latest Koaloader version: {}", version);
Ok(version)
}
async fn download_to_cache() -> Result<String, String> {
let version = Self::get_latest_version().await?;
info!("Downloading Koaloader version {}...", version);
let client = reqwest::Client::new();
let releases_url = format!(
"https://api.github.com/repos/{}/releases/latest",
KOALOADER_REPO
);
let release_info: serde_json::Value = client
.get(&releases_url)
.header("User-Agent", "CreamLinux")
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch Koaloader release: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse release info: {}", e))?;
let zip_url = release_info
.get("assets")
.and_then(|a| a.as_array())
.and_then(|assets| {
assets.iter().find(|asset| {
asset
.get("name")
.and_then(|n| n.as_str())
.map(|n| n.ends_with(".zip"))
.unwrap_or(false)
})
})
.and_then(|asset| asset.get("browser_download_url"))
.and_then(|u| u.as_str())
.ok_or_else(|| "No zip asset found in Koaloader release".to_string())?
.to_string();
let response = client
.get(&zip_url)
.timeout(Duration::from_secs(60))
.send()
.await
.map_err(|e| format!("Failed to download Koaloader: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to download Koaloader: HTTP {}",
response.status()
));
}
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
let zip_path = temp_dir.path().join("koaloader.zip");
let content = response
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
fs::write(&zip_path, &content)
.map_err(|e| format!("Failed to write zip file: {}", e))?;
let version_dir = crate::cache::get_koaloader_version_dir(&version)?;
let file =
fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
let mut archive =
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
let zip_entry = file.name().to_string();
if zip_entry.ends_with('/') {
continue;
}
let out_path = version_dir.join(&zip_entry);
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
let mut outfile = fs::File::create(&out_path).map_err(|e| {
format!("Failed to create output file {}: {}", out_path.display(), e)
})?;
io::copy(&mut file, &mut outfile)
.map_err(|e| format!("Failed to extract file: {}", e))?;
}
info!("Koaloader version {} downloaded to cache successfully", version);
Ok(version)
}
/// context = relative executable path (e.g. "en_us/Sources/Bin/SnowRunner.exe")
/// Progress events are emitted by installer/mod.rs, not here.
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> {
// Install without progress called internally (e.g. from installer/mod.rs
// after it has already emitted its own progress steps)
let exe_path = Self::resolve_exe(game_path, context)?;
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
let is_64bit = crate::pe_inspector::is_64bit_exe(&exe_path);
let scan = crate::pe_inspector::find_best_proxy(&exe_path);
let proxy_stem = scan.proxy_name.trim_end_matches(".dll").to_string();
let proxy_src = Self::get_proxy_dll(&proxy_stem, is_64bit)?;
fs::copy(&proxy_src, exe_dir.join(&scan.proxy_name))
.map_err(|e| format!("Failed to copy Koaloader proxy DLL: {}", e))?;
let exe_dir_str = exe_dir.to_string_lossy().to_string();
crate::unlockers::ScreamAPI::install_to_game(&exe_dir_str, "koaloader").await?;
let exe_name = exe_path.file_name().unwrap_or_default().to_string_lossy().to_string();
let koa_config = serde_json::json!({
"logging": false,
"enabled": true,
"auto_load": true,
"targets": [exe_name],
"modules": []
});
fs::write(
exe_dir.join("Koaloader.config.json"),
serde_json::to_string_pretty(&koa_config).unwrap(),
)
.map_err(|e| format!("Failed to write Koaloader config: {}", e))?;
info!("Koaloader installation complete for: {}", game_path);
Ok(())
}
async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String> {
let exe_path = Self::resolve_exe(game_path, context)?;
let exe_dir = exe_path.parent().ok_or("Failed to get executable directory")?;
let exe_dir_str = exe_dir.to_string_lossy().to_string();
let koa_config = exe_dir.join("Koaloader.config.json");
if koa_config.exists() {
fs::remove_file(&koa_config)
.map_err(|e| format!("Failed to remove Koaloader config: {}", e))?;
}
if let Ok(entries) = fs::read_dir(exe_dir) {
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
let name_lower = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if KOA_VARIANTS.contains(&name_lower.as_str()) {
fs::remove_file(&path).ok();
info!("Removed proxy DLL: {}", path.display());
}
}
}
crate::unlockers::ScreamAPI::uninstall_from_game(&exe_dir_str, "koaloader").await?;
info!("Koaloader uninstallation complete for: {}", game_path);
Ok(())
}
fn name() -> &'static str {
"Koaloader"
}
}
impl Koaloader {
/// Public wrapper for installer/mod.rs to call.
pub fn resolve_exe_pub(game_path: &str, exe_relative: &str) -> Result<std::path::PathBuf, String> {
Self::resolve_exe(game_path, exe_relative)
}
fn resolve_exe(game_path: &str, exe_relative: &str) -> Result<std::path::PathBuf, String> {
use walkdir::WalkDir;
let full = Path::new(game_path).join(exe_relative);
if full.exists() {
return Ok(full);
}
let exe_name = Path::new(exe_relative)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
for entry in WalkDir::new(game_path)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{
if entry.file_name().to_string_lossy() == exe_name {
return Ok(entry.path().to_path_buf());
}
}
Err(format!(
"Executable not found: {} (searched in {})",
exe_relative, game_path
))
}
pub fn get_proxy_dll(proxy_stem: &str, is_64bit: bool) -> Result<std::path::PathBuf, String> {
let versions = crate::cache::read_versions()?;
if versions.koaloader.latest.is_empty() {
return Err("Koaloader is not cached. Please restart the app.".to_string());
}
let version_dir = crate::cache::get_koaloader_version_dir(&versions.koaloader.latest)?;
let bitness = if is_64bit { "64" } else { "32" };
let folder = format!("{}-{}", proxy_stem, bitness);
let dll_path = version_dir.join(&folder).join(format!("{}.dll", proxy_stem));
if !dll_path.exists() {
return Err(format!(
"Koaloader proxy DLL not found in cache: {}",
dll_path.display()
));
}
Ok(dll_path)
}
}

View File

@@ -1,8 +1,12 @@
mod creamlinux;
mod smokeapi;
pub mod koaloader;
mod screamapi;
pub use creamlinux::CreamLinux;
pub use smokeapi::SmokeAPI;
pub use screamapi::ScreamAPI;
pub use koaloader::Koaloader;
use async_trait::async_trait;

View File

@@ -0,0 +1,339 @@
use super::Unlocker;
use async_trait::async_trait;
use log::info;
use reqwest;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tempfile::tempdir;
use walkdir::WalkDir;
use zip::ZipArchive;
const SCREAMAPI_REPO: &str = "acidicoala/ScreamAPI";
pub struct ScreamAPI;
#[async_trait]
impl Unlocker for ScreamAPI {
async fn get_latest_version() -> Result<String, String> {
info!("Fetching latest ScreamAPI version...");
let client = reqwest::Client::new();
let releases_url = format!(
"https://api.github.com/repos/{}/releases/latest",
SCREAMAPI_REPO
);
let response = client
.get(&releases_url)
.header("User-Agent", "CreamLinux")
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch ScreamAPI releases: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch ScreamAPI releases: HTTP {}",
response.status()
));
}
let release_info: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse release info: {}", e))?;
let version = release_info
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Failed to extract version from release info".to_string())?
.to_string();
info!("Latest ScreamAPI version: {}", version);
Ok(version)
}
async fn download_to_cache() -> Result<String, String> {
let version = Self::get_latest_version().await?;
info!("Downloading ScreamAPI version {}...", version);
let client = reqwest::Client::new();
let releases_url = format!(
"https://api.github.com/repos/{}/releases/latest",
SCREAMAPI_REPO
);
let release_info: serde_json::Value = client
.get(&releases_url)
.header("User-Agent", "CreamLinux")
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Failed to fetch ScreamAPI release: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse release info: {}", e))?;
let zip_url = release_info
.get("assets")
.and_then(|a| a.as_array())
.and_then(|assets| {
assets.iter().find(|asset| {
asset
.get("name")
.and_then(|n| n.as_str())
.map(|n| n.ends_with(".zip"))
.unwrap_or(false)
})
})
.and_then(|asset| asset.get("browser_download_url"))
.and_then(|u| u.as_str())
.ok_or_else(|| "No zip asset found in ScreamAPI release".to_string())?
.to_string();
info!("Downloading ScreamAPI from: {}", zip_url);
let response = client
.get(&zip_url)
.timeout(Duration::from_secs(60))
.send()
.await
.map_err(|e| format!("Failed to download ScreamAPI: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to download ScreamAPI: HTTP {}",
response.status()
));
}
let temp_dir = tempdir().map_err(|e| format!("Failed to create temp dir: {}", e))?;
let zip_path = temp_dir.path().join("screamapi.zip");
let content = response
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {}", e))?;
fs::write(&zip_path, &content)
.map_err(|e| format!("Failed to write zip file: {}", e))?;
let version_dir = crate::cache::get_screamapi_version_dir(&version)?;
let file =
fs::File::open(&zip_path).map_err(|e| format!("Failed to open zip: {}", e))?;
let mut archive =
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to access zip entry: {}", e))?;
let file_name = file.name().to_string();
let base_name = Path::new(&file_name)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let should_extract = base_name.to_lowercase().ends_with(".dll")
|| base_name == "ScreamAPI.config.json";
if should_extract {
let output_path = version_dir.join(&base_name);
let mut outfile = fs::File::create(&output_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
io::copy(&mut file, &mut outfile)
.map_err(|e| format!("Failed to extract file: {}", e))?;
info!("Extracted: {}", output_path.display());
}
}
info!("ScreamAPI version {} downloaded to cache successfully", version);
Ok(version)
}
/// context = "" -> direct install (replace EOSSDK DLLs)
/// context = "koaloader" -> payload install (drop DLL in exe dir)
async fn install_to_game(game_path: &str, context: &str) -> Result<(), String> {
if context == "koaloader" {
Self::install_as_koaloader_payload(game_path).await
} else {
Self::install_direct(game_path).await
}
}
async fn uninstall_from_game(game_path: &str, context: &str) -> Result<(), String> {
if context == "koaloader" {
Self::uninstall_as_koaloader_payload(game_path).await
} else {
Self::uninstall_direct(game_path).await
}
}
fn name() -> &'static str {
"ScreamAPI"
}
}
impl ScreamAPI {
// Direct install
async fn install_direct(game_path: &str) -> Result<(), String> {
info!("Installing ScreamAPI (direct) to: {}", game_path);
let install_path = Path::new(game_path);
let eos_dlls = Self::find_eossdk_dlls(install_path);
if eos_dlls.is_empty() {
return Err(format!(
"No EOSSDK-Win*-Shipping.dll found in {}",
game_path
));
}
info!("Found {} EOSSDK DLL(s)", eos_dlls.len());
let versions = crate::cache::read_versions()?;
if versions.screamapi.latest.is_empty() {
return Err("ScreamAPI is not cached. Please restart the app.".to_string());
}
let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?;
for eos_dll in &eos_dlls {
let filename = eos_dll.file_name().unwrap_or_default().to_string_lossy();
let is_64bit = filename.to_lowercase().contains("64");
let stem = filename.trim_end_matches(".dll");
let backup = eos_dll.with_file_name(format!("{}_o.dll", stem));
if !backup.exists() && eos_dll.exists() {
fs::copy(eos_dll, &backup)
.map_err(|e| format!("Failed to backup {}: {}", filename, e))?;
info!("Backed up {} -> {}", eos_dll.display(), backup.display());
}
let scream_dll_name = if is_64bit { "ScreamAPI64.dll" } else { "ScreamAPI32.dll" };
let src = scream_dir.join(scream_dll_name);
if !src.exists() {
return Err(format!("ScreamAPI DLL not found in cache: {}", src.display()));
}
fs::copy(&src, eos_dll)
.map_err(|e| format!("Failed to install ScreamAPI DLL: {}", e))?;
info!("Installed {} as {}", scream_dll_name, eos_dll.display());
}
let config_dir = eos_dlls[0].parent().ok_or("Failed to get parent of EOS DLL")?;
crate::screamapi_config::write_default_config(config_dir)?;
info!("ScreamAPI (direct) installation complete for: {}", game_path);
Ok(())
}
async fn uninstall_direct(game_path: &str) -> Result<(), String> {
info!("Uninstalling ScreamAPI (direct) from: {}", game_path);
let install_path = Path::new(game_path);
for entry in WalkDir::new(install_path)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
let lower = filename.to_lowercase();
if lower.starts_with("eossdk-win") && lower.ends_with("_o.dll") {
let original_name = filename.trim_end_matches("_o.dll").to_string() + ".dll";
let original = path.parent().unwrap_or(install_path).join(&original_name);
fs::copy(path, &original)
.map_err(|e| format!("Failed to restore {}: {}", original_name, e))?;
fs::remove_file(path)
.map_err(|e| format!("Failed to remove backup file: {}", e))?;
info!("Restored {} from backup", original.display());
}
}
crate::screamapi_config::delete_config(game_path)?;
info!("ScreamAPI (direct) uninstallation complete for: {}", game_path);
Ok(())
}
// Koaloader payload
async fn install_as_koaloader_payload(exe_dir: &str) -> Result<(), String> {
info!("Installing ScreamAPI as Koaloader payload in: {}", exe_dir);
let versions = crate::cache::read_versions()?;
if versions.screamapi.latest.is_empty() {
return Err("ScreamAPI is not cached. Please restart the app.".to_string());
}
let scream_dir = crate::cache::get_screamapi_version_dir(&versions.screamapi.latest)?;
let exe_dir_path = Path::new(exe_dir);
for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] {
let src = scream_dir.join(dll_name);
if src.exists() {
let dest = exe_dir_path.join(dll_name);
fs::copy(&src, &dest)
.map_err(|e| format!("Failed to copy {}: {}", dll_name, e))?;
info!("Placed {} in exe dir", dll_name);
}
}
crate::screamapi_config::write_default_config(exe_dir_path)?;
info!("ScreamAPI (Koaloader payload) install complete");
Ok(())
}
async fn uninstall_as_koaloader_payload(exe_dir: &str) -> Result<(), String> {
info!("Removing ScreamAPI Koaloader payload from: {}", exe_dir);
let exe_dir_path = Path::new(exe_dir);
for dll_name in &["ScreamAPI32.dll", "ScreamAPI64.dll"] {
let path = exe_dir_path.join(dll_name);
if path.exists() {
fs::remove_file(&path)
.map_err(|e| format!("Failed to remove {}: {}", dll_name, e))?;
info!("Removed {}", dll_name);
}
}
let cfg = exe_dir_path.join("ScreamAPI.config.json");
if cfg.exists() {
fs::remove_file(&cfg).ok();
}
info!("ScreamAPI (Koaloader payload) uninstall complete");
Ok(())
}
// Helpers
pub fn find_eossdk_dlls(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
for entry in WalkDir::new(root)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
let lower = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if lower.starts_with("eossdk-win")
&& lower.ends_with("-shipping.dll")
&& !lower.contains("_o")
{
found.push(path.to_path_buf());
}
}
found
}
}

View File

@@ -410,9 +410,9 @@ impl SmokeAPI {
fn find_libsteam_api(game_path: &Path) -> Result<std::path::PathBuf, String> {
use walkdir::WalkDir;
// Scan for libsteam_api.so (not too deep to keep it fast)
// Scan for libsteam_api.so (some games place it several subdirectories deep)
for entry in WalkDir::new(game_path)
.max_depth(3)
.max_depth(8)
.into_iter()
.filter_map(Result::ok)
{

View File

@@ -19,7 +19,7 @@
},
"productName": "Creamlinux",
"mainBinaryName": "creamlinux",
"version": "1.4.1",
"version": "1.5.5",
"identifier": "com.creamlinux.dev",
"app": {
"withGlobalTauri": false,

View File

@@ -25,7 +25,7 @@ import {
} from '@/components/dialogs'
// Game components
import { GameList } from '@/components/games'
import { GameList, EpicGameList } from '@/components/games'
/**
* Main application component
@@ -64,16 +64,32 @@ function App() {
handleSettingsOpen,
handleSettingsClose,
handleSmokeAPISettingsOpen,
handleOpenRating,
reportingEnabled,
showToast,
unlockerSelectionDialog,
handleSelectCreamLinux,
handleSelectSmokeAPI,
closeUnlockerDialog,
epicGames,
epicLoading,
epicInstallingId,
loadEpicGames,
handleEpicInstall,
handleEpicUninstallScream,
handleEpicUninstallKoaloader,
handleEpicSettings,
} = useAppContext()
// Conflict detection
const { conflicts, showDialog, resolveConflict, closeDialog } =
useConflictDetection(games)
const { conflicts, showDialog, resolveConflict, closeDialog } = useConflictDetection(games)
const handleSetFilter = async (f: string) => {
setFilter(f)
if (f === 'epic' && epicGames.length === 0 && !epicLoading) {
await loadEpicGames()
}
}
// Handle conflict resolution
const handleConflictResolve = async (
@@ -124,13 +140,22 @@ function App() {
<div className="main-content">
{/* Sidebar for filtering */}
<Sidebar
setFilter={setFilter}
setFilter={handleSetFilter}
currentFilter={filter}
onSettingsClick={handleSettingsOpen}
/>
{/* Show error or game list */}
{error ? (
{filter === 'epic' ? (
<EpicGameList
games={epicGames}
isLoading={epicLoading}
installingId={epicInstallingId}
onInstall={handleEpicInstall}
onUninstallScream={handleEpicUninstallScream}
onUninstallKoaloader={handleEpicUninstallKoaloader}
onSettings={handleEpicSettings}
/>
) : error ? (
<div className="error-message">
<h3>Error Loading Games</h3>
<p>{error}</p>
@@ -143,6 +168,8 @@ function App() {
onAction={handleGameAction}
onEdit={handleGameEdit}
onSmokeAPISettings={handleSmokeAPISettingsOpen}
onRate={handleOpenRating}
reportingEnabled={reportingEnabled}
/>
)}
</div>
@@ -190,6 +217,7 @@ function App() {
{/* Unlocker Selection Dialog */}
<UnlockerSelectionDialog
visible={unlockerSelectionDialog.visible}
gameId={unlockerSelectionDialog.gameId}
gameTitle={unlockerSelectionDialog.gameTitle || ''}
onClose={closeUnlockerDialog}
onSelectCreamLinux={handleSelectCreamLinux}

BIN
src/assets/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View 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

View File

@@ -1,6 +1,8 @@
export { default as LoadingIndicator } from './LoadingIndicator'
export { default as ProgressBar } from './ProgressBar'
export { default as Dropdown } from './Dropdown'
export { default as VotesDisplay } from './VotesDisplay'
export type { LoadingSize, LoadingType } from './LoadingIndicator'
export type { DropdownOption } from './Dropdown'
export type { DropdownOption } from './Dropdown'
export type { GameVotes } from './VotesDisplay'

View 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

View File

@@ -4,6 +4,7 @@ import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import AddDlcDialog from './AddDlcDialog'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { DlcInfo } from '@/types'
import { Icon, check, info } from '@/components/icons'
@@ -51,6 +52,7 @@ const DlcSelectionDialog = ({
const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false)
const [showAddDlc, setShowAddDlc] = useState(false)
// Reset dialog state when it opens or closes
useEffect(() => {
@@ -126,6 +128,11 @@ const DlcSelectionDialog = ({
)
}, [selectAll])
// Add a manually-entered DLC to the list
const handleAddDlc = useCallback((dlc: DlcInfo) => {
setSelectedDlcs((prev) => [...prev, dlc])
}, [])
// Submit selected DLCs to parent component
const handleConfirm = useCallback(() => {
// Create a deep copy to prevent reference issues
@@ -151,124 +158,141 @@ const DlcSelectionDialog = ({
}
return (
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{dialogTitle}</h3>
<div className="dlc-game-info">
<span className="game-title">{gameTitle}</span>
<span className="dlc-count">
{selectedCount} of {selectedDlcs.length} DLCs selected
{getLoadingInfoText()}
</span>
</div>
</DialogHeader>
<>
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{dialogTitle}</h3>
<div className="dlc-game-info">
<span className="game-title">{gameTitle}</span>
<span className="dlc-count">
{selectedCount} of {selectedDlcs.length} DLCs selected
{getLoadingInfoText()}
</span>
</div>
</DialogHeader>
<div className="dlc-dialog-search">
<input
type="text"
placeholder="Search DLCs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input"
/>
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
<div className="dlc-dialog-search">
<input
type="text"
placeholder="Search DLCs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input"
/>
</div>
</div>
{(isLoading || isUpdating) && loadingProgress > 0 && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
/>
</div>
</div>
)}
<DialogBody className="dlc-list-container">
{selectedDlcs.length > 0 ? (
<ul className="dlc-list">
{filteredDlcs.map((dlc) => (
<li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox
checked={dlc.enabled}
onChange={() => handleToggleDlc(dlc.appid)}
label={dlc.name}
sublabel={`ID: ${dlc.appid}`}
/>
</li>
))}
{isLoading && (
<li className="dlc-item dlc-item-loading">
<div className="loading-pulse"></div>
</li>
)}
</ul>
) : (
<div className="dlc-loading">
<div className="loading-spinner"></div>
<p>Loading DLC information...</p>
{(isLoading || isUpdating) && loadingProgress > 0 && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div>
</div>
)}
</DialogBody>
<DialogFooter>
{/* Show update results */}
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
<>
{newDlcsCount > 0 && (
<div className="dlc-update-results dlc-update-success">
<span className="update-message">
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
</span>
</div>
)}
{newDlcsCount === 0 && (
<div className="dlc-update-results dlc-update-info">
<span className="update-message">
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
</span>
</div>
)}
</>
)}
<DialogActions>
<Button
variant="secondary"
onClick={onClose}
disabled={(isLoading || isUpdating) && loadingProgress < 10}
>
Cancel
</Button>
{/* Update button - only show in edit mode */}
{isEditMode && onUpdate && (
<DialogBody className="dlc-list-container">
{selectedDlcs.length > 0 ? (
<ul className="dlc-list">
{filteredDlcs.map((dlc) => (
<li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox
checked={dlc.enabled}
onChange={() => handleToggleDlc(dlc.appid)}
label={dlc.name}
sublabel={`ID: ${dlc.appid}`}
/>
</li>
))}
{isLoading && (
<li className="dlc-item dlc-item-loading">
<div className="loading-pulse"></div>
</li>
)}
</ul>
) : (
<div className="dlc-loading">
<div className="loading-spinner"></div>
<p>Loading DLC information...</p>
</div>
)}
</DialogBody>
<DialogFooter>
{/* Show update results */}
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
<>
{newDlcsCount > 0 && (
<div className="dlc-update-results dlc-update-success">
<span className="update-message">
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
</span>
</div>
)}
{newDlcsCount === 0 && (
<div className="dlc-update-results dlc-update-info">
<span className="update-message">
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
</span>
</div>
)}
</>
)}
<DialogActions>
<Button
variant="warning"
onClick={() => onUpdate(gameId)}
variant="secondary"
onClick={onClose}
disabled={(isLoading || isUpdating) && loadingProgress < 10}
>
Cancel
</Button>
<Button
variant="secondary"
onClick={() => setShowAddDlc(true)}
disabled={isLoading || isUpdating}
>
{isUpdating ? 'Updating...' : 'Update DLC List'}
Add DLC Manually
</Button>
)}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
{actionButtonText}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
{/* Update button - only show in edit mode */}
{isEditMode && onUpdate && (
<Button
variant="warning"
onClick={() => onUpdate(gameId)}
disabled={isLoading || isUpdating}
>
{isUpdating ? 'Updating...' : 'Update DLC List'}
</Button>
)}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
{actionButtonText}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
<AddDlcDialog
visible={showAddDlc}
onClose={() => setShowAddDlc(false)}
onAdd={handleAddDlc}
existingIds={new Set(selectedDlcs.map((d) => d.appid))}
/>
</>
)
}
export default DlcSelectionDialog
export default DlcSelectionDialog

View File

@@ -0,0 +1,97 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, info } from '@/components/icons'
import { EpicGame } from '@/types/EpicGame'
export interface EpicUnlockerSelectionDialogProps {
visible: boolean
game: EpicGame | null
onClose: () => void
onSelectScreamAPI: () => void
onSelectKoaloader: () => void
}
/**
* Unlocker selection dialog for Epic games.
* Recommended: ScreamAPI (direct EOSSDK replacement).
* Alternative: Koaloader + ScreamAPI (proxy DLL injection).
*/
const EpicUnlockerSelectionDialog: React.FC<EpicUnlockerSelectionDialogProps> = ({
visible,
game,
onClose,
onSelectScreamAPI,
onSelectKoaloader,
}) => {
return (
<Dialog visible={visible} onClose={onClose} size="medium">
<DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="unlocker-selection-header">
<h3>Choose Unlocker</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="unlocker-selection-content">
<p className="game-title-info">
Select which unlocker to install for <strong>{game?.title}</strong>:
</p>
<div className="unlocker-options">
<div className="unlocker-option recommended">
<div className="option-header">
<h4>ScreamAPI</h4>
<span className="recommended-badge">Recommended</span>
</div>
<p className="option-description">
Replaces the EOS SDK DLL directly with ScreamAPI. Works for most Epic games and
requires no additional files. DLC unlocking is automatic.
</p>
<Button variant="primary" onClick={onSelectScreamAPI} fullWidth>
Install ScreamAPI
</Button>
</div>
<div className="unlocker-option">
<div className="option-header">
<h4>Koaloader + ScreamAPI</h4>
<span className="alternative-badge">Alternative</span>
</div>
<p className="option-description">
Uses a proxy DLL to inject ScreamAPI without modifying the EOS SDK. Try this if the
recommended method doesn't work for your game.
</p>
<Button variant="secondary" onClick={onSelectKoaloader} fullWidth>
Install Koaloader
</Button>
</div>
</div>
<div className="selection-info">
<Icon name={info} variant="solid" size="md" />
<span>
You can always uninstall and try the other option if one doesn't work properly.
</span>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default EpicUnlockerSelectionDialog

View 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

View 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

View File

@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { Dropdown, DropdownOption } from '@/components/common'
interface ScreamAPIConfig {
$schema: string
$version: number
logging: boolean
log_eos: boolean
block_metrics: boolean
namespace_id: string
default_dlc_status: 'unlocked' | 'locked' | 'original'
override_dlc_status: Record<string, string>
extra_graphql_endpoints: string[]
extra_entitlements: Record<string, string>
}
interface ScreamAPISettingsDialogProps {
visible: boolean
onClose: () => void
gamePath: string
gameTitle: string
}
const DEFAULT_CONFIG: ScreamAPIConfig = {
$schema:
'https://raw.githubusercontent.com/acidicoala/ScreamAPI/master/res/ScreamAPI.schema.json',
$version: 3,
logging: false,
log_eos: false,
block_metrics: false,
namespace_id: '',
default_dlc_status: 'unlocked',
override_dlc_status: {},
extra_graphql_endpoints: [],
extra_entitlements: {},
}
const DLC_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
{ value: 'unlocked', label: 'Unlocked' },
{ value: 'locked', label: 'Locked' },
{ value: 'original', label: 'Original' },
]
const ScreamAPISettingsDialog = ({
visible,
onClose,
gamePath,
gameTitle,
}: ScreamAPISettingsDialogProps) => {
const [enabled, setEnabled] = useState(false)
const [config, setConfig] = useState<ScreamAPIConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const loadConfig = useCallback(async () => {
setIsLoading(true)
try {
const existingConfig = await invoke<ScreamAPIConfig | null>('read_screamapi_config', {
gamePath,
})
if (existingConfig) {
setConfig(existingConfig)
setEnabled(true)
} else {
setConfig(DEFAULT_CONFIG)
setEnabled(false)
}
setHasChanges(false)
} catch (error) {
console.error('Failed to load ScreamAPI config:', error)
setConfig(DEFAULT_CONFIG)
setEnabled(false)
} finally {
setIsLoading(false)
}
}, [gamePath])
useEffect(() => {
if (visible && gamePath) {
loadConfig()
}
}, [visible, gamePath, loadConfig])
const handleSave = async () => {
setIsLoading(true)
try {
if (enabled) {
await invoke('write_screamapi_config', { gamePath, config })
} else {
await invoke('delete_screamapi_config', { gamePath })
}
setHasChanges(false)
onClose()
} catch (error) {
console.error('Failed to save ScreamAPI config:', error)
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
setHasChanges(false)
onClose()
}
const updateConfig = <K extends keyof ScreamAPIConfig>(key: K, value: ScreamAPIConfig[K]) => {
setConfig((prev) => ({ ...prev, [key]: value }))
setHasChanges(true)
}
return (
<Dialog visible={visible} onClose={handleCancel} size="medium">
<DialogHeader onClose={handleCancel} hideCloseButton={true}>
<div className="settings-header">
<h3>ScreamAPI Settings</h3>
</div>
<p className="dialog-subtitle">{gameTitle}</p>
</DialogHeader>
<DialogBody>
<div className="smokeapi-settings-content">
<div className="settings-section">
<AnimatedCheckbox
checked={enabled}
onChange={() => {
setEnabled(!enabled)
setHasChanges(true)
}}
label="Enable ScreamAPI Configuration"
sublabel="Enable this to customise ScreamAPI settings for this game"
/>
</div>
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
<div className="settings-section">
<h4>General Settings</h4>
<Dropdown
label="Default DLC Status"
description="Specifies the default DLC unlock status"
value={config.default_dlc_status}
options={DLC_STATUS_OPTIONS}
onChange={(value) => updateConfig('default_dlc_status', value)}
disabled={!enabled}
/>
</div>
<div className="settings-section">
<h4>Logging</h4>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.logging}
onChange={() => updateConfig('logging', !config.logging)}
label="Enable Logging"
sublabel="Enables logging to ScreamAPI.log.log file"
/>
</div>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.log_eos}
onChange={() => updateConfig('log_eos', !config.log_eos)}
label="Log EOS SDK"
sublabel="Intercept and log EOS SDK calls (requires logging enabled)"
/>
</div>
</div>
<div className="settings-section">
<h4>Privacy</h4>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.block_metrics}
onChange={() => updateConfig('block_metrics', !config.block_metrics)}
label="Block Metrics"
sublabel="Block game analytics/usage reporting to Epic Online Services"
/>
</div>
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
Cancel
</Button>
<Button variant="primary" onClick={handleSave} disabled={isLoading || !hasChanges}>
{isLoading ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ScreamAPISettingsDialog

View 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

View File

@@ -1,4 +1,5 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import {
Dialog,
DialogHeader,
@@ -8,10 +9,12 @@ import {
} 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
gameTitle: string
gameId: string | null
gameTitle: string | null
onClose: () => void
onSelectCreamLinux: () => void
onSelectSmokeAPI: () => void
@@ -19,15 +22,39 @@ export interface UnlockerSelectionDialogProps {
/**
* Unlocker Selection Dialog component
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games
* 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}>
@@ -52,6 +79,7 @@ const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
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>
@@ -66,6 +94,7 @@ const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
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>

View File

@@ -6,11 +6,17 @@ export { default as DialogFooter } from './DialogFooter'
export { default as DialogActions } from './DialogActions'
export { default as ProgressDialog } from './ProgressDialog'
export { default as DlcSelectionDialog } from './DlcSelectionDialog'
export { default as AddDlcDialog } from './AddDlcDialog'
export { default as SettingsDialog } from './SettingsDialog'
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
export { default as ScreamAPISettingsDialog } from './ScreamAPISettingsDialog'
export { default as ConflictDialog } from './ConflictDialog'
export { default as DisclaimerDialog } from './DisclaimerDialog'
export { default as UnlockerSelectionDialog} from './UnlockerSelectionDialog'
export { default as UnlockerSelectionDialog } from './UnlockerSelectionDialog'
export { default as OptInDialog } from './OptInDialog'
export { default as RatingDialog } from './RatingDialog'
export { default as SmokeAPIVotesDialog } from './SmokeAPIVotesDialog'
export { default as EpicUnlockerSelectionDialog } from './EpicUnlockerSelectionDialog'
// Export types
export type { DialogProps } from './Dialog'
@@ -20,5 +26,8 @@ export type { DialogFooterProps } from './DialogFooter'
export type { DialogActionsProps } from './DialogActions'
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
export type { AddDlcDialogProps } from './AddDlcDialog'
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'
export type { RatingDialogProps } from './RatingDialog'
export type { SmokeAPIVotesDialogProps } from './SmokeAPIVotesDialog'

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { EpicGame } from '@/types/EpicGame'
import { ActionButton, Button } from '@/components/buttons'
import { Icon } from '@/components/icons'
interface EpicGameItemProps {
game: EpicGame
installing?: boolean
onInstall: (game: EpicGame) => void
onUninstallScream: (game: EpicGame) => void
onUninstallKoaloader: (game: EpicGame) => void
onSettings: (game: EpicGame) => void
}
const EpicGameItem = ({
game,
installing,
onInstall,
onUninstallScream,
onUninstallKoaloader,
onSettings,
}: EpicGameItemProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [hasError, setHasError] = useState(false)
useEffect(() => {
if (game.box_art_url) {
setImageUrl(game.box_art_url)
}
}, [game.box_art_url])
const backgroundImage =
imageUrl && !hasError
? `url(${imageUrl})`
: 'linear-gradient(135deg, #232323, #1A1A1A)'
const anyInstalled = game.scream_installed || game.koaloader_installed
const isWorking = !!installing
return (
<div
className="game-item-card"
style={{
backgroundImage,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{imageUrl && !hasError && (
<img
src={imageUrl}
alt=""
style={{ display: 'none' }}
onError={() => setHasError(true)}
/>
)}
<div className="game-item-overlay">
<div className="game-badges">
<span className="status-badge epic">Epic</span>
{game.scream_installed && <span className="status-badge smoke">ScreamAPI</span>}
{game.koaloader_installed && <span className="status-badge smoke">Koaloader</span>}
</div>
<div className="game-title">
<h3>{game.title}</h3>
</div>
<div className="game-actions">
{/* Nothing installed - install button */}
{!anyInstalled && (
<ActionButton
action="install_unlocker"
isInstalled={false}
isWorking={isWorking}
onClick={() => { if (!isWorking) onInstall(game) }}
/>
)}
{/* ScreamAPI installed - uninstall + settings */}
{game.scream_installed && (
<ActionButton
action="uninstall_smoke"
isInstalled={true}
isWorking={isWorking}
onClick={() => { if (!isWorking) onUninstallScream(game) }}
/>
)}
{/* Koaloader installed - uninstall */}
{game.koaloader_installed && (
<ActionButton
action="uninstall_smoke"
isInstalled={true}
isWorking={isWorking}
onClick={() => { if (!isWorking) onUninstallKoaloader(game) }}
/>
)}
{/* Settings button - only for direct ScreamAPI (not Koaloader) */}
{game.scream_installed && !game.koaloader_installed && (
<Button
variant="secondary"
size="small"
onClick={() => onSettings(game)}
disabled={isWorking}
title="Configure ScreamAPI"
className="edit-button settings-icon-button"
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
iconOnly
/>
)}
</div>
</div>
</div>
)
}
export default EpicGameItem

View File

@@ -0,0 +1,65 @@
import { useMemo } from 'react'
import EpicGameItem from '@/components/games/EpicGameItem'
import { EpicGame } from '@/types/EpicGame'
import LoadingIndicator from '../common/LoadingIndicator'
interface EpicGameListProps {
games: EpicGame[]
isLoading: boolean
installingId: string | null
onInstall: (game: EpicGame) => void
onUninstallScream: (game: EpicGame) => void
onUninstallKoaloader: (game: EpicGame) => void
onSettings: (game: EpicGame) => void
}
const EpicGameList = ({
games,
isLoading,
installingId,
onInstall,
onUninstallScream,
onUninstallKoaloader,
onSettings,
}: EpicGameListProps) => {
const sortedGames = useMemo(
() => [...games].sort((a, b) => a.title.localeCompare(b.title)),
[games]
)
if (isLoading) {
return (
<div className="game-list">
<LoadingIndicator type="spinner" size="large" message="Scanning for Epic games..." />
</div>
)
}
return (
<div className="game-list">
<h2>Epic Games ({games.length})</h2>
{games.length === 0 ? (
<div className="no-games-message">
No Epic games found. Make sure Heroic is installed and has games downloaded.
</div>
) : (
<div className="game-grid">
{sortedGames.map((game) => (
<EpicGameItem
key={game.app_name}
game={game}
installing={installingId === game.app_name}
onInstall={onInstall}
onUninstallScream={onUninstallScream}
onUninstallKoaloader={onUninstallKoaloader}
onSettings={onSettings}
/>
))}
</div>
)}
</div>
)
}
export default EpicGameList

View File

@@ -9,13 +9,15 @@ interface GameItemProps {
onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void
onRate?: (gameId: string) => void
reportingEnabled?: boolean // When false/undefined, rate button is not rendered at all.
}
/**
* Individual game card component
* Displays game information and action buttons
*/
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps) => {
const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameItemProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
@@ -93,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
const backgroundImage =
!isLoading && imageUrl
@@ -179,6 +188,20 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
</div>
)}
{/* Rate button */}
{(game.cream_installed || game.smoke_installed) && onRate && reportingEnabled && (
<Button
variant="primary"
size="small"
onClick={handleRate}
disabled={!!game.installing}
title="Rate compatibility"
className="edit-button rate-button"
leftIcon={<Icon name="Star" variant="solid" size="md" />}
iconOnly
/>
)}
{/* Edit button - only enabled if CreamLinux is installed */}
{game.cream_installed && (
<Button

View File

@@ -10,13 +10,15 @@ interface GameListProps {
onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void
onRate?: (gameId: string) => void
reportingEnabled?: boolean
}
/**
* Main game list component
* Displays games in a grid with search and filtering applied
*/
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: GameListProps) => {
const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings, onRate, reportingEnabled }: GameListProps) => {
const [imagesPreloaded, setImagesPreloaded] = useState(false)
// Sort games alphabetically by title
@@ -57,7 +59,7 @@ const GameList = ({ games, isLoading, onAction, onEdit, onSmokeAPISettings }: Ga
) : (
<div className="game-grid">
{sortedGames.map((game) => (
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} />
<GameItem key={game.id} game={game} onAction={onAction} onEdit={onEdit} onSmokeAPISettings={onSmokeAPISettings} onRate={onRate} reportingEnabled={reportingEnabled} />
))}
</div>
)}

View File

@@ -2,3 +2,5 @@
export { default as GameList } from './GameList'
export { default as GameItem } from './GameItem'
export { default as ImagePreloader } from './ImagePreloader'
export { default as EpicGameItem } from './EpicGameItem'
export { default as EpicGameList } from './EpicGameList'

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 1a1.5 1.5 0 0 0-1.5 1.5v16a.5.5 0 0 0 .297.457l9 4a.5.5 0 0 0 .406 0l9-4a.5.5 0 0 0 .297-.457v-16A1.5 1.5 0 0 0 20 1zm10.25 11.75h-1.5v-8.5h1.5zM8 18.5l4 2l4-2zM8 4.25H5.25v8.5H8v-1.5H6.75v-2H8v-1.5H6.75v-2H8zm2.5 0H8.75v8.5h1.5v-2.5h.25a1.75 1.75 0 0 0 1.75-1.75V6a1.75 1.75 0 0 0-1.75-1.75m0 4.5h-.25v-3h.25a.25.25 0 0 1 .25.25v2.5a.25.25 0 0 1-.25.25m4.25-3.25c0-.69.56-1.25 1.25-1.25h1.5c.69 0 1.25.56 1.25 1.25v2h-1.5V5.75h-1v5.5h1V9.5h1.5v2c0 .69-.56 1.25-1.25 1.25H16c-.69 0-1.25-.56-1.25-1.25zM5.5 16.25h12v-1.5h-12z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -5,3 +5,4 @@ export { ReactComponent as Windows } from './windows.svg'
export { ReactComponent as Github } from './github.svg'
export { ReactComponent as Discord } from './discord.svg'
export { ReactComponent as Proton } from './proton.svg'
export { ReactComponent as Epic } from './epic.svg'

View File

@@ -29,6 +29,7 @@ export const warning = 'Warning'
export const wine = 'Wine'
export const diamond = 'Diamond'
export const settings = 'Settings'
export const star = 'Star'
// Brand icons
export const discord = 'Discord'
@@ -37,6 +38,7 @@ export const linux = 'Linux'
export const proton = 'Proton'
export const steam = 'Steam'
export const windows = 'Windows'
export const epic = 'Epic'
// Keep the IconNames object for backward compatibility and autocompletion
export const IconNames = {
@@ -59,6 +61,7 @@ export const IconNames = {
Wine: wine,
Diamond: diamond,
Settings: settings,
Star: star,
// Brand icons
Discord: discord,
@@ -67,6 +70,7 @@ export const IconNames = {
Proton: proton,
Steam: steam,
Windows: windows,
Epic: epic,
} as const
// Export direct icon components using createIconComponent from IconFactory

View File

@@ -13,6 +13,7 @@ export { ReactComponent as Layers } from './layers.svg'
export { ReactComponent as Refresh } from './refresh.svg'
export { ReactComponent as Search } from './search.svg'
export { ReactComponent as Settings } from './settings.svg'
export { ReactComponent as Star } from './star.svg'
export { ReactComponent as Trash } from './trash.svg'
export { ReactComponent as Warning } from './warning.svg'
export { ReactComponent as Wine } from './wine.svg'

View 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

View File

@@ -13,6 +13,7 @@ export { ReactComponent as Layers } from './layers.svg'
export { ReactComponent as Refresh } from './refresh.svg'
export { ReactComponent as Search } from './search.svg'
export { ReactComponent as Settings } from './settings.svg'
export { ReactComponent as Star } from './star.svg'
export { ReactComponent as Trash } from './trash.svg'
export { ReactComponent as Warning } from './warning.svg'
export { ReactComponent as Wine } from './wine.svg'

View 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

View File

@@ -1,4 +1,5 @@
import { Icon, layers, linux, proton, settings } from '@/components/icons'
import { epic } from '@/components/icons'
import { Button } from '@/components/buttons'
interface SidebarProps {
@@ -7,7 +8,6 @@ interface SidebarProps {
onSettingsClick: () => void
}
// Define a type for filter items that makes variant optional
type FilterItem = {
id: string
label: string
@@ -15,38 +15,49 @@ type FilterItem = {
variant?: string
}
/**
* Application sidebar component
* Contains filters for game types
*/
const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) => {
// Available filter options with icons
const filters: FilterItem[] = [
const steamFilters: FilterItem[] = [
{ id: 'all', label: 'All Games', icon: layers, variant: 'solid' },
{ id: 'native', label: 'Native', icon: linux, variant: 'brand' },
{ id: 'proton', label: 'Proton Required', icon: proton, variant: 'brand' },
{ id: 'proton', label: 'Proton', icon: proton, variant: 'brand' },
]
const epicFilters: FilterItem[] = [
{ id: 'epic', label: 'All Games', icon: epic, variant: 'brand' },
]
const renderFilter = (filter: FilterItem) => (
<li
key={filter.id}
className={currentFilter === filter.id ? 'active' : ''}
onClick={() => setFilter(filter.id)}
>
<div className="filter-item">
<Icon name={filter.icon} variant={filter.variant} size="md" className="filter-icon" />
<span>{filter.label}</span>
</div>
</li>
)
return (
<div className="sidebar">
<div className="sidebar-header">
<h2>Library</h2>
</div>
<ul className="filter-list">
{filters.map((filter) => (
<li
key={filter.id}
className={currentFilter === filter.id ? 'active' : ''}
onClick={() => setFilter(filter.id)}
>
<div className="filter-item">
<Icon name={filter.icon} variant={filter.variant} size="md" className="filter-icon" />
<span>{filter.label}</span>
</div>
</li>
))}
</ul>
<div className="sidebar-section">
<span className="sidebar-section-label">Steam</span>
<ul className="filter-list">
{steamFilters.map(renderFilter)}
</ul>
</div>
<div className="sidebar-section">
<span className="sidebar-section-label">Epic Games</span>
<ul className="filter-list">
{epicFilters.map(renderFilter)}
</ul>
</div>
<Button
variant="secondary"
@@ -58,9 +69,8 @@ const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) =>
>
Settings
</Button>
</div>
)
}
export default Sidebar
export default Sidebar

View File

@@ -1,5 +1,5 @@
import { createContext } from 'react'
import { Game, DlcInfo } from '@/types'
import { Game, DlcInfo, EpicGame } from '@/types'
import { ActionType } from '@/components/buttons/ActionButton'
import { DlcDialogState } from '@/hooks/useDlcManager'
@@ -26,6 +26,14 @@ export interface SmokeAPISettingsDialogState {
gameTitle: string
}
export interface RatingDialogState {
visible: boolean
gameId: string
gameTitle: string
unlocker: 'creamlinux' | 'smokeapi'
steamPath: string
}
// Define the context type
export interface AppContextType {
// Game state
@@ -41,6 +49,16 @@ export interface AppContextType {
handleDlcDialogClose: () => void
handleUpdateDlcs: (gameId: string) => Promise<void>
// Epic Games
epicGames: EpicGame[]
epicLoading: boolean
epicInstallingId: string | null
loadEpicGames: () => Promise<void>
handleEpicInstall: (game: EpicGame) => void
handleEpicUninstallScream: (game: EpicGame) => void
handleEpicUninstallKoaloader: (game: EpicGame) => void
handleEpicSettings: (game: EpicGame) => void
// Game actions
progressDialog: ProgressDialogState
handleGameAction: (gameId: string, action: ActionType) => Promise<void>
@@ -56,6 +74,22 @@ export interface AppContextType {
handleSmokeAPISettingsOpen: (gameId: string) => void
handleSmokeAPISettingsClose: () => void
// SmokeAPI votes dialog
smokeAPIVotesDialog: {
visible: boolean
gameId: string | null
gameTitle: string | null
}
handleSmokeAPIVotesClose: () => void
handleSmokeAPIVotesConfirm: () => void
// Rating dialog
ratingDialog: RatingDialogState
handleOpenRating: (gameId: string) => void
handleCloseRating: () => void
handleSubmitRating: (worked: boolean) => Promise<void>
reportingEnabled: boolean
// Toast notifications
showToast: (
message: string,

View File

@@ -1,10 +1,12 @@
import { ReactNode, useState } from 'react'
import { ReactNode, useState, useEffect } from 'react'
import { AppContext, AppContextType } from './AppContext'
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
import { DlcInfo } from '@/types'
import { DlcInfo, Config, EpicGame } from '@/types'
import { ActionType } from '@/components/buttons/ActionButton'
import { ToastContainer } from '@/components/notifications'
import { SmokeAPISettingsDialog } from '@/components/dialogs'
import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog, EpicUnlockerSelectionDialog, ScreamAPISettingsDialog } from '@/components/dialogs'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
// Context provider component
interface AppProviderProps {
@@ -42,6 +44,20 @@ export const AppProvider = ({ children }: AppProviderProps) => {
// Settings dialog state
const [settingsDialog, setSettingsDialog] = useState({ visible: false })
const [epicGames, setEpicGames] = useState<EpicGame[]>([])
const [epicLoading, setEpicLoading] = useState(false)
const [epicInstallingId, setEpicInstallingId] = useState<string | null>(null)
const [epicUnlockerDialog, setEpicUnlockerDialog] = useState<{
visible: boolean
game: EpicGame | null
}>({ visible: false, game: null })
const [screamSettingsDialog, setScreamSettingsDialog] = useState<{
visible: boolean
game: EpicGame | null
}>({ visible: false, game: null })
// SmokeAPI settings dialog state
const [smokeAPISettingsDialog, setSmokeAPISettingsDialog] = useState<{
visible: boolean
@@ -53,6 +69,132 @@ export const AppProvider = ({ children }: AppProviderProps) => {
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))
}, [])
useEffect(() => {
let unlisten: (() => void) | undefined
listen<EpicGame>('epic-game-updated', (event) => {
const updated = event.payload
const prev = epicGames.find((g) => g.app_name === updated.app_name)
setEpicGames((games) =>
games.map((g) => (g.app_name === updated.app_name ? updated : g))
)
setEpicInstallingId(null)
// Determine what changed and show appropriate toast
if (prev) {
const installedScream = !prev.scream_installed && updated.scream_installed
const uninstalledScream = prev.scream_installed && !updated.scream_installed
const installedKoa = !prev.koaloader_installed && updated.koaloader_installed
const uninstalledKoa = prev.koaloader_installed && !updated.koaloader_installed
if (installedScream) {
success(`ScreamAPI installed for ${updated.title}`)
} else if (uninstalledScream) {
info(`ScreamAPI removed from ${updated.title}`)
} else if (installedKoa) {
success(`Koaloader installed for ${updated.title}`)
} else if (uninstalledKoa) {
info(`Koaloader removed from ${updated.title}`)
}
if (updated.proxy_fallback_used) {
warning(
'No compatible proxy import found - installed using version.dll as a fallback. ' +
'If the game has issues, try the direct ScreamAPI method instead.'
)
}
}
}).then((fn) => { unlisten = fn })
return () => { unlisten?.() }
}, [epicGames, success, info, warning])
const loadEpicGames = async () => {
setEpicLoading(true)
try {
const games = await invoke<EpicGame[]>('scan_epic_games')
setEpicGames(games)
} catch (e) {
showError(`Failed to scan Epic games: ${e}`)
} finally {
setEpicLoading(false)
}
}
const runEpicAction = async (game: EpicGame, action: string) => {
setEpicInstallingId(game.app_name)
try {
await invoke('process_epic_action', { epicAction: { game, action } })
// state updated via epic-game-updated event listener
} catch (e) {
showError(`Action failed: ${e}`)
setEpicInstallingId(null)
}
}
const handleEpicInstall = (game: EpicGame) => {
setEpicUnlockerDialog({ visible: true, game })
}
const handleEpicUninstallScream = (game: EpicGame) => runEpicAction(game, 'uninstall_scream')
const handleEpicUninstallKoaloader = (game: EpicGame) => runEpicAction(game, 'uninstall_koaloader')
const handleEpicSettings = (game: EpicGame) => {
setScreamSettingsDialog({ visible: true, game })
}
const handleSelectScreamAPI = () => {
const game = epicUnlockerDialog.game
setEpicUnlockerDialog({ visible: false, game: null })
if (game) runEpicAction(game, 'install_scream')
}
const handleSelectKoaloader = () => {
const game = epicUnlockerDialog.game
setEpicUnlockerDialog({ visible: false, game: null })
if (game) runEpicAction(game, 'install_koaloader')
}
// Settings handlers
const handleSettingsOpen = () => {
setSettingsDialog({ visible: true })
@@ -85,6 +227,69 @@ export const AppProvider = ({ children }: AppProviderProps) => {
})
}
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
const handleGameAction = async (gameId: string, action: ActionType) => {
const game = games.find((g) => g.id === gameId)
@@ -117,6 +322,16 @@ 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') {
@@ -251,6 +466,16 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleDlcDialogClose: closeDlcDialog,
handleUpdateDlcs: (gameId: string) => handleUpdateDlcs(gameId),
// Epic games
epicGames,
epicLoading,
epicInstallingId,
loadEpicGames,
handleEpicInstall,
handleEpicUninstallScream,
handleEpicUninstallKoaloader,
handleEpicSettings,
// Game actions
progressDialog,
handleGameAction,
@@ -267,6 +492,18 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleSmokeAPISettingsOpen,
handleSmokeAPISettingsClose,
// SmokeAPI Votes
smokeAPIVotesDialog,
handleSmokeAPIVotesClose,
handleSmokeAPIVotesConfirm,
// Rating
ratingDialog,
handleOpenRating,
handleCloseRating,
handleSubmitRating,
reportingEnabled,
// Toast notifications
showToast,
@@ -330,6 +567,49 @@ export const AppProvider = ({ children }: AppProviderProps) => {
gamePath={smokeAPISettingsDialog.gamePath}
gameTitle={smokeAPISettingsDialog.gameTitle}
/>
{/* Epic Unlocker Selection Dialog */}
<EpicUnlockerSelectionDialog
visible={epicUnlockerDialog.visible}
game={epicUnlockerDialog.game}
onClose={() => setEpicUnlockerDialog({ visible: false, game: null })}
onSelectScreamAPI={handleSelectScreamAPI}
onSelectKoaloader={handleSelectKoaloader}
/>
{/* ScreamAPI Settings Dialog */}
<ScreamAPISettingsDialog
visible={screamSettingsDialog.visible}
onClose={() => setScreamSettingsDialog({ visible: false, game: null })}
gamePath={screamSettingsDialog.game?.install_path ?? ''}
gameTitle={screamSettingsDialog.game?.title ?? ''}
/>
{/* 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>
)
}

View File

@@ -160,3 +160,33 @@
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;
}
}

View File

@@ -1,3 +1,4 @@
@forward './loading';
@forward './progress_bar';
@forward './dropdown';
@forward './votes_display';

View 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);
}
}
}

View File

@@ -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
@keyframes spin {
0% {

View File

@@ -6,3 +6,6 @@
@forward './conflict_dialog';
@forward './disclaimer_dialog';
@forward './unlocker_selection_dialog';
@forward './optin_dialog';
@forward './rating_dialog';
@forward './smokeapi_votes_dialog';

View 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;
}
}
}

View 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;
}
}

View 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;
}
}
}

View File

@@ -41,6 +41,10 @@
.status-badge.smoke {
box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
}
.status-badge.epic {
box-shadow: 0 0 10px rgba(15, 25, 35, 0.5);
}
}
// Special styling for cards with different statuses
@@ -56,6 +60,12 @@
0 0 15px rgba(255, 239, 150, 0.15);
}
.game-item-card:has(.status-badge.epic) {
box-shadow:
var(--shadow-standard),
0 0 15px rgba(15, 25, 35, 0.15);
}
// Game item overlay
.game-item-overlay {
position: absolute;
@@ -126,6 +136,11 @@
color: var(--text-heavy);
}
.status-badge.epic {
background-color: var(--epic);
color: var(--text-primary);
}
// Game title
.game-title {
padding: 0;

View File

@@ -31,9 +31,29 @@
}
}
.sidebar-section {
margin-bottom: 0.5rem;
}
.sidebar-section-label {
display: block;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-secondary);
opacity: 0.6;
padding: 0 1rem;
margin-bottom: 0.25rem;
}
.sidebar-section .filter-list {
margin-bottom: 0.75rem;
}
.filter-list {
list-style: none;
margin-bottom: 1.5rem;
margin-bottom: 0;
li {
transition: all var(--duration-normal) var(--easing-ease-out);

View File

@@ -48,6 +48,7 @@
--proton: #ffc896;
--cream: #80b4ff;
--smoke: #fff096;
--epic: #0f1923;
--modal-backdrop: rgba(30, 30, 30, 0.95);
}

View File

@@ -5,4 +5,6 @@
export interface Config {
/** Whether to show the disclaimer on startup */
show_disclaimer: boolean
reporting_opted_in: boolean
reporting_has_seen_prompt: boolean
}

13
src/types/EpicGame.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Epic game discovered via Heroic/Legendary
*/
export interface EpicGame {
app_name: string
title: string
install_path: string
executable: string
box_art_url: string | null
scream_installed: boolean
koaloader_installed: boolean
proxy_fallback_used: boolean
}

View File

@@ -1,3 +1,4 @@
export * from './Game'
export * from './DlcInfo'
export * from './Config'
export * from './Config'
export * from './EpicGame'

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]