From 0d2520fd16b661dd7bf10575c8a78c5cdf73a641 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 15 May 2026 12:22:02 +0200 Subject: [PATCH] fix(ui): stop showing manually deleted games as installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A game that the user deletes from disk while the launcher is running stayed visible as "Installed" in the UI indefinitely, both as a status label and as a member of the Installed tab. After a restart the Install button reappeared but the game still wrongly showed up under Installed. The backend rescan (`set_all_uninstalled` + `update_game_installation_state` in src-tauri/src/lib.rs) was already producing the correct `installed: false` on each refresh; the React store was just refusing to honour it. Two independent UI bugs were in play: 1. The `games-list-updated` listener merged each update with `previous?.install_status ?? ...`, which preserved a prior `Installed` value regardless of what the backend now reported. The fix introduces `mergeGameUpdate`: the backend `installed` flag wins for settled state (Installed vs NotInstalled), while genuine in-progress states (CheckingPeers / Downloading / Unpacking) are preserved across refreshes so concurrent backend ticks cannot blow away an active download UI. `status_message` and `status_level` are cleared only when the local `installed` / `downloaded` flags actually flip, so a transient error ("No peers currently have this game.") survives a cosmetic refresh but is wiped once the underlying state changes. 2. The Installed tab filter was `installed || downloaded`, which leaked downloaded-but-not-yet-installed games into a tab whose label promises only ready-to-play titles. It now filters on `installed` alone, matching `getActionLabel`'s own definition of when "Install" appears. While the install-state semantics were being sorted out, the filter taxonomy was clarified to match what users actually mean: | Button | Filter | |------------|---------------------------------------------------| | All Games | installed || downloaded || peer_count > 0 | | Local | installed || downloaded | | Installed | installed | The "Available" button was renamed "Local" because users do not think of themselves as a peer; Local means "on my system, whether the archive is still packed or already installed". "All Games" previously surfaced every row in the bundled game.db, including catalogue entries that no peer on the LAN holds — confusing, since those games cannot be acted on. It now scopes to LAN-reachable games. The `isUnavailable` helper and its `Unavailable` action label are left in place: with this filter no displayed game can hit that state today, but the helper is cheap to keep as a safety net for transient peer-count flips and for a possible future "also show catalogue-only entries" toggle. Tooltips were rewritten to a consistent `Show games … on your system` / `Show all games available on the LAN` pattern, all phrased from the user's point of view (no "peer" jargon in user-facing strings; doc/code comments still use "peer" where it reflects the actual protocol). Two stale comments were dropped along the way: a note on `getInitialGameDir` that claimed it only sets the directory if not already set (the function unconditionally calls `setGameDir` when a value is persisted), and a leftover `// Rest of your component remains the same` marker from an earlier scaffold. Test plan: - `npm --prefix crates/lanspread-tauri-deno-ts exec tsc -- --noEmit` passes (run as part of this change). - `just run`, point the launcher at a game directory holding two installed games, then manually `rm -rf` each game's local folder. Within one refresh cycle the Installed tab should empty and each game's action button should flip to Install / Download as appropriate, without needing a restart. - Start a download and verify the UI does not regress to NotInstalled when the next `games-list-updated` arrives mid-flight. - Cycle through All Games / Local / Installed and confirm membership matches the table above; in particular, a game whose archive is downloaded but not installed appears under Local and All Games but not Installed. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/lanspread-tauri-deno-ts/src/App.tsx | 73 +++++++++++++--------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/crates/lanspread-tauri-deno-ts/src/App.tsx b/crates/lanspread-tauri-deno-ts/src/App.tsx index 696131b..820f4a4 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.tsx +++ b/crates/lanspread-tauri-deno-ts/src/App.tsx @@ -29,7 +29,7 @@ enum InstallStatus { type StatusLevel = 'info' | 'error'; -type GameFilter = 'all' | 'available' | 'installed'; +type GameFilter = 'all' | 'local' | 'installed'; interface Game { id: string; @@ -80,11 +80,41 @@ const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) => return {alt}; }; +const IN_PROGRESS_INSTALL_STATUSES = new Set([ + InstallStatus.CheckingPeers, + InstallStatus.Downloading, + InstallStatus.Unpacking, +]); + +const isInProgressInstallStatus = (status: InstallStatus): boolean => { + return IN_PROGRESS_INSTALL_STATUSES.has(status); +}; + +const mergeGameUpdate = (game: Game, previous?: Game): Game => { + let installStatus = InstallStatus.NotInstalled; + if (game.installed) { + installStatus = InstallStatus.Installed; + } else if (previous && isInProgressInstallStatus(previous.install_status)) { + installStatus = previous.install_status; + } + + const localStateChanged = previous !== undefined + && (previous.installed !== game.installed || previous.downloaded !== game.downloaded); + + return { + ...game, + install_status: installStatus, + status_message: localStateChanged ? undefined : previous?.status_message, + status_level: localStateChanged ? undefined : previous?.status_level, + peer_count: game.peer_count ?? 0, + }; +}; + const App = () => { const [gameItems, setGameItems] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [gameDir, setGameDir] = useState(''); - const [currentFilter, setCurrentFilter] = useState('available'); + const [currentFilter, setCurrentFilter] = useState('local'); const [totalPeerCount, setTotalPeerCount] = useState(0); const checkingPeersTimeouts = useRef>>({}); const [thumbnails, setThumbnails] = useState>(new Map()); @@ -108,14 +138,15 @@ const App = () => { const getFilteredGames = (games: Game[], filter: GameFilter): Game[] => { switch (filter) { - case 'available': - // Show union of installed games and games with peers - return games.filter(game => game.installed || game.downloaded || game.peer_count > 0); - case 'installed': + case 'local': + // Games present on this machine, whether the archive is downloaded or already installed. return games.filter(game => game.installed || game.downloaded); + case 'installed': + return games.filter(game => game.installed); case 'all': default: - return games; + // Games reachable on the LAN: held on this machine or advertised by another peer. + return games.filter(game => game.installed || game.downloaded || game.peer_count > 0); } }; @@ -157,8 +188,6 @@ const App = () => { }, []); const getInitialGameDir = useCallback(async () => { - // update game directory from storage (if exists) - // only if it's not already set await new Promise(resolve => setTimeout(resolve, 1000)); const store = await load(FILE_STORAGE, STORE_OPTIONS); const savedGameDir = await store.get(GAME_DIR_KEY); @@ -321,18 +350,7 @@ const App = () => { console.log(`🎮 ${games.length} Games received`); setGameItems(prev => { const previousById = new Map(prev.map(item => [item.id, item])); - return games.map(game => { - const previous = previousById.get(game.id); - const installStatus = previous?.install_status - ?? (game.installed ? InstallStatus.Installed : InstallStatus.NotInstalled); - return { - ...game, - install_status: installStatus, - status_message: previous?.status_message, - status_level: previous?.status_level, - peer_count: game.peer_count ?? 0, // Ensure peer_count is always set - }; - }); + return games.map(game => mergeGameUpdate(game, previousById.get(game.id))); }); void getInitialGameDir(); }); @@ -535,7 +553,6 @@ const App = () => { } }; - // Rest of your component remains the same return (
@@ -554,21 +571,21 @@ const App = () => {