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
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
import { Modal } from '../Modal';
|
|
import { Icon } from '../Icon';
|
|
import { GameCover } from '../grid/GameCover';
|
|
import { StateChip } from '../StateChip';
|
|
import { ActionButton } from '../ActionButton';
|
|
|
|
import { Game, InstallStatus } from '../../lib/types';
|
|
import { deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
|
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
|
|
|
interface Props {
|
|
game: Game;
|
|
thumbnailUrl: string | null;
|
|
onClose: () => void;
|
|
onPrimary: (game: Game) => void;
|
|
onUninstall: (game: Game) => void;
|
|
onRemoveDownload: (game: Game) => void;
|
|
onCancelDownload: (game: Game) => void;
|
|
onStartServer: (game: Game) => void;
|
|
onViewFiles: (game: Game) => void;
|
|
}
|
|
|
|
const tagsFromGame = (game: Game): string[] => {
|
|
const tags: string[] = [];
|
|
if (game.genre) tags.push(game.genre);
|
|
if (game.publisher) tags.push(game.publisher);
|
|
if (game.release_year) tags.push(game.release_year);
|
|
return tags;
|
|
};
|
|
|
|
const statusLabelFor = (game: Game): string => {
|
|
switch (deriveState(game)) {
|
|
case 'installed': return 'Installed';
|
|
case 'local': return 'Downloaded';
|
|
case 'downloading': return 'Downloading';
|
|
case 'busy': return 'Working…';
|
|
case 'none': return 'Not downloaded';
|
|
}
|
|
};
|
|
|
|
export const GameDetailModal = ({
|
|
game,
|
|
thumbnailUrl,
|
|
onClose,
|
|
onPrimary,
|
|
onUninstall,
|
|
onRemoveDownload,
|
|
onCancelDownload,
|
|
onStartServer,
|
|
onViewFiles,
|
|
}: Props) => {
|
|
const tags = tagsFromGame(game);
|
|
// Some game metadata contains a literal <br>; keep sanitization exact.
|
|
const description = game.description.split('<br>').join('');
|
|
const canRemoveDownload = game.downloaded
|
|
&& !game.installed
|
|
&& !isInProgress(game.install_status);
|
|
const canViewFiles = game.downloaded
|
|
|| game.installed
|
|
|| game.install_status === InstallStatus.Downloading
|
|
|| game.install_status === InstallStatus.Installing;
|
|
const newerThanExpected = hasNewerLocalVersion(game);
|
|
const newerStatus = newerThanExpected
|
|
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
|
: undefined;
|
|
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
|
const outboundStatus = hasOutbound
|
|
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
|
: undefined;
|
|
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
|
const statusLevel = hasOutbound
|
|
? 'info'
|
|
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
|
return (
|
|
<Modal onClose={onClose}>
|
|
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
|
<Icon.close />
|
|
</button>
|
|
<div className="modal-hero">
|
|
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle />
|
|
<div className="modal-hero-fade" />
|
|
<div className="modal-hero-text">
|
|
{tags.length > 0 && (
|
|
<div className="modal-tags">
|
|
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
|
|
</div>
|
|
)}
|
|
<h2 className="modal-title">{game.name}</h2>
|
|
</div>
|
|
<div className="modal-state">
|
|
<StateChip game={game} showNone />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="modal-body">
|
|
<div className="modal-meta">
|
|
<div className="meta-cell">
|
|
<div className="meta-label">Size</div>
|
|
<div className="meta-value">{formatBytes(game.size)}</div>
|
|
</div>
|
|
<div className="meta-cell">
|
|
<div className="meta-label">Players</div>
|
|
<div className="meta-value">
|
|
<Icon.users /> {formatPlayers(game.max_players)}
|
|
</div>
|
|
</div>
|
|
<div className="meta-cell">
|
|
<div className="meta-label">Version</div>
|
|
<div className="meta-value meta-mono">
|
|
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
|
</div>
|
|
</div>
|
|
<div className="meta-cell">
|
|
<div className="meta-label">Status</div>
|
|
<div className="meta-value">{statusLabelFor(game)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{description && (
|
|
<p className="modal-desc">{description}</p>
|
|
)}
|
|
|
|
{statusMessage && (
|
|
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
|
{statusMessage}
|
|
</p>
|
|
)}
|
|
|
|
<div className="modal-actions">
|
|
<ActionButton
|
|
game={game}
|
|
size="lg"
|
|
onClick={() => onPrimary(game)}
|
|
onCancelDownload={onCancelDownload}
|
|
/>
|
|
{game.installed && game.can_host_server === true && (
|
|
<button
|
|
type="button"
|
|
className="act-btn act-lg act-server"
|
|
onClick={() => onStartServer(game)}
|
|
>
|
|
<Icon.server />
|
|
<span className="act-label">Start Server</span>
|
|
</button>
|
|
)}
|
|
{game.installed && (
|
|
<button
|
|
type="button"
|
|
className="ghost-btn ghost-danger"
|
|
onClick={() => onUninstall(game)}
|
|
>
|
|
<Icon.trash />
|
|
<span>Uninstall</span>
|
|
</button>
|
|
)}
|
|
{canRemoveDownload && (
|
|
<button
|
|
type="button"
|
|
className="ghost-btn ghost-danger"
|
|
onClick={() => onRemoveDownload(game)}
|
|
>
|
|
<Icon.trash />
|
|
<span>Remove files</span>
|
|
</button>
|
|
)}
|
|
{canViewFiles && (
|
|
<>
|
|
<div className="modal-actions-spacer" />
|
|
<button
|
|
type="button"
|
|
className="ghost-btn"
|
|
onClick={() => onViewFiles(game)}
|
|
>
|
|
<Icon.folder />
|
|
<span>View Files</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|