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]
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
log::info!("update_game_directory: {path}");
@@ -1803,6 +1808,7 @@ pub fn run() {
install_game,
run_game,
start_server,
game_directory_exists,
update_game_directory,
update_game,
uninstall_game,
@@ -7,11 +7,7 @@ interface Props {
export const NoDirectoryState = ({ onChooseDirectory }: Props) => (
<div className="empty-state">
<div className="empty-state-icon"><Icon.folder /></div>
<h2 className="empty-state-title">Pick a game directory</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>
<h2 className="empty-state-title">Please select a game folder</h2>
<button type="button" className="ghost-btn" onClick={onChooseDirectory}>
<Icon.folder />
<span>Choose folder</span>
@@ -2,19 +2,21 @@ import { Icon } from '../Icon';
interface Props {
path: string | null;
exists: boolean;
onClick: () => void;
}
export const DirectoryButton = ({ path, onClick }: Props) => {
export const DirectoryButton = ({ path, exists, onClick }: Props) => {
const isSet = !!(path && path.trim());
const label = isSet ? 'Game folder' : 'Set game folder';
const tooltip = isSet ? (path as string) : 'Please select a game folder';
const ariaLabel = isSet ? `Game folder: ${path}` : 'Set game folder';
const isValid = isSet && exists;
const label = isValid ? 'Game folder' : 'Set game folder';
const tooltip = isValid ? (path as string) : 'Please select a game folder';
const ariaLabel = isValid ? `Game folder: ${path}` : 'Set game folder';
return (
<button
type="button"
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
className={`dirbtn ${isValid ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip}
aria-label={ariaLabel}
onClick={onClick}
@@ -18,6 +18,7 @@ interface Props {
sort: GameSort;
setSort: (value: GameSort) => void;
gameDir: string;
gameDirExists: boolean;
onPickDirectory: () => void;
kebabItems: ReadonlyArray<KebabItem>;
}
@@ -32,6 +33,7 @@ export const TopBar = ({
sort,
setSort,
gameDir,
gameDirExists,
onPickDirectory,
kebabItems,
}: Props) => (
@@ -49,7 +51,7 @@ export const TopBar = ({
<div className="topbar-right-lead">
<SortMenu value={sort} onChange={setSort} />
</div>
<DirectoryButton path={gameDir} onClick={onPickDirectory} />
<DirectoryButton path={gameDir} exists={gameDirExists} onClick={onPickDirectory} />
<KebabMenu items={kebabItems} />
</div>
</header>
@@ -11,6 +11,7 @@ import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store
*/
export const useGameDirectory = () => {
const [gameDir, setGameDir] = useState('');
const [gameDirExists, setGameDirExists] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -30,7 +31,11 @@ export const useGameDirectory = () => {
}, []);
useEffect(() => {
if (!gameDir) return;
if (!gameDir.trim()) {
setGameDirExists(false);
return;
}
let cancelled = false;
const sync = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
@@ -38,19 +43,48 @@ export const useGameDirectory = () => {
} catch (err) {
console.error('Failed to persist game directory:', err);
}
};
void sync();
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();
return () => {
cancelled = true;
};
}, [gameDir]);
const rescan = useCallback(() => {
if (!gameDir) return;
if (!gameDir.trim()) {
setGameDirExists(false);
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]);
return { gameDir, setGameDir, rescan };
return { gameDir, gameDirExists, setGameDir, rescan };
};
@@ -1718,6 +1718,9 @@
margin: 0 0 20px;
max-width: 44ch;
}
.empty-state-title + .ghost-btn {
margin-top: 14px;
}
.empty-state .ghost-btn {
background: var(--accent);
color: white;
@@ -45,7 +45,7 @@ const openLogsWindow = async () => {
export const MainWindow = () => {
const { settings, set: setSetting } = useSettings();
const { gameDir, setGameDir, rescan } = useGameDirectory();
const { gameDir, gameDirExists, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
@@ -54,13 +54,18 @@ export const MainWindow = () => {
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
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).
const [query, setQuery] = useState('');
const filteredGames = useMemo(
() => applyFilterAndSort(games.games, settings.filter, settings.sort, query),
[games.games, settings.filter, settings.sort, query],
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[visibleGames, settings.filter, settings.sort, query],
);
const openGame = useMemo<Game | null>(
@@ -116,8 +121,6 @@ export const MainWindow = () => {
return (
<div className={className} style={rootStyle}>
{gameDir ? (
<>
<TopBar
peerCount={games.totalPeerCount}
filter={settings.filter}
@@ -128,13 +131,16 @@ export const MainWindow = () => {
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
gameDir={gameDir}
gameDirExists={gameDirExists}
onPickDirectory={() => void pickDirectory()}
kebabItems={kebabItems}
/>
<main className="grid-wrap">
{hasGameDirectory ? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0 ? (
games.games.length === 0 ? (
visibleGames.length === 0 ? (
<EmptyResultsState
title="Scanning for games"
hint="Looking for game bundles in your selected directory…"
@@ -155,13 +161,11 @@ export const MainWindow = () => {
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</main>
</>
) : (
<main className="grid-wrap">
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
</main>
)}
</main>
{openGame && (
<GameDetailModal