36 Commits

Author SHA1 Message Date
Novattz
09e7bcac6f bump version 2026-01-18 09:43:08 +01:00
Novattz
b7f219a25f changelog 2026-01-18 09:43:02 +01:00
Novattz
2b205d8376 reduce time to detect game bitness 2026-01-18 09:42:58 +01:00
Novattz
4cf1e2caf4 version bump 2026-01-17 20:49:22 +01:00
Novattz
0ee10d07fc changelog 2026-01-17 20:48:44 +01:00
Novattz
365063d30d fix notifications for smokeapi install 2026-01-17 20:48:15 +01:00
Novattz
61ad3f1d54 fix notifications 2026-01-17 20:30:29 +01:00
Novattz
d3a91f5722 fix conflict detection 2026-01-17 20:30:14 +01:00
Novattz
9ba307f9f8 fix typo ELF magic number check 2026-01-17 20:29:54 +01:00
Novattz
1123012737 install smokeapi native #61 2026-01-17 17:58:14 +01:00
Novattz
7a07399946 cache validation 2026-01-17 17:57:49 +01:00
Novattz
40b9ec9b01 bitness detection 2026-01-17 17:57:17 +01:00
Novattz
05e4275962 unlocker selection styling #61 2026-01-17 17:56:57 +01:00
Novattz
03cae08df1 implement unlocker selection #61 2026-01-17 17:56:46 +01:00
Novattz
6b16ec6168 hook index 2026-01-17 17:56:20 +01:00
Novattz
a786530572 game action hook 2026-01-17 17:56:07 +01:00
Novattz
ef7dfdd6c5 unlocker select hook #61 2026-01-17 17:55:40 +01:00
Novattz
5998e77272 unlocker select dialog #61 2026-01-17 17:55:09 +01:00
Novattz
fab29f5778 change download icon 2026-01-17 17:54:38 +01:00
Novattz
bec190691b universal button 2026-01-17 17:54:04 +01:00
Novattz
58217d61d1 changelog 2026-01-09 20:44:10 +01:00
Novattz
0f4db7bbb7 gitignore 2026-01-09 20:44:02 +01:00
Novattz
22c8f41f93 bump version 2026-01-09 20:41:11 +01:00
Novattz
5ff51d1174 Remove reminder #92 2026-01-09 20:40:35 +01:00
Novattz
169b7d5edd redesign conflict dialog #92 2026-01-09 20:37:55 +01:00
Novattz
41da6731a7 update workflow 2026-01-03 00:37:31 +01:00
Novattz
5f8f389687 version bump 2026-01-03 00:31:25 +01:00
Novattz
1d8422dc65 changelog 2026-01-03 00:31:01 +01:00
Novattz
677e3ef12d disclaimer hook #87 2026-01-03 00:26:23 +01:00
Novattz
33266f3781 index #87 2026-01-03 00:26:00 +01:00
Novattz
9703f21209 disclaimer dialog & styles #87 2026-01-03 00:25:40 +01:00
Novattz
3459158d3f config types #88 2026-01-03 00:24:56 +01:00
Novattz
418b470d4a format 2026-01-03 00:24:23 +01:00
Novattz
fd606cbc2e config manager #88 2026-01-03 00:23:47 +01:00
Tickbase
5845cf9bd8 Update README for clarity and corrections 2026-01-02 19:57:25 +01:00
Tickbase
6294b99a14 Update LICENSE.md 2026-01-01 21:44:50 +01:00
40 changed files with 1735 additions and 249 deletions

View File

@@ -142,3 +142,24 @@ jobs:
includeUpdaterJson: true
tauriScript: 'npm run tauri'
args: ${{ matrix.args }}
publish-release:
name: Publish release
needs: [create-release, build-tauri]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Publish GitHub release (unset draft)
uses: actions/github-script@v6
with:
script: |
const release_id = Number("${{ needs.create-release.outputs.release_id }}");
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id,
draft: false
});

1
.gitignore vendored
View File

