5 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
11 changed files with 224 additions and 156 deletions

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,12 @@
## [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 ## [1.3.4] - 03-01-2026
### Added ### Added

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "creamlinux", "name": "creamlinux",
"version": "1.3.4", "version": "1.3.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "creamlinux", "name": "creamlinux",
"version": "1.3.4", "version": "1.3.5",
"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.4", "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",

View File

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

View File

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

View File

@@ -20,7 +20,6 @@ import {
DlcSelectionDialog, DlcSelectionDialog,
SettingsDialog, SettingsDialog,
ConflictDialog, ConflictDialog,
ReminderDialog,
DisclaimerDialog, DisclaimerDialog,
} from '@/components/dialogs' } from '@/components/dialogs'
@@ -68,23 +67,28 @@ function App() {
} = useAppContext() } = useAppContext()
// Conflict detection // Conflict detection
const { currentConflict, showReminder, resolveConflict, closeReminder } = const { conflicts, showDialog, resolveConflict, closeDialog } =
useConflictDetection(games) useConflictDetection(games)
// Handle conflict resolution // Handle conflict resolution
const handleConflictResolve = async () => { const handleConflictResolve = async (
const resolution = resolveConflict() gameId: string,
if (!resolution) return conflictType: 'cream-to-proton' | 'smoke-to-native'
) => {
// Always remove files - use the special conflict resolution command
try { try {
// Invoke backend to resolve the conflict
await invoke('resolve_platform_conflict', { await invoke('resolve_platform_conflict', {
gameId: resolution.gameId, gameId,
conflictType: resolution.conflictType, conflictType,
}) })
// Remove from UI
resolveConflict(gameId, conflictType)
showToast('Conflict resolved successfully', 'success')
} catch (error) { } catch (error) {
console.error('Error resolving conflict:', error) console.error('Error resolving conflict:', error)
showToast(`Failed to resolve conflict: ${error}`, 'error') showToast('Failed to resolve conflict', 'error')
} }
} }
@@ -171,17 +175,12 @@ function App() {
<SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} /> <SettingsDialog visible={settingsDialog.visible} onClose={handleSettingsClose} />
{/* Conflict Detection Dialog */} {/* Conflict Detection Dialog */}
{currentConflict && ( <ConflictDialog
<ConflictDialog visible={showDialog}
visible={true} conflicts={conflicts}
gameTitle={currentConflict.gameTitle} onResolve={handleConflictResolve}
conflictType={currentConflict.type} onClose={closeDialog}
onConfirm={handleConflictResolve} />
/>
)}
{/* Steam Launch Options Reminder */}
<ReminderDialog visible={showReminder} onClose={closeReminder} />
{/* Disclaimer Dialog - Shows AFTER everything is loaded */} {/* Disclaimer Dialog - Shows AFTER everything is loaded */}
<DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} /> <DisclaimerDialog visible={showDisclaimer} onClose={handleDisclaimerClose} />

View File

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

View File

@@ -9,7 +9,6 @@ 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 ConflictDialog } from './ConflictDialog'
export { default as ReminderDialog } from './ReminderDialog'
export { default as DisclaimerDialog } from './DisclaimerDialog' export { default as DisclaimerDialog } from './DisclaimerDialog'
// Export types // Export types
@@ -20,5 +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 } from './ConflictDialog' export type { ConflictDialogProps, Conflict } from './ConflictDialog'
export type { ReminderDialogProps } from './ReminderDialog'

View File

@@ -9,7 +9,6 @@ export interface Conflict {
export interface ConflictResolution { export interface ConflictResolution {
gameId: string gameId: string
removeFiles: boolean
conflictType: 'cream-to-proton' | 'smoke-to-native' conflictType: 'cream-to-proton' | 'smoke-to-native'
} }
@@ -19,10 +18,9 @@ export interface ConflictResolution {
*/ */
export function useConflictDetection(games: Game[]) { export function useConflictDetection(games: Game[]) {
const [conflicts, setConflicts] = useState<Conflict[]>([]) const [conflicts, setConflicts] = useState<Conflict[]>([])
const [currentConflict, setCurrentConflict] = useState<Conflict | null>(null) const [showDialog, setShowDialog] = useState(false)
const [showReminder, setShowReminder] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set()) const [resolvedConflicts, setResolvedConflicts] = useState<Set<string>>(new Set())
const [hasShownThisSession, setHasShownThisSession] = useState(false)
// Detect conflicts whenever games change // Detect conflicts whenever games change
useEffect(() => { useEffect(() => {
@@ -55,69 +53,50 @@ export function useConflictDetection(games: Game[]) {
setConflicts(detectedConflicts) setConflicts(detectedConflicts)
// Show the first conflict if we have any and not currently processing // Show dialog only if:
if (detectedConflicts.length > 0 && !currentConflict && !isProcessing) { // 1. We have conflicts
setCurrentConflict(detectedConflicts[0]) // 2. Dialog isn't already visible
// 3. We haven't shown it this session
if (detectedConflicts.length > 0 && !showDialog && !hasShownThisSession) {
setShowDialog(true)
setHasShownThisSession(true)
} }
}, [games, currentConflict, isProcessing, resolvedConflicts]) }, [games, resolvedConflicts, showDialog, hasShownThisSession])
// Handle conflict resolution // Handle resolving a single conflict
const resolveConflict = useCallback((): ConflictResolution | null => { const resolveConflict = useCallback(
if (!currentConflict || isProcessing) return null (gameId: string, conflictType: 'cream-to-proton' | 'smoke-to-native'): ConflictResolution => {
// Mark this game as resolved
setResolvedConflicts((prev) => new Set(prev).add(gameId))
setIsProcessing(true) // Remove from conflicts list
setConflicts((prev) => prev.filter((c) => c.gameId !== gameId))
const resolution: ConflictResolution = { return {
gameId: currentConflict.gameId, gameId,
removeFiles: true, // Always remove files conflictType,
conflictType: currentConflict.type, }
},
[]
)
// Auto-close dialog when all conflicts are resolved
useEffect(() => {
if (conflicts.length === 0 && showDialog) {
setShowDialog(false)
} }
}, [conflicts.length, showDialog])
// Mark this game as resolved so we don't re-detect the conflict // Handle dialog close
setResolvedConflicts((prev) => new Set(prev).add(currentConflict.gameId)) const closeDialog = useCallback(() => {
setShowDialog(false)
// 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 { return {
currentConflict, conflicts,
showReminder, showDialog,
resolveConflict, resolveConflict,
closeReminder, closeDialog,
hasConflicts: conflicts.length > 0, hasConflicts: conflicts.length > 0,
} }
} }

View File

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