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
162 lines
6.0 KiB
TypeScript
162 lines
6.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { load } from '@tauri-apps/plugin-store';
|
|
|
|
import { acceptGameDirectory, hydrateGameDirectory } from '../lib/gameDirectory';
|
|
import { windowAsyncScope } from '../lib/asyncOwnership';
|
|
import {
|
|
startAdmittedPersistenceEffect,
|
|
windowPersistenceScope,
|
|
} from '../lib/frontendPersistence';
|
|
import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store';
|
|
|
|
const requestGameDirectory = (requestedPath: string): Promise<string> =>
|
|
acceptGameDirectory(requestedPath, {
|
|
updateBackend: path => invoke<unknown>('update_game_directory', { path }),
|
|
persist: async acceptedPath => {
|
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
|
await store.set(GAME_DIR_KEY, acceptedPath);
|
|
},
|
|
reportPersistenceError: error => {
|
|
console.error('Failed to persist accepted game directory:', error);
|
|
},
|
|
});
|
|
|
|
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.
|
|
*/
|
|
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);
|
|
const selectionVersionRef = useRef(0);
|
|
const updateQueueRef = useRef<Promise<void>>(Promise.resolve());
|
|
const hydrationRef = useRef<Promise<void>>(Promise.resolve());
|
|
|
|
const shutdown = useCallback(async (): Promise<void> => {
|
|
acceptingRef.current = false;
|
|
await hydrationRef.current;
|
|
await updateQueueRef.current;
|
|
}, []);
|
|
|
|
const enqueueUpdate = useCallback((
|
|
requestedPath: () => string,
|
|
failureMessage: string,
|
|
): Promise<void> => {
|
|
if (!acceptingRef.current) return Promise.resolve();
|
|
|
|
const update = updateQueueRef.current.then(async () => {
|
|
const path = requestedPath();
|
|
if (!path.trim()) return;
|
|
|
|
try {
|
|
const acceptedPath = await requestGameDirectory(path);
|
|
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;
|
|
windowAsyncScope.adopt(update);
|
|
return update;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!backendPolicyReady) {
|
|
setReady(false);
|
|
setError(null);
|
|
return;
|
|
}
|
|
setReady(false);
|
|
setError(null);
|
|
return startAdmittedPersistenceEffect(
|
|
windowPersistenceScope,
|
|
shutdown,
|
|
() => {
|
|
let cancelled = false;
|
|
mountedRef.current = true;
|
|
acceptingRef.current = true;
|
|
const initialSelectionVersion = selectionVersionRef.current;
|
|
const hydration = hydrateGameDirectory({
|
|
loadSavedPath: async () => {
|
|
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
|
|
return await store.get<string>(GAME_DIR_KEY);
|
|
},
|
|
acceptSavedPath: async saved => {
|
|
if (
|
|
!cancelled
|
|
&& acceptingRef.current
|
|
&& selectionVersionRef.current === initialSelectionVersion
|
|
) {
|
|
await enqueueUpdate(
|
|
() => saved,
|
|
'Failed to restore game directory:',
|
|
);
|
|
}
|
|
},
|
|
reportLoadError: error => {
|
|
console.error('Failed to load game directory:', error);
|
|
},
|
|
}).finally(() => {
|
|
if (!cancelled && mountedRef.current && acceptingRef.current) {
|
|
setReady(true);
|
|
}
|
|
});
|
|
hydrationRef.current = hydration;
|
|
windowAsyncScope.adopt(hydration);
|
|
return () => {
|
|
cancelled = true;
|
|
mountedRef.current = false;
|
|
acceptingRef.current = false;
|
|
};
|
|
},
|
|
);
|
|
}, [backendPolicyReady, enqueueUpdate, shutdown]);
|
|
|
|
const setGameDir = useCallback((requestedPath: string) => {
|
|
selectionVersionRef.current += 1;
|
|
void enqueueUpdate(
|
|
() => requestedPath,
|
|
'Failed to update game directory:',
|
|
);
|
|
}, [enqueueUpdate]);
|
|
|
|
const rescan = useCallback(() => {
|
|
void enqueueUpdate(
|
|
() => acceptedGameDirRef.current,
|
|
'Failed to rescan game directory:',
|
|
);
|
|
}, [enqueueUpdate]);
|
|
|
|
return {
|
|
gameDir,
|
|
ready,
|
|
error,
|
|
hasGameDirectory: gameDir !== '',
|
|
setGameDir,
|
|
rescan,
|
|
shutdown,
|
|
};
|
|
};
|