@@ -14,7 +14,6 @@ docs
*.local
*.lock
.env
CHANGELOG.md
# Editor directories and files
.vscode/*

View File

@@ -1,3 +1,33 @@
## [1.4.1] - 18-01-2026
### Added
- Dramatically reduced the time that bitness detection takes to detect game bitness
## [1.4.0] - 17-01-2026
### Added
- Unlocker selection dialog for native games, allowing users to choose between CreamLinux and SmokeAPI
- Game bitness detection
### Fixed
- Cache now validates if expected files are missing.
## [1.3.5] - 09-01-2026
### Changed
- Redesigned conflict detection dialog to show all conflicts at once
- Integrated Steam launch option reminder directly into the conflict dialog
### Fixed
- Improved UX by allowing users to resolve conflicts in any order or defer to later
## [1.3.4] - 03-01-2026
### Added
- Disclaimer dialog explaining that CreamLinux Installer manages DLC IDs, not actual DLC files
- User config stored in `~/.config/creamlinux/config.json`
- **"Don't show again" option**: Users can permanently dismiss the disclaimer via checkbox
## [1.3.3] - 26-12-2025
### Added

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Tickbase
Copyright (c) 2026 Tickbase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,6 +1,6 @@
# CreamLinux
CreamLinux is a GUI application for Linux that simplifies the management of DLC in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
CreamLinux is a GUI application for Linux that simplifies the management of DLC IDs in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
## Watch the demo here:
@@ -61,7 +61,7 @@ While the core functionality is working, please be aware that this is an early r
```bash
git clone https://github.com/Novattz/creamlinux-installer.git
cd creamlinux
cd creamlinux-installer
```
2. Install dependencies:
@@ -124,7 +124,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) f
## Credits
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support
- [Creamlinux](https://github.com/anticitizn/creamlinux) - Native support
- [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
- [Tauri](https://tauri.app/) - Framework for building the desktop application
- [React](https://reactjs.org/) - UI library

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -2,9 +2,10 @@ mod storage;
mod version;
pub use storage::{
get_creamlinux_version_dir, get_smokeapi_version_dir, is_cache_initialized,
list_creamlinux_files, list_smokeapi_dlls, read_versions, update_creamlinux_version,
update_smokeapi_version,
get_creamlinux_version_dir, get_smokeapi_version_dir,
list_creamlinux_files, list_smokeapi_files, read_versions,
update_creamlinux_version, update_smokeapi_version, validate_smokeapi_cache,
validate_creamlinux_cache,
};
pub use version::{
@@ -22,39 +23,87 @@ use std::collections::HashMap;
pub async fn initialize_cache() -> Result<(), String> {
info!("Initializing cache...");
// Check if cache is already initialized
if is_cache_initialized()? {
info!("Cache already initialized");
return Ok(());
let versions = read_versions()?;
let mut needs_smokeapi = false;
let mut needs_creamlinux = false;
// Check if SmokeAPI is properly cached
if versions.smokeapi.latest.is_empty() {
info!("No SmokeAPI version in manifest");
needs_smokeapi = true
} else {
// Validate that all files exist
match validate_smokeapi_cache(&versions.smokeapi.latest) {
Ok(true) => {
info!("SmokeAPI cache validated successfully");
}
Ok(false) => {
info!("SmokeAPI cache incomplete, re-downloading");
needs_smokeapi = true;
}
Err(e) => {
warn!("Failed to validate SmokeAPI cache: {}, re-downloading", e);
needs_smokeapi = true;
}
}
}
info!("Cache not initialized, downloading unlockers...");
// Check if CreamLinux is properly cached
if versions.creamlinux.latest.is_empty() {
info!("No CreamLinux version in manifest");
needs_creamlinux = true;
} else {
match validate_creamlinux_cache(&versions.creamlinux.latest) {
Ok(true) => {
info!("CreamLinux cache validated successfully");
}
Ok(false) => {
info!("CreamLinux cache incomplete, re-downloading");
needs_creamlinux = true;
}
Err(e) => {
warn!("Failed to validate CreamLinux cache: {}, re-downloading", e);
needs_creamlinux = true;
}
}
}
// Download SmokeAPI
match SmokeAPI::download_to_cache().await {
Ok(version) => {
info!("Downloaded SmokeAPI version: {}", version);
update_smokeapi_version(&version)?;
}
Err(e) => {
error!("Failed to download SmokeAPI: {}", e);
return Err(format!("Failed to download SmokeAPI: {}", e));
if needs_smokeapi {
info!("Downloading SmokeAPI...");
match SmokeAPI::download_to_cache().await {
Ok(version) => {
info!("Downloaded SmokeAPI version: {}", version);
update_smokeapi_version(&version)?;
}
Err(e) => {
error!("Failed to download SmokeAPI: {}", e);
return Err(format!("Failed to download SmokeAPI: {}", e));
}
}
}
// Download CreamLinux
match CreamLinux::download_to_cache().await {
Ok(version) => {
info!("Downloaded CreamLinux version: {}", version);
update_creamlinux_version(&version)?;
}
Err(e) => {
error!("Failed to download CreamLinux: {}", e);
return Err(format!("Failed to download CreamLinux: {}", e));
if needs_creamlinux {
info!("Downloading CreamLinux...");
match CreamLinux::download_to_cache().await {
Ok(version) => {
info!("Downloaded CreamLinux version: {}", version);
update_creamlinux_version(&version)?;
}
Err(e) => {
error!("Failed to download CreamLinux: {}", e);
return Err(format!("Failed to download CreamLinux: {}", e));
}
}
}
info!("Cache initialization complete");
if !needs_smokeapi && !needs_creamlinux {
info!("Cache already initialized and validated");
} else {
info!("Cache initialization complete");
}
Ok(())
}

View File

@@ -204,12 +204,6 @@ pub fn update_creamlinux_version(new_version: &str) -> Result<(), String> {
Ok(())
}
// Check if the cache is initialized (has both unlockers cached)
pub fn is_cache_initialized() -> Result<bool, String> {
let versions = read_versions()?;
Ok(!versions.smokeapi.latest.is_empty() && !versions.creamlinux.latest.is_empty())
}
// Get the SmokeAPI DLL path for the latest cached version
#[allow(dead_code)]
pub fn get_smokeapi_dll_path() -> Result<PathBuf, String> {
@@ -233,8 +227,8 @@ pub fn get_creamlinux_files_dir() -> Result<PathBuf, String> {
get_creamlinux_version_dir(&versions.creamlinux.latest)
}
// List all SmokeAPI DLL files in the cached version directory
pub fn list_smokeapi_dlls() -> Result<Vec<PathBuf>, String> {
/// List all SmokeAPI files in the cached version directory
pub fn list_smokeapi_files() -> Result<Vec<PathBuf>, String> {
let versions = read_versions()?;
if versions.smokeapi.latest.is_empty() {
return Ok(Vec::new());
@@ -249,17 +243,20 @@ pub fn list_smokeapi_dlls() -> Result<Vec<PathBuf>, String> {
let entries = fs::read_dir(&version_dir)
.map_err(|e| format!("Failed to read SmokeAPI directory: {}", e))?;
let mut dlls = Vec::new();
let mut files = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("dll") {
dlls.push(path);
// Get both .dll and .so files
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if ext == "dll" || ext == "so" {
files.push(path);
}
}
}
}
Ok(dlls)
Ok(files)
}
// List all CreamLinux files in the cached version directory
@@ -289,4 +286,70 @@ pub fn list_creamlinux_files() -> Result<Vec<PathBuf>, String> {
}
Ok(files)
}
/// Validate that all required files exist for SmokeAPI
pub fn validate_smokeapi_cache(version: &str) -> Result<bool, String> {
let version_dir = get_smokeapi_version_dir(version)?;
if !version_dir.exists() {
return Ok(false);
}
// Required files for SmokeAPI
let required_files = vec![
"smoke_api32.dll",
"smoke_api64.dll",
"libsmoke_api32.so",
"libsmoke_api64.so",
];
let mut missing_files = Vec::new();
for file in &required_files {
let file_path = version_dir.join(file);
if !file_path.exists() {
missing_files.push(file.to_string());
}
}
if !missing_files.is_empty() {
info!("Missing required files in cache: {:?}", missing_files);
return Ok(false);
}
Ok(true)
}
/// Validate that all required files exist for CreamLinux
pub fn validate_creamlinux_cache(version: &str) -> Result<bool, String> {
let version_dir = get_creamlinux_version_dir(version)?;
if !version_dir.exists() {
return Ok(false);
}
// Required files for CreamLinux
let required_files = vec![
"cream.sh",
"cream_api.ini",
"lib32Creamlinux.so",
"lib64Creamlinux.so",
];
let mut missing_files = Vec::new();
for file in &required_files {
let file_path = version_dir.join(file);
if !file_path.exists() {
missing_files.push(file.to_string());
}
}
if !missing_files.is_empty() {
info!("Missing required files in cache: {:?}", missing_files);
return Ok(false);
}
Ok(true)
}

118
src-tauri/src/config.rs Normal file
View File

@@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use log::info;
// User configuration structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
// Whether to show the disclaimer on startup
pub show_disclaimer: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
show_disclaimer: true,
}
}
}
// Get the config directory path (~/.config/creamlinux)
fn get_config_dir() -> Result<PathBuf, String> {
let home = std::env::var("HOME")
.map_err(|_| "Failed to get HOME directory".to_string())?;
let config_dir = PathBuf::from(home).join(".config").join("creamlinux");
Ok(config_dir)
}
// Get the config file path
fn get_config_path() -> Result<PathBuf, String> {
let config_dir = get_config_dir()?;
Ok(config_dir.join("config.json"))
}
// Ensure the config directory exists
fn ensure_config_dir() -> Result<(), String> {
let config_dir = get_config_dir()?;
if !config_dir.exists() {
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
info!("Created config directory at {:?}", config_dir);
}
Ok(())
}
// Load configuration from disk
pub fn load_config() -> Result<Config, String> {
ensure_config_dir()?;
let config_path = get_config_path()?;
// If config file doesn't exist, create default config
if !config_path.exists() {
let default_config = Config::default();
save_config(&default_config)?;
info!("Created default config file at {:?}", config_path);
return Ok(default_config);
}
// Read and parse config file
let config_str = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
let config: Config = serde_json::from_str(&config_str)
.map_err(|e| format!("Failed to parse config file: {}", e))?;
info!("Loaded config from {:?}", config_path);
Ok(config)
}
// Save configuration to disk
pub fn save_config(config: &Config) -> Result<(), String> {
ensure_config_dir()?;
let config_path = get_config_path()?;
let config_str = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, config_str)
.map_err(|e| format!("Failed to write config file: {}", e))?;
info!("Saved config to {:?}", config_path);
Ok(())
}
// Update a specific config value
pub fn update_config<F>(updater: F) -> Result<Config, String>
where
F: FnOnce(&mut Config),
{
let mut config = load_config()?;
updater(&mut config);
save_config(&config)?;
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.show_disclaimer);
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(config.show_disclaimer, parsed.show_disclaimer);
}
}

View File

@@ -241,8 +241,26 @@ async fn uninstall_creamlinux(game: Game, app_handle: AppHandle) -> Result<(), S
Ok(())
}
// Install SmokeAPI to a game
async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
// Check if native or proton and route accordingly
if game.native {
install_smokeapi_native(game, app_handle).await
} else {
install_smokeapi_proton(game, app_handle).await
}
}
async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
// Check if native or proton and route accordingly
if game.native {
uninstall_smokeapi_native(game, app_handle).await
} else {
uninstall_smokeapi_proton(game, app_handle).await
}
}
// Install SmokeAPI to a proton game
async fn install_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
if game.native {
return Err("SmokeAPI can only be installed on Proton/Windows games".to_string());
}
@@ -286,8 +304,8 @@ async fn install_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), Strin
Ok(())
}
// Uninstall SmokeAPI from a game
async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), String> {
// Uninstall SmokeAPI from a proton game
async fn uninstall_smokeapi_proton(game: Game, app_handle: AppHandle) -> Result<(), String> {
if game.native {
return Err("SmokeAPI can only be uninstalled from Proton/Windows games".to_string());
}
@@ -329,6 +347,99 @@ async fn uninstall_smokeapi(game: Game, app_handle: AppHandle) -> Result<(), Str
Ok(())
}
// Install SmokeAPI to a native Linux game
async fn install_smokeapi_native(
game: Game,
app_handle: AppHandle,
) -> Result<(), String> {
info!("Installing SmokeAPI (native) for game: {}", game.title);
let game_title = game.title.clone();
emit_progress(
&app_handle,
&format!("Installing SmokeAPI for {}", game_title),
"Detecting game architecture...",
20.0,
false,
false,
None,
);
emit_progress(
&app_handle,
&format!("Installing SmokeAPI for {}", game_title),
"Installing from cache...",
50.0,
false,
false,
None,
);
// Install SmokeAPI for native Linux (empty string for api_files_str)
SmokeAPI::install_to_game(&game.path, "")
.await
.map_err(|e| format!("Failed to install SmokeAPI: {}", e))?;
// Update version manifest
let cached_versions = crate::cache::read_versions()?;
update_game_smokeapi_version(&game.path, cached_versions.smokeapi.latest)?;
emit_progress(
&app_handle,
&format!("Installation Completed: {}", game_title),
"SmokeAPI has been installed successfully!",
100.0,
true,
false,
None,
);
info!("SmokeAPI (native) installation completed for: {}", game_title);
Ok(())
}
// Uninstall SmokeAPI from a native Linux game
async fn uninstall_smokeapi_native(game: Game, app_handle: AppHandle) -> Result<(), String> {
if !game.native {
return Err("This function is only for native Linux games".to_string());
}
let game_title = game.title.clone();
info!("Uninstalling SmokeAPI (native) from game: {}", game_title);
emit_progress(
&app_handle,
&format!("Uninstalling SmokeAPI from {}", game_title),
"Removing SmokeAPI files...",
50.0,
false,
false,
None,
);
// Uninstall SmokeAPI (empty string for api_files_str)
SmokeAPI::uninstall_from_game(&game.path, "")
.await
.map_err(|e| format!("Failed to uninstall SmokeAPI: {}", e))?;
// Remove version from manifest
remove_smokeapi_version(&game.path)?;
emit_progress(
&app_handle,
&format!("Uninstallation Completed: {}", game_title),
"SmokeAPI has been removed successfully!",
100.0,
true,
false,
None,
);
info!("SmokeAPI (native) uninstallation completed for: {}", game_title);
Ok(())
}
// Fetch DLC details from Steam API (simple version without progress)
pub async fn fetch_dlc_details(app_id: &str) -> Result<Vec<DlcInfo>, String> {
let client = reqwest::Client::new();

View File

@@ -4,12 +4,15 @@
)]
mod cache;
mod utils;
mod dlc_manager;
mod installer;
mod searcher;
mod unlockers;
mod smokeapi_config;
mod config;
use crate::config::Config;
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
use dlc_manager::DlcInfoWithState;
use installer::{Game, InstallerAction, InstallerType};
@@ -46,6 +49,19 @@ pub struct AppState {
fetch_cancellation: Arc<AtomicBool>,
}
// Load the current configuration
#[tauri::command]
fn load_config() -> Result<Config, String> {
config::load_config()
}
// Update configuration
#[tauri::command]
fn update_config(config_data: Config) -> Result<Config, String> {
config::save_config(&config_data)?;
Ok(config_data)
}
#[tauri::command]
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
@@ -658,6 +674,8 @@ fn main() {
write_smokeapi_config,
delete_smokeapi_config,
resolve_platform_conflict,
load_config,
update_config,
])
.setup(|app| {
info!("Tauri application setup");

View File

@@ -256,18 +256,40 @@ fn check_creamlinux_installed(game_path: &Path) -> bool {
// Check if a game has SmokeAPI installed
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
// First check the provided api_files for backup files
for api_file in api_files {
let api_path = game_path.join(api_file);
let api_dir = api_path.parent().unwrap_or(game_path);
let api_filename = api_path.file_name().unwrap_or_default();
// For Proton games: check for backup DLL files
if !api_files.is_empty() {
for api_file in api_files {
let api_path = game_path.join(api_file);
let api_dir = api_path.parent().unwrap_or(game_path);
let api_filename = api_path.file_name().unwrap_or_default();
// Check for backup file (original file renamed with _o.dll suffix)
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
let backup_path = api_dir.join(backup_name);
// Check for backup file (original file renamed with _o.dll suffix)
let backup_name = api_filename.to_string_lossy().replace(".dll", "_o.dll");
let backup_path = api_dir.join(backup_name);
if backup_path.exists() {
debug!("SmokeAPI backup file found: {}", backup_path.display());
if backup_path.exists() {
debug!("SmokeAPI backup file found: {}", backup_path.display());
return true;
}
}
}
// For Native games: check for lib_steam_api_o.so backup
for entry in WalkDir::new(game_path)
.max_depth(3)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Check for native SmokeAPI backup
if filename == "libsteam_api_o.so" {
debug!("Found native SmokeAPI backup: {}", path.display());
return true;
}
}

View File

@@ -94,7 +94,7 @@ impl Unlocker for SmokeAPI {
let mut archive =
ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {}", e))?;
// Extract all DLL files
// Extract both DLL files (for Proton) and .so files (for native Linux)
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
@@ -102,8 +102,11 @@ impl Unlocker for SmokeAPI {
let file_name = file.name();
// Only extract DLL files
if file_name.to_lowercase().ends_with(".dll") {
// Extract DLL files for Proton and .so files for native Linux
let should_extract = file_name.to_lowercase().ends_with(".dll")
|| file_name.to_lowercase().ends_with(".so");
if should_extract {
let output_path = version_dir.join(
Path::new(file_name)
.file_name()
@@ -127,17 +130,56 @@ impl Unlocker for SmokeAPI {
}
async fn install_to_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Check if this is a native Linux game or Proton game
// Native games have empty api_files_str, Proton games have DLL paths
let is_native = api_files_str.is_empty();
if is_native {
Self::install_to_native_game(game_path).await
} else {
Self::install_to_proton_game(game_path, api_files_str).await
}
}
async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Check if this is a native Linux game or Proton game
let is_native = api_files_str.is_empty();
if is_native {
Self::uninstall_from_native_game(game_path).await
} else {
Self::uninstall_from_proton_game(game_path, api_files_str).await
}
}
fn name() -> &'static str {
"SmokeAPI"
}
}
impl SmokeAPI {
/// Install SmokeAPI to a Proton/Windows game
async fn install_to_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Parse api_files from the context string (comma-separated)
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
info!(
"Installing SmokeAPI to {} for {} API files",
"Installing SmokeAPI (Proton) to {} for {} API files",
game_path,
api_files.len()
);
// Get the cached SmokeAPI DLLs
let cached_dlls = crate::cache::list_smokeapi_dlls()?;
let cached_files = crate::cache::list_smokeapi_files()?;
if cached_files.is_empty() {
return Err("No SmokeAPI files found in cache".to_string());
}
let cached_dlls: Vec<_> = cached_files
.iter()
.filter(|f| f.extension().and_then(|e| e.to_str()) == Some("dll"))
.collect();
if cached_dlls.is_empty() {
return Err("No SmokeAPI DLLs found in cache".to_string());
}
@@ -195,15 +237,77 @@ impl Unlocker for SmokeAPI {
);
}
info!("SmokeAPI installation completed for: {}", game_path);
info!("SmokeAPI (Proton) installation completed for: {}", game_path);
Ok(())
}
async fn uninstall_from_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
/// Install SmokeAPI to a native Linux game
async fn install_to_native_game(game_path: &str) -> Result<(), String> {
info!("Installing SmokeAPI (native) to {}", game_path);
// Detect game bitness
let bitness = crate::utils::bitness::detect_game_bitness(game_path)?;
info!("Detected game bitness: {:?}", bitness);
// Get the cached SmokeAPI files
let cached_files = crate::cache::list_smokeapi_files()?;
if cached_files.is_empty() {
return Err("No SmokeAPI files found in cache".to_string());
}
// Determine which .so file to use based on bitness
let target_so = match bitness {
crate::utils::bitness::Bitness::Bit32 => "libsmoke_api32.so",
crate::utils::bitness::Bitness::Bit64 => "libsmoke_api64.so",
};
// Find the matching .so file in cache
let matching_so = cached_files
.iter()
.find(|file| {
file.file_name()
.unwrap_or_default()
.to_string_lossy()
== target_so
})
.ok_or_else(|| format!("No matching {} found in cache", target_so))?;
let game_path_obj = Path::new(game_path);
// Look for libsteam_api.so in the game directory (scan up to depth 3)
let libsteam_path = Self::find_libsteam_api(game_path_obj)?;
info!("Found libsteam_api.so at: {}", libsteam_path.display());
// Create backup of original libsteam_api.so
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
// Only backup if not already backed up
if !backup_path.exists() && libsteam_path.exists() {
fs::copy(&libsteam_path, &backup_path)
.map_err(|e| format!("Failed to backup libsteam_api.so: {}", e))?;
info!("Created backup: {}", backup_path.display());
}
// Replace libsteam_api.so with SmokeAPI's libsmoke_api.so
fs::copy(matching_so, &libsteam_path)
.map_err(|e| format!("Failed to replace libsteam_api.so: {}", e))?;
info!(
"Replaced libsteam_api.so with {}",
target_so
);
info!("SmokeAPI (native) installation completed for: {}", game_path);
Ok(())
}
/// Uninstall SmokeAPI from a Proton/Windows game
async fn uninstall_from_proton_game(game_path: &str, api_files_str: &str) -> Result<(), String> {
// Parse api_files from the context string (comma-separated)
let api_files: Vec<String> = api_files_str.split(',').map(|s| s.to_string()).collect();
info!("Uninstalling SmokeAPI from: {}", game_path);
info!("Uninstalling SmokeAPI (Proton) from: {}", game_path);
for api_file in &api_files {
let api_path = Path::new(game_path).join(api_file);
@@ -250,11 +354,79 @@ impl Unlocker for SmokeAPI {
}
}
info!("SmokeAPI uninstallation completed for: {}", game_path);
info!("SmokeAPI (Proton) uninstallation completed for: {}", game_path);
Ok(())
}
fn name() -> &'static str {
"SmokeAPI"
/// Uninstall SmokeAPI from a native Linux game
async fn uninstall_from_native_game(game_path: &str) -> Result<(), String> {
info!("Uninstalling SmokeAPI (native) from: {}", game_path);
let game_path_obj = Path::new(game_path);
// Look for libsteam_api.so (which is actually our SmokeAPI now)
let libsteam_path = match Self::find_libsteam_api(game_path_obj) {
Ok(path) => path,
Err(_) => {
warn!("libsteam_api.so not found, nothing to uninstall");
return Ok(());
}
};
// Look for backup
let backup_path = libsteam_path.with_file_name("libsteam_api_o.so");
if backup_path.exists() {
// Remove the SmokeAPI version
if libsteam_path.exists() {
match fs::remove_file(&libsteam_path) {
Ok(_) => info!("Removed SmokeAPI version: {}", libsteam_path.display()),
Err(e) => warn!("Failed to remove SmokeAPI file: {}", e),
}
}
// Restore the original file
match fs::rename(&backup_path, &libsteam_path) {
Ok(_) => info!("Restored original libsteam_api.so"),
Err(e) => {
warn!("Failed to restore original file: {}", e);
// Try to copy instead if rename fails
if let Err(copy_err) = fs::copy(&backup_path, &libsteam_path)
.and_then(|_| fs::remove_file(&backup_path))
{
error!("Failed to copy backup file: {}", copy_err);
}
}
}
} else {
warn!("No backup found (libsteam_api_o.so), cannot restore original");
}
info!("SmokeAPI (native) uninstallation completed for: {}", game_path);
Ok(())
}
/// Find libsteam_api.so in the game directory
fn find_libsteam_api(game_path: &Path) -> Result<std::path::PathBuf, String> {
use walkdir::WalkDir;
// Scan for libsteam_api.so (not too deep to keep it fast)
for entry in WalkDir::new(game_path)
.max_depth(3)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy();
if filename == "libsteam_api.so" {
return Ok(path.to_path_buf());
}
}
Err("libsteam_api.so not found in game directory".to_string())
}
}

View File

@@ -0,0 +1,204 @@
use log::{debug, info, warn};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
/// Represents the bitness of a binary
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bitness {
Bit32,
Bit64,
}
/// Detect the bitness of a Linux Binary by reading ELF header
/// ELF format: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
fn detect_binary_bitness(file_path: &Path) -> Option<Bitness> {
use std::io::Read;
// Only read first 5 bytes
let mut file = fs::File::open(file_path).ok()?;
let mut bytes = [0u8; 5];
// Read exactly 5 bytes or fail
if file.read_exact(&mut bytes).is_err() {
return None;
}
// Check for ELF magic number (0x7F 'E' 'L' 'F')
if &bytes[0..4] != b"\x7FELF" {
return None;
}
// Byte 4 (EI_CLASS) indicates 32-bit or 64-bit
// 1 = ELFCLASS32 (32-bit)
// 2 = ELFCLASS64 (64-bit)
match bytes[4] {
1 => Some(Bitness::Bit32),
2 => Some(Bitness::Bit64),
_ => None,
}
}
/// Scan game directory for Linux binaries and determine bitness
/// Returns the detected bitness, prioritizing the main game executable
pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
info!("Detecting bitness for game at: {}", game_path);
let game_path_obj = Path::new(game_path);
if !game_path_obj.exists() {
return Err("Game path does not exist".to_string());
}
// Directories to skip for performance
let skip_dirs = [
"videos",
"video",
"movies",
"movie",
"sound",
"sounds",
"audio",
"textures",
"music",
"localization",
"shaders",
"logs",
"assets",
"_CommonRedist",
"data",
"Data",
"Docs",
"docs",
"screenshots",
"Screenshots",
"saves",
"Saves",
"mods",
"Mods",
"maps",
"Maps",
];
// Limit scan depth to avoid deep recursion
const MAX_DEPTH: usize = 3;
// Stop after finding reasonable confidence (10 binaries)
const CONFIDENCE_THRESHOLD: usize = 10;
let mut bit64_binaries = Vec::new();
let mut bit32_binaries = Vec::new();
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
// Scan for Linux binaries
for entry in WalkDir::new(game_path_obj)
.max_depth(MAX_DEPTH)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let dir_name = e.file_name().to_string_lossy().to_lowercase();
!skip_dirs.iter().any(|&skip| dir_name.contains(skip))
} else {
true
}
})
.filter_map(Result::ok)
{
// Early termination when we have high confidence
if bit64_binaries.len() >= CONFIDENCE_THRESHOLD || bit32_binaries.len() >= CONFIDENCE_THRESHOLD {
debug!("Reached confidence threshold, stopping scan early");
break;
}
let path = entry.path();
// Only check files
if !path.is_file() {
continue;
}
// Skip non-binary files early for performance
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Check for common Linux executable extensions or shared libraries
let has_binary_extension = filename.ends_with(".x86")
|| filename.ends_with(".x86_64")
|| filename.ends_with(".bin")
|| filename.ends_with(".so")
|| filename.contains(".so.")
|| filename.starts_with("lib");
// Check if file is executable
let is_executable = {
{
// Get metadata once and check both extension and permissions
if let Ok(metadata) = fs::metadata(path) {
let permissions = metadata.permissions();
let executable = permissions.mode() & 0o111 != 0;
// Skip files that are neither executable nor have binary extensions
executable || has_binary_extension
} else {
// If we can't read metadata, only proceed if it has binary extension
has_binary_extension
}
}
};
if !is_executable {
continue;
}
// Detect bitness
if let Some(bitness) = detect_binary_bitness(path) {
debug!("Found {:?} binary: {}", bitness, path.display());
match bitness {
Bitness::Bit64 => {
bit64_binaries.push(path.to_path_buf());
// If we find libsteam_api.so and it's 64-bit, we can be very confident
if filename == "libsteam_api.so" {
info!("Found 64-bit libsteam_api.so");
return Ok(Bitness::Bit64);
}
},
Bitness::Bit32 => {
bit32_binaries.push(path.to_path_buf());
// If we find libsteam_api.so and it's 32-bit, we can be very confident
if filename == "libsteam_api.so" {
info!("Found 32-bit libsteam_api.so");
return Ok(Bitness::Bit32);
}
},
}
}
}
// Decision logic: prioritize finding the main game executable
// 1. If we found any 64-bit binaries and no 32-bit, it's 64-bit
// 2. If we found any 32-bit binaries and no 64-bit, it's 32-bit
// 3. If we found both, prefer 64-bit (modern games are usually 64-bit)
// 4. If we found neither, return an error
if !bit64_binaries.is_empty() && bit32_binaries.is_empty() {
info!("Detected 64-bit game (Only 64-bit binaries found)");
Ok(Bitness::Bit64)
} else if !bit32_binaries.is_empty() && bit64_binaries.is_empty() {
info!("Detected 32-bit game (Only 32-bit binaries found)");
Ok(Bitness::Bit32)
} else if !bit64_binaries.is_empty() && !bit32_binaries.is_empty() {
warn!(
"Found both 32-bit and 64-bit binaries, defaulting to 64-bit. 32-bit: {}, 64-bit: {}",
bit32_binaries.len(),
bit64_binaries.len()
);
info!("Detected 64-bit game (mixed binaries, defaulting to 64-bit)");
Ok(Bitness::Bit64)
} else {
Err("Could not detect game bitness: no Linux binaries found".to_string())
}
}

View File

@@ -0,0 +1 @@
pub mod bitness;

View File

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

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useAppContext } from '@/contexts/useAppContext'
import { useAppLogic, useConflictDetection } from '@/hooks'
import { useAppLogic, useConflictDetection, useDisclaimer } from '@/hooks'
import './styles/main.scss'
// Layout components
@@ -20,7 +20,8 @@ import {
DlcSelectionDialog,
SettingsDialog,
ConflictDialog,
ReminderDialog,
DisclaimerDialog,
UnlockerSelectionDialog,
} from '@/components/dialogs'
// Game components
@@ -32,6 +33,8 @@ import { GameList } from '@/components/games'
function App() {
const [updateComplete, setUpdateComplete] = useState(false)
const { showDisclaimer, handleDisclaimerClose } = useDisclaimer()
// Get application logic from hook
const {
filter,
@@ -62,26 +65,35 @@ function App() {
handleSettingsClose,
handleSmokeAPISettingsOpen,
showToast,
unlockerSelectionDialog,
handleSelectCreamLinux,
handleSelectSmokeAPI,
closeUnlockerDialog,
} = useAppContext()
// Conflict detection
const { currentConflict, showReminder, resolveConflict, closeReminder } =
const { conflicts, showDialog, resolveConflict, closeDialog } =
useConflictDetection(games)
// Handle conflict resolution
const handleConflictResolve = async () => {
const resolution = resolveConflict()
if (!resolution) return
// Always remove files - use the special conflict resolution command
const handleConflictResolve = async (
gameId: string,
conflictType: 'cream-to-proton' | 'smoke-to-native'
) => {
try {
// Invoke backend to resolve the conflict
await invoke('resolve_platform_conflict', {
gameId: resolution.gameId,
conflictType: resolution.conflictType,
gameId,
conflictType,
})
// Remove from UI
resolveConflict(gameId, conflictType)
showToast('Conflict resolved successfully', 'success')
} catch (error) {
console.error('Error resolving conflict:', error)
showToast(`Failed to resolve conflict: ${error}`, 'error')
showToast('Failed to resolve conflict', 'error')
}
}
@@ -168,17 +180,24 @@ function App() {
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
{/* Conflict Detection Dialog */}
{currentConflict && (
<ConflictDialog
visible={true}
gameTitle={currentConflict.gameTitle}
conflictType={currentConflict.type}
onConfirm={handleConflictResolve}
/>
)}
<ConflictDialog
visible={showDialog}
conflicts={conflicts}
onResolve={handleConflictResolve}
onClose={closeDialog}
/>
{/* Steam Launch Options Reminder */}
<ReminderDialog visible={showReminder} onClose={closeReminder} />
{/* Unlocker Selection Dialog */}
<UnlockerSelectionDialog
visible={unlockerSelectionDialog.visible}
gameTitle={unlockerSelectionDialog.gameTitle || ''}
onClose={closeUnlockerDialog}
onSelectCreamLinux={handleSelectCreamLinux}
onSelectSmokeAPI={handleSelectSmokeAPI}
/>
{/* Disclaimer Dialog - Shows AFTER everything is loaded */}
<DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} />
</div>
</ErrorBoundary>
)

