fix(ui): treat missing game folders as unset

Validate the persisted game directory before sending it to the backend or
showing library content for it. When the saved path no longer exists, the
launcher keeps the top bar visible but shows the folder picker empty state
and labels the Game Folder button as an unset folder.

This keeps stale local data from being presented as the active library when
an old path is deleted or disconnected.

Test Plan:
- git diff --check
- just frontend-test
- just build
This commit is contained in:
2026-05-21 19:14:11 +02:00
parent e0efb69bf0
commit 31ace174e3
7 changed files with 94 additions and 47 deletions
@@ -856,6 +856,11 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
} }
} }
#[tauri::command]
fn game_directory_exists(path: String) -> bool {
PathBuf::from(path).is_dir()
}
#[tauri::command] #[tauri::command]
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> { async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
log::info!("update_game_directory: {path}"); log::info!("update_game_directory: {path}");
@@ -1803,6 +1808,7 @@ pub fn run() {
install_game, install_game,
run_game, run_game,
start_server, start_server,
game_directory_exists,
update_game_directory, update_game_directory,
update_game, update_game,
uninstall_game, uninstall_game,
@@ -7,11 +7,7 @@ interface Props {
export const NoDirectoryState = ({ onChooseDirectory }: Props) => ( export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
<div className="empty-state"> <div className="empty-state">
<div className="empty-state-icon"><Icon.folder /></div> <div className="empty-state-icon"><Icon.folder /></div>
<h2 className="empty-state-title">Pick a game directory</h2> <h2 className="empty-state-title">Please select a game folder</h2>
<p className="empty-state-hint">
SoftLAN scans the folder you point it at for installable game bundles
and tracks what your peers on the LAN have available.
</p>
<button type="button" className="ghost-btn" onClick={onChooseDirectory}> <button type="button" className="ghost-btn" onClick={onChooseDirectory}>
<Icon.folder /> <Icon.folder />
<span>Choose folder</span> <span>Choose folder</span>
@@ -2,19 +2,21 @@ import { Icon } from '../Icon';
interface Props { interface Props {
path: string | null; path: string | null;
exists: boolean;
onClick: () => void; onClick: () => void;
} }
export const DirectoryButton = ({ path, onClick }: Props) => { export const DirectoryButton = ({ path, exists, onClick }: Props) => {
const isSet = !!(path && path.trim()); const isSet = !!(path && path.trim());
const label = isSet ? 'Game folder' : 'Set game folder'; const isValid = isSet && exists;
const tooltip = isSet ? (path as string) : 'Please select a game folder'; const label = isValid ? 'Game folder' : 'Set game folder';
const ariaLabel = isSet ? `Game folder: ${path}` : 'Set game folder'; const tooltip = isValid ? (path as string) : 'Please select a game folder';
const ariaLabel = isValid ? `Game folder: ${path}` : 'Set game folder';
return ( return (
<button <button
type="button" type="button"
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`} className={`dirbtn ${isValid ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip} title={tooltip}
aria-label={ariaLabel} aria-label={ariaLabel}
onClick={onClick} onClick={onClick}
@@ -18,6 +18,7 @@ interface Props {
sort: GameSort; sort: GameSort;
setSort: (value: GameSort) => void; setSort: (value: GameSort) => void;
gameDir: string; gameDir: string;
gameDirExists: boolean;
onPickDirectory: () => void; onPickDirectory: () => void;
kebabItems: ReadonlyArray<KebabItem>; kebabItems: ReadonlyArray<KebabItem>;
} }
@@ -32,6 +33,7 @@ export const TopBar = ({
sort, sort,
setSort, setSort,
gameDir, gameDir,
gameDirExists,
onPickDirectory, onPickDirectory,
kebabItems, kebabItems,
}: Props) => ( }: Props) => (
@@ -49,7 +51,7 @@ export const TopBar = ({
<div className="topbar-right-lead"> <div className="topbar-right-lead">
<SortMenu value={sort} onChange={setSort} /> <SortMenu value={sort} onChange={setSort} />
</div> </div>
<DirectoryButton path={gameDir} onClick={onPickDirectory} /> <DirectoryButton path={gameDir} exists={gameDirExists} onClick={onPickDirectory} />
<KebabMenu items={kebabItems} /> <KebabMenu items={kebabItems} />
</div> </div>
</header> </header>
@@ -11,6 +11,7 @@ import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store
*/ */
export const useGameDirectory = () => { export const useGameDirectory = () => {
const [gameDir, setGameDir] = useState(''); const [gameDir, setGameDir] = useState('');
const [gameDirExists, setGameDirExists] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -30,7 +31,11 @@ export const useGameDirectory = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!gameDir) return; if (!gameDir.trim()) {
setGameDirExists(false);
return;
}
let cancelled = false;
const sync = async () => { const sync = async () => {
try { try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
@@ -38,19 +43,48 @@ export const useGameDirectory = () => {
} catch (err) { } catch (err) {
console.error('Failed to persist game directory:', err); console.error('Failed to persist game directory:', err);
} }
let exists = false;
try {
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
} catch (err) {
console.error('Failed to validate game directory:', err);
}
if (cancelled) return;
setGameDirExists(exists);
if (!exists) return;
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to push game directory to backend:', err),
);
}; };
void sync(); void sync();
invoke('update_game_directory', { path: gameDir }).catch(err => return () => {
console.error('Failed to push game directory to backend:', err), cancelled = true;
); };
}, [gameDir]); }, [gameDir]);
const rescan = useCallback(() => { const rescan = useCallback(() => {
if (!gameDir) return; if (!gameDir.trim()) {
invoke('update_game_directory', { path: gameDir }).catch(err => setGameDirExists(false);
console.error('Failed to rescan game directory:', err), return;
); }
const sync = async () => {
let exists = false;
try {
exists = await invoke<boolean>('game_directory_exists', { path: gameDir });
} catch (err) {
console.error('Failed to validate game directory:', err);
}
setGameDirExists(exists);
if (!exists) return;
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to rescan game directory:', err),
);
};
void sync();
}, [gameDir]); }, [gameDir]);
return { gameDir, setGameDir, rescan }; return { gameDir, gameDirExists, setGameDir, rescan };
}; };
@@ -1718,6 +1718,9 @@
margin: 0 0 20px; margin: 0 0 20px;
max-width: 44ch; max-width: 44ch;
} }
.empty-state-title + .ghost-btn {
margin-top: 14px;
}
.empty-state .ghost-btn { .empty-state .ghost-btn {
background: var(--accent); background: var(--accent);
color: white; color: white;
@@ -45,7 +45,7 @@ const openLogsWindow = async () => {
export const MainWindow = () => { export const MainWindow = () => {
const { settings, set: setSetting } = useSettings(); const { settings, set: setSetting } = useSettings();
const { gameDir, setGameDir, rescan } = useGameDirectory(); const { gameDir, gameDirExists, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan); const games = useGames(rescan);
const actions = useGameActions(games, settings); const actions = useGameActions(games, settings);
const thumbnails = useThumbnails(); const thumbnails = useThumbnails();
@@ -54,13 +54,18 @@ export const MainWindow = () => {
const [removeGameId, setRemoveGameId] = useState<string | null>(null); const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const counts = useMemo(() => countByFilter(games.games), [games.games]); const hasGameDirectory = !!gameDir.trim() && gameDirExists;
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
);
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
// Query is local UI state (no need to persist). // Query is local UI state (no need to persist).
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const filteredGames = useMemo( const filteredGames = useMemo(
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query), () => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[games.games, settings.filter, settings.sort, query], [visibleGames, settings.filter, settings.sort, query],
); );
const openGame = useMemo<Game | null>( const openGame = useMemo<Game | null>(
@@ -116,25 +121,26 @@ export const MainWindow = () => {
return ( return (
<div className={className} style={rootStyle}> <div className={className} style={rootStyle}>
{gameDir ? ( <TopBar
<> peerCount={games.totalPeerCount}
<TopBar filter={settings.filter}
peerCount={games.totalPeerCount} setFilter={(v) => setSetting('filter', v)}
filter={settings.filter} counts={counts}
setFilter={(v) => setSetting('filter', v)} query={query}
counts={counts} setQuery={setQuery}
query={query} sort={settings.sort}
setQuery={setQuery} setSort={(v) => setSetting('sort', v)}
sort={settings.sort} gameDir={gameDir}
setSort={(v) => setSetting('sort', v)} gameDirExists={gameDirExists}
gameDir={gameDir} onPickDirectory={() => void pickDirectory()}
onPickDirectory={() => void pickDirectory()} kebabItems={kebabItems}
kebabItems={kebabItems} />
/> <main className="grid-wrap">
<main className="grid-wrap"> {hasGameDirectory ? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} /> <ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0 ? ( {filteredGames.length === 0 ? (
games.games.length === 0 ? ( visibleGames.length === 0 ? (
<EmptyResultsState <EmptyResultsState
title="Scanning for games" title="Scanning for games"
hint="Looking for game bundles in your selected directory…" hint="Looking for game bundles in your selected directory…"
@@ -155,13 +161,11 @@ export const MainWindow = () => {
onCancelDownload={(g) => actions.cancelDownload(g.id)} onCancelDownload={(g) => actions.cancelDownload(g.id)}
/> />
)} )}
</main> </>
</> ) : (
) : (
<main className="grid-wrap">
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} /> <NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
</main> )}
)} </main>
{openGame && ( {openGame && (
<GameDetailModal <GameDetailModal