738095235f
Updating or removing a local game rewrites its on-disk files. Peers that were mid-download of that game would keep streaming bytes from files that are being deleted or replaced, handing them a corrupt or stale copy. There was also no authoritative notion of which game version a peer should serve or accept, so a peer could serve whatever happened to be on disk and downloaders could aggregate files from peers running mismatched versions. This introduces a reader-writer coordination scheme between outbound file transfers (readers) and local mutation operations (writers), and gates both serving and downloading on an authoritative game catalog version. Reader-writer coordination: - Track active outbound transfers per game in a shared `OutboundTransfers` map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and registered by a `TransferGuard` in the stream service. The guard is registered *before* the serve-eligibility check to close a TOCTOU window where a writer could miss an in-flight reader. - `stream_file_bytes` now honors a cancellation token at every await point (file read, network send, stream close) via `tokio::select!`, so a transfer aborts promptly instead of hanging on a stalled receiver. - `begin_operation` marks a game active first, then cancels its outbound transfers and waits for the count to reach zero before any Updating/RemovingDownload work touches the filesystem. - Active games are now hidden from library snapshots entirely while an operation is in flight, instead of freezing their last announced state, so peers stop discovering a game that is being mutated. Authoritative version catalog: - Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each game id to its expected version (from the bundled game.db / ETI data). - Serving requires the local `version.ini` to match the catalog version (`local_download_matches_catalog`); peer selection, file aggregation, and majority size validation all filter on the expected version (`peers_with_expected_version`, `aggregated_game_files`, and friends). User-visible changes: - The GUI shows confirmation dialogs before Update and Remove, and surfaces a sharing-status indicator on game cards and the detail modal. - A new `OutboundTransferCountChanged` event lets the UI reflect live outbound transfer activity. Test Plan: - just test - just frontend-test - just clippy
134 lines
4.9 KiB
TypeScript
134 lines
4.9 KiB
TypeScript
import { useCallback } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { ask } from '@tauri-apps/plugin-dialog';
|
|
|
|
import { type UseGamesResult } from './useGames';
|
|
import { type UISettings } from './useSettings';
|
|
|
|
export interface GameActions {
|
|
play: (id: string) => Promise<void>;
|
|
startServer: (id: string) => Promise<void>;
|
|
install: (id: string) => Promise<void>;
|
|
update: (id: string) => Promise<void>;
|
|
uninstall: (id: string) => Promise<void>;
|
|
removeDownload: (id: string) => Promise<void>;
|
|
cancelDownload: (id: string) => Promise<void>;
|
|
viewFiles: (id: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
|
|
* / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads
|
|
* are marked as "checking peers" until the backend emits an authoritative
|
|
* operation snapshot; cancellation waits for the backend to clear that snapshot.
|
|
*/
|
|
export const useGameActions = (
|
|
games: UseGamesResult,
|
|
settings: Pick<UISettings, 'language' | 'username'>,
|
|
): GameActions => {
|
|
const play = useCallback(async (id: string) => {
|
|
try {
|
|
await invoke('run_game', {
|
|
id,
|
|
language: settings.language,
|
|
username: settings.username,
|
|
});
|
|
} catch (err) {
|
|
console.error('run_game failed:', err);
|
|
}
|
|
}, [settings.language, settings.username]);
|
|
|
|
const startServer = useCallback(async (id: string) => {
|
|
try {
|
|
await invoke('start_server', {
|
|
id,
|
|
language: settings.language,
|
|
username: settings.username,
|
|
});
|
|
} catch (err) {
|
|
console.error('start_server failed:', err);
|
|
}
|
|
}, [settings.language, settings.username]);
|
|
|
|
const install = useCallback(async (id: string) => {
|
|
try {
|
|
const success = await invoke<boolean>('install_game', {
|
|
id,
|
|
language: settings.language,
|
|
username: settings.username,
|
|
});
|
|
if (!success) return;
|
|
|
|
const game = games.games.find(item => item.id === id);
|
|
if (!game?.downloaded) {
|
|
games.markChecking(id);
|
|
}
|
|
} catch (err) {
|
|
console.error('install_game failed:', err);
|
|
}
|
|
}, [games, settings.language, settings.username]);
|
|
|
|
const update = useCallback(async (id: string) => {
|
|
try {
|
|
const game = games.games.find(item => item.id === id);
|
|
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
|
const confirmed = await ask(
|
|
`Peers are currently downloading this game from you. Updating will abort their downloads. Do you want to proceed?`,
|
|
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
const success = await invoke<boolean>('update_game', {
|
|
id,
|
|
language: settings.language,
|
|
username: settings.username,
|
|
});
|
|
if (success) games.markChecking(id);
|
|
} catch (err) {
|
|
console.error('update_game failed:', err);
|
|
}
|
|
}, [games, settings.language, settings.username]);
|
|
|
|
const uninstall = useCallback(async (id: string) => {
|
|
try {
|
|
await invoke('uninstall_game', { id });
|
|
} catch (err) {
|
|
console.error('uninstall_game failed:', err);
|
|
}
|
|
}, []);
|
|
|
|
const removeDownload = useCallback(async (id: string) => {
|
|
try {
|
|
const game = games.games.find(item => item.id === id);
|
|
if (game && game.active_outbound_transfers && game.active_outbound_transfers > 0) {
|
|
const confirmed = await ask(
|
|
`Peers are currently downloading this game from you. Removing game files will abort their downloads. Do you want to proceed?`,
|
|
{ title: 'Active Transfers in Progress', kind: 'warning' }
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
await invoke('remove_downloaded_game', { id });
|
|
} catch (err) {
|
|
console.error('remove_downloaded_game failed:', err);
|
|
}
|
|
}, [games]);
|
|
|
|
const cancelDownload = useCallback(async (id: string) => {
|
|
try {
|
|
await invoke('cancel_download', { id });
|
|
} catch (err) {
|
|
console.error('cancel_download failed:', err);
|
|
}
|
|
}, []);
|
|
|
|
const viewFiles = useCallback(async (id: string) => {
|
|
try {
|
|
await invoke('open_game_files', { id });
|
|
} catch (err) {
|
|
console.error('open_game_files failed:', err);
|
|
}
|
|
}, []);
|
|
|
|
return { play, startServer, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
|
};
|