View File

@@ -3,7 +3,7 @@ import Button, { ButtonVariant } from '../buttons/Button'
import { Icon, trash, download } from '@/components/icons'
// Define available action types
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke' | 'install_unlocker'
interface ActionButtonProps {
action: ActionType
@@ -18,7 +18,6 @@ interface ActionButtonProps {
* Specialized button for game installation actions
*/
const ActionButton: FC<ActionButtonProps> = ({
action,
isInstalled,
isWorking,
onClick,
@@ -29,10 +28,7 @@ const ActionButton: FC<ActionButtonProps> = ({
const getButtonText = () => {
if (isWorking) return 'Working...'
const isCream = action.includes('cream')
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
return isInstalled ? 'Uninstall' : 'Install'
}
// Map to button variant

View File

@@ -7,66 +7,95 @@ import {
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, warning } from '@/components/icons'
import { Icon, warning, info } from '@/components/icons'
export interface Conflict {
gameId: string
gameTitle: string
type: 'cream-to-proton' | 'smoke-to-native'
}
export interface ConflictDialogProps {
visible: boolean
gameTitle: string
conflictType: 'cream-to-proton' | 'smoke-to-native'
onConfirm: () => void
conflicts: Conflict[]
onResolve: (gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native') => void
onClose: () => void
}
/**
* Conflict Dialog component
* Shows when incompatible unlocker files are detected after platform switch
* Shows all conflicts at once with individual resolve buttons
*/
const ConflictDialog: React.FC<ConflictDialogProps> = ({
visible,
gameTitle,
conflictType,
onConfirm,
conflicts,
onResolve,
onClose,
}) => {
const getConflictMessage = () => {
if (conflictType === 'cream-to-proton') {
return {
title: 'CreamLinux unlocker detected, but game is set to Proton',
bodyPrefix: 'It looks like you previously installed CreamLinux while ',
bodySuffix: ' was running natively. Steam is now configured to run it with Proton, so CreamLinux files will be removed automatically.',
}
// Check if any CreamLinux conflicts exist
const hasCreamConflicts = conflicts.some((c) => c.type === 'cream-to-proton')
const getConflictDescription = (type: 'cream-to-proton' | 'smoke-to-native') => {
if (type === 'cream-to-proton') {
return 'Will remove existing unlocker files and restore the game to a clean state.'
} else {
return {
title: 'SmokeAPI unlocker detected, but game is set to Native',
bodyPrefix: 'It looks like you previously installed SmokeAPI while ',
bodySuffix: ' was running with Proton. Steam is now configured to run it natively, so SmokeAPI files will be removed automatically.',
}
return 'Will remove existing unlocker files and restore the game to a clean state.'
}
}
const message = getConflictMessage()
return (
<Dialog visible={visible} size="large" preventBackdropClose={true}>
<DialogHeader hideCloseButton={true}>
<div className="conflict-dialog-header">
<Icon name={warning} variant="solid" size="lg" />
<h3>{message.title}</h3>
<h3>Unlocker conflicts detected</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="conflict-dialog-body">
<p>
{message.bodyPrefix}
<strong>{gameTitle}</strong>
{message.bodySuffix}
<p className="conflict-intro">
Some games have conflicting unlocker states that need attention.
</p>
<div className="conflict-list">
{conflicts.map((conflict) => (
<div key={conflict.gameId} className="conflict-item">
<div className="conflict-info">
<div className="conflict-icon">
<Icon name={warning} variant="solid" size="md" />
</div>
<div className="conflict-details">
<h4>{conflict.gameTitle}</h4>
<p>{getConflictDescription(conflict.type)}</p>
</div>
</div>
<Button
variant="primary"
onClick={() => onResolve(conflict.gameId, conflict.type)}
className="conflict-resolve-btn"
>
Resolve
</Button>
</div>
))}
</div>
</div>
</DialogBody>
<DialogFooter>
{hasCreamConflicts && (
<div className="conflict-reminder">
<Icon name={info} variant="solid" size="md" />
<span>
Remember to remove <code>sh ./cream.sh %command%</code> from Steam launch options
after resolving CreamLinux conflicts.
</span>
</div>
)}
<DialogActions>
<Button variant="primary" onClick={onConfirm}>
OK
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogFooter>

View File

@@ -0,0 +1,69 @@
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { useState } from 'react'
export interface DisclaimerDialogProps {
visible: boolean
onClose: (dontShowAgain: boolean) => void
}
/**
* Disclaimer dialog that appears on app startup
* Informs users that CreamLinux manages DLC IDs, not actual DLC files
*/
const DisclaimerDialog = ({ visible, onClose }: DisclaimerDialogProps) => {
const [dontShowAgain, setDontShowAgain] = useState(false)
const handleOkClick = () => {
onClose(dontShowAgain)
}
return (
<Dialog visible={visible} onClose={() => onClose(false)} size="medium" preventBackdropClose>
<DialogHeader hideCloseButton={true}>
<div className="disclaimer-header">
<h3>Important Notice</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="disclaimer-content">
<p>
<strong>CreamLinux Installer</strong> does not install any DLC content files.
</p>
<p>
This application manages the <strong>DLC IDs</strong> associated with DLCs you want to
use. You must obtain the actual DLC files separately.
</p>
<p>
This tool only configures which DLC IDs are recognized by the game unlockers
(CreamLinux and SmokeAPI).
</p>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<div className="disclaimer-footer">
<AnimatedCheckbox
checked={dontShowAgain}
onChange={() => setDontShowAgain(!dontShowAgain)}
label="Don't show this disclaimer again"
/>
<Button variant="primary" onClick={handleOkClick}>
OK
</Button>
</div>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default DisclaimerDialog

View File

@@ -0,0 +1,95 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, info } from '@/components/icons'
export interface UnlockerSelectionDialogProps {
visible: boolean
gameTitle: string
onClose: () => void
onSelectCreamLinux: () => void
onSelectSmokeAPI: () => void
}
/**
* Unlocker Selection Dialog component
* Allows users to choose between CreamLinux and SmokeAPI for native Linux games
*/
const UnlockerSelectionDialog: React.FC<UnlockerSelectionDialogProps> = ({
visible,
gameTitle,
onClose,
onSelectCreamLinux,
onSelectSmokeAPI,
}) => {
return (
<Dialog visible={visible} onClose={onClose} size="medium">
<DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="unlocker-selection-header">
<h3>Choose Unlocker</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="unlocker-selection-content">
<p className="game-title-info">
Select which unlocker to install for <strong>{gameTitle}</strong>:
</p>
<div className="unlocker-options">
<div className="unlocker-option recommended">
<div className="option-header">
<h4>CreamLinux</h4>
<span className="recommended-badge">Recommended</span>
</div>
<p className="option-description">
Native Linux DLC unlocker. Works best with most native Linux games and provides
better compatibility.
</p>
<Button variant="primary" onClick={onSelectCreamLinux} fullWidth>
Install CreamLinux
</Button>
</div>
<div className="unlocker-option">
<div className="option-header">
<h4>SmokeAPI</h4>
<span className="alternative-badge">Alternative</span>
</div>
<p className="option-description">
Cross-platform DLC unlocker. Try this if CreamLinux doesn't work for your game.
Automatically fetches DLC information.
</p>
<Button variant="secondary" onClick={onSelectSmokeAPI} fullWidth>
Install SmokeAPI
</Button>
</div>
</div>
<div className="selection-info">
<Icon name={info} variant="solid" size="md" />
<span>
You can always uninstall and try the other option if one doesn't work properly.
</span>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default UnlockerSelectionDialog

View File

@@ -9,7 +9,8 @@ export { default as DlcSelectionDialog } from './DlcSelectionDialog'
export { default as SettingsDialog } from './SettingsDialog'
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
export { default as ConflictDialog } from './ConflictDialog'
export { default as ReminderDialog } from './ReminderDialog'
export { default as DisclaimerDialog } from './DisclaimerDialog'
export { default as UnlockerSelectionDialog} from './UnlockerSelectionDialog'
// Export types
export type { DialogProps } from './Dialog'
@@ -19,5 +20,5 @@ export type { DialogFooterProps } from './DialogFooter'
export type { DialogActionsProps } from './DialogActions'
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
export type { ConflictDialogProps } from './ConflictDialog'
export type { ReminderDialogProps } from './ReminderDialog'
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'

View File

@@ -51,11 +51,14 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
}, [game.id, imageUrl])
// Determine if we should show CreamLinux buttons (only for native games)
const shouldShowCream = game.native === true
const shouldShowCream = game.native && game.cream_installed // Only show if installed (for uninstall)
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
// Show generic button if nothing installed
const shouldShowUnlocker = game.native && !game.cream_installed && !game.smoke_installed
// Check if this is a Proton game without API files
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
@@ -71,6 +74,11 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
onAction(game.id, action)
}
const handleUnlockerAction = () => {
if (game.installing) return
onAction(game.id, 'install_unlocker')
}
// Handle edit button click
const handleEdit = () => {
if (onEdit && game.cream_installed) {
@@ -116,17 +124,27 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
</div>
<div className="game-actions">
{/* Show CreamLinux button only for native games */}
{/* Show generic "Install" button for native games with nothing installed */}
{shouldShowUnlocker && (
<ActionButton
action="install_unlocker"
isInstalled={false}
isWorking={!!game.installing}
onClick={handleUnlockerAction}
/>
)}
{/* Show CreamLinux uninstall button if CreamLinux is installed */}
{shouldShowCream && (
<ActionButton
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
isInstalled={!!game.cream_installed}
action="uninstall_cream"
isInstalled={true}
isWorking={!!game.installing}
onClick={handleCreamAction}
/>
)}
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
{/* Show SmokeAPI button for Proton games OR native games with SmokeAPI installed */}
{shouldShowSmoke && (
<ActionButton
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
@@ -136,6 +154,16 @@ const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps)
/>
)}
{/* Show SmokeAPI uninstall for native games if installed */}
{game.native && game.smoke_installed && (
<ActionButton
action="uninstall_smoke"
isInstalled={true}
isWorking={!!game.installing}
onClick={handleSmokeAction}
/>
)}
{/* Show message for Proton games without API files */}
{isProtonNoApi && (
<div className="api-not-found-message">

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none">
<path d="M20.25 14.75V10.75C20.25 8.83608 20.2477 7.50125 20.1123 6.49411C19.9808 5.51577 19.7401 4.99789 19.3711 4.62888C19.0021 4.25987 18.4842 4.01921 17.5059 3.88767C16.4987 3.75226 15.1639 3.74997 13.25 3.74997H10.75C8.83611 3.74997 7.50128 3.75226 6.49414 3.88767C5.5158 4.01921 4.99792 4.25987 4.62891 4.62888C4.2599 4.99789 4.01924 5.51577 3.8877 6.49411C3.75229 7.50125 3.75 8.83608 3.75 10.75V14.75C3.75 15.3023 3.30229 15.75 2.75 15.75C2.19772 15.75 1.75 15.3023 1.75 14.75V10.75C1.75 8.89262 1.74779 7.39889 1.90528 6.22751C2.06664 5.02741 2.41231 4.01735 3.21485 3.21481C4.01738 2.41228 5.02745 2.06661 6.22754 1.90524C7.39892 1.74776 8.89265 1.74997 10.75 1.74997H13.25C15.1074 1.74997 16.6011 1.74776 17.7725 1.90524C18.9726 2.06661 19.9826 2.41228 20.7852 3.21481C21.5877 4.01735 21.9334 5.02742 22.0947 6.22751C22.2522 7.39889 22.25 8.89262 22.25 10.75V14.75C22.25 15.3023 21.8023 15.75 21.25 15.75C20.6977 15.75 20.25 15.3023 20.25 14.75Z" fill="currentColor" />
<path d="M14.0312 5.74997C15.6419 5.74996 16.9169 5.74997 17.9248 5.86911C18.9557 5.99098 19.8048 6.2463 20.5137 6.82809C20.7541 7.02541 20.9746 7.24589 21.1719 7.4863C21.7537 8.19522 22.009 9.04424 22.1309 10.0752C22.25 11.0831 22.25 12.3581 22.25 13.9687V14.0312C22.25 15.6418 22.25 16.9169 22.1309 17.9248C22.009 18.9557 21.7537 19.8047 21.1719 20.5136C20.9746 20.7541 20.7541 20.9745 20.5137 21.1718C19.8048 21.7536 18.9557 22.009 17.9248 22.1308C16.9169 22.25 15.6419 22.25 14.0312 22.25H9.96875C8.35815 22.25 7.0831 22.25 6.0752 22.1308C5.04427 22.009 4.19525 21.7536 3.48633 21.1718C3.24592 20.9745 3.02544 20.7541 2.82812 20.5136C2.24633 19.8047 1.99101 18.9557 1.86914 17.9248C1.75 16.9169 1.75 15.6418 1.75 14.0312V13.9687C1.75 12.3581 1.75 11.0831 1.86914 10.0752C1.99101 9.04424 2.24633 8.19522 2.82812 7.4863C3.02544 7.24589 3.24592 7.02541 3.48633 6.82809C4.19525 6.2463 5.04427 5.99098 6.0752 5.86911C7.0831 5.74997 8.35815 5.74996 9.96875 5.74997H14.0312ZM12 9.49997C11.4477 9.49997 11 9.94768 11 10.5V15.3906C10.6896 15.0331 10.3585 14.6264 10.1455 14.3535C10.0396 14.2178 9.86489 13.9856 9.80566 13.9072C9.47825 13.4626 8.8519 13.3671 8.40723 13.6943C7.96265 14.0217 7.86716 14.6481 8.19434 15.0927C8.259 15.1784 8.45594 15.4386 8.56934 15.584C8.7953 15.8735 9.10761 16.2629 9.44824 16.6552C9.78455 17.0426 10.1683 17.456 10.5352 17.7802C10.7175 17.9414 10.9198 18.1021 11.1279 18.2275C11.3086 18.3364 11.6228 18.5 12 18.5C12.3772 18.5 12.6914 18.3364 12.8721 18.2275C13.0802 18.1021 13.2825 17.9414 13.4648 17.7802C13.8317 17.4561 14.2154 17.0426 14.5518 16.6552C14.8924 16.2629 15.2047 15.8735 15.4307 15.584C15.5441 15.4386 15.741 15.1784 15.8057 15.0927C16.1328 14.6481 16.0373 14.0227 15.5928 13.6953C15.1481 13.3678 14.5219 13.4635 14.1943 13.9082C14.1351 13.9866 13.9604 14.2178 13.8545 14.3535C13.6415 14.6264 13.3104 15.0331 13 15.3906V10.5C13 9.9477 12.5523 9.49999 12 9.49997Z" fill="currentColor" />
<path d="M16.1439 10.8544C15.7604 10.7888 15.2902 10.7658 14.7504 10.7567V4.99991C14.7504 4.5833 14.7563 4.22799 14.6732 3.91788C14.4652 3.1414 13.8589 2.5351 13.0824 2.32706C12.7723 2.24399 12.417 2.24991 12.0004 2.24991C11.5838 2.24991 11.2285 2.244 10.9183 2.32706C10.1419 2.5351 9.53459 3.1414 9.32654 3.91788C9.24355 4.22794 9.25037 4.5834 9.25037 4.99991V10.7567C8.71056 10.7658 8.24038 10.7888 7.85681 10.8544C7.344 10.9421 6.77397 11.1384 6.46033 11.6796L6.40174 11.7929L6.35193 11.9081C6.08178 12.5976 6.3948 13.2355 6.73279 13.7284C7.07715 14.2305 7.6246 14.832 8.28226 15.5546L8.31873 15.5946C9.03427 16.3808 9.62531 17.0262 10.1595 17.4687C10.7074 17.9223 11.2882 18.2426 11.9926 18.2499H12.0082C12.7125 18.2426 13.2934 17.9223 13.8412 17.4687C14.3754 17.0262 14.9665 16.3808 15.682 15.5946L15.7185 15.5546C16.3761 14.832 16.9236 14.2305 17.2679 13.7284C17.6059 13.2355 17.919 12.5976 17.6488 11.9081L17.599 11.7929L17.5404 11.6796C17.2268 11.1384 16.6567 10.9421 16.1439 10.8544Z" fill="currentColor" />
<path d="M18.75 19.7499C19.3023 19.7499 19.75 20.1976 19.75 20.7499C19.75 21.3022 19.3023 21.7499 18.75 21.7499H5.25C4.69772 21.7499 4.25 21.3022 4.25 20.7499C4.25 20.1976 4.69772 19.7499 5.25 19.7499H18.75Z" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,5 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#000000" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 21.5H10C6.71252 21.5 5.06878 21.5 3.96243 20.592C3.75989 20.4258 3.57418 20.2401 3.40796 20.0376C2.5 18.9312 2.5 17.2875 2.5 14C2.5 10.7125 2.5 9.06878 3.40796 7.96243C3.57418 7.75989 3.75989 7.57418 3.96243 7.40796C5.06878 6.5 6.71252 6.5 10 6.5H14C17.2875 6.5 18.9312 6.5 20.0376 7.40796C20.2401 7.57418 20.4258 7.75989 20.592 7.96243C21.5 9.06878 21.5 10.7125 21.5 14C21.5 17.2875 21.5 18.9312 20.592 20.0376C20.4258 20.2401 20.2401 20.4258 20.0376 20.592C18.9312 21.5 17.2875 21.5 14 21.5Z" />
<path d="M2.5 14.5V10.5C2.5 6.72876 2.5 4.84315 3.67157 3.67157C4.84315 2.5 6.72876 2.5 10.5 2.5H13.5C17.2712 2.5 19.1569 2.5 20.3284 3.67157C21.5 4.84315 21.5 6.72876 21.5 10.5V14.5" />
<path d="M15 14.5C15 14.5 12.7905 17.4999 12 17.4999C11.2094 17.5 9 14.4999 9 14.4999M12 17L12 10.5" />
<path d="M16.9504 12.1817C17.1981 12.814 16.5076 13.5726 15.1267 15.0899C13.6702 16.6902 12.9201 17.4904 12 17.5C11.0799 17.4904 10.3298 16.6902 8.87331 15.0899C7.49239 13.5726 6.80193 12.814 7.04964 12.1817C7.05868 12.1586 7.06851 12.1359 7.0791 12.1135C7.34928 11.542 8.24477 11.5029 10 11.5002V4.99998C10 4.53501 10 4.30253 10.0511 4.11179C10.1898 3.59414 10.5941 3.1898 11.1118 3.05111C11.3025 3 11.535 3 12 3C12.4649 3 12.6974 3 12.8882 3.05111C13.4058 3.1898 13.8102 3.59414 13.9489 4.11179C14 4.30253 14 4.53501 14 4.99998V11.5002C15.7552 11.5029 16.6507 11.542 16.9209 12.1135C16.9315 12.1359 16.9413 12.1586 16.9504 12.1817Z" />
<path d="M5.00006 21H19.0001" />
</svg>

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 885 B

View File

@@ -62,6 +62,16 @@ export interface AppContextType {
type: 'success' | 'error' | 'warning' | 'info',
options?: Record<string, unknown>
) => void
// Unlocker selection
unlockerSelectionDialog: {
visible: boolean
gameId: string | null
gameTitle: string | null
}
handleSelectCreamLinux: () => void
handleSelectSmokeAPI: () => void
closeUnlockerDialog: () => void
}
// Create the context with a default value

View File

@@ -33,6 +33,8 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleCloseProgressDialog,
handleGameAction: executeGameAction,
handleDlcConfirm: executeDlcConfirm,
unlockerSelectionDialog,
closeUnlockerDialog,
} = useGameActions()
const { toasts, removeToast, success, error: showError, warning, info } = useToasts()
@@ -76,7 +78,11 @@ export const AppProvider = ({ children }: AppProviderProps) => {
}
const handleSmokeAPISettingsClose = () => {
setSmokeAPISettingsDialog((prev) => ({ ...prev, visible: false }))
setSmokeAPISettingsDialog({
visible: false,
gamePath: '',
gameTitle: '',
})
}
// Game action handler with proper error reporting
@@ -111,6 +117,28 @@ export const AppProvider = ({ children }: AppProviderProps) => {
}
}
// For install_unlocker action, executeGameAction will handle showing the dialog
// We should NOT show any notifications here - they'll be shown after actual installation
if (action === 'install_unlocker') {
// Mark game as installing while the user makes a selection
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
)
try {
// This will show the UnlockerSelectionDialog and handle the callback
await executeGameAction(gameId, action, games)
} catch (error) {
showError(`Action failed: ${error}`)
} finally {
// Reset installing state
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
)
}
return // Don't show any notifications for install_unlocker
}
// For other actions (uninstall cream, install/uninstall smoke)
// Mark game as installing
setGames((prevGames) =>
@@ -121,7 +149,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
await executeGameAction(gameId, action, games)
// Show appropriate success message based on action type
const product = action.includes('cream') ? 'Creamlinux' : 'SmokeAPI'
const product = action.includes('cream') ? 'CreamLinux' : 'SmokeAPI'
const isUninstall = action.includes('uninstall')
const isInstall = action.includes('install') && !isUninstall
@@ -241,6 +269,53 @@ export const AppProvider = ({ children }: AppProviderProps) => {
// Toast notifications
showToast,
// Unlocker selection - Pass wrapped handlers that also handle the installing state
unlockerSelectionDialog,
handleSelectCreamLinux: () => {
// When CreamLinux is selected, trigger the DLC dialog flow
const gameId = unlockerSelectionDialog.gameId
if (gameId) {
const game = games.find((g) => g.id === gameId)
if (game) {
closeUnlockerDialog()
// Reset installing state before showing DLC dialog
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
)
// Show DLC selection dialog directly
setDlcDialog({
...dlcDialog,
visible: true,
gameId,
gameTitle: game.title,
dlcs: [],
isLoading: true,
isEditMode: false,
progress: 0,
})
streamGameDlcs(gameId)
}
}
},
handleSelectSmokeAPI: () => {
// When SmokeAPI is selected, trigger the actual installation
const gameId = unlockerSelectionDialog.gameId
if (gameId) {
const game = games.find((g) => g.id === gameId)
if (game) {
closeUnlockerDialog()
setTimeout(() => {
handleGameAction(gameId, 'install_smoke')
}, 0)
}
}
},
closeUnlockerDialog,
}
return (

View File

@@ -5,6 +5,8 @@ export { useGameActions } from './useGameActions'
export { useToasts } from './useToasts'
export { useAppLogic } from './useAppLogic'
export { useConflictDetection } from './useConflictDetection'
export { useDisclaimer } from './useDisclaimer'
export { useUnlockerSelection } from './useUnlockerSelection'
// Export types
export type { ToastType, Toast, ToastOptions } from './useToasts'

View File

@@ -9,7 +9,6 @@ export interface Conflict {
export interface ConflictResolution {
gameId: string
removeFiles: boolean
conflictType: 'cream-to-proton' | 'smoke-to-native'
}
@@ -19,10 +18,9 @@ export interface ConflictResolution {
*/
export function useConflictDetection(games: Game[]) {
const [conflicts, setConflicts] = useState<Conflict[]>([])
const [currentConflict, setCurrentConflict] = useState<Conflict | null>(null)
const [showReminder, setShowReminder] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [showDialog, setShowDialog] = useState(false)
const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set())
const [hasShownThisSession, setHasShownThisSession] = useState(false)
// Detect conflicts whenever games change
useEffect(() => {
@@ -43,8 +41,8 @@ export function useConflictDetection(games: Game[]) {
})
}
// Conflict 2: SmokeAPI installed but game is now Native
if (game.native && game.smoke_installed) {
// Conflict 2: Orphaned Proton SmokeAPI DLL files on a native game
if (game.native && game.smoke_installed && game.api_files && game.api_files.length > 0) {
detectedConflicts.push({
gameId: game.id,
gameTitle: game.title,
@@ -55,69 +53,50 @@ export function useConflictDetection(games: Game[]) {
setConflicts(detectedConflicts)
// Show the first conflict if we have any and not currently processing
if (detectedConflicts.length > 0 && !currentConflict && !isProcessing) {
setCurrentConflict(detectedConflicts[0])
// Show dialog only if:
// 1. We have conflicts
// 2. Dialog isn't already visible
// 3. We haven't shown it this session
if (detectedConflicts.length > 0 && !showDialog && !hasShownThisSession) {
setShowDialog(true)
setHasShownThisSession(true)
}
}, [games, currentConflict, isProcessing, resolvedConflicts])
}, [games, resolvedConflicts, showDialog, hasShownThisSession])
// Handle conflict resolution
const resolveConflict = useCallback((): ConflictResolution | null => {
if (!currentConflict || isProcessing) return null
// Handle resolving a single conflict
const resolveConflict = useCallback(
(gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native'): ConflictResolution => {
// Mark this game as resolved
setResolvedConflicts((prev) => new Set(prev).add(gameId))
setIsProcessing(true)
// Remove from conflicts list
setConflicts((prev) => prev.filter((c) => c.gameId !== gameId))
const resolution: ConflictResolution = {
gameId: currentConflict.gameId,
removeFiles: true, // Always remove files
conflictType: currentConflict.type,
return {
gameId,
conflictType,
}
},
[]
)
// Auto-close dialog when all conflicts are resolved
useEffect(() => {
if (conflicts.length === 0 && showDialog) {
setShowDialog(false)
}
}, [conflicts.length, showDialog])
// Mark this game as resolved so we don't re-detect the conflict
setResolvedConflicts((prev) => new Set(prev).add(currentConflict.gameId))
// Remove this conflict from the list
const remainingConflicts = conflicts.filter((c) => c.gameId !== currentConflict.gameId)
setConflicts(remainingConflicts)
// Close current conflict dialog immediately
setCurrentConflict(null)
// Determine what to show next based on conflict type
if (resolution.conflictType === 'cream-to-proton') {
// CreamLinux removal - show reminder after delay
setTimeout(() => {
setShowReminder(true)
setIsProcessing(false)
}, 100)
} else {
// SmokeAPI removal - no reminder, just show next conflict or finish
setTimeout(() => {
if (remainingConflicts.length > 0) {
setCurrentConflict(remainingConflicts[0])
}
setIsProcessing(false)
}, 100)
}
return resolution
}, [currentConflict, conflicts, isProcessing])
// Close reminder dialog
const closeReminder = useCallback(() => {
setShowReminder(false)
// After closing reminder, check if there are more conflicts
if (conflicts.length > 0) {
setCurrentConflict(conflicts[0])
}
}, [conflicts])
// Handle dialog close
const closeDialog = useCallback(() => {
setShowDialog(false)
}, [])
return {
currentConflict,
showReminder,
conflicts,
showDialog,
resolveConflict,
closeReminder,
closeDialog,
hasConflicts: conflicts.length > 0,
}
}

View File

@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { Config } from '@/types/Config'
/**
* Hook to manage disclaimer dialog state
* Loads config on mount and provides methods to update it
*/
export function useDisclaimer() {
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// Load config on mount
useEffect(() => {
loadConfig()
}, [])
const loadConfig = async () => {
try {
const config = await invoke<Config>('load_config')
setShowDisclaimer(config.show_disclaimer)
} catch (error) {
console.error('Failed to load config:', error)
// Default to showing disclaimer if config load fails
setShowDisclaimer(true)
} finally {
setIsLoading(false)
}
}
const handleDisclaimerClose = async (dontShowAgain: boolean) => {
setShowDisclaimer(false)
if (dontShowAgain) {
try {
// Load the current config first
const currentConfig = await invoke<Config>('load_config')
// Update the show_disclaimer field
const updatedConfig: Config = {
...currentConfig,
show_disclaimer: false,
}
// Save the updated config
await invoke('update_config', { configData: updatedConfig })
} catch (error) {
console.error('Failed to update config:', error)
}
}
}
return {
showDisclaimer,
isLoading,
handleDisclaimerClose,
}
}

View File

@@ -4,6 +4,7 @@ import { listen } from '@tauri-apps/api/event'
import { ActionType } from '@/components/buttons/ActionButton'
import { Game, DlcInfo } from '@/types'
import { InstallationInstructions } from '@/contexts/AppContext'
import { useUnlockerSelection } from './useUnlockerSelection'
/**
* Hook for managing game action operations
@@ -79,22 +80,38 @@ export function useGameActions() {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, [])
// Unlocker selection hook for native games
const {
selectionState,
showUnlockerSelection,
handleSelectCreamLinux,
handleSelectSmokeAPI,
closeDialog: closeUnlockerDialog,
} = useUnlockerSelection()
// Unified handler for game actions (install/uninstall)
const handleGameAction = useCallback(
async (gameId: string, action: ActionType, games: Game[]) => {
try {
// For CreamLinux installation, we should NOT call process_game_action directly
// Instead, we show the DLC selection dialog first, which is handled in AppProvider
// Find the game
const game = games.find((g) => g.id === gameId)
if (!game) return
// For CreamLinux installation, DLC dialog is handled in AppProvider
if (action === 'install_cream') {
return
}
// For other actions (uninstall_cream, install_smoke, uninstall_smoke)
// Find game to get title
const game = games.find((g) => g.id === gameId)
if (!game) return
// Handle generic "install_unlocker" action for native games
if (action === 'install_unlocker') {
showUnlockerSelection(game, (chosenAction: ActionType) => {
// User chose, now proceed with that action
handleGameAction(gameId, chosenAction, games)
})
return
}
// Get title based on action
// For other actions (uninstall_cream, install_smoke, uninstall_smoke)
const isCream = action.includes('cream')
const isInstall = action.includes('install')
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
@@ -138,7 +155,7 @@ export function useGameActions() {
throw error
}
},
[]
[showUnlockerSelection]
)
// Handle DLC selection confirmation
@@ -231,5 +248,9 @@ export function useGameActions() {
handleCloseProgressDialog,
handleGameAction,
handleDlcConfirm,
unlockerSelectionDialog: selectionState,
handleSelectCreamLinux,
handleSelectSmokeAPI,
closeUnlockerDialog,
}
}

View File

@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react'
import { Game } from '@/types'
import { ActionType } from '@/components/buttons'
export interface UnlockerSelectionState {
visible: boolean
gameId: string | null
gameTitle: string | null
}
/**
* Hook for managing unlocker selection on native games
*/
export function useUnlockerSelection() {
const [selectionState, setSelectionState] = useState<UnlockerSelectionState>({
visible: false,
gameId: null,
gameTitle: null,
})
// Store the callback to call when user makes a selection
const [selectionCallback, setSelectionCallback] = useState<((action: ActionType) => void) | null>(
null
)
// Show the dialog and store the callback
const showUnlockerSelection = useCallback(
(game: Game, callback: (action: ActionType) => void) => {
setSelectionState({
visible: true,
gameId: game.id,
gameTitle: game.title,
})
// Wrap in function to avoid stale closure
setSelectionCallback(() => callback)
},
[]
)
// User selected CreamLinux
const handleSelectCreamLinux = useCallback(() => {
setSelectionState({ visible: false, gameId: null, gameTitle: null })
if (selectionCallback) {
selectionCallback('install_cream')
}
setSelectionCallback(null)
}, [selectionCallback])
// User selected SmokeAPI
const handleSelectSmokeAPI = useCallback(() => {
setSelectionState({ visible: false, gameId: null, gameTitle: null })
if (selectionCallback) {
selectionCallback('install_smoke')
}
setSelectionCallback(null)
}, [selectionCallback])
// Close dialog without selection
const closeDialog = useCallback(() => {
setSelectionState({ visible: false, gameId: null, gameTitle: null })
setSelectionCallback(null)
}, [])
return {
selectionState,
showUnlockerSelection,
handleSelectCreamLinux,
handleSelectSmokeAPI,
closeDialog,
}
}

View File

@@ -25,64 +25,119 @@
}
.conflict-dialog-body {
p {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
.conflict-intro {
margin: 0;
color: var(--text-secondary);
line-height: 1.5;
}
&:last-of-type {
margin-bottom: 0;
.conflict-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.conflict-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.15);
}
}
strong {
.conflict-info {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex: 1;
min-width: 0; // Enable text truncation
}
.conflict-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(255, 193, 7, 0.1);
border-radius: 8px;
svg {
color: var(--warning);
}
}
.conflict-details {
flex: 1;
min-width: 0;
h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
font-weight: var(--semibold);
color: var(--text-primary);
font-weight: var(--bold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
}
}
.conflict-resolve-btn {
flex-shrink: 0;
min-width: 100px;
}
}
/*
Reminder Dialog Styles
Used for Steam launch option reminders
*/
.reminder-dialog-header {
.conflict-reminder {
display: flex;
align-items: center;
gap: 0.75rem;
h3 {
margin: 0;
flex: 1;
font-size: 1.1rem;
color: var(--text-primary);
}
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(33, 150, 243, 0.1);
border: 1px solid rgba(33, 150, 243, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: 1rem;
svg {
color: var(--info);
flex-shrink: 0;
}
}
.reminder-dialog-body {
p {
margin-bottom: 1rem;
color: var(--text-secondary);
line-height: 1.5;
span {
flex: 1;
}
.reminder-steps {
margin: 1rem 0 0 1.5rem;
padding: 0;
color: var(--text-secondary);
line-height: 1.6;
li {
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
code {
padding: 0.125rem 0.375rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: var(--text-primary);
white-space: nowrap;
}
}

View File

@@ -0,0 +1,38 @@
@use '../../themes/index' as *;
@use '../../abstracts/index' as *;
/*
Disclaimer Dialog Styles
Used for the startup disclaimer dialog
*/
.disclaimer-header {
h3 {
margin-bottom: 0;
}
}
.disclaimer-content {
p {
margin-bottom: 1rem;
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.95rem;
&:last-of-type {
margin-bottom: 0;
}
strong {
color: var(--text-primary);
font-weight: var(--bold);
}
}
}
.disclaimer-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
width: 100%;
}

View File

@@ -4,3 +4,5 @@
@forward './settings_dialog';
@forward './smokeapi_settings_dialog';
@forward './conflict_dialog';
@forward './disclaimer_dialog';
@forward './unlocker_selection_dialog';

View File

@@ -0,0 +1,122 @@
@use '../../themes/index' as *;
@use '../../abstracts/index' as *;
/*
Unlocker Selection Dialog styles
For choosing between CreamLinux and SmokeAPI on native games
*/
.unlocker-selection-header {
display: flex;
align-items: center;
gap: 0.75rem;
h3 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
}
.unlocker-selection-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
.game-title-info {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
strong {
color: var(--text-primary);
}
}
.unlocker-options {
display: flex;
flex-direction: column;
gap: 1rem;
}
.unlocker-option {
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 1.25rem;
transition: all var(--duration-normal) var(--easing-ease-out);
&:hover {
background-color: rgba(255, 255, 255, 0.05);
border-color: var(--border);
}
&.recommended {
border-color: var(--primary-color);
background-color: rgba(245, 150, 130, 0.05);
&:hover {
background-color: rgba(245, 150, 130, 0.08);
}
}
.option-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.recommended-badge {
background-color: var(--primary-color);
color: var(--text-heavy);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.alternative-badge {
background-color: var(--border);
color: var(--text-secondary);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
}
.option-description {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 1rem 0;
line-height: 1.5;
}
}
.selection-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background-color: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-soft);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-muted);
svg {
flex-shrink: 0;
color: var(--info);
}
}
}

8
src/types/Config.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* User configuration structure
* Matches the Rust Config struct
*/
export interface Config {
/** Whether to show the disclaimer on startup */
show_disclaimer: boolean
}

View File

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