24 Commits

Author SHA1 Message Date
Tickbase
6ff6c06bec Update README to include Epic Games support
Added Epic Games support for ScreamAPI in the application description and features.
2026-05-01 11:49:22 +02:00
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
36 changed files with 2525 additions and 65 deletions

View File

@@ -1,3 +1,10 @@
## [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

View File

@@ -1,6 +1,6 @@
# CreamLinux
CreamLinux is a GUI application for Linux that simplifies the management of DLC IDs in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
CreamLinux is a GUI application for Linux that simplifies the management of DLC IDs in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games), SmokeAPI (for Windows games running through Proton) and ScreamAPI (Epic Games).
## Watch the demo here:
@@ -22,6 +22,7 @@ While the core functionality is working, please be aware that this is an early r
- **Auto-discovery**: Automatically finds Steam games installed on your system
- **Native support**: Installs CreamLinux for native Linux games
- **Proton support**: Installs SmokeAPI for Windows games running through Proton
- **Epic Games support**: Installs ScreamAPI for games running through Heroic/Legendary
- **DLC management**: Easily select which DLCs to enable
- **Modern UI**: Clean, responsive interface that's easy to use
@@ -82,10 +83,11 @@ npins add github Novattz creamlinux-installer --branch main
```nix
let
sources = import ./npins;
creamlinux = pkgs.callPackage sources.creamlinux-installer {};
in
{
environment.systemPackages = [ creamlinux ];
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:
@@ -109,6 +111,7 @@ 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

View File

@@ -3,7 +3,7 @@
src = ./.;
patchSassEmbedded = pkgs.writeShellScriptBin "patch-sass-embedded" ''
NIX_LD="${pkgs.lib.fileContents "${pkgs.stdenv.cc}/nix-support/dynamic-linker"}"
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"

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "creamlinux",
"version": "1.5.0",
"version": "1.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "creamlinux",
"version": "1.5.0",
"version": "1.5.5",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2.5.0",

View File

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

2
src-tauri/Cargo.lock generated
View File

@@ -602,7 +602,7 @@ dependencies = [
[[package]]
name = "creamlinux-installer"
version = "1.5.0"
version = "1.5.5"
dependencies = [
"async-trait",
"log",

View File

@@ -1,6 +1,6 @@
[package]
name = "creamlinux-installer"
version = "1.5.0"
version = "1.5.5"
description = "DLC Manager for Steam games on Linux"
authors = ["tickbase"]
license = "MIT"

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, get_cache_dir,
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

@@ -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

@@ -12,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};
@@ -35,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)]
@@ -69,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>,
@@ -252,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,
@@ -756,6 +848,11 @@ fn main() {
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)
}

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

@@ -19,7 +19,7 @@
},
"productName": "Creamlinux",
"mainBinaryName": "creamlinux",
"version": "1.5.0",
"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
@@ -71,11 +71,25 @@ function App() {
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 (
@@ -126,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>

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,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

@@ -9,12 +9,14 @@ 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 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'

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

@@ -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

@@ -38,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 = {
@@ -69,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

@@ -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'
@@ -49,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>

View File

@@ -1,11 +1,12 @@
import { ReactNode, useState, useEffect } from 'react'
import { AppContext, AppContextType } from './AppContext'
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
import { DlcInfo, Config } from '@/types'
import { DlcInfo, Config, EpicGame } from '@/types'
import { ActionType } from '@/components/buttons/ActionButton'
import { ToastContainer } from '@/components/notifications'
import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog } 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 {
@@ -43,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
@@ -95,6 +110,91 @@ export const AppProvider = ({ children }: AppProviderProps) => {
.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 })
@@ -366,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,
@@ -458,6 +568,23 @@ export const AppProvider = ({ children }: AppProviderProps) => {
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}

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

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'