From d4eb610bb9929394aa5da53dd155024ecb3663a4 Mon Sep 17 00:00:00 2001 From: Tickbase Date: Sat, 11 Jul 2026 12:21:22 +0200 Subject: [PATCH] contexts --- src/contexts/AppContext.tsx | 20 +++- src/contexts/AppProvider.tsx | 177 ++++++++++++++++++++++------------- 2 files changed, 125 insertions(+), 72 deletions(-) diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx index 6cb97be..39075c4 100644 --- a/src/contexts/AppContext.tsx +++ b/src/contexts/AppContext.tsx @@ -34,11 +34,23 @@ export interface RatingDialogState { steamPath: string } +// A single entry in the Overview page's recent-activity feed. Session-only +// (not persisted) just a friendlier view of what you've been doing since +// the app was launched. +export interface ActivityItem { + id: string + message: string + type: 'install' | 'uninstall' | 'update' + timestamp: number +} + // Define the context type export interface AppContextType { // Game state games: Game[] isLoading: boolean + isInitialLoad: boolean + scanProgress: { message: string; progress: number } error: string | null loadGames: () => Promise handleProgressDialogClose: () => void @@ -64,11 +76,6 @@ export interface AppContextType { handleGameAction: (gameId: string, action: ActionType) => Promise handleDlcConfirm: (selectedDlcs: DlcInfo[]) => void - // Settings - settingsDialog: { visible: boolean } - handleSettingsOpen: () => void - handleSettingsClose: () => void - // SmokeAPI settings smokeAPISettingsDialog: SmokeAPISettingsDialogState handleSmokeAPISettingsOpen: (gameId: string) => void @@ -90,6 +97,9 @@ export interface AppContextType { handleSubmitRating: (worked: boolean) => Promise reportingEnabled: boolean + // Recent activity feed (session-only, shown on Overview) + activityFeed: ActivityItem[] + // Toast notifications showToast: ( message: string, diff --git a/src/contexts/AppProvider.tsx b/src/contexts/AppProvider.tsx index 73c5235..2872219 100644 --- a/src/contexts/AppProvider.tsx +++ b/src/contexts/AppProvider.tsx @@ -1,10 +1,10 @@ -import { ReactNode, useState, useEffect } from 'react' -import { AppContext, AppContextType } from './AppContext' +import { ReactNode, useState, useEffect, useRef } from 'react' +import { AppContext, AppContextType, ActivityItem } from './AppContext' import { useGames, useDlcManager, useGameActions, useToasts } from '@/hooks' -import { DlcInfo, Config, EpicGame } from '@/types' +import { DlcInfo, Config, EpicGame, Game } from '@/types' import { ActionType } from '@/components/buttons/ActionButton' import { ToastContainer } from '@/components/notifications' -import { SmokeAPISettingsDialog, OptInDialog, RatingDialog, SmokeAPIVotesDialog, EpicUnlockerSelectionDialog, ScreamAPISettingsDialog } from '@/components/dialogs' +import { ApiSettingsDialog, smokeApiSettingsSpec, screamApiSettingsSpec, OptInDialog, RatingDialog, SmokeAPIVotesDialog, UnlockerChoiceDialog } from '@/components/dialogs' import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' @@ -19,7 +19,7 @@ interface AppProviderProps { */ export const AppProvider = ({ children }: AppProviderProps) => { // Use our custom hooks - const { games, isLoading, error, loadGames, setGames } = useGames() + const { games, isLoading, isInitialLoad, scanProgress, error, loadGames, setGames } = useGames() const { dlcDialog, @@ -41,12 +41,11 @@ export const AppProvider = ({ children }: AppProviderProps) => { const { toasts, removeToast, success, error: showError, warning, info } = useToasts() - // Settings dialog state - const [settingsDialog, setSettingsDialog] = useState({ visible: false }) - const [epicGames, setEpicGames] = useState([]) const [epicLoading, setEpicLoading] = useState(false) const [epicInstallingId, setEpicInstallingId] = useState(null) + const epicGamesRef = useRef(epicGames) + epicGamesRef.current = epicGames const [epicUnlockerDialog, setEpicUnlockerDialog] = useState<{ visible: boolean @@ -84,6 +83,17 @@ export const AppProvider = ({ children }: AppProviderProps) => { const [optInDialog, setOptInDialog] = useState(false) const [reportingEnabled, setReportingEnabled] = useState(false) + // Recent activity feed (session-only) shown on the Overview page + const [activityFeed, setActivityFeed] = useState([]) + const pushActivity = (message: string, type: ActivityItem['type']) => { + setActivityFeed((prev) => + [{ id: `${Date.now()}-${Math.random()}`, message, type, timestamp: Date.now() }, ...prev].slice( + 0, + 20 + ) + ) + } + // Rating dialog state const [ratingDialog, setRatingDialog] = useState<{ visible: boolean @@ -114,13 +124,16 @@ export const AppProvider = ({ children }: AppProviderProps) => { let unlisten: (() => void) | undefined listen('epic-game-updated', (event) => { const updated = event.payload - const prev = epicGames.find((g) => g.app_name === updated.app_name) - + // Read from the ref (not the epicGames closure) so this effect doesn't + // need epicGames as a dependency. That would tear down and re-create + // the Tauri listener on every single update instead of registering once. + const prev = epicGamesRef.current.find((g) => g.app_name === updated.app_name) + setEpicGames((games) => games.map((g) => (g.app_name === updated.app_name ? updated : g)) ) setEpicInstallingId(null) - + // Determine what changed and show appropriate toast if (prev) { const installedScream = !prev.scream_installed && updated.scream_installed @@ -130,12 +143,16 @@ export const AppProvider = ({ children }: AppProviderProps) => { if (installedScream) { success(`ScreamAPI installed for ${updated.title}`) + pushActivity(`Installed ScreamAPI for ${updated.title}`, 'install') } else if (uninstalledScream) { info(`ScreamAPI removed from ${updated.title}`) + pushActivity(`Removed ScreamAPI from ${updated.title}`, 'uninstall') } else if (installedKoa) { success(`Koaloader installed for ${updated.title}`) + pushActivity(`Installed Koaloader for ${updated.title}`, 'install') } else if (uninstalledKoa) { info(`Koaloader removed from ${updated.title}`) + pushActivity(`Removed Koaloader from ${updated.title}`, 'uninstall') } if (updated.proxy_fallback_used) { @@ -147,7 +164,7 @@ export const AppProvider = ({ children }: AppProviderProps) => { } }).then((fn) => { unlisten = fn }) return () => { unlisten?.() } - }, [epicGames, success, info, warning]) + }, [success, info, warning]) const loadEpicGames = async () => { setEpicLoading(true) @@ -195,15 +212,6 @@ export const AppProvider = ({ children }: AppProviderProps) => { if (game) runEpicAction(game, 'install_koaloader') } - // Settings handlers - const handleSettingsOpen = () => { - setSettingsDialog({ visible: true }) - } - - const handleSettingsClose = () => { - setSettingsDialog({ visible: false }) - } - // SmokeAPI settings handlers const handleSmokeAPISettingsOpen = (gameId: string) => { const game = games.find((g) => g.id === gameId) @@ -234,9 +242,12 @@ export const AppProvider = ({ children }: AppProviderProps) => { const handleSmokeAPIVotesConfirm = () => { const gameId = smokeAPIVotesDialog.gameId setSmokeAPIVotesDialog({ visible: false, gameId: null, gameTitle: null }) - if (gameId) { - // Now actually run the install - executeGameAction(gameId, 'install_smoke', games) + const game = gameId ? games.find((g) => g.id === gameId) : undefined + if (gameId && game) { + // Now actually run the install this is what was silently skipping + // the success toast and activity feed entry before, since it called + // the raw action executor instead of the notifying wrapper. + runGameAction(gameId, 'install_smoke', game) } } @@ -290,6 +301,39 @@ export const AppProvider = ({ children }: AppProviderProps) => { } } + // Runs a game action and reports the result via toast + activity feed. + // Used both by handleGameAction's own fallthrough case and by + // handleSmokeAPIVotesConfirm, which needs to run the action *after* the + // votes dialog closes without re-triggering handleGameAction's own + // interception checks (which would just show that dialog again). + const runGameAction = async (gameId: string, action: ActionType, game: Game) => { + setGames((prevGames) => + prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g)) + ) + + try { + await executeGameAction(gameId, action, games) + + const product = action.includes('cream') ? 'CreamLinux' : 'SmokeAPI' + const isUninstall = action.includes('uninstall') + const isInstall = action.includes('install') && !isUninstall + + if (isInstall) { + success(`Successfully installed ${product} for ${game.title}`) + pushActivity(`Installed ${product} for ${game.title}`, 'install') + } else if (isUninstall) { + info(`${product} uninstalled from ${game.title}`) + pushActivity(`Uninstalled ${product} from ${game.title}`, 'uninstall') + } + } catch (error) { + showError(`Action failed: ${error}`) + } finally { + setGames((prevGames) => + prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) + ) + } + } + // Game action handler with proper error reporting const handleGameAction = async (gameId: string, action: ActionType) => { const game = games.find((g) => g.id === gameId) @@ -341,7 +385,7 @@ export const AppProvider = ({ children }: AppProviderProps) => { ) try { - // This will show the UnlockerSelectionDialog and handle the callback + // This will show the UnlockerChoiceDialog and handle the callback await executeGameAction(gameId, action, games) } catch (error) { showError(`Action failed: ${error}`) @@ -355,41 +399,12 @@ export const AppProvider = ({ children }: AppProviderProps) => { } // For other actions (uninstall cream, install/uninstall smoke) - // Mark game as installing - setGames((prevGames) => - prevGames.map((g) => (g.id === gameId ? { ...g, installing: true } : g)) - ) - - try { - await executeGameAction(gameId, action, games) - - // Show appropriate success message based on action type - const product = action.includes('cream') ? 'CreamLinux' : 'SmokeAPI' - const isUninstall = action.includes('uninstall') - const isInstall = action.includes('install') && !isUninstall - - console.log('DEBUG: Action processed. Product:', product, 'isInstall:', isInstall, 'isUninstall:', isUninstall, 'action:', action) - - if (isInstall) { - success(`Successfully installed ${product} for ${game.title}`) - } else if (isUninstall) { - info(`${product} uninstalled from ${game.title}`) - } else { - console.log('Unknown action type:', action) - } - } catch (error) { - showError(`Action failed: ${error}`) - } finally { - // Reset installing state - setGames((prevGames) => - prevGames.map((g) => (g.id === gameId ? { ...g, installing: false } : g)) - ) - } + await runGameAction(gameId, action, game) } // DLC confirmation wrapper const handleDlcConfirm = (selectedDlcs: DlcInfo[]) => { - const { gameId, isEditMode } = dlcDialog + const { gameId, gameTitle, isEditMode } = dlcDialog // Create a deep copy to ensure we don't have reference issues const dlcsCopy = selectedDlcs.map((dlc) => ({ ...dlc })) @@ -416,6 +431,12 @@ export const AppProvider = ({ children }: AppProviderProps) => { ? 'DLC configuration updated successfully' : 'CreamLinux installed with selected DLCs' ) + pushActivity( + isEditMode + ? `Updated DLC configuration for ${gameTitle}` + : `Installed CreamLinux for ${gameTitle}`, + isEditMode ? 'update' : 'install' + ) }) .catch((error) => { showError(`DLC operation failed: ${error}`) @@ -455,6 +476,8 @@ export const AppProvider = ({ children }: AppProviderProps) => { // Game state games, isLoading, + isInitialLoad, + scanProgress, error, loadGames, @@ -482,11 +505,6 @@ export const AppProvider = ({ children }: AppProviderProps) => { handleDlcConfirm, handleProgressDialogClose: handleCloseProgressDialog, - // Settings - settingsDialog, - handleSettingsOpen, - handleSettingsClose, - // SmokeAPI Settings smokeAPISettingsDialog, handleSmokeAPISettingsOpen, @@ -504,6 +522,9 @@ export const AppProvider = ({ children }: AppProviderProps) => { handleSubmitRating, reportingEnabled, + // Recent activity feed + activityFeed, + // Toast notifications showToast, @@ -561,28 +582,50 @@ export const AppProvider = ({ children }: AppProviderProps) => { {/* SmokeAPI Settings Dialog */} - {/* Epic Unlocker Selection Dialog */} - setEpicUnlockerDialog({ visible: false, game: null })} - onSelectScreamAPI={handleSelectScreamAPI} - onSelectKoaloader={handleSelectKoaloader} + options={[ + { + key: 'scream', + title: 'ScreamAPI', + badge: 'recommended', + description: + 'Replaces the EOS SDK DLL directly with ScreamAPI. Works for most Epic games and requires no additional files. DLC unlocking is automatic.', + buttonLabel: 'Install ScreamAPI', + buttonVariant: 'primary', + onSelect: handleSelectScreamAPI, + }, + { + key: 'koaloader', + title: 'Koaloader + ScreamAPI', + badge: 'alternative', + description: + "Uses a proxy DLL to inject ScreamAPI without modifying the EOS SDK. Try this if the recommended method doesn't work for your game.", + buttonLabel: 'Install Koaloader', + buttonVariant: 'secondary', + onSelect: handleSelectKoaloader, + }, + ]} /> {/* ScreamAPI Settings Dialog */} - setScreamSettingsDialog({ visible: false, game: null })} gamePath={screamSettingsDialog.game?.install_path ?? ''} gameTitle={screamSettingsDialog.game?.title ?? ''} + spec={screamApiSettingsSpec} /> {/* SmokeAPI Votes Dialog */}