Initial changes

This commit is contained in:
Tickbase
2025-05-18 08:06:56 +02:00
parent 19087c00da
commit 0be15f83e7
82 changed files with 4636 additions and 3237 deletions

View File

@@ -1,854 +1,114 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useAppContext } from '@/contexts/useAppContext'
import { useAppLogic } from '@/hooks'
import './styles/main.scss'
import GameList from './components/GameList'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import ProgressDialog from './components/ProgressDialog'
import DlcSelectionDialog from './components/DlcSelectionDialog'
import AnimatedBackground from './components/AnimatedBackground'
import InitialLoadingScreen from './components/InitialLoadingScreen'
import { ActionType } from './components/ActionButton'
// Game interface
interface Game {
id: string
title: string
path: string
native: boolean
platform?: string
api_files: string[]
cream_installed?: boolean
smoke_installed?: boolean
installing?: boolean
}
// Layout components
import { Header, Sidebar, InitialLoadingScreen, ErrorBoundary } from '@/components/layout'
import AnimatedBackground from '@/components/layout/AnimatedBackground'
// Interface for installation instructions
interface InstructionInfo {
type: string
command: string
game_title: string
dlc_count?: number
}
// Dialog components
import { ProgressDialog, DlcSelectionDialog } from '@/components/dialogs'
// Interface for DLC information
interface DlcInfo {
appid: string
name: string
enabled: boolean
}
// Game components
import { GameList } from '@/components/games'
/**
* Main application component
*/
function App() {
const [games, setGames] = useState<Game[]>([])
const [filter, setFilter] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isInitialLoad, setIsInitialLoad] = useState(true)
const [scanProgress, setScanProgress] = useState({
message: 'Initializing...',
progress: 0,
})
const [error, setError] = useState<string | null>(null)
const refreshInProgress = useRef(false)
const [isFetchingDlcs, setIsFetchingDlcs] = useState(false)
const dlcFetchController = useRef<AbortController | null>(null)
const activeDlcFetchId = useRef<string | null>(null)
// Progress dialog state
const [progressDialog, setProgressDialog] = useState({
visible: false,
title: '',
message: '',
progress: 0,
showInstructions: false,
instructions: undefined as InstructionInfo | undefined,
})
// DLC selection dialog state
const [dlcDialog, setDlcDialog] = useState({
visible: false,
gameId: '',
gameTitle: '',
dlcs: [] as DlcInfo[],
enabledDlcs: [] as string[],
isLoading: false,
isEditMode: false,
progress: 0,
progressMessage: '',
timeLeft: '',
error: null as string | null,
})
// Handle search query changes
const handleSearchChange = (query: string) => {
setSearchQuery(query)
}
// LoadGames function outside of the useEffect to make it reusable
const loadGames = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
console.log('Invoking scan_steam_games')
const steamGames = await invoke<Game[]>('scan_steam_games').catch((err) => {
console.error('Error from scan_steam_games:', err)
throw err
})
// Platform property to match the GameList component's expectation
const gamesWithPlatform = steamGames.map((game) => ({
...game,
platform: 'Steam',
}))
console.log(`Loaded ${gamesWithPlatform.length} games`)
setGames(gamesWithPlatform)
setIsInitialLoad(false) // Mark initial load as complete
return true
} catch (error) {
console.error('Error loading games:', error)
setError(`Failed to load games: ${error}`)
setIsInitialLoad(false) // Mark initial load as complete even on error
return false
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
// Set up event listeners first
const setupEventListeners = async () => {
try {
console.log('Setting up event listeners')
// Listen for progress updates from the backend
const unlistenProgress = await listen('installation-progress', (event) => {
console.log('Received installation-progress event:', event)
const { title, message, progress, complete, show_instructions, instructions } =
event.payload as {
title: string
message: string
progress: number
complete: boolean
show_instructions?: boolean
instructions?: InstructionInfo
}
if (complete && !show_instructions) {
// Hide dialog when complete if no instructions
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
// Only refresh games list if dialog is closing without instructions
if (!refreshInProgress.current) {
refreshInProgress.current = true
setTimeout(() => {
loadGames().then(() => {
refreshInProgress.current = false
})
}, 100)
}
}, 1000)
} else {
// Update progress dialog
setProgressDialog({
visible: true,
title,
message,
progress,
showInstructions: show_instructions || false,
instructions,
})
}
})
// Listen for scan progress events
const unlistenScanProgress = await listen('scan-progress', (event) => {
const { message, progress } = event.payload as {
message: string
progress: number
}
console.log('Received scan-progress event:', message, progress)
// Update scan progress state
setScanProgress({
message,
progress,
})
})
// Listen for individual game updates
const unlistenGameUpdated = await listen('game-updated', (event) => {
console.log('Received game-updated event:', event)
const updatedGame = event.payload as Game
// Update only the specific game in the state
setGames((prevGames) =>
prevGames.map((game) =>
game.id === updatedGame.id ? { ...updatedGame, platform: 'Steam' } : game
)
)
})
return () => {
unlistenProgress()
unlistenScanProgress()
unlistenGameUpdated()
}
} catch (error) {
console.error('Error setting up event listeners:', error)
return () => {}
}
}
// First set up event listeners, then load games
let unlisten: (() => void) | null = null
setupEventListeners()
.then((unlistenFn) => {
unlisten = unlistenFn
return loadGames()
})
.catch((error) => {
console.error('Failed to initialize:', error)
})
return () => {
if (unlisten) {
unlisten()
}
}
}, [loadGames])
// Debugging for state changes
useEffect(() => {
// Debug state changes
if (games.length > 0) {
// Count native and installed games
const nativeCount = games.filter((g) => g.native).length
const creamInstalledCount = games.filter((g) => g.cream_installed).length
const smokeInstalledCount = games.filter((g) => g.smoke_installed).length
console.log(
`Game state updated: ${games.length} total games, ${nativeCount} native, ${creamInstalledCount} with CreamLinux, ${smokeInstalledCount} with SmokeAPI`
)
// Log any games with unexpected states
const problematicGames = games.filter((g) => {
// Native games that have SmokeAPI installed (shouldn't happen)
if (g.native && g.smoke_installed) return true
// Non-native games with CreamLinux installed (shouldn't happen)
if (!g.native && g.cream_installed) return true
// Non-native games without API files but with SmokeAPI installed (shouldn't happen)
if (!g.native && (!g.api_files || g.api_files.length === 0) && g.smoke_installed)
return true
return false
})
if (problematicGames.length > 0) {
console.warn('Found games with unexpected states:', problematicGames)
}
}
}, [games])
// Set up event listeners for DLC streaming
useEffect(() => {
// Listen for individual DLC found events
const setupDlcEventListeners = async () => {
try {
// This event is emitted for each DLC as it's found
const unlistenDlcFound = await listen('dlc-found', (event) => {
const dlc = JSON.parse(event.payload as string) as { appid: string; name: string }
// Add the DLC to the current list with enabled=true
setDlcDialog((prev) => ({
...prev,
dlcs: [...prev.dlcs, { ...dlc, enabled: true }],
}))
})
// When progress is 100%, mark loading as complete and reset fetch state
const unlistenDlcProgress = await listen('dlc-progress', (event) => {
const { message, progress, timeLeft } = event.payload as {
message: string
progress: number
timeLeft?: string
}
// Update the progress indicator
setDlcDialog((prev) => ({
...prev,
progress,
progressMessage: message,
timeLeft: timeLeft || '',
}))
// If progress is 100%, mark loading as complete
if (progress === 100) {
setTimeout(() => {
setDlcDialog((prev) => ({
...prev,
isLoading: false,
}))
// Reset fetch state
setIsFetchingDlcs(false)
activeDlcFetchId.current = null
}, 500)
}
})
// This event is emitted if there's an error
const unlistenDlcError = await listen('dlc-error', (event) => {
const { error } = event.payload as { error: string }
console.error('DLC streaming error:', error)
// Show error in dialog
setDlcDialog((prev) => ({
...prev,
error,
isLoading: false,
}))
})
return () => {
unlistenDlcFound()
unlistenDlcProgress()
unlistenDlcError()
}
} catch (error) {
console.error('Error setting up DLC event listeners:', error)
return () => {}
}
}
const unlisten = setupDlcEventListeners()
return () => {
unlisten.then((fn) => fn())
}
}, [])
// Listen for scan progress events
useEffect(() => {
const listenToScanProgress = async () => {
try {
const unlistenScanProgress = await listen('scan-progress', (event) => {
const { message, progress } = event.payload as {
message: string
progress: number
}
// Update loading message
setProgressDialog((prev) => ({
...prev,
visible: true,
title: 'Scanning for Games',
message,
progress,
showInstructions: false,
instructions: undefined,
}))
// Auto-close when complete
if (progress >= 100) {
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 1500)
}
})
return unlistenScanProgress
} catch (error) {
console.error('Error setting up scan progress listener:', error)
return () => {}
}
}
const unlistenPromise = listenToScanProgress()
return () => {
unlistenPromise.then((unlisten) => unlisten())
}
}, [])
const handleCloseProgressDialog = () => {
// Just hide the dialog without refreshing game list
setProgressDialog((prev) => ({ ...prev, visible: false }))
// Only refresh if we need to (instructions didn't trigger update)
if (progressDialog.showInstructions === false && !refreshInProgress.current) {
refreshInProgress.current = true
setTimeout(() => {
loadGames().then(() => {
refreshInProgress.current = false
})
}, 100)
}
}
// Function to fetch DLCs for a game with streaming updates
const streamGameDlcs = async (gameId: string): Promise<void> => {
try {
// Set up flag to indicate we're fetching DLCs
setIsFetchingDlcs(true)
activeDlcFetchId.current = gameId
// Start streaming DLCs - this won't return DLCs directly
// Instead, it triggers events that we'll listen for
await invoke('stream_game_dlcs', { gameId })
return
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('DLC fetching was aborted')
} else {
console.error('Error starting DLC stream:', error)
throw error
}
} finally {
// Reset state when done or on error
setIsFetchingDlcs(false)
activeDlcFetchId.current = null
}
}
// Clean up if component unmounts during a fetch
useEffect(() => {
return () => {
// Clean up any ongoing fetch operations
if (dlcFetchController.current) {
dlcFetchController.current.abort()
dlcFetchController.current = null
}
}
}, [])
// Handle game edit (show DLC management dialog)
const handleGameEdit = async (gameId: string) => {
const game = games.find((g) => g.id === gameId)
if (!game || !game.cream_installed) return
// Check if we're already fetching DLCs for this game
if (isFetchingDlcs && activeDlcFetchId.current === gameId) {
console.log(`Already fetching DLCs for ${gameId}, ignoring duplicate request`)
return
}
try {
// Show dialog immediately with empty DLC list
setDlcDialog({
visible: true,
gameId,
gameTitle: game.title,
dlcs: [],
enabledDlcs: [] as string[],
isLoading: true,
isEditMode: true,
progress: 0,
progressMessage: 'Reading DLC configuration...',
timeLeft: '',
error: null,
})
// Try to read all DLCs from the configuration file first (including disabled ones)
try {
const allDlcs = await invoke<DlcInfo[]>('get_all_dlcs_command', {
gamePath: game.path,
}).catch(() => [] as DlcInfo[])
if (allDlcs.length > 0) {
// If we have DLCs from the config file, use them
console.log('Loaded existing DLC configuration:', allDlcs)
setDlcDialog((prev) => ({
...prev,
dlcs: allDlcs,
isLoading: false,
progress: 100,
progressMessage: 'Loaded existing DLC configuration',
}))
return
}
} catch (error) {
console.warn('Could not read existing DLC configuration, falling back to API:', error)
// Continue with API loading if config reading fails
}
// Mark that we're fetching DLCs for this game
setIsFetchingDlcs(true)
activeDlcFetchId.current = gameId
// Create abort controller for fetch operation
dlcFetchController.current = new AbortController()
// Start streaming DLCs
await streamGameDlcs(gameId).catch((error) => {
if (error.name !== 'AbortError') {
console.error('Error streaming DLCs:', error)
setDlcDialog((prev) => ({
...prev,
error: `Failed to load DLCs: ${error}`,
isLoading: false,
}))
}
})
// Try to get the enabled DLCs
const enabledDlcs = await invoke<string[]>('get_enabled_dlcs_command', {
gamePath: game.path,
}).catch(() => [] as string[])
// We'll update the enabled state of DLCs as they come in
setDlcDialog((prev) => ({
...prev,
enabledDlcs,
}))
} catch (error) {
console.error('Error preparing DLC edit:', error)
setDlcDialog((prev) => ({
...prev,
error: `Failed to prepare DLC editor: ${error}`,
isLoading: false,
}))
}
}
// Unified handler for all game actions (install/uninstall cream/smoke)
const handleGameAction = async (gameId: string, action: ActionType) => {
try {
// Find game to get title
const game = games.find((g) => g.id === gameId)
if (!game) return
// If we're installing CreamLinux, show DLC selection first
if (action === 'install_cream') {
try {
// Show dialog immediately with empty DLC list and loading state
setDlcDialog({
visible: true,
gameId,
gameTitle: game.title,
dlcs: [], // Start with an empty array
enabledDlcs: [] as string[],
isLoading: true,
isEditMode: false,
progress: 0,
progressMessage: 'Fetching DLC list...',
timeLeft: '',
error: null,
})
// Start streaming DLCs - only once
await streamGameDlcs(gameId).catch((error) => {
console.error('Error streaming DLCs:', error)
setDlcDialog((prev) => ({
...prev,
error: `Failed to load DLCs: ${error}`,
isLoading: false,
}))
})
} catch (error) {
console.error('Error fetching DLCs:', error)
// If DLC fetching fails, close dialog and show error
setDlcDialog((prev) => ({
...prev,
visible: false,
isLoading: false,
}))
setProgressDialog({
visible: true,
title: `Error fetching DLCs for ${game.title}`,
message: `Failed to fetch DLCs: ${error}`,
progress: 100,
showInstructions: false,
instructions: undefined,
})
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 3000)
}
return
}
// For other actions, proceed directly
// Update local state to show installation in progress
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
)
// Get title based on action
const isCream = action.includes('cream')
const isInstall = action.includes('install')
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
const operation = isInstall ? 'Installing' : 'Uninstalling'
// Show progress dialog
setProgressDialog({
visible: true,
title: `${operation} ${product} for ${game.title}`,
message: isInstall ? 'Downloading required files...' : 'Removing files...',
progress: isInstall ? 0 : 30,
showInstructions: false,
instructions: undefined,
})
console.log(`Invoking process_game_action for game ${gameId} with action ${action}`)
// Call the backend with the unified action
const updatedGame = await invoke('process_game_action', {
gameAction: {
game_id: gameId,
action,
},
}).catch((err) => {
console.error(`Error from process_game_action:`, err)
throw err
})
console.log('Game action completed, updated game:', updatedGame)
// Update our local state with the result from the backend
if (updatedGame) {
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
)
}
} catch (error) {
console.error(`Error processing action ${action} for game ${gameId}:`, error)
// Show error in progress dialog
setProgressDialog((prev) => ({
...prev,
message: `Error: ${error}`,
progress: 100,
}))
// Reset installing state
setGames((prevGames) =>
prevGames.map((game) => (game.id === gameId ? { ...game, installing: false } : game))
)
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 3000)
}
}
// Handle DLC selection dialog close
const handleDlcDialogClose = () => {
// Cancel any in-progress DLC fetching
if (isFetchingDlcs && activeDlcFetchId.current) {
console.log(`Aborting DLC fetch for game ${activeDlcFetchId.current}`)
// This will signal to the Rust backend that we want to stop the process
invoke('abort_dlc_fetch', { gameId: activeDlcFetchId.current }).catch((err) =>
console.error('Error aborting DLC fetch:', err)
)
// Reset state
activeDlcFetchId.current = null
setIsFetchingDlcs(false)
}
// Clear controller
if (dlcFetchController.current) {
dlcFetchController.current.abort()
dlcFetchController.current = null
}
// Close dialog
setDlcDialog((prev) => ({ ...prev, visible: false }))
}
// Handle DLC selection confirmation
const handleDlcConfirm = async (selectedDlcs: DlcInfo[]) => {
// Close the dialog first
setDlcDialog((prev) => ({ ...prev, visible: false }))
const gameId = dlcDialog.gameId
const game = games.find((g) => g.id === gameId)
if (!game) return
// Update local state to show installation in progress
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g))
)
try {
if (dlcDialog.isEditMode) {
// If in edit mode, we're updating existing cream_api.ini
// Show progress dialog for editing
setProgressDialog({
visible: true,
title: `Updating DLCs for ${game.title}`,
message: 'Updating DLC configuration...',
progress: 30,
showInstructions: false,
instructions: undefined,
})
// Call the backend to update the DLC configuration
await invoke('update_dlc_configuration_command', {
gamePath: game.path,
dlcs: selectedDlcs,
})
// Update progress dialog for completion
setProgressDialog((prev) => ({
...prev,
title: `Update Complete: ${game.title}`,
message: 'DLC configuration updated successfully!',
progress: 100,
}))
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
// Reset installing state
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
)
}, 2000)
} else {
// We're doing a fresh install with selected DLCs
// Show progress dialog for installation right away
setProgressDialog({
visible: true,
title: `Installing CreamLinux for ${game.title}`,
message: 'Processing...',
progress: 0,
showInstructions: false,
instructions: undefined,
})
// Invoke the installation with the selected DLCs
await invoke('install_cream_with_dlcs_command', {
gameId,
selectedDlcs,
}).catch((err) => {
console.error(`Error installing CreamLinux with selected DLCs:`, err)
throw err
})
// We don't need to manually close the dialog or update the game state
// because the backend will emit progress events that handle this
}
} catch (error) {
console.error('Error processing DLC selection:', error)
// Show error in progress dialog
setProgressDialog((prev) => ({
...prev,
message: `Error: ${error}`,
progress: 100,
}))
// Reset installing state
setGames((prevGames) =>
prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g))
)
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog((prev) => ({ ...prev, visible: false }))
}, 3000)
}
}
// Update DLCs being streamed with enabled state
useEffect(() => {
if (dlcDialog.enabledDlcs.length > 0) {
setDlcDialog((prev) => ({
...prev,
dlcs: prev.dlcs.map((dlc) => ({
...dlc,
enabled: prev.enabledDlcs.length === 0 || prev.enabledDlcs.includes(dlc.appid),
})),
}))
}
}, [dlcDialog.dlcs, dlcDialog.enabledDlcs])
// Filter games based on sidebar filter and search query
const filteredGames = games.filter((game) => {
// First filter by the platform/type
const platformMatch =
filter === 'all' ||
(filter === 'native' && game.native) ||
(filter === 'proton' && !game.native)
// Then filter by search query (if any)
const searchMatch =
searchQuery.trim() === '' || game.title.toLowerCase().includes(searchQuery.toLowerCase())
// Both filters must match
return platformMatch && searchMatch
})
// Check if we should show the initial loading screen
// Get application logic from hook
const {
filter,
setFilter,
searchQuery,
handleSearchChange,
isInitialLoad,
scanProgress,
filteredGames,
handleRefresh,
isLoading,
error
} = useAppLogic({ autoLoad: true })
// Get action handlers from context
const {
dlcDialog,
handleDlcDialogClose,
progressDialog,
handleGameAction,
handleDlcConfirm,
handleGameEdit
} = useAppContext()
// Show loading screen during initial load
if (isInitialLoad) {
return <InitialLoadingScreen message={scanProgress.message} progress={scanProgress.progress} />
return <InitialLoadingScreen
message={scanProgress.message}
progress={scanProgress.progress}
/>
}
return (
<div className="app-container">
{/* Animated background */}
<AnimatedBackground />
<ErrorBoundary>
<div className="app-container">
{/* Animated background */}
<AnimatedBackground />
<Header onRefresh={loadGames} onSearch={handleSearchChange} searchQuery={searchQuery} />
<div className="main-content">
<Sidebar setFilter={setFilter} currentFilter={filter} />
{error ? (
<div className="error-message">
<h3>Error Loading Games</h3>
<p>{error}</p>
<button onClick={loadGames}>Retry</button>
</div>
) : (
<GameList
games={filteredGames}
isLoading={isLoading}
onAction={handleGameAction}
onEdit={handleGameEdit}
/>
)}
{/* Header with search */}
<Header
onRefresh={handleRefresh}
onSearch={handleSearchChange}
searchQuery={searchQuery}
refreshDisabled={isLoading}
/>
<div className="main-content">
{/* Sidebar for filtering */}
<Sidebar setFilter={setFilter} currentFilter={filter} />
{/* Show error or game list */}
{error ? (
<div className="error-message">
<h3>Error Loading Games</h3>
<p>{error}</p>
<button onClick={handleRefresh}>Retry</button>
</div>
) : (
<GameList
games={filteredGames}
isLoading={isLoading}
onAction={handleGameAction}
onEdit={handleGameEdit}
/>
)}
</div>
{/* Progress Dialog */}
<ProgressDialog
visible={progressDialog.visible}
title={progressDialog.title}
message={progressDialog.message}
progress={progressDialog.progress}
showInstructions={progressDialog.showInstructions}
instructions={progressDialog.instructions}
onClose={() => {}}
/>
{/* DLC Selection Dialog */}
<DlcSelectionDialog
visible={dlcDialog.visible}
gameTitle={dlcDialog.gameTitle}
dlcs={dlcDialog.dlcs}
isLoading={dlcDialog.isLoading}
isEditMode={dlcDialog.isEditMode}
loadingProgress={dlcDialog.progress}
estimatedTimeLeft={dlcDialog.timeLeft}
onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm}
/>
</div>
{/* Progress Dialog */}
<ProgressDialog
visible={progressDialog.visible}
title={progressDialog.title}
message={progressDialog.message}
progress={progressDialog.progress}
showInstructions={progressDialog.showInstructions}
instructions={progressDialog.instructions}
onClose={handleCloseProgressDialog}
/>
{/* DLC Selection Dialog */}
<DlcSelectionDialog
visible={dlcDialog.visible}
gameTitle={dlcDialog.gameTitle}
dlcs={dlcDialog.dlcs}
isLoading={dlcDialog.isLoading}
isEditMode={dlcDialog.isEditMode}
loadingProgress={dlcDialog.progress}
estimatedTimeLeft={dlcDialog.timeLeft}
onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm}
/>
</div>
</ErrorBoundary>
)
}
export default App
export default App