fix(tauri): make game-directory changes observable

Game-directory selection could reach update_game_directory and then fail before
peer startup, while the frontend only logged the rejected invoke. Preserve the
last accepted root until peer acknowledgement, surface backend rejection in
the settings and main-window UI, and avoid holding the published control lock
across runtime replies. Startup preflight remains fail-closed; synchronous
setup stays lexically owned through scoped_blocking so cancellation cannot
strand a partially published runtime.

Add peer-cli scenario S50 to verify invalid changes preserve the existing
library and valid changes acknowledge and refresh the library in both
directions. Modernize the fixed-size hex decoders to satisfy the current
workspace Clippy lint without changing their behavior.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `just frontend-test` -- passed
- `deno task build` -- passed
- `just peer-cli-tests S50` -- passed
- `just build` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-08-25 00:10:01 +02:00
1 parent 3bbfe919d2
commit a49b51d3d8
9 files changed
+118 -8

No files matched your search

@@ -19,6 +19,7 @@ import {
interface Props {
settings: UISettings;
gameDir: string;
gameDirectoryError: string | null;
hasGameDirectory: boolean;
onPickDirectory: () => void;
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
@@ -116,6 +117,7 @@ const GameFolderField = ({ path, isValid, onPickDirectory }: GameFolderFieldProp
export const SettingsDialog = ({
settings,
gameDir,
gameDirectoryError,
hasGameDirectory,
onPickDirectory,
onChange,
@@ -205,6 +207,11 @@ export const SettingsDialog = ({
onPickDirectory={onPickDirectory}
/>
</Row>
{gameDirectoryError && (
<div className="settings-inline-error" role="status">
Could not set game folder: {gameDirectoryError}
</div>
)}
<Row label="Grid density" hint="How tightly cards are packed">
<SegmentedRadio
value={settings.density}
@@ -22,6 +22,16 @@ const requestGameDirectory = (requestedPath: string): Promise<string> =>
},
});
const describeGameDirectoryError = (error: unknown): string => {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message.trim()) return error.message;
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as { message: unknown }).message;
if (typeof message === 'string' && message.trim()) return message;
}
return 'The backend rejected the selected game folder.';
};
/**
* Owns the backend-accepted game directory. Both restored and newly selected
* paths enter frontend state only after the backend returns its canonical path.
@@ -29,6 +39,7 @@ const requestGameDirectory = (requestedPath: string): Promise<string> =>
export const useGameDirectory = (backendPolicyReady = true) => {
const [gameDir, setAcceptedGameDir] = useState('');
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const acceptedGameDirRef = useRef('');
const mountedRef = useRef(false);
const acceptingRef = useRef(false);
@@ -57,9 +68,13 @@ export const useGameDirectory = (backendPolicyReady = true) => {
acceptedGameDirRef.current = acceptedPath;
if (mountedRef.current && acceptingRef.current) {
setAcceptedGameDir(acceptedPath);
setError(null);
}
} catch (error) {
console.error(failureMessage, error);
if (mountedRef.current && acceptingRef.current) {
setError(describeGameDirectoryError(error));
}
}
});
updateQueueRef.current = update;
@@ -70,9 +85,11 @@ export const useGameDirectory = (backendPolicyReady = true) => {
useEffect(() => {
if (!backendPolicyReady) {
setReady(false);
setError(null);
return;
}
setReady(false);
setError(null);
return startAdmittedPersistenceEffect(
windowPersistenceScope,
shutdown,
@@ -135,6 +152,7 @@ export const useGameDirectory = (backendPolicyReady = true) => {
return {
gameDir,
ready,
error,
hasGameDirectory: gameDir !== '',
setGameDir,
rescan,
@@ -141,6 +141,7 @@ export const MainWindow = () => {
const {
gameDir,
ready: gameDirectoryReady,
error: gameDirectoryError,
hasGameDirectory,
setGameDir,
rescan,
@@ -315,6 +316,11 @@ export const MainWindow = () => {
{localNetworkSharing.error}
</div>
)}
{gameDirectoryError && (
<div className="network-notice is-error" role="status">
Could not set game folder: {gameDirectoryError}
</div>
)}
{identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
<div className="network-notice identity-notice" role="status">
{EPHEMERAL_IDENTITY_NOTICE}
@@ -406,6 +412,7 @@ export const MainWindow = () => {
<SettingsDialog
settings={settings}
gameDir={gameDir}
gameDirectoryError={gameDirectoryError}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => windowAsyncScope.adopt(pickDirectory())}
onChange={setSetting}