mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2026-05-02 04:52:03 -04:00
Compare commits
28 Commits
5896733fd4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ff6c06bec | ||
|
|
d5f9d50248 | ||
|
|
d70b174dd4 | ||
|
|
2164492934 | ||
|
|
7733d9732e | ||
|
|
f151f5ee4f | ||
|
|
ad910cce0a | ||
|
|
9621cba58d | ||
|
|
f18cffaa09 | ||
|
|
17de5172e4 | ||
|
|
1d4c75bffd | ||
|
|
2d524de661 | ||
|
|
832841134a | ||
|
|
b3e92d2165 | ||
|
|
348b1a5ed0 | ||
|
|
cf7fe20aa6 | ||
|
|
62a1dca0aa | ||
|
|
214564d67f | ||
|
|
9c70530890 | ||
|
|
568c02495c | ||
|
|
285256bfb8 | ||
|
|
42d8618f37 | ||
|
|
483e58dfd1 | ||
|
|
ae9c012040 | ||
|
|
220763b389 | ||
|
|
3d894266a7 | ||
|
|
33492a6a55 | ||
|
|
92f4d82e6c |
@@ -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
|
||||
|
||||
69
README.md
69
README.md
@@ -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
|
||||
|
||||
@@ -46,6 +47,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
57
default.nix
Normal file
@@ -0,0 +1,57 @@
|
||||
{pkgs ? import <nixpkgs> {}}: let
|
||||
cargoRoot = "src-tauri";
|
||||
src = ./.;
|
||||
|
||||
patchSassEmbedded = pkgs.writeShellScriptBin "patch-sass-embedded" ''
|
||||
NIX_LD="$(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;
|
||||
}
|
||||
4685
package-lock.json
generated
4685
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -602,7 +602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "creamlinux-installer"
|
||||
version = "1.5.0"
|
||||
version = "1.5.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"log",
|
||||
|
||||
@@ -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"
|
||||
@@ -30,7 +30,7 @@ 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"
|
||||
@@ -39,4 +39,4 @@ rand = "0.9.2"
|
||||
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"
|
||||
|
||||
78
src-tauri/src/cache/mod.rs
vendored
78
src-tauri/src/cache/mod.rs
vendored
@@ -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");
|
||||
|
||||
135
src-tauri/src/cache/storage.rs
vendored
135
src-tauri/src/cache/storage.rs
vendored
@@ -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()?;
|
||||
@@ -124,12 +162,32 @@ pub fn read_versions() -> Result<CacheVersions, String> {
|
||||
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)?;
|
||||
|
||||
184
src-tauri/src/epic_scanner.rs
Normal file
184
src-tauri/src/epic_scanner.rs
Normal 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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
287
src-tauri/src/pe_inspector.rs
Normal file
287
src-tauri/src/pe_inspector.rs
Normal 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)
|
||||
}
|
||||
137
src-tauri/src/screamapi_config.rs
Normal file
137
src-tauri/src/screamapi_config.rs
Normal 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
|
||||
}
|
||||
289
src-tauri/src/unlockers/koaloader.rs
Normal file
289
src-tauri/src/unlockers/koaloader.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
339
src-tauri/src/unlockers/screamapi.rs
Normal file
339
src-tauri/src/unlockers/screamapi.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"productName": "Creamlinux",
|
||||
"mainBinaryName": "creamlinux",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.5",
|
||||
"identifier": "com.creamlinux.dev",
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -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>
|
||||
|
||||
97
src/components/dialogs/EpicUnlockerSelectionDialog.tsx
Normal file
97
src/components/dialogs/EpicUnlockerSelectionDialog.tsx
Normal 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
|
||||
209
src/components/dialogs/ScreamAPISettingsDialog.tsx
Normal file
209
src/components/dialogs/ScreamAPISettingsDialog.tsx
Normal 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
|
||||
@@ -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'
|
||||
|
||||
119
src/components/games/EpicGameItem.tsx
Normal file
119
src/components/games/EpicGameItem.tsx
Normal 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
|
||||
65
src/components/games/EpicGameList.tsx
Normal file
65
src/components/games/EpicGameList.tsx
Normal 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
|
||||
@@ -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'
|
||||
1
src/components/icons/brands/epic.svg
Normal file
1
src/components/icons/brands/epic.svg
Normal 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 |
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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,26 +15,18 @@ 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' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2>Library</h2>
|
||||
</div>
|
||||
const epicFilters: FilterItem[] = [
|
||||
{ id: 'epic', label: 'All Games', icon: epic, variant: 'brand' },
|
||||
]
|
||||
|
||||
<ul className="filter-list">
|
||||
{filters.map((filter) => (
|
||||
const renderFilter = (filter: FilterItem) => (
|
||||
<li
|
||||
key={filter.id}
|
||||
className={currentFilter === filter.id ? 'active' : ''}
|
||||
@@ -45,8 +37,27 @@ const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) =>
|
||||
<span>{filter.label}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2>Library</h2>
|
||||
</div>
|
||||
|
||||
<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,7 +69,6 @@ const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) =>
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
13
src/types/EpicGame.ts
Normal 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
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Game'
|
||||
export * from './DlcInfo'
|
||||
export * from './Config'
|
||||
export * from './EpicGame'
|
||||
Reference in New Issue
Block a user