37 Commits

Author SHA1 Message Date
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
Novattz
595fe53254 version bump & changelog 2025-12-26 22:12:02 +01:00
Novattz
3801404138 index & hook #89 2025-12-26 22:11:44 +01:00
Novattz
919749d0ae conflict & reminder dialogs & styles #89 2025-12-26 22:11:07 +01:00
Novattz
d4ae5d74e9 conflict backend stuff #89 2025-12-26 22:10:34 +01:00
Novattz
7fd3147f44 apperantly not a valid flag 2025-12-23 03:04:47 +01:00
Novattz
87dc328434 changelog 2025-12-23 03:01:42 +01:00
Novattz
b227dff339 version bump 2025-12-23 03:01:28 +01:00
Novattz
04910e84cf Add response if we got any new dlcs or not #64 2025-12-23 02:59:12 +01:00
Novattz
7960019cd9 update creamlinux config #64 2025-12-23 02:42:19 +01:00
Novattz
a00cc92b70 adjust settings dialog 2025-12-23 02:00:09 +01:00
Novattz
85520f8916 add settings button to game cards with smokeapi installed #67 2025-12-23 01:59:53 +01:00
Novattz
ac96e7be69 smokeapi config backend implementation #67 2025-12-23 01:59:06 +01:00
Novattz
3675ff8fae add smokeapi settings dialog & styling #67 2025-12-23 01:58:30 +01:00
Novattz
ab057b8d10 add dropdown component 2025-12-23 01:57:26 +01:00
Novattz
952749cc93 fix depraction warning 2025-12-23 01:56:46 +01:00
Tickbase
4c4e087be7 Merge pull request #86 from Novattz/dependabot/npm_and_yarn/multi-ed0ec66f32
Bump glob and semantic-release
2025-12-22 22:04:41 +01:00
dependabot[bot]
1e52c2071c Bump glob and semantic-release
Bumps [glob](https://github.com/isaacs/node-glob) and [semantic-release](https://github.com/semantic-release/semantic-release). These dependencies needed to be updated together.

Updates `glob` from 11.0.2 to 11.1.0
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v11.0.2...v11.1.0)

