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
|
## [1.5.0] - 28-03-2026
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -1,6 +1,6 @@
|
|||||||
# CreamLinux
|
# 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:
|
## 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
|
- **Auto-discovery**: Automatically finds Steam games installed on your system
|
||||||
- **Native support**: Installs CreamLinux for native Linux games
|
- **Native support**: Installs CreamLinux for native Linux games
|
||||||
- **Proton support**: Installs SmokeAPI for Windows games running through Proton
|
- **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
|
- **DLC management**: Easily select which DLCs to enable
|
||||||
- **Modern UI**: Clean, responsive interface that's easy to use
|
- **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
|
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
|
### Building from Source
|
||||||
|
|
||||||
#### Prerequisites
|
#### 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;
|
||||||
|
}
|
||||||
4683
package-lock.json
generated
4683
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "creamlinux",
|
"name": "creamlinux",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Tickbase",
|
"author": "Tickbase",
|
||||||
"repository": "https://github.com/Novattz/creamlinux-installer",
|
"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]]
|
[[package]]
|
||||||
name = "creamlinux-installer"
|
name = "creamlinux-installer"
|
||||||
version = "1.5.0"
|
version = "1.5.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "creamlinux-installer"
|
name = "creamlinux-installer"
|
||||||
version = "1.5.0"
|
version = "1.5.5"
|
||||||
description = "DLC Manager for Steam games on Linux"
|
description = "DLC Manager for Steam games on Linux"
|
||||||
authors = ["tickbase"]
|
authors = ["tickbase"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -30,7 +30,7 @@ tauri-plugin-shell = "2.0.0-rc"
|
|||||||
tauri-plugin-dialog = "2.0.0-rc"
|
tauri-plugin-dialog = "2.0.0-rc"
|
||||||
tauri-plugin-fs = "2.0.0-rc"
|
tauri-plugin-fs = "2.0.0-rc"
|
||||||
num_cpus = "1.16.0"
|
num_cpus = "1.16.0"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2.2.1"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
@@ -39,4 +39,4 @@ rand = "0.9.2"
|
|||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2.7.1"
|
||||||
|
|||||||
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,
|
get_creamlinux_version_dir, get_smokeapi_version_dir,
|
||||||
list_creamlinux_files, list_smokeapi_files, read_versions,
|
list_creamlinux_files, list_smokeapi_files, read_versions,
|
||||||
update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache,
|
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::{
|
pub use version::{
|
||||||
@@ -14,7 +14,7 @@ pub use version::{
|
|||||||
update_smokeapi_version as update_game_smokeapi_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 log::{error, info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ pub async fn initialize_cache() -> Result<(), String> {
|
|||||||
let versions = read_versions()?;
|
let versions = read_versions()?;
|
||||||
let mut needs_smokeapi = false;
|
let mut needs_smokeapi = false;
|
||||||
let mut needs_creamlinux = false;
|
let mut needs_creamlinux = false;
|
||||||
|
let mut needs_screamapi = false;
|
||||||
|
let mut needs_koaloader = false;
|
||||||
|
|
||||||
// Check if SmokeAPI is properly cached
|
// Check if SmokeAPI is properly cached
|
||||||
if versions.smokeapi.latest.is_empty() {
|
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
|
// Download SmokeAPI
|
||||||
if needs_smokeapi {
|
if needs_smokeapi {
|
||||||
info!("Downloading 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");
|
info!("Cache already initialized and validated");
|
||||||
} else {
|
} else {
|
||||||
info!("Cache initialization complete");
|
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 struct CacheVersions {
|
||||||
pub smokeapi: VersionInfo,
|
pub smokeapi: VersionInfo,
|
||||||
pub creamlinux: VersionInfo,
|
pub creamlinux: VersionInfo,
|
||||||
|
pub screamapi: VersionInfo,
|
||||||
|
pub koaloader: VersionInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
@@ -18,12 +20,10 @@ pub struct VersionInfo {
|
|||||||
impl Default for CacheVersions {
|
impl Default for CacheVersions {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
smokeapi: VersionInfo {
|
smokeapi: VersionInfo { latest: String::new() },
|
||||||
latest: String::new(),
|
creamlinux: VersionInfo { latest: String::new() },
|
||||||
},
|
screamapi: VersionInfo { latest: String::new() },
|
||||||
creamlinux: VersionInfo {
|
koaloader: VersionInfo { latest: String::new() },
|
||||||
latest: String::new(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,26 @@ pub fn get_smokeapi_dir() -> Result<PathBuf, String> {
|
|||||||
Ok(smokeapi_dir)
|
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
|
// Get the CreamLinux cache directory path
|
||||||
pub fn get_creamlinux_dir() -> Result<PathBuf, String> {
|
pub fn get_creamlinux_dir() -> Result<PathBuf, String> {
|
||||||
let cache_dir = get_cache_dir()?;
|
let cache_dir = get_cache_dir()?;
|
||||||
@@ -94,6 +114,24 @@ pub fn get_smokeapi_version_dir(version: &str) -> Result<PathBuf, String> {
|
|||||||
Ok(version_dir)
|
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
|
// Get the path to a versioned CreamLinux directory
|
||||||
pub fn get_creamlinux_version_dir(version: &str) -> Result<PathBuf, String> {
|
pub fn get_creamlinux_version_dir(version: &str) -> Result<PathBuf, String> {
|
||||||
let creamlinux_dir = get_creamlinux_dir()?;
|
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)
|
let content = fs::read_to_string(&versions_path)
|
||||||
.map_err(|e| format!("Failed to read versions.json: {}", e))?;
|
.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))?;
|
.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!(
|
info!(
|
||||||
"Read cached versions - SmokeAPI: {}, CreamLinux: {}",
|
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
|
||||||
versions.smokeapi.latest, versions.creamlinux.latest
|
versions.smokeapi.latest,
|
||||||
|
versions.creamlinux.latest,
|
||||||
|
versions.screamapi.latest,
|
||||||
|
versions.koaloader.latest,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(versions)
|
Ok(versions)
|
||||||
@@ -147,8 +205,11 @@ pub fn write_versions(versions: &CacheVersions) -> Result<(), String> {
|
|||||||
.map_err(|e| format!("Failed to write versions.json: {}", e))?;
|
.map_err(|e| format!("Failed to write versions.json: {}", e))?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Wrote versions.json - SmokeAPI: {}, CreamLinux: {}",
|
"Read cached versions - SmokeAPI: {}, CreamLinux: {}, ScreamAPI: {}, Koaloader: {}",
|
||||||
versions.smokeapi.latest, versions.creamlinux.latest
|
versions.smokeapi.latest,
|
||||||
|
versions.creamlinux.latest,
|
||||||
|
versions.screamapi.latest,
|
||||||
|
versions.koaloader.latest,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -179,6 +240,34 @@ pub fn update_smokeapi_version(new_version: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
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
|
// Update the CreamLinux version in versions.json and clean old version directories
|
||||||
pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
|
pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
|
||||||
let mut versions = read_versions()?;
|
let mut versions = read_versions()?;
|
||||||
@@ -321,6 +410,30 @@ pub fn validate_smokeapi_cache(version: &str) -> Result<bool, String> {
|
|||||||
Ok(true)
|
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
|
/// Validate that all required files exist for CreamLinux
|
||||||
pub fn validate_creamlinux_cache(version: &str) -> Result<bool, String> {
|
pub fn validate_creamlinux_cache(version: &str) -> Result<bool, String> {
|
||||||
let version_dir = get_creamlinux_version_dir(version)?;
|
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,
|
remove_creamlinux_version, remove_smokeapi_version,
|
||||||
update_game_creamlinux_version, update_game_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 crate::AppState;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use reqwest;
|
use reqwest;
|
||||||
@@ -440,6 +441,215 @@ async fn uninstall_smokeapi_native(game: Game, app_handle: AppHandle) -> Result<
|
|||||||
Ok(())
|
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)
|
// Fetch DLC details from Steam API (simple version without progress)
|
||||||
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
|
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ mod searcher;
|
|||||||
mod unlockers;
|
mod unlockers;
|
||||||
mod smokeapi_config;
|
mod smokeapi_config;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod epic_scanner;
|
||||||
|
mod pe_inspector;
|
||||||
|
mod screamapi_config;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
|
||||||
|
use epic_scanner::EpicGame;
|
||||||
use dlc_manager::DlcInfoWithState;
|
use dlc_manager::DlcInfoWithState;
|
||||||
use installer::{Game, InstallerAction, InstallerType};
|
use installer::{Game, InstallerAction, InstallerType};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
@@ -35,6 +39,22 @@ pub struct GameAction {
|
|||||||
action: String,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct DlcCache {
|
struct DlcCache {
|
||||||
#[allow(dead_code)]
|
#[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)
|
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]
|
#[tauri::command]
|
||||||
async fn scan_steam_games(
|
async fn scan_steam_games(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -252,6 +280,70 @@ async fn process_game_action(
|
|||||||
Ok(updated_game)
|
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]
|
#[tauri::command]
|
||||||
async fn fetch_game_dlcs(
|
async fn fetch_game_dlcs(
|
||||||
game_id: String,
|
game_id: String,
|
||||||
@@ -756,6 +848,11 @@ fn main() {
|
|||||||
submit_report,
|
submit_report,
|
||||||
get_local_reports,
|
get_local_reports,
|
||||||
get_game_votes,
|
get_game_votes,
|
||||||
|
scan_epic_games,
|
||||||
|
process_epic_action,
|
||||||
|
read_screamapi_config,
|
||||||
|
write_screamapi_config,
|
||||||
|
delete_screamapi_config,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
info!("Tauri application setup");
|
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 creamlinux;
|
||||||
mod smokeapi;
|
mod smokeapi;
|
||||||
|
pub mod koaloader;
|
||||||
|
mod screamapi;
|
||||||
|
|
||||||
pub use creamlinux::CreamLinux;
|
pub use creamlinux::CreamLinux;
|
||||||
pub use smokeapi::SmokeAPI;
|
pub use smokeapi::SmokeAPI;
|
||||||
|
pub use screamapi::ScreamAPI;
|
||||||
|
pub use koaloader::Koaloader;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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",
|
"productName": "Creamlinux",
|
||||||
"mainBinaryName": "creamlinux",
|
"mainBinaryName": "creamlinux",
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"identifier": "com.creamlinux.dev",
|
"identifier": "com.creamlinux.dev",
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": false,
|
"withGlobalTauri": false,
|
||||||
|
|||||||
35
src/App.tsx
35
src/App.tsx
@@ -25,7 +25,7 @@ import {
|
|||||||
} from '@/components/dialogs'
|
} from '@/components/dialogs'
|
||||||
|
|
||||||
// Game components
|
// Game components
|
||||||
import { GameList } from '@/components/games'
|
import { GameList, EpicGameList } from '@/components/games'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application component
|
* Main application component
|
||||||
@@ -71,11 +71,25 @@ function App() {
|
|||||||
handleSelectCreamLinux,
|
handleSelectCreamLinux,
|
||||||
handleSelectSmokeAPI,
|
handleSelectSmokeAPI,
|
||||||
closeUnlockerDialog,
|
closeUnlockerDialog,
|
||||||
|
epicGames,
|
||||||
|
epicLoading,
|
||||||
|
epicInstallingId,
|
||||||
|
loadEpicGames,
|
||||||
|
handleEpicInstall,
|
||||||
|
handleEpicUninstallScream,
|
||||||
|
handleEpicUninstallKoaloader,
|
||||||
|
handleEpicSettings,
|
||||||
} = useAppContext()
|
} = useAppContext()
|
||||||
|
|
||||||
// Conflict detection
|
// Conflict detection
|
||||||
const { conflicts, showDialog, resolveConflict, closeDialog } =
|
const { conflicts, showDialog, resolveConflict, closeDialog } = useConflictDetection(games)
|
||||||
useConflictDetection(games)
|
|
||||||
|
const handleSetFilter = async (f: string) => {
|
||||||
|
setFilter(f)
|
||||||
|
if (f === 'epic' && epicGames.length === 0 && !epicLoading) {
|
||||||
|
await loadEpicGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle conflict resolution
|
// Handle conflict resolution
|
||||||
const handleConflictResolve = async (
|
const handleConflictResolve = async (
|
||||||
@@ -126,13 +140,22 @@ function App() {
|
|||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
{/* Sidebar for filtering */}
|
{/* Sidebar for filtering */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
setFilter={setFilter}
|
setFilter={handleSetFilter}
|
||||||
currentFilter={filter}
|
currentFilter={filter}
|
||||||
onSettingsClick={handleSettingsOpen}
|
onSettingsClick={handleSettingsOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Show error or game list */}
|
{filter === 'epic' ? (
|
||||||
{error ? (
|
<EpicGameList
|
||||||
|
games={epicGames}
|
||||||
|
isLoading={epicLoading}
|
||||||
|
installingId={epicInstallingId}
|
||||||
|
onInstall={handleEpicInstall}
|
||||||
|
onUninstallScream={handleEpicUninstallScream}
|
||||||
|
onUninstallKoaloader={handleEpicUninstallKoaloader}
|
||||||
|
onSettings={handleEpicSettings}
|
||||||
|
/>
|
||||||
|
) : error ? (
|
||||||
<div className="error-message">
|
<div className="error-message">
|
||||||
<h3>Error Loading Games</h3>
|
<h3>Error Loading Games</h3>
|
||||||
<p>{error}</p>
|
<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 AddDlcDialog } from './AddDlcDialog'
|
||||||
export { default as SettingsDialog } from './SettingsDialog'
|
export { default as SettingsDialog } from './SettingsDialog'
|
||||||
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
|
||||||
|
export { default as ScreamAPISettingsDialog } from './ScreamAPISettingsDialog'
|
||||||
export { default as ConflictDialog } from './ConflictDialog'
|
export { default as ConflictDialog } from './ConflictDialog'
|
||||||
export { default as DisclaimerDialog } from './DisclaimerDialog'
|
export { default as DisclaimerDialog } from './DisclaimerDialog'
|
||||||
export { default as UnlockerSelectionDialog } from './UnlockerSelectionDialog'
|
export { default as UnlockerSelectionDialog } from './UnlockerSelectionDialog'
|
||||||
export { default as OptInDialog } from './OptInDialog'
|
export { default as OptInDialog } from './OptInDialog'
|
||||||
export { default as RatingDialog } from './RatingDialog'
|
export { default as RatingDialog } from './RatingDialog'
|
||||||
export { default as SmokeAPIVotesDialog } from './SmokeAPIVotesDialog'
|
export { default as SmokeAPIVotesDialog } from './SmokeAPIVotesDialog'
|
||||||
|
export { default as EpicUnlockerSelectionDialog } from './EpicUnlockerSelectionDialog'
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { DialogProps } from './Dialog'
|
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 GameList } from './GameList'
|
||||||
export { default as GameItem } from './GameItem'
|
export { default as GameItem } from './GameItem'
|
||||||
export { default as ImagePreloader } from './ImagePreloader'
|
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 Github } from './github.svg'
|
||||||
export { ReactComponent as Discord } from './discord.svg'
|
export { ReactComponent as Discord } from './discord.svg'
|
||||||
export { ReactComponent as Proton } from './proton.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 proton = 'Proton'
|
||||||
export const steam = 'Steam'
|
export const steam = 'Steam'
|
||||||
export const windows = 'Windows'
|
export const windows = 'Windows'
|
||||||
|
export const epic = 'Epic'
|
||||||
|
|
||||||
// Keep the IconNames object for backward compatibility and autocompletion
|
// Keep the IconNames object for backward compatibility and autocompletion
|
||||||
export const IconNames = {
|
export const IconNames = {
|
||||||
@@ -69,6 +70,7 @@ export const IconNames = {
|
|||||||
Proton: proton,
|
Proton: proton,
|
||||||
Steam: steam,
|
Steam: steam,
|
||||||
Windows: windows,
|
Windows: windows,
|
||||||
|
Epic: epic,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Export direct icon components using createIconComponent from IconFactory
|
// Export direct icon components using createIconComponent from IconFactory
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Icon, layers, linux, proton, settings } from '@/components/icons'
|
import { Icon, layers, linux, proton, settings } from '@/components/icons'
|
||||||
|
import { epic } from '@/components/icons'
|
||||||
import { Button } from '@/components/buttons'
|
import { Button } from '@/components/buttons'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -7,7 +8,6 @@ interface SidebarProps {
|
|||||||
onSettingsClick: () => void
|
onSettingsClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a type for filter items that makes variant optional
|
|
||||||
type FilterItem = {
|
type FilterItem = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -15,38 +15,49 @@ type FilterItem = {
|
|||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Application sidebar component
|
|
||||||
* Contains filters for game types
|
|
||||||
*/
|
|
||||||
const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) => {
|
const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) => {
|
||||||
// Available filter options with icons
|
const steamFilters: FilterItem[] = [
|
||||||
const filters: FilterItem[] = [
|
|
||||||
{ id: 'all', label: 'All Games', icon: layers, variant: 'solid' },
|
{ id: 'all', label: 'All Games', icon: layers, variant: 'solid' },
|
||||||
{ id: 'native', label: 'Native', icon: linux, variant: 'brand' },
|
{ id: 'native', label: 'Native', icon: linux, variant: 'brand' },
|
||||||
{ id: 'proton', label: 'Proton Required', icon: proton, variant: 'brand' },
|
{ id: 'proton', label: 'Proton', icon: proton, variant: 'brand' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const epicFilters: FilterItem[] = [
|
||||||
|
{ id: 'epic', label: 'All Games', icon: epic, variant: 'brand' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderFilter = (filter: FilterItem) => (
|
||||||
|
<li
|
||||||
|
key={filter.id}
|
||||||
|
className={currentFilter === filter.id ? 'active' : ''}
|
||||||
|
onClick={() => setFilter(filter.id)}
|
||||||
|
>
|
||||||
|
<div className="filter-item">
|
||||||
|
<Icon name={filter.icon} variant={filter.variant} size="md" className="filter-icon" />
|
||||||
|
<span>{filter.label}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<h2>Library</h2>
|
<h2>Library</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="filter-list">
|
<div className="sidebar-section">
|
||||||
{filters.map((filter) => (
|
<span className="sidebar-section-label">Steam</span>
|
||||||
<li
|
<ul className="filter-list">
|
||||||
key={filter.id}
|
{steamFilters.map(renderFilter)}
|
||||||
className={currentFilter === filter.id ? 'active' : ''}
|
</ul>
|
||||||
onClick={() => setFilter(filter.id)}
|
</div>
|
||||||
>
|
|
||||||
<div className="filter-item">
|
<div className="sidebar-section">
|
||||||
<Icon name={filter.icon} variant={filter.variant} size="md" className="filter-icon" />
|
<span className="sidebar-section-label">Epic Games</span>
|
||||||
<span>{filter.label}</span>
|
<ul className="filter-list">
|
||||||
</div>
|
{epicFilters.map(renderFilter)}
|
||||||
</li>
|
</ul>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -58,7 +69,6 @@ const Sidebar = ({ setFilter, currentFilter, onSettingsClick }: SidebarProps) =>
|
|||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext } from 'react'
|
import { createContext } from 'react'
|
||||||
import { Game, DlcInfo } from '@/types'
|
import { Game, DlcInfo, EpicGame } from '@/types'
|
||||||
import { ActionType } from '@/components/buttons/ActionButton'
|
import { ActionType } from '@/components/buttons/ActionButton'
|
||||||
import { DlcDialogState } from '@/hooks/useDlcManager'
|
import { DlcDialogState } from '@/hooks/useDlcManager'
|
||||||
|
|
||||||
@@ -49,6 +49,16 @@ export interface AppContextType {
|
|||||||
handleDlcDialogClose: () => void
|
handleDlcDialogClose: () => void
|
||||||
handleUpdateDlcs: (gameId: string) => Promise<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
|
// Game actions
|
||||||
progressDialog: ProgressDialogState
|
progressDialog: ProgressDialogState
|
||||||
handleGameAction: (gameId: string, action: ActionType) => Promise<void>
|
handleGameAction: (gameId: string, action: ActionType) => Promise<void>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ReactNode, useState, useEffect } from 'react'
|
import { ReactNode, useState, useEffect } from 'react'
|
||||||
import { AppContext, AppContextType } from './AppContext'
|
import { AppContext, AppContextType } from './AppContext'
|
||||||
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
|
||||||
import { DlcInfo, Config } from '@/types'
|
import { DlcInfo, Config, EpicGame } from '@/types'
|
||||||
import { ActionType } from '@/components/buttons/ActionButton'
|
import { ActionType } from '@/components/buttons/ActionButton'
|
||||||
import { ToastContainer } from '@/components/notifications'
|
import { ToastContainer } from '@/components/notifications'
|
||||||
import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog } from '@/components/dialogs'
|
import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog, EpicUnlockerSelectionDialog, ScreamAPISettingsDialog } from '@/components/dialogs'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { listen } from '@tauri-apps/api/event'
|
||||||
|
|
||||||
// Context provider component
|
// Context provider component
|
||||||
interface AppProviderProps {
|
interface AppProviderProps {
|
||||||
@@ -43,6 +44,20 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
// Settings dialog state
|
// Settings dialog state
|
||||||
const [settingsDialog, setSettingsDialog] = useState({ visible: false })
|
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
|
// SmokeAPI settings dialog state
|
||||||
const [smokeAPISettingsDialog, setSmokeAPISettingsDialog] = useState<{
|
const [smokeAPISettingsDialog, setSmokeAPISettingsDialog] = useState<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -95,6 +110,91 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
.catch((err) => console.error('Failed to load config for reporting check:', err))
|
.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
|
// Settings handlers
|
||||||
const handleSettingsOpen = () => {
|
const handleSettingsOpen = () => {
|
||||||
setSettingsDialog({ visible: true })
|
setSettingsDialog({ visible: true })
|
||||||
@@ -366,6 +466,16 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
handleDlcDialogClose: closeDlcDialog,
|
handleDlcDialogClose: closeDlcDialog,
|
||||||
handleUpdateDlcs: (gameId: string) => handleUpdateDlcs(gameId),
|
handleUpdateDlcs: (gameId: string) => handleUpdateDlcs(gameId),
|
||||||
|
|
||||||
|
// Epic games
|
||||||
|
epicGames,
|
||||||
|
epicLoading,
|
||||||
|
epicInstallingId,
|
||||||
|
loadEpicGames,
|
||||||
|
handleEpicInstall,
|
||||||
|
handleEpicUninstallScream,
|
||||||
|
handleEpicUninstallKoaloader,
|
||||||
|
handleEpicSettings,
|
||||||
|
|
||||||
// Game actions
|
// Game actions
|
||||||
progressDialog,
|
progressDialog,
|
||||||
handleGameAction,
|
handleGameAction,
|
||||||
@@ -458,6 +568,23 @@ export const AppProvider = ({ children }: AppProviderProps) => {
|
|||||||
gameTitle={smokeAPISettingsDialog.gameTitle}
|
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 */}
|
{/* SmokeAPI Votes Dialog */}
|
||||||
<SmokeAPIVotesDialog
|
<SmokeAPIVotesDialog
|
||||||
visible={smokeAPIVotesDialog.visible}
|
visible={smokeAPIVotesDialog.visible}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
.status-badge.smoke {
|
.status-badge.smoke {
|
||||||
box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
|
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
|
// Special styling for cards with different statuses
|
||||||
@@ -56,6 +60,12 @@
|
|||||||
0 0 15px rgba(255, 239, 150, 0.15);
|
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
|
||||||
.game-item-overlay {
|
.game-item-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -126,6 +136,11 @@
|
|||||||
color: var(--text-heavy);
|
color: var(--text-heavy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-badge.epic {
|
||||||
|
background-color: var(--epic);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
// Game title
|
// Game title
|
||||||
.game-title {
|
.game-title {
|
||||||
padding: 0;
|
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 {
|
.filter-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 0;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
--proton: #ffc896;
|
--proton: #ffc896;
|
||||||
--cream: #80b4ff;
|
--cream: #80b4ff;
|
||||||
--smoke: #fff096;
|
--smoke: #fff096;
|
||||||
|
--epic: #0f1923;
|
||||||
|
|
||||||
--modal-backdrop: rgba(30, 30, 30, 0.95);
|
--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 './Game'
|
||||||
export * from './DlcInfo'
|
export * from './DlcInfo'
|
||||||
export * from './Config'
|
export * from './Config'
|
||||||
|
export * from './EpicGame'
|
||||||
Reference in New Issue
Block a user