9 Commits

Author SHA1 Message Date
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
21 changed files with 760 additions and 43 deletions

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ mod searcher;
mod unlockers; mod unlockers;
mod smokeapi_config; mod smokeapi_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};
@@ -456,6 +457,146 @@ fn delete_smokeapi_config(game_path: String) -> Result<(), String> {
smokeapi_config::delete_config(&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;
@@ -516,6 +657,7 @@ fn main() {
read_smokeapi_config, read_smokeapi_config,
write_smokeapi_config, write_smokeapi_config,
delete_smokeapi_config, delete_smokeapi_config,
resolve_platform_conflict,
]) ])
.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

@@ -19,7 +19,7 @@
}, },
"productName": "Creamlinux", "productName": "Creamlinux",
"mainBinaryName": "creamlinux", "mainBinaryName": "creamlinux",
"version": "1.3.0", "version": "1.3.3",
"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 } 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,
ReminderDialog,
} from '@/components/dialogs'
// Game components // Game components
import { GameList } from '@/components/games' import { GameList } from '@/components/games'
@@ -17,6 +31,7 @@ import { GameList } from '@/components/games'
*/ */
function App() { function App() {
const [updateComplete, setUpdateComplete] = useState(false) const [updateComplete, setUpdateComplete] = useState(false)
// Get application logic from hook // Get application logic from hook
const { const {
filter, filter,
@@ -33,6 +48,7 @@ function App() {
// Get action handlers from context // Get action handlers from context
const { const {
games,
dlcDialog, dlcDialog,
handleDlcDialogClose, handleDlcDialogClose,
handleProgressDialogClose, handleProgressDialogClose,
@@ -40,12 +56,35 @@ function App() {
handleGameAction, handleGameAction,
handleDlcConfirm, handleDlcConfirm,
handleGameEdit, handleGameEdit,
handleUpdateDlcs,
settingsDialog, settingsDialog,
handleSettingsOpen, handleSettingsOpen,
handleSettingsClose, handleSettingsClose,
handleSmokeAPISettingsOpen, handleSmokeAPISettingsOpen,
showToast,
} = useAppContext() } = useAppContext()
// Conflict detection
const { currentConflict, showReminder, resolveConflict, closeReminder } =
useConflictDetection(games)
// Handle conflict resolution
const handleConflictResolve = async () => {
const resolution = resolveConflict()
if (!resolution) return
// Always remove files - use the special conflict resolution command
try {
await invoke('resolve_platform_conflict', {
gameId: resolution.gameId,
conflictType: resolution.conflictType,
})
} catch (error) {
console.error('Error resolving conflict:', error)
showToast(`Failed to resolve conflict: ${error}`, 'error')
}
}
// Show update screen first // Show update screen first
if (!updateComplete) { if (!updateComplete) {
return <UpdateScreen onComplete={() => setUpdateComplete(true)} /> return <UpdateScreen onComplete={() => setUpdateComplete(true)} />
@@ -72,7 +111,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 ? (
@@ -107,20 +150,35 @@ 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 */}
/> {currentConflict && (
<ConflictDialog
visible={true}
gameTitle={currentConflict.gameTitle}
conflictType={currentConflict.type}
onConfirm={handleConflictResolve}
/>
)}
{/* Steam Launch Options Reminder */}
<ReminderDialog visible={showReminder} onClose={closeReminder} />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
) )

View File

@@ -0,0 +1,77 @@
import React from 'react'
import {
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
DialogActions,
} from '@/components/dialogs'
import { Button } from '@/components/buttons'
import { Icon, warning } from '@/components/icons'
export interface ConflictDialogProps {
visible: boolean
gameTitle: string
conflictType: 'cream-to-proton' | 'smoke-to-native'
onConfirm: () => void
}
/**
* Conflict Dialog component
* Shows when incompatible unlocker files are detected after platform switch
*/
const ConflictDialog: React.FC<ConflictDialogProps> = ({
visible,
gameTitle,
conflictType,
onConfirm,
}) => {
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.',
}
} 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.',
}
}
}
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>
</div>
</DialogHeader>
<DialogBody>
<div className="conflict-dialog-body">
<p>
{message.bodyPrefix}
<strong>{gameTitle}</strong>
{message.bodySuffix}
</p>
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="primary" onClick={onConfirm}>
OK
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ConflictDialog

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

@@ -8,6 +8,8 @@ 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 SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
export { default as ConflictDialog } from './ConflictDialog'
export { default as ReminderDialog } from './ReminderDialog'
// Export types // Export types
export type { DialogProps } from './Dialog' export type { DialogProps } from './Dialog'
@@ -17,3 +19,5 @@ 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 } from './ConflictDialog'
export type { ReminderDialogProps } from './ReminderDialog'

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

@@ -25,6 +25,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
handleDlcDialogClose: closeDlcDialog, handleDlcDialogClose: closeDlcDialog,
streamGameDlcs, streamGameDlcs,
handleGameEdit, handleGameEdit,
handleUpdateDlcs,
} = useDlcManager() } = useDlcManager()
const { const {
@@ -220,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,

View File

@@ -4,7 +4,9 @@ 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 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,123 @@
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
removeFiles: boolean
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 [currentConflict, setCurrentConflict] = useState<Conflict | null>(null)
const [showReminder, setShowReminder] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set())
// 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 the first conflict if we have any and not currently processing
if (detectedConflicts.length > 0 && !currentConflict && !isProcessing) {
setCurrentConflict(detectedConflicts[0])
}
}, [games, currentConflict, isProcessing, resolvedConflicts])
// Handle conflict resolution
const resolveConflict = useCallback((): ConflictResolution | null => {
if (!currentConflict || isProcessing) return null
setIsProcessing(true)
const resolution: ConflictResolution = {
gameId: currentConflict.gameId,
removeFiles: true, // Always remove files
conflictType: currentConflict.type,
}
// 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])
return {
currentConflict,
showReminder,
resolveConflict,
closeReminder,
hasConflicts: conflicts.length > 0,
}
}

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,88 @@
@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 {
p {
margin-bottom: 1rem;
color: var(--text-secondary);
line-height: 1.5;
&:last-of-type {
margin-bottom: 0;
}
strong {
color: var(--text-primary);
font-weight: var(--bold);
}
}
}
/*
Reminder Dialog Styles
Used for Steam launch option reminders
*/
.reminder-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(--info);
flex-shrink: 0;
}
}
.reminder-dialog-body {
p {
margin-bottom: 1rem;
color: var(--text-secondary);
line-height: 1.5;
}
.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;
}
}
}
}

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

@@ -3,3 +3,4 @@
@forward './progress_dialog'; @forward './progress_dialog';
@forward './settings_dialog'; @forward './settings_dialog';
@forward './smokeapi_settings_dialog'; @forward './smokeapi_settings_dialog';
@forward './conflict_dialog';

View File

@@ -10,7 +10,6 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"ignoreDeprecations": "6.0",
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",