Updates `semantic-release` from 24.2.4 to 25.0.2
- [Release notes](https://github.com/semantic-release/semantic-release/releases)
- [Commits](https://github.com/semantic-release/semantic-release/compare/v24.2.4...v25.0.2)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 11.1.0
  dependency-type: direct:development
- dependency-name: semantic-release
  dependency-version: 25.0.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 21:04:04 +00:00
Tickbase
fc8c69a915 Merge pull request #85 from Novattz/dependabot/npm_and_yarn/js-yaml-4.1.1
Bump js-yaml from 4.1.0 to 4.1.1
2025-12-22 22:02:31 +01:00
dependabot[bot]
2d7077a05b Bump js-yaml from 4.1.0 to 4.1.1
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 20:52:35 +00:00
Tickbase
081d61afc7 Merge pull request #84 from Novattz/dependabot/npm_and_yarn/vite-6.4.1 2025-12-22 20:32:44 +01:00
dependabot[bot]
0bfd36aea9 Bump vite from 6.3.5 to 6.4.1
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.4.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 19:31:54 +00:00
41 changed files with 3298 additions and 1466 deletions

View File

@@ -142,3 +142,24 @@ jobs:
includeUpdaterJson: true includeUpdaterJson: true
tauriScript: 'npm run tauri' tauriScript: 'npm run tauri'
args: ${{ matrix.args }} 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 *.local
*.lock *.lock
.env .env
CHANGELOG.md
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -1,3 +1,37 @@
## [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
- Platform conflict detection
- Automatic removal of incompatible unlocker files when switching between Native/Proton
- Reminder dialog for steam launch options after creamlinux removal
- Conflict dialog to show which game had the conflict
## [1.3.2] - 23-12-2025
### Added
- New dropdown component
- Settings dialog for SmokeAPI configuration
- Update creamlinux config functionality
### Changed
- Adjusted styling for CreamLinux settings dialog
## [1.3.0] - 22-12-2025 ## [1.3.0] - 22-12-2025
### Added ### Added

View File

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

View File

@@ -1,6 +1,6 @@
# CreamLinux # 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: ## Watch the demo here:
@@ -61,7 +61,7 @@ While the core functionality is working, please be aware that this is an early r
```bash ```bash
git clone https://github.com/Novattz/creamlinux-installer.git git clone https://github.com/Novattz/creamlinux-installer.git
cd creamlinux cd creamlinux-installer
``` ```
2. Install dependencies: 2. Install dependencies:
@@ -124,7 +124,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) f
## Credits ## 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 - [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
- [Tauri](https://tauri.app/) - Framework for building the desktop application - [Tauri](https://tauri.app/) - Framework for building the desktop application
- [React](https://reactjs.org/) - UI library - [React](https://reactjs.org/) - UI library

2768
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "creamlinux", "name": "creamlinux",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.5",
"type": "module", "type": "module",
"author": "Tickbase", "author": "Tickbase",
"repository": "https://github.com/Novattz/creamlinux-installer", "repository": "https://github.com/Novattz/creamlinux-installer",
@@ -40,14 +40,14 @@
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"glob": "^11.0.2", "glob": "^11.1.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"sass-embedded": "^1.86.3", "sass-embedded": "^1.86.3",
"semantic-release": "^24.2.4", "semantic-release": "^25.0.2",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.26.1",
"vite": "^6.3.5", "vite": "^6.4.1",
"vite-plugin-svgr": "^4.3.0" "vite-plugin-svgr": "^4.3.0"
} }
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "creamlinux-installer" name = "creamlinux-installer"
version = "1.3.0" version = "1.3.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"

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

@@ -8,7 +8,11 @@ mod dlc_manager;
mod installer; mod installer;
mod searcher; mod searcher;
mod unlockers; mod unlockers;
mod smokeapi_config;
mod config;
use crate::config::Config;
use crate::unlockers::{CreamLinux, SmokeAPI, Unlocker};
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};
@@ -44,6 +48,19 @@ pub struct AppState {
fetch_cancellation: Arc<AtomicBool>, 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] #[tauri::command]
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> { fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
info!("Getting all DLCs (enabled and disabled) for: {}", game_path); info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
@@ -434,6 +451,167 @@ async fn install_cream_with_dlcs_command(
} }
} }
#[tauri::command]
fn read_smokeapi_config(game_path: String) -> Result<Option<smokeapi_config::SmokeAPIConfig>, String> {
info!("Reading SmokeAPI config for: {}", game_path);
smokeapi_config::read_config(&game_path)
}
#[tauri::command]
fn write_smokeapi_config(
game_path: String,
config: smokeapi_config::SmokeAPIConfig,
) -> Result<(), String> {
info!("Writing SmokeAPI config for: {}", game_path);
smokeapi_config::write_config(&game_path, &config)
}
#[tauri::command]
fn delete_smokeapi_config(game_path: String) -> Result<(), String> {
info!("Deleting SmokeAPI config for: {}", game_path);
smokeapi_config::delete_config(&game_path)
}
#[tauri::command]
async fn resolve_platform_conflict(
game_id: String,
conflict_type: String, // "cream-to-proton" or "smoke-to-native"
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Game, String> {
info!(
"Resolving platform conflict for game {}: {}",
game_id, conflict_type
);
let game = {
let games = state.games.lock();
games
.get(&game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id))?
};
let game_title = game.title.clone();
// Emit progress
installer::emit_progress(
&app_handle,
&format!("Resolving Conflict: {}", game_title),
"Removing conflicting files...",
50.0,
false,
false,
None,
);
// Perform the appropriate removal based on conflict type
match conflict_type.as_str() {
"cream-to-proton" => {
// Remove CreamLinux files (bypassing native check)
info!("Removing CreamLinux files from Proton game: {}", game_title);
CreamLinux::uninstall_from_game(&game.path, &game.id)
.await
.map_err(|e| format!("Failed to remove CreamLinux files: {}", e))?;
// Remove version from manifest
crate::cache::remove_creamlinux_version(&game.path)?;
}
"smoke-to-native" => {
// Remove SmokeAPI files (bypassing proton check)
info!("Removing SmokeAPI files from native game: {}", game_title);
// For native games, we need to manually remove backup files since
// the main DLL might already be gone
// Look for and remove *_o.dll backup files
use walkdir::WalkDir;
let mut removed_files = false;
for entry in WalkDir::new(&game.path)
.max_depth(5)
.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();
// Remove steam_api*_o.dll backup files
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
match std::fs::remove_file(path) {
Ok(_) => {
info!("Removed SmokeAPI backup file: {}", path.display());
removed_files = true;
}
Err(e) => {
warn!("Failed to remove backup file {}: {}", path.display(), e);
}
}
}
}
// Also try the normal uninstall if api_files are present
if !game.api_files.is_empty() {
let api_files_str = game.api_files.join(",");
if let Err(e) = SmokeAPI::uninstall_from_game(&game.path, &api_files_str).await {
// Don't fail if this errors - we might have already cleaned up manually above
warn!("SmokeAPI uninstall warning: {}", e);
}
}
if !removed_files {
warn!("No SmokeAPI files found to remove for: {}", game_title);
}
// Remove version from manifest
crate::cache::remove_smokeapi_version(&game.path)?;
}
_ => return Err(format!("Invalid conflict type: {}", conflict_type)),
}
installer::emit_progress(
&app_handle,
&format!("Conflict Resolved: {}", game_title),
"Conflicting files have been removed successfully!",
100.0,
true,
false,
None,
);
// Update game state
let updated_game = {
let mut games_map = state.games.lock();
let game = games_map
.get_mut(&game_id)
.ok_or_else(|| format!("Game with ID {} not found after conflict resolution", game_id))?;
match conflict_type.as_str() {
"cream-to-proton" => {
game.cream_installed = false;
}
"smoke-to-native" => {
game.smoke_installed = false;
}
_ => {}
}
game.installing = false;
game.clone()
};
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
info!("Platform conflict resolved successfully for: {}", game_title);
Ok(updated_game)
}
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> { fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
use log::LevelFilter; use log::LevelFilter;
use log4rs::append::file::FileAppender; use log4rs::append::file::FileAppender;
@@ -491,6 +669,12 @@ fn main() {
get_all_dlcs_command, get_all_dlcs_command,
clear_caches, clear_caches,
abort_dlc_fetch, abort_dlc_fetch,
read_smokeapi_config,
write_smokeapi_config,
delete_smokeapi_config,
resolve_platform_conflict,
load_config,
update_config,
]) ])
.setup(|app| { .setup(|app| {
info!("Tauri application setup"); info!("Tauri application setup");

View File

@@ -256,11 +256,7 @@ fn check_creamlinux_installed(game_path: &Path) -> bool {
// Check if a game has SmokeAPI installed // Check if a game has SmokeAPI installed
fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool { fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
if api_files.is_empty() { // First check the provided api_files for backup files
return false;
}
// SmokeAPI creates backups with _o.dll suffix
for api_file in api_files { for api_file in api_files {
let api_path = game_path.join(api_file); let api_path = game_path.join(api_file);
let api_dir = api_path.parent().unwrap_or(game_path); let api_dir = api_path.parent().unwrap_or(game_path);
@@ -275,6 +271,28 @@ fn check_smokeapi_installed(game_path: &Path, api_files: &[String]) -> bool {
return true; return true;
} }
} }
// Also scan for orphaned backup files (in case the main DLL was removed)
// This handles the Proton->Native switch case where steam_api*.dll is gone
// but steam_api*_o.dll backup remains
for entry in WalkDir::new(game_path)
.max_depth(5)
.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();
// Look for steam_api*_o.dll backup files (SmokeAPI pattern)
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
debug!("Found orphaned SmokeAPI backup file: {}", path.display());
return true;
}
}
false false
} }
@@ -631,12 +649,10 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
// Check for CreamLinux installation // Check for CreamLinux installation
let cream_installed = check_creamlinux_installed(&game_path); let cream_installed = check_creamlinux_installed(&game_path);
// Check for SmokeAPI installation (only for non-native games with Steam API DLLs) // Check for SmokeAPI installation
let smoke_installed = if !is_native && !api_files.is_empty() { // For Proton games: check if api_files exist
check_smokeapi_installed(&game_path, &api_files) // For Native games: ALSO check for orphaned backup files (proton->native switch)
} else { let smoke_installed = check_smokeapi_installed(&game_path, &api_files);
false
};
// Create the game info // Create the game info
let game_info = GameInfo { let game_info = GameInfo {

View File

@@ -0,0 +1,128 @@
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SmokeAPIConfig {
#[serde(rename = "$schema")]
pub schema: String,
#[serde(rename = "$version")]
pub version: u32,
pub logging: bool,
pub log_steam_http: bool,
pub default_app_status: String,
pub override_app_status: HashMap<String, String>,
pub override_dlc_status: HashMap<String, String>,
pub auto_inject_inventory: bool,
pub extra_inventory_items: Vec<u32>,
pub extra_dlcs: HashMap<String, serde_json::Value>,
}
impl Default for SmokeAPIConfig {
fn default() -> Self {
Self {
schema: "https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json".to_string(),
version: 4,
logging: false,
log_steam_http: false,
default_app_status: "unlocked".to_string(),
override_app_status: HashMap::new(),
override_dlc_status: HashMap::new(),
auto_inject_inventory: true,
extra_inventory_items: Vec::new(),
extra_dlcs: HashMap::new(),
}
}
}
// Read SmokeAPI config from a game directory
// Returns None if the config doesn't exist
pub fn read_config(game_path: &str) -> Result<Option<SmokeAPIConfig>, String> {
info!("Reading SmokeAPI config from: {}", game_path);
// Find the SmokeAPI DLL location in the game directory
let config_path = find_smokeapi_config_path(game_path)?;
if !config_path.exists() {
info!("No SmokeAPI config found at: {}", config_path.display());
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read SmokeAPI config: {}", e))?;
let config: SmokeAPIConfig = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse SmokeAPI config: {}", e))?;
info!("Successfully read SmokeAPI config");
Ok(Some(config))
}
// Write SmokeAPI config to a game directory
pub fn write_config(game_path: &str, config: &SmokeAPIConfig) -> Result<(), String> {
info!("Writing SmokeAPI config to: {}", game_path);
let config_path = find_smokeapi_config_path(game_path)?;
let content = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize SmokeAPI config: {}", e))?;
fs::write(&config_path, content)
.map_err(|e| format!("Failed to write SmokeAPI config: {}", e))?;
info!("Successfully wrote SmokeAPI config to: {}", config_path.display());
Ok(())
}
// Delete SmokeAPI config from a game directory
pub fn delete_config(game_path: &str) -> Result<(), String> {
info!("Deleting SmokeAPI config from: {}", game_path);
let config_path = find_smokeapi_config_path(game_path)?;
if config_path.exists() {
fs::remove_file(&config_path)
.map_err(|e| format!("Failed to delete SmokeAPI config: {}", e))?;
info!("Successfully deleted SmokeAPI config");
} else {
info!("No SmokeAPI config to delete");
}
Ok(())
}
// Find the path where SmokeAPI.config.json should be located
// This is in the same directory as the SmokeAPI DLL files
fn find_smokeapi_config_path(game_path: &str) -> Result<std::path::PathBuf, String> {
let game_path_obj = Path::new(game_path);
// Search for steam_api*.dll files with _o.dll backups (indicating SmokeAPI installation)
let mut smokeapi_dir: Option<std::path::PathBuf> = None;
// Use walkdir to search recursively
for entry in walkdir::WalkDir::new(game_path_obj)
.max_depth(5)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
// Look for steam_api*_o.dll (backup files created by SmokeAPI)
if filename.starts_with("steam_api") && filename.ends_with("_o.dll") {
smokeapi_dir = path.parent().map(|p| p.to_path_buf());
break;
}
}
// If we found a SmokeAPI directory, return the config path
if let Some(dir) = smokeapi_dir {
Ok(dir.join("SmokeAPI.config.json"))
} else {
// Fallback to game root directory
warn!("Could not find SmokeAPI DLL directory, using game root");
Ok(game_path_obj.join("SmokeAPI.config.json"))
}
}

View File

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

View File

@@ -1,13 +1,27 @@
import { useState } from 'react' import { useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useAppContext } from '@/contexts/useAppContext' import { useAppContext } from '@/contexts/useAppContext'
import { useAppLogic } from '@/hooks' import { useAppLogic, useConflictDetection, useDisclaimer } from '@/hooks'
import './styles/main.scss' import './styles/main.scss'
// Layout components // Layout components
import { Header, Sidebar, InitialLoadingScreen, ErrorBoundary, UpdateScreen, AnimatedBackground } from '@/components/layout' import {
Header,
Sidebar,
InitialLoadingScreen,
ErrorBoundary,
UpdateScreen,
AnimatedBackground,
} from '@/components/layout'
// Dialog components // Dialog components
import { ProgressDialog, DlcSelectionDialog, SettingsDialog } from '@/components/dialogs' import {
ProgressDialog,
DlcSelectionDialog,
SettingsDialog,
ConflictDialog,
DisclaimerDialog,
} from '@/components/dialogs'
// Game components // Game components
import { GameList } from '@/components/games' import { GameList } from '@/components/games'
@@ -17,6 +31,9 @@ import { GameList } from '@/components/games'
*/ */
function App() { function App() {
const [updateComplete, setUpdateComplete] = useState(false) const [updateComplete, setUpdateComplete] = useState(false)
const { showDisclaimer, handleDisclaimerClose } = useDisclaimer()
// Get application logic from hook // Get application logic from hook
const { const {
filter, filter,
@@ -33,6 +50,7 @@ function App() {
// Get action handlers from context // Get action handlers from context
const { const {
games,
dlcDialog, dlcDialog,
handleDlcDialogClose, handleDlcDialogClose,
handleProgressDialogClose, handleProgressDialogClose,
@@ -40,11 +58,40 @@ function App() {
handleGameAction, handleGameAction,
handleDlcConfirm, handleDlcConfirm,
handleGameEdit, handleGameEdit,
handleUpdateDlcs,
settingsDialog, settingsDialog,
handleSettingsOpen, handleSettingsOpen,
handleSettingsClose, handleSettingsClose,
handleSmokeAPISettingsOpen,
showToast,
} = useAppContext() } = useAppContext()
// Conflict detection
const { conflicts, showDialog, resolveConflict, closeDialog } =
useConflictDetection(games)
// Handle conflict resolution
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,
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')
}
}
// Show update screen first // Show update screen first
if (!updateComplete) { if (!updateComplete) {
return <UpdateScreen onComplete={() => setUpdateComplete(true)} /> return <UpdateScreen onComplete={() => setUpdateComplete(true)} />
@@ -71,7 +118,11 @@ function App() {
<div className="main-content"> <div className="main-content">
{/* Sidebar for filtering */} {/* Sidebar for filtering */}
<Sidebar setFilter={setFilter} currentFilter={filter} onSettingsClick={handleSettingsOpen} /> <Sidebar
setFilter={setFilter}
currentFilter={filter}
onSettingsClick={handleSettingsOpen}
/>
{/* Show error or game list */} {/* Show error or game list */}
{error ? ( {error ? (
@@ -86,6 +137,7 @@ function App() {
isLoading={isLoading} isLoading={isLoading}
onAction={handleGameAction} onAction={handleGameAction}
onEdit={handleGameEdit} onEdit={handleGameEdit}
onSmokeAPISettings={handleSmokeAPISettingsOpen}
/> />
)} )}
</div> </div>
@@ -105,20 +157,33 @@ function App() {
<DlcSelectionDialog <DlcSelectionDialog
visible={dlcDialog.visible} visible={dlcDialog.visible}
gameTitle={dlcDialog.gameTitle} gameTitle={dlcDialog.gameTitle}
gameId={dlcDialog.gameId}
dlcs={dlcDialog.dlcs} dlcs={dlcDialog.dlcs}
isLoading={dlcDialog.isLoading} isLoading={dlcDialog.isLoading}
isEditMode={dlcDialog.isEditMode} isEditMode={dlcDialog.isEditMode}
isUpdating={dlcDialog.isUpdating}
updateAttempted={dlcDialog.updateAttempted}
loadingProgress={dlcDialog.progress} loadingProgress={dlcDialog.progress}
estimatedTimeLeft={dlcDialog.timeLeft} estimatedTimeLeft={dlcDialog.timeLeft}
newDlcsCount={dlcDialog.newDlcsCount}
onClose={handleDlcDialogClose} onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm} onConfirm={handleDlcConfirm}
onUpdate={handleUpdateDlcs}
/> />
{/* Settings Dialog */} {/* Settings Dialog */}
<SettingsDialog <SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
visible ={settingsDialog.visible}
onClose={handleSettingsClose} {/* Conflict Detection Dialog */}
<ConflictDialog
visible={showDialog}
conflicts={conflicts}
onResolve={handleConflictResolve}
onClose={closeDialog}
/> />
{/* Disclaimer Dialog - Shows AFTER everything is loaded */}
<DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
) )

View File

@@ -0,0 +1,97 @@
import { useState, useRef, useEffect } from 'react'
import { Icon, arrowUp } from '@/components/icons'
export interface DropdownOption<T = string> {
value: T
label: string
}
interface DropdownProps<T = string> {
label: string
description?: string
value: T
options: DropdownOption<T>[]
onChange: (value: T) => void
disabled?: boolean
className?: string
}
/**
* Dropdown component for selecting from a list of options
*/
const Dropdown = <T extends string | number | boolean>({
label,
description,
value,
options,
onChange,
disabled = false,
className = '',
}: DropdownProps<T>) => {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
const selectedOption = options.find((opt) => opt.value === value)
const handleSelect = (optionValue: T) => {
onChange(optionValue)
setIsOpen(false)
}
return (
<div className={`dropdown-container ${className}`}>
<div className="dropdown-label-container">
<label className="dropdown-label">{label}</label>
{description && <p className="dropdown-description">{description}</p>}
</div>
<div className={`dropdown ${disabled ? 'disabled' : ''}`} ref={dropdownRef}>
<button
type="button"
className="dropdown-trigger"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
>
<span className="dropdown-value">{selectedOption?.label || 'Select...'}</span>
<Icon
name={arrowUp}
variant="solid"
size="sm"
className={`dropdown-icon ${isOpen ? 'open' : ''}`}
/>
</button>
{isOpen && !disabled && (
<div className="dropdown-menu">
{options.map((option) => (
<button
key={String(option.value)}
type="button"
className={`dropdown-option ${option.value === value ? 'selected' : ''}`}
onClick={() => handleSelect(option.value)}
>
{option.label}
</button>
))}
</div>
)}
</div>
</div>
)
}
export default Dropdown

View File

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

View File

@@ -0,0 +1,106 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
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
conflicts: Conflict[]
onResolve: (gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native') => void
onClose: () => void
}
/**
* Conflict Dialog component
* Shows all conflicts at once with individual resolve buttons
*/
const ConflictDialog: React.FC<ConflictDialogProps> = ({
visible,
conflicts,
onResolve,
onClose,
}) => {
// 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 'Will remove existing unlocker files and restore the game to a clean state.'
}
}
return (
<Dialog visible={visible} size="large" preventBackdropClose={true}>
<DialogHeader hideCloseButton={true}>
<div className="conflict-dialog-header">
<Icon name={warning} variant="solid" size="lg" />
<h3>Unlocker conflicts detected</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="conflict-dialog-body">
<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="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ConflictDialog

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

@@ -6,17 +6,23 @@ import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions' import DialogActions from './DialogActions'
import { Button, AnimatedCheckbox } from '@/components/buttons' import { Button, AnimatedCheckbox } from '@/components/buttons'
import { DlcInfo } from '@/types' import { DlcInfo } from '@/types'
import { Icon, check, info } from '@/components/icons'
export interface DlcSelectionDialogProps { export interface DlcSelectionDialogProps {
visible: boolean visible: boolean
gameTitle: string gameTitle: string
gameId: string
dlcs: DlcInfo[] dlcs: DlcInfo[]
onClose: () => void onClose: () => void
onConfirm: (selectedDlcs: DlcInfo[]) => void onConfirm: (selectedDlcs: DlcInfo[]) => void
onUpdate?: (gameId: string) => void
isLoading: boolean isLoading: boolean
isEditMode?: boolean isEditMode?: boolean
isUpdating?: boolean
updateAttempted?: boolean
loadingProgress?: number loadingProgress?: number
estimatedTimeLeft?: string estimatedTimeLeft?: string
newDlcsCount?: number
} }
/** /**
@@ -27,13 +33,18 @@ export interface DlcSelectionDialogProps {
const DlcSelectionDialog = ({ const DlcSelectionDialog = ({
visible, visible,
gameTitle, gameTitle,
gameId,
dlcs, dlcs,
onClose, onClose,
onConfirm, onConfirm,
onUpdate,
isLoading, isLoading,
isEditMode = false, isEditMode = false,
isUpdating = false,
updateAttempted = false,
loadingProgress = 0, loadingProgress = 0,
estimatedTimeLeft = '', estimatedTimeLeft = '',
newDlcsCount = 0,
}: DlcSelectionDialogProps) => { }: DlcSelectionDialogProps) => {
// State for DLC management // State for DLC management
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([]) const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
@@ -169,13 +180,13 @@ const DlcSelectionDialog = ({
</div> </div>
</div> </div>
{isLoading && loadingProgress > 0 && ( {(isLoading || isUpdating) && loadingProgress > 0 && (
<div className="dlc-loading-progress"> <div className="dlc-loading-progress">
<div className="progress-bar-container"> <div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} /> <div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div> </div>
<div className="loading-details"> <div className="loading-details">
<span>Loading DLCs: {loadingProgress}%</span> <span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
{estimatedTimeLeft && ( {estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span> <span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)} )}
@@ -211,15 +222,47 @@ const DlcSelectionDialog = ({
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
{/* Show update results */}
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
<>
{newDlcsCount > 0 && (
<div className="dlc-update-results dlc-update-success">
<span className="update-message">
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
</span>
</div>
)}
{newDlcsCount === 0 && (
<div className="dlc-update-results dlc-update-info">
<span className="update-message">
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
</span>
</div>
)}
</>
)}
<DialogActions> <DialogActions>
<Button <Button
variant="secondary" variant="secondary"
onClick={onClose} onClick={onClose}
disabled={isLoading && loadingProgress < 10} disabled={(isLoading || isUpdating) && loadingProgress < 10}
> >
Cancel Cancel
</Button> </Button>
<Button variant="primary" onClick={handleConfirm} disabled={isLoading}>
{/* Update button - only show in edit mode */}
{isEditMode && onUpdate && (
<Button
variant="warning"
onClick={() => onUpdate(gameId)}
disabled={isLoading || isUpdating}
>
{isUpdating ? 'Updating...' : 'Update DLC List'}
</Button>
)}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
{actionButtonText} {actionButtonText}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -228,4 +271,4 @@ const DlcSelectionDialog = ({
) )
} }
export default DlcSelectionDialog export default DlcSelectionDialog

View File

@@ -0,0 +1,56 @@
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 ReminderDialogProps {
visible: boolean
onClose: () => void
}
/**
* Reminder Dialog component
* Reminds users to remove Steam launch options after removing CreamLinux
*/
const ReminderDialog: React.FC<ReminderDialogProps> = ({ visible, onClose }) => {
return (
<Dialog visible={visible} onClose={onClose} size="small">
<DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="reminder-dialog-header">
<Icon name={info} variant="solid" size="lg" />
<h3>Reminder</h3>
</div>
</DialogHeader>
<DialogBody>
<div className="reminder-dialog-body">
<p>
If you added a Steam launch option for CreamLinux, remember to remove it in Steam:
</p>
<ol className="reminder-steps">
<li>Right-click the game in Steam</li>
<li>Select "Properties"</li>
<li>Go to "Launch Options"</li>
<li>Remove the CreamLinux command</li>
</ol>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="primary" onClick={onClose}>
Got it
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ReminderDialog

View File

@@ -41,7 +41,7 @@ const SettingsDialog: React.FC<SettingsDialogProps> = ({ visible, onClose }) =>
<Dialog visible={visible} onClose={onClose} size="medium"> <Dialog visible={visible} onClose={onClose} size="medium">
<DialogHeader onClose={onClose} hideCloseButton={true}> <DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="settings-header"> <div className="settings-header">
<Icon name={settings} variant="solid" size="md" /> {/*<Icon name={settings} variant="solid" size="md" />*/}
<h3>Settings</h3> <h3>Settings</h3>
</div> </div>
</DialogHeader> </DialogHeader>

View File

@@ -0,0 +1,228 @@
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'
//import { Icon, settings } from '@/components/icons'
interface SmokeAPIConfig {
$schema: string
$version: number
logging: boolean
log_steam_http: boolean
default_app_status: 'unlocked' | 'locked' | 'original'
override_app_status: Record<string, string>
override_dlc_status: Record<string, string>
auto_inject_inventory: boolean
extra_inventory_items: number[]
extra_dlcs: Record<string, unknown>
}
interface SmokeAPISettingsDialogProps {
visible: boolean
onClose: () => void
gamePath: string
gameTitle: string
}
const DEFAULT_CONFIG: SmokeAPIConfig = {
$schema:
'https://raw.githubusercontent.com/acidicoala/SmokeAPI/refs/tags/v4.0.0/res/SmokeAPI.schema.json',
$version: 4,
logging: false,
log_steam_http: false,
default_app_status: 'unlocked',
override_app_status: {},
override_dlc_status: {},
auto_inject_inventory: true,
extra_inventory_items: [],
extra_dlcs: {},
}
const APP_STATUS_OPTIONS: DropdownOption<'unlocked' | 'locked' | 'original'>[] = [
{ value: 'unlocked', label: 'Unlocked' },
{ value: 'locked', label: 'Locked' },
{ value: 'original', label: 'Original' },
]
/**
* SmokeAPI Settings Dialog
* Allows configuration of SmokeAPI for a specific game
*/
const SmokeAPISettingsDialog = ({
visible,
onClose,
gamePath,
gameTitle,
}: SmokeAPISettingsDialogProps) => {
const [enabled, setEnabled] = useState(false)
const [config, setConfig] = useState<SmokeAPIConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
// Load existing config when dialog opens
const loadConfig = useCallback(async () => {
setIsLoading(true)
try {
const existingConfig = await invoke<SmokeAPIConfig | null>('read_smokeapi_config', {
gamePath,
})
if (existingConfig) {
setConfig(existingConfig)
setEnabled(true)
} else {
setConfig(DEFAULT_CONFIG)
setEnabled(false)
}
setHasChanges(false)
} catch (error) {
console.error('Failed to load SmokeAPI 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) {
// Save the config
await invoke('write_smokeapi_config', {
gamePath,
config,
})
} else {
// Delete the config
await invoke('delete_smokeapi_config', {
gamePath,
})
}
setHasChanges(false)
onClose()
} catch (error) {
console.error('Failed to save SmokeAPI config:', error)
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
setHasChanges(false)
onClose()
}
const updateConfig = <K extends keyof SmokeAPIConfig>(key: K, value: SmokeAPIConfig[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">
{/*<Icon name={settings} variant="solid" size="md" />*/}
<h3>SmokeAPI Settings</h3>
</div>
<p className="dialog-subtitle">{gameTitle}</p>
</DialogHeader>
<DialogBody>
<div className="smokeapi-settings-content">
{/* Enable/Disable Section */}
<div className="settings-section">
<AnimatedCheckbox
checked={enabled}
onChange={() => {
setEnabled(!enabled)
setHasChanges(true)
}}
label="Enable SmokeAPI Configuration"
sublabel="Enable this to customize SmokeAPI settings for this game"
/>
</div>
{/* Settings Options */}
<div className={`settings-options ${!enabled ? 'disabled' : ''}`}>
<div className="settings-section">
<h4>General Settings</h4>
<Dropdown
label="Default App Status"
description="Specifies the default DLC status"
value={config.default_app_status}
options={APP_STATUS_OPTIONS}
onChange={(value) => updateConfig('default_app_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 SmokeAPI.log.log file"
/>
</div>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.log_steam_http}
onChange={() => updateConfig('log_steam_http', !config.log_steam_http)}
label="Log Steam HTTP"
sublabel="Toggles logging of SteamHTTP traffic"
/>
</div>
</div>
<div className="settings-section">
<h4>Inventory</h4>
<div className="checkbox-option">
<AnimatedCheckbox
checked={config.auto_inject_inventory}
onChange={() =>
updateConfig('auto_inject_inventory', !config.auto_inject_inventory)
}
label="Auto Inject Inventory"
sublabel="Automatically inject a list of all registered inventory items when the game queries user inventory"
/>
</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 SmokeAPISettingsDialog

View File

@@ -7,6 +7,9 @@ export { default as DialogActions } from './DialogActions'
export { default as ProgressDialog } from './ProgressDialog' export { default as ProgressDialog } from './ProgressDialog'
export { default as DlcSelectionDialog } from './DlcSelectionDialog' export { default as DlcSelectionDialog } from './DlcSelectionDialog'
export { default as SettingsDialog } from './SettingsDialog' export { default as SettingsDialog } from './SettingsDialog'
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
export { default as ConflictDialog } from './ConflictDialog'
export { default as DisclaimerDialog } from './DisclaimerDialog'
// Export types // Export types
export type { DialogProps } from './Dialog' export type { DialogProps } from './Dialog'
@@ -16,3 +19,4 @@ export type { DialogFooterProps } from './DialogFooter'
export type { DialogActionsProps } from './DialogActions' export type { DialogActionsProps } from './DialogActions'
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog' export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
export type { DlcSelectionDialogProps } from './DlcSelectionDialog' export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
export type { ConflictDialogProps, Conflict } from './ConflictDialog'

View File

@@ -8,13 +8,14 @@ interface GameItemProps {
game: Game game: Game
onAction: (gameId: string, action: ActionType) => Promise<void> onAction: (gameId: string, action: ActionType) => Promise<void>
onEdit?: (gameId: string) => void onEdit?: (gameId: string) => void
onSmokeAPISettings?: (gameId: string) => void
} }
/** /**
* Individual game card component * Individual game card component
* Displays game information and action buttons * Displays game information and action buttons
*/ */
const GameItem = ({ game, onAction, onEdit }: GameItemProps) => { const GameItem = ({ game, onAction, onEdit, onSmokeAPISettings }: GameItemProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null) const [imageUrl, setImageUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
@@ -77,6 +78,13 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
} }
} }
// SmokeAPI settings handler
const handleSmokeAPISettings = () => {
if (onSmokeAPISettings && game.smoke_installed) {
onSmokeAPISettings(game.id)
}
}
// Determine background image // Determine background image
const backgroundImage = const backgroundImage =
!isLoading && imageUrl !isLoading && imageUrl
@@ -156,6 +164,20 @@ const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
iconOnly iconOnly
/> />
)} )}
{/* Edit button - only enabled if SmokeAPI is installed */}
{game.smoke_installed && (
<Button
variant="secondary"
size="small"
onClick={handleSmokeAPISettings}
disabled={!game.smoke_installed || !!game.installing}
title="Configure SmokeAPI"
className="edit-button settings-icon-button"
leftIcon={<Icon name="Settings" variant="solid" size="md" />}
iconOnly
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,6 +1,7 @@
import { createContext } from 'react' import { createContext } from 'react'
import { Game, DlcInfo } from '@/types' import { Game, DlcInfo } from '@/types'
import { ActionType } from '@/components/buttons/ActionButton' import { ActionType } from '@/components/buttons/ActionButton'
import { DlcDialogState } from '@/hooks/useDlcManager'
// Types for context sub-components // Types for context sub-components
export interface InstallationInstructions { export interface InstallationInstructions {
@@ -10,17 +11,6 @@ export interface InstallationInstructions {
dlc_count?: number dlc_count?: number
} }
export interface DlcDialogState {
visible: boolean
gameId: string
gameTitle: string
dlcs: DlcInfo[]
isLoading: boolean
isEditMode: boolean
progress: number
timeLeft?: string
}
export interface ProgressDialogState { export interface ProgressDialogState {
visible: boolean visible: boolean
title: string title: string
@@ -30,6 +20,12 @@ export interface ProgressDialogState {
instructions?: InstallationInstructions instructions?: InstallationInstructions
} }
export interface SmokeAPISettingsDialogState {
visible: boolean
gamePath: string
gameTitle: string
}
// Define the context type // Define the context type
export interface AppContextType { export interface AppContextType {
// Game state // Game state
@@ -43,6 +39,7 @@ export interface AppContextType {
dlcDialog: DlcDialogState dlcDialog: DlcDialogState
handleGameEdit: (gameId: string) => void handleGameEdit: (gameId: string) => void
handleDlcDialogClose: () => void handleDlcDialogClose: () => void
handleUpdateDlcs: (gameId: string) => Promise<void>
// Game actions // Game actions
progressDialog: ProgressDialogState progressDialog: ProgressDialogState
@@ -54,6 +51,11 @@ export interface AppContextType {
handleSettingsOpen: () => void handleSettingsOpen: () => void
handleSettingsClose: () => void handleSettingsClose: () => void
// SmokeAPI settings
smokeAPISettingsDialog: SmokeAPISettingsDialogState
handleSmokeAPISettingsOpen: (gameId: string) => void
handleSmokeAPISettingsClose: () => void
// Toast notifications // Toast notifications
showToast: ( showToast: (
message: string, message: string,
@@ -63,4 +65,4 @@ export interface AppContextType {
} }
// Create the context with a default value // Create the context with a default value
export const AppContext = createContext<AppContextType | undefined>(undefined) export const AppContext = createContext<AppContextType | undefined>(undefined)

View File

@@ -4,6 +4,7 @@ import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks'
import { DlcInfo } from '@/types' import { DlcInfo } 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 } from '@/components/dialogs'
// Context provider component // Context provider component
interface AppProviderProps { interface AppProviderProps {
@@ -24,6 +25,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleDlcDialogClose: closeDlcDialog, handleDlcDialogClose: closeDlcDialog,
streamGameDlcs, streamGameDlcs,
handleGameEdit, handleGameEdit,
handleUpdateDlcs,
} = useDlcManager() } = useDlcManager()
const { const {
@@ -38,6 +40,17 @@ export const AppProvider = ({ children }: AppProviderProps) => {
// Settings dialog state // Settings dialog state
const [settingsDialog, setSettingsDialog] = useState({ visible: false }) const [settingsDialog, setSettingsDialog] = useState({ visible: false })
// SmokeAPI settings dialog state
const [smokeAPISettingsDialog, setSmokeAPISettingsDialog] = useState<{
visible: boolean
gamePath: string
gameTitle: string
}>({
visible: false,
gamePath: '',
gameTitle: '',
})
// Settings handlers // Settings handlers
const handleSettingsOpen = () => { const handleSettingsOpen = () => {
setSettingsDialog({ visible: true }) setSettingsDialog({ visible: true })
@@ -47,6 +60,25 @@ export const AppProvider = ({ children }: AppProviderProps) => {
setSettingsDialog({ visible: false }) setSettingsDialog({ visible: false })
} }
// SmokeAPI settings handlers
const handleSmokeAPISettingsOpen = (gameId: string) => {
const game = games.find((g) => g.id === gameId)
if (!game) {
showError('Game not found')
return
}
setSmokeAPISettingsDialog({
visible: true,
gamePath: game.path,
gameTitle: game.title,
})
}
const handleSmokeAPISettingsClose = () => {
setSmokeAPISettingsDialog((prev) => ({ ...prev, visible: false }))
}
// Game action handler with proper error reporting // Game action handler with proper error reporting
const handleGameAction = async (gameId: string, action: ActionType) => { const handleGameAction = async (gameId: string, action: ActionType) => {
const game = games.find((g) => g.id === gameId) const game = games.find((g) => g.id === gameId)
@@ -189,6 +221,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleGameEdit(gameId, games) handleGameEdit(gameId, games)
}, },
handleDlcDialogClose: closeDlcDialog, handleDlcDialogClose: closeDlcDialog,
handleUpdateDlcs: (gameId: string) => handleUpdateDlcs(gameId),
// Game actions // Game actions
progressDialog, progressDialog,
@@ -201,6 +234,11 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleSettingsOpen, handleSettingsOpen,
handleSettingsClose, handleSettingsClose,
// SmokeAPI Settings
smokeAPISettingsDialog,
handleSmokeAPISettingsOpen,
handleSmokeAPISettingsClose,
// Toast notifications // Toast notifications
showToast, showToast,
} }
@@ -209,6 +247,14 @@ export const AppProvider = ({ children }: AppProviderProps) => {
<AppContext.Provider value={contextValue}> <AppContext.Provider value={contextValue}>
{children} {children}
<ToastContainer toasts={toasts} onDismiss={removeToast} /> <ToastContainer toasts={toasts} onDismiss={removeToast} />
{/* SmokeAPI Settings Dialog */}
<SmokeAPISettingsDialog
visible={smokeAPISettingsDialog.visible}
onClose={handleSmokeAPISettingsClose}
gamePath={smokeAPISettingsDialog.gamePath}
gameTitle={smokeAPISettingsDialog.gameTitle}
/>
</AppContext.Provider> </AppContext.Provider>
) )
} }

View File

@@ -4,7 +4,10 @@ export { useDlcManager } from './useDlcManager'
export { useGameActions } from './useGameActions' export { useGameActions } from './useGameActions'
export { useToasts } from './useToasts' export { useToasts } from './useToasts'
export { useAppLogic } from './useAppLogic' export { useAppLogic } from './useAppLogic'
export { useConflictDetection } from './useConflictDetection'
export { useDisclaimer } from './useDisclaimer'
// Export types // Export types
export type { ToastType, Toast, ToastOptions } from './useToasts' export type { ToastType, Toast, ToastOptions } from './useToasts'
export type { DlcDialogState } from './useDlcManager' export type { DlcDialogState } from './useDlcManager'
export type { Conflict, ConflictResolution } from './useConflictDetection'

View File

@@ -0,0 +1,102 @@
import { useState, useEffect, useCallback } from 'react'
import { Game } from '@/types'
export interface Conflict {
gameId: string
gameTitle: string
type: 'cream-to-proton' | 'smoke-to-native'
}
export interface ConflictResolution {
gameId: string
conflictType: 'cream-to-proton' | 'smoke-to-native'
}
/**
* Hook for detecting platform conflicts
* Identifies when unlocker files exist for the wrong platform
*/
export function useConflictDetection(games: Game[]) {
const [conflicts, setConflicts] = useState<Conflict[]>([])
const [showDialog, setShowDialog] = useState(false)
const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set())
const [hasShownThisSession, setHasShownThisSession] = useState(false)
// Detect conflicts whenever games change
useEffect(() => {
const detectedConflicts: Conflict[] = []
games.forEach((game) => {
// Skip if we've already resolved a conflict for this game
if (resolvedConflicts.has(game.id)) {
return
}
// Conflict 1: CreamLinux installed but game is now Proton
if (!game.native && game.cream_installed) {
detectedConflicts.push({
gameId: game.id,
gameTitle: game.title,
type: 'cream-to-proton',
})
}
// Conflict 2: SmokeAPI installed but game is now Native
if (game.native && game.smoke_installed) {
detectedConflicts.push({
gameId: game.id,
gameTitle: game.title,
type: 'smoke-to-native',
})
}
})
setConflicts(detectedConflicts)
// 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, resolvedConflicts, showDialog, hasShownThisSession])
// 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))
// Remove from conflicts list
setConflicts((prev) => prev.filter((c) => c.gameId !== gameId))
return {
gameId,
conflictType,
}
},
[]
)
// Auto-close dialog when all conflicts are resolved
useEffect(() => {
if (conflicts.length === 0 && showDialog) {
setShowDialog(false)
}
}, [conflicts.length, showDialog])
// Handle dialog close
const closeDialog = useCallback(() => {
setShowDialog(false)
}, [])
return {
conflicts,
showDialog,
resolveConflict,
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

@@ -11,10 +11,13 @@ export interface DlcDialogState {
enabledDlcs: string[] enabledDlcs: string[]
isLoading: boolean isLoading: boolean
isEditMode: boolean isEditMode: boolean
isUpdating: boolean
updateAttempted: boolean
progress: number progress: number
progressMessage: string progressMessage: string
timeLeft: string timeLeft: string
error: string | null error: string | null
newDlcsCount: number
} }
/** /**
@@ -36,10 +39,13 @@ export function useDlcManager() {
enabledDlcs: [], enabledDlcs: [],
isLoading: false, isLoading: false,
isEditMode: false, isEditMode: false,
isUpdating: false,
updateAttempted: false,
progress: 0, progress: 0,
progressMessage: '', progressMessage: '',
timeLeft: '', timeLeft: '',
error: null, error: null,
newDlcsCount: 0,
}) })
// Set up event listeners for DLC streaming // Set up event listeners for DLC streaming
@@ -80,6 +86,7 @@ export function useDlcManager() {
setDlcDialog((prev) => ({ setDlcDialog((prev) => ({
...prev, ...prev,
isLoading: false, isLoading: false,
isUpdating: false,
})) }))
// Reset fetch state // Reset fetch state
@@ -177,10 +184,13 @@ export function useDlcManager() {
enabledDlcs: [], enabledDlcs: [],
isLoading: true, isLoading: true,
isEditMode: true, isEditMode: true,
isUpdating: false,
updateAttempted: false,
progress: 0, progress: 0,
progressMessage: 'Reading DLC configuration...', progressMessage: 'Reading DLC configuration...',
timeLeft: '', timeLeft: '',
error: null, error: null,
newDlcsCount: 0,
}) })
// Always get a fresh copy from the config file // Always get a fresh copy from the config file
@@ -302,6 +312,58 @@ export function useDlcManager() {
} }
}, [dlcDialog.dlcs, dlcDialog.enabledDlcs]) }, [dlcDialog.dlcs, dlcDialog.enabledDlcs])
// Function to update DLC list (refetch from Steam API)
const handleUpdateDlcs = async (gameId: string) => {
try {
// Store current app IDs to identify new DLCs later
const currentAppIds = new Set(dlcDialog.dlcs.map((dlc) => dlc.appid))
// Set updating state and clear DLCs
setDlcDialog((prev) => ({
...prev,
isUpdating: true,
isLoading: true,
updateAttempted: true,
progress: 0,
progressMessage: 'Checking for new DLCs...',
newDlcsCount: 0,
dlcs: [], // Clear current DLCs to start fresh
}))
// Mark that we're fetching DLCs for this game
setIsFetchingDlcs(true)
activeDlcFetchId.current = gameId
// Start streaming DLCs
await streamGameDlcs(gameId)
// After streaming completes, calculate new DLCs
// Wait a bit longer to ensure all DLCs have been added
setTimeout(() => {
setDlcDialog((prev) => {
// Count how many DLCs are new (not in the original list)
const actualNewCount = prev.dlcs.filter(dlc => !currentAppIds.has(dlc.appid)).length
console.log(`Update complete: Found ${actualNewCount} new DLCs out of ${prev.dlcs.length} total`)
return {
...prev,
newDlcsCount: actualNewCount,
}
})
}, 1500) // Increased timeout to ensure all DLCs are processed
} catch (error) {
console.error('Error updating DLCs:', error)
setDlcDialog((prev) => ({
...prev,
error: `Failed to update DLCs: ${error}`,
isLoading: false,
isUpdating: false,
}))
}
}
return { return {
dlcDialog, dlcDialog,
setDlcDialog, setDlcDialog,
@@ -309,6 +371,7 @@ export function useDlcManager() {
streamGameDlcs, streamGameDlcs,
handleGameEdit, handleGameEdit,
handleDlcDialogClose, handleDlcDialogClose,
handleUpdateDlcs,
forceReload, forceReload,
} }
} }

View File

@@ -0,0 +1,127 @@
@use '../../themes/index' as *;
@use '../../abstracts/index' as *;
/*
Dropdown component styles
*/
.dropdown-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.dropdown-label-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dropdown-label {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
}
.dropdown-description {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
margin: 0;
}
.dropdown {
position: relative;
width: 100%;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.dropdown-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--border-dark);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
color: var(--text-primary);
cursor: pointer;
transition: all var(--duration-normal) var(--easing-ease-out);
&:hover:not(:disabled) {
border-color: var(--border);
background-color: rgba(255, 255, 255, 0.05);
}
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.2);
}
&:disabled {
cursor: not-allowed;
}
}
.dropdown-value {
flex: 1;
text-align: left;
font-size: 0.9rem;
}
.dropdown-icon {
transition: transform var(--duration-normal) var(--easing-ease-out);
color: var(--text-secondary);
transform: rotate(180deg);
&.open {
transform: rotate(0deg);
}
}
.dropdown-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
background-color: var(--elevated-bg);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
z-index: var(--z-modal);
max-height: 200px;
overflow-y: auto;
@include custom-scrollbar;
}
.dropdown-option {
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
color: var(--text-primary);
text-align: left;
cursor: pointer;
transition: all var(--duration-normal) var(--easing-ease-out);
font-size: 0.9rem;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&.selected {
background-color: rgba(var(--primary-color), 0.2);
color: var(--primary-color);
}
&:not(:last-child) {
border-bottom: 1px solid var(--border-soft);
}
}

View File

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

View File

@@ -0,0 +1,143 @@
@use '../../themes/index' as *;
@use '../../abstracts/index' as *;
/*
Conflict Dialog Styles
Used for platform conflict detection dialogs
*/
.conflict-dialog-header {
display: flex;
align-items: center;
gap: 0.75rem;
h3 {
margin: 0;
flex: 1;
font-size: 1.1rem;
color: var(--text-primary);
}
svg {
color: var(--warning);
flex-shrink: 0;
}
}
.conflict-dialog-body {
display: flex;
flex-direction: column;
gap: 1rem;
.conflict-intro {
margin: 0;
color: var(--text-secondary);
line-height: 1.5;
}
.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);
}
}
.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);
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;
}
}
.conflict-reminder {
display: flex;
align-items: center;
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;
}
span {
flex: 1;
}
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

@@ -154,6 +154,40 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
// Update results message
.dlc-update-results {
padding: 0.75rem 1.5rem;
background-color: var(--elevated-bg);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
margin-bottom: 0.75rem;
.update-message {
color: var(--text-primary);
font-weight: 600;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
&.dlc-update-success {
.update-message {
.dlc-update-icon-success {
color: var(--success);
}
}
}
&.dlc-update-info {
.update-message {
.dlc-update-icon-info {
color: var(--info);
}
}
}
}
// Game information in DLC dialog // Game information in DLC dialog
.dlc-game-info { .dlc-game-info {
display: flex; display: flex;

View File

@@ -2,3 +2,6 @@
@forward './dlc_dialog'; @forward './dlc_dialog';
@forward './progress_dialog'; @forward './progress_dialog';
@forward './settings_dialog'; @forward './settings_dialog';
@forward './smokeapi_settings_dialog';
@forward './conflict_dialog';
@forward './disclaimer_dialog';

View File

@@ -18,8 +18,8 @@
.settings-section { .settings-section {
h4 { h4 {
font-size: 1.1rem; font-size: 1.2rem;
font-weight: 600; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;

View File

@@ -0,0 +1,66 @@
@use '../../themes/index' as *;
@use '../../abstracts/index' as *;
/*
SmokeAPI Settings Dialog styles
*/
.dialog-subtitle {
color: var(--text-secondary);
font-weight: 500;
margin-top: 0.25rem;
font-weight: normal;
}
.smokeapi-settings-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.settings-options {
display: flex;
flex-direction: column;
gap: 1.5rem;
transition: opacity var(--duration-normal) var(--easing-ease-out);
&.disabled {
opacity: 0.4;
pointer-events: none;
}
}
.settings-section {
display: flex;
flex-direction: column;
gap: 1rem;
h4 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-soft);
}
}
.checkbox-option {
padding: 0.5rem 0;
&:not(:last-child) {
border-bottom: 1px solid var(--border-soft);
}
.animated-checkbox {
width: 100%;
.checkbox-content {
flex: 1;
}
.checkbox-sublabel {
margin-top: 0.25rem;
}
}
}

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 './Game'
export * from './DlcInfo' export * from './DlcInfo'
export * from './Config'