feat(app): expose local sharing and verified transfers

Add a durable, acknowledged Local network sharing switch with fail-closed
hydration, serialized mutation, and redacted ephemeral-identity diagnostics.
Keep local Call-to-Play state available while gating every network action on the
effective sharing generation.

Render revisioned verification, invalid-source retry, and sticky source
exhaustion states. Preserve opaque attempt IDs through progress delivery so
out-of-order webview events cannot attach stale bytes to a successor transfer,
and keep terminal exhaustion visible after the last source departs.

Own listeners, native invokes, persistence, dialogs, and companion-window
creation through webview close. Late creation is settled and cleaned before the
parent realm is destroyed.

Test Plan:
- `just frontend-test` -- passed (91/91)
- `just build` -- passed with TypeScript, Vite, and release Tauri compilation
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-10 13:59:40 +02:00
parent 60fd7ba0c2
commit 71dbf27d8b
42 changed files with 7457 additions and 2101 deletions
@@ -19,6 +19,7 @@ import {
rowFromPayload,
rowsFromHistory,
} from './lib/mainLogs';
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
import './MainLogsWindow.css';
@@ -43,6 +44,8 @@ export const MainLogsWindow = () => {
const pausedRef = useRef(false);
const lastHistorySequenceRef = useRef(0);
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
const viewOwnerRef = useRef<AsyncOwner | null>(null);
const copyStatusTimerRef = useRef<number | undefined>(undefined);
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
setLogs(current => capLogRows([...current, ...rows]));
@@ -54,10 +57,18 @@ export const MainLogsWindow = () => {
}, []);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
const owner = new AsyncOwner(error => {
console.error('Failed to unregister main log listener:', error);
});
viewOwnerRef.current = owner;
owner.own(() => {
if (copyStatusTimerRef.current !== undefined) {
window.clearTimeout(copyStatusTimerRef.current);
copyStatusTimerRef.current = undefined;
}
});
const handleIncomingRow = (row: MainLogRow) => {
const handleIncomingRow = owner.guard((row: MainLogRow) => {
if (!historyLoadedRef.current) {
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
return;
@@ -76,43 +87,55 @@ export const MainLogsWindow = () => {
return;
}
appendVisibleRows([row]);
};
});
const setup = async () => {
try {
unlisten = await listen<MainLogLinePayload>('main-log-line', event => {
handleIncomingRow(rowFromPayload(event.payload));
});
const history = await invoke<MainLogHistoryPayload>('get_main_logs');
if (cancelled) return;
lastHistorySequenceRef.current = history.lastSequence;
const historyRows = rowsFromHistory(history.contents);
const historyLineCounts = lineCountsFromRows(historyRows);
const liveRows = dedupeBufferedRows(
historyLineCounts,
initialBufferRef.current,
lastHistorySequenceRef.current,
const registered = await owner.register(() =>
listen<MainLogLinePayload>('main-log-line', event => {
handleIncomingRow(rowFromPayload(event.payload));
})
);
initialBufferRef.current = [];
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true;
if (pausedRef.current) {
setLogs(capLogRows(historyRows));
bufferPausedRows(liveRows);
} else {
setLogs(capLogRows([...historyRows, ...liveRows]));
}
setLoadError(null);
if (!registered) return;
} catch (err) {
if (!cancelled) {
if (owner.isActive()) {
historyLoadedRef.current = true;
setLoadError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
} finally {
if (!cancelled) {
return;
}
try {
await owner.applyIfActive(
() => invoke<MainLogHistoryPayload>('get_main_logs'),
history => {
lastHistorySequenceRef.current = history.lastSequence;
const historyRows = rowsFromHistory(history.contents);
const historyLineCounts = lineCountsFromRows(historyRows);
const liveRows = dedupeBufferedRows(
historyLineCounts,
initialBufferRef.current,
lastHistorySequenceRef.current,
);
initialBufferRef.current = [];
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true;
if (pausedRef.current) {
setLogs(capLogRows(historyRows));
bufferPausedRows(liveRows);
} else {
setLogs(capLogRows([...historyRows, ...liveRows]));
}
setLoadError(null);
setLoading(false);
},
);
} catch (err) {
if (owner.isActive()) {
historyLoadedRef.current = true;
setLoadError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
}
@@ -121,12 +144,12 @@ export const MainLogsWindow = () => {
void setup();
return () => {
cancelled = true;
windowAsyncScope.adopt(owner.dispose());
if (viewOwnerRef.current === owner) viewOwnerRef.current = null;
historyLoadedRef.current = false;
initialBufferRef.current = [];
lastHistorySequenceRef.current = 0;
historyLineCountsRef.current = new Map();
unlisten?.();
};
}, [appendVisibleRows, bufferPausedRows]);
@@ -156,9 +179,10 @@ export const MainLogsWindow = () => {
const viewport = viewportRef.current;
if (!viewport) return;
requestAnimationFrame(() => {
const frame = requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight;
});
return () => cancelAnimationFrame(frame);
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
const flushPausedRows = useCallback(() => {
@@ -189,13 +213,24 @@ export const MainLogsWindow = () => {
}, []);
const copyFilteredLogs = useCallback(async () => {
const owner = viewOwnerRef.current;
if (!owner) return;
try {
await navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n'));
setCopyStatus('Copied');
if (!await owner.applyIfActive(
() => navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')),
() => setCopyStatus('Copied'),
)) return;
} catch {
if (!owner.isActive()) return;
setCopyStatus('Copy failed');
}
window.setTimeout(() => setCopyStatus(null), 1600);
if (copyStatusTimerRef.current !== undefined) {
window.clearTimeout(copyStatusTimerRef.current);
}
copyStatusTimerRef.current = window.setTimeout(owner.guard(() => {
copyStatusTimerRef.current = undefined;
setCopyStatus(null);
}), 1600);
}, [filteredRows]);
return (
@@ -219,7 +254,7 @@ export const MainLogsWindow = () => {
</button>
<button
className="settings-button"
onClick={() => void copyFilteredLogs()}
onClick={() => windowAsyncScope.adopt(copyFilteredLogs())}
disabled={filteredRows.length === 0}
>
Copy
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
import './UnpackLogsWindow.css';
interface UnpackLogEntry {
@@ -49,28 +51,56 @@ export const UnpackLogsWindow = () => {
const [errorsOnly, setErrorsOnly] = useState(false);
const [regexInput, setRegexInput] = useState('');
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null);
const refreshOwnerRef = useRef<AsyncOwner | null>(null);
const loadLogs = useCallback((owner: AsyncOwner): Promise<boolean> =>
owner.applyLatestIfActive(
() => invoke<UnpackLogEntry[]>('get_unpack_logs'),
unpackLogs => setLogs(unpackLogs),
), []);
const refreshLogs = useCallback(async () => {
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs');
setLogs(unpackLogs);
}, []);
const owner = refreshOwnerRef.current;
if (!owner) return;
try {
await loadLogs(owner);
} catch (error) {
if (owner.isActive()) console.error('Failed to refresh unpack logs:', error);
}
}, [loadLogs]);
useEffect(() => {
let unlisten: (() => void) | undefined;
const owner = new AsyncOwner(error => {
console.error('Failed to unregister unpack log listener:', error);
});
refreshOwnerRef.current = owner;
const setup = async () => {
await refreshLogs();
unlisten = await listen('unpack-logs-updated', () => {
void refreshLogs();
});
try {
const registered = await owner.register(() => listen(
'unpack-logs-updated',
owner.guard(() => {
void loadLogs(owner).catch(error => {
if (owner.isActive()) {
console.error('Failed to refresh unpack logs:', error);
}
});
}),
));
if (!registered) return;
await loadLogs(owner);
} catch (error) {
if (owner.isActive()) console.error('Failed to set up unpack logs:', error);
}
};
void setup();
return () => {
unlisten?.();
windowAsyncScope.adopt(owner.dispose());
if (refreshOwnerRef.current === owner) refreshOwnerRef.current = null;
};
}, [refreshLogs]);
}, [loadLogs]);
const { regex, regexError } = useMemo(() => {
if (!regexInput) {
@@ -1,137 +1,160 @@
import { CSSProperties, MouseEvent } from 'react';
import { CSSProperties, MouseEvent } from "react";
import { Game } from '../lib/types';
import { Game } from "../lib/types";
import {
downloadProgressPercent,
formatDownloadBytes,
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
} from '../lib/gameState';
downloadProgressAriaLabel,
downloadProgressPercent,
downloadProgressTransferLabel,
formatDownloadBytes,
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
} from "../lib/gameState";
import { Icon } from './Icon';
import { Icon } from "./Icon";
interface Props {
game: Game;
size?: 'md' | 'lg';
full?: boolean;
onCancel?: (game: Game) => void;
game: Game;
size?: "md" | "lg";
full?: boolean;
onCancel?: (game: Game) => void;
}
const progressStats = (game: Game) => {
const progress = game.download_progress;
const downloaded = progress?.downloaded_bytes ?? 0;
const total = progress?.total_bytes ?? game.size;
const speed = progress?.bytes_per_second ?? 0;
const remaining = Math.max(0, total - downloaded);
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
const progress = game.download_progress;
const downloaded = progress?.downloaded_bytes ?? 0;
const total = progress?.total_bytes ?? game.size;
const speed = progress?.bytes_per_second ?? 0;
const remaining = Math.max(0, total - downloaded);
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
return {
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
downloaded,
total,
speed,
eta: etaSeconds,
activePeerCount: progress?.active_peer_count ?? 0,
};
return {
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
downloaded,
total,
speed,
eta: etaSeconds,
activePeerCount: progress?.active_peer_count ?? 0,
};
};
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => {
const stats = progressStats(game);
const progressStyle = {
'--download-progress': `${stats.pct}%`,
} as CSSProperties;
const className = [
'dl',
size === 'lg' ? 'dl-lg' : 'dl-md',
full ? 'dl-full' : '',
].filter(Boolean).join(' ');
export const DownloadProgress = (
{ game, size = "md", full = false, onCancel }: Props,
) => {
const stats = progressStats(game);
const transferLabel = downloadProgressTransferLabel(game);
const ariaLabel = downloadProgressAriaLabel(game);
const progressStyle = {
"--download-progress": `${stats.pct}%`,
} as CSSProperties;
const className = [
"dl",
size === "lg" ? "dl-lg" : "dl-md",
full ? "dl-full" : "",
].filter(Boolean).join(" ");
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCancel?.(game);
};
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCancel?.(game);
};
if (size === 'lg') {
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers';
return (
<div
className={className}
role="progressbar"
aria-label={`Downloading ${game.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden />
<span className="dl-label">Downloading</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
<span className="dl-of"> / {formatDownloadBytes(stats.total)}</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
{stats.activePeerCount > 0 && (
<>
<span className="dl-sep dl-sep-peers">·</span>
<span
className="dl-peers"
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
>
<Icon.users />
<span>{stats.activePeerCount}</span>
</span>
</>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
</div>
<div className="dl-lg-pct">
{stats.pct}
<span className="dl-pct-sym">%</span>
</div>
{onCancel && (
<button
type="button"
className="dl-cancel"
onClick={handleCancel}
aria-label={`Cancel download of ${game.name}`}
>
<Icon.close />
</button>
)}
</div>
</div>
);
}
if (size === "lg") {
const peerUnit = stats.activePeerCount === 1 ? "peer" : "peers";
return (
<div
className={className}
role="progressbar"
aria-label={`Downloading ${game.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-md-row">
<span className="dl-pct">
<span className="dl-pulse" aria-hidden />
{stats.pct}
<span className="dl-pct-sym">%</span>
<div
className={className}
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden />
<span className="dl-label">{transferLabel ?? "Downloading"}</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
<span className="dl-of">
/ {formatDownloadBytes(stats.total)}
</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
{stats.activePeerCount > 0 && (
<>
<span className="dl-sep dl-sep-peers">·</span>
<span
className="dl-peers"
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
>
<Icon.users />
<span>{stats.activePeerCount}</span>
</span>
<span className="dl-speed">{formatDownloadSpeedShort(stats.speed)}</span>
</div>
</>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
</div>
<div className="dl-lg-pct">
{stats.pct}
<span className="dl-pct-sym">%</span>
</div>
{onCancel && (
<button
type="button"
className="dl-cancel"
onClick={handleCancel}
aria-label={`Cancel download of ${game.name}`}
>
<Icon.close />
</button>
)}
</div>
</div>
);
}
return (
<div
className={className}
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={stats.pct}
title={`${transferLabel ? `${transferLabel} · ` : ""}${stats.pct}% · ${
formatDownloadSpeed(stats.speed)
} · ${formatDownloadEta(stats.eta)} left`}
style={progressStyle}
>
<div className="dl-fill" aria-hidden />
<div className="dl-md-row">
{transferLabel
? (
<span className="dl-md-status">
<span className="dl-pulse" aria-hidden />
<span>{transferLabel}</span>
</span>
)
: (
<>
<span className="dl-pct">
<span className="dl-pulse" aria-hidden />
{stats.pct}
<span className="dl-pct-sym">%</span>
</span>
<span className="dl-speed">
{formatDownloadSpeedShort(stats.speed)}
</span>
</>
)}
</div>
</div>
);
};
@@ -1,90 +1,109 @@
import { JSX, KeyboardEvent } from 'react';
import { JSX, KeyboardEvent } from "react";
import { Game } from '../../lib/types';
import { CoverAspect } from '../../hooks/useSettings';
import { formatBytes } from '../../lib/format';
import { hasNewerLocalVersion } from '../../lib/gameState';
import { Game } from "../../lib/types";
import { CoverAspect } from "../../hooks/useSettings";
import { formatBytes } from "../../lib/format";
import {
gameTransferStatusPresentation,
hasNewerLocalVersion,
} from "../../lib/gameState";
import { GameCover } from './GameCover';
import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Icon } from '../Icon';
import { GameCover } from "./GameCover";
import { StateChip } from "../StateChip";
import { ActionButton } from "../ActionButton";
import { Icon } from "../Icon";
interface Props {
game: Game;
aspect: CoverAspect;
thumbnailUrl: string | null;
onOpen: (game: Game) => void;
onPrimary: (game: Game) => void;
onCancelDownload: (game: Game) => void;
game: Game;
aspect: CoverAspect;
thumbnailUrl: string | null;
onOpen: (game: Game) => void;
onPrimary: (game: Game) => void;
onCancelDownload: (game: Game) => void;
}
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
const filtered = parts.filter(Boolean) as string[];
const out: JSX.Element[] = [];
filtered.forEach((p, i) => {
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
out.push(<span key={`p${i}`}>{p}</span>);
});
return out;
const metaSeparator = (
...parts: Array<string | null | undefined>
): JSX.Element[] => {
const filtered = parts.filter(Boolean) as string[];
const out: JSX.Element[] = [];
filtered.forEach((p, i) => {
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
out.push(<span key={`p${i}`}>{p}</span>);
});
return out;
};
export const GameCard = ({
game,
aspect,
thumbnailUrl,
onOpen,
onPrimary,
onCancelDownload,
game,
aspect,
thumbnailUrl,
onOpen,
onPrimary,
onCancelDownload,
}: Props) => {
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(game);
}
};
const newerThanExpected = hasNewerLocalVersion(game);
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
const statusMessage = hasOutbound
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
const statusLevel = hasOutbound
? 'info'
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(game);
}
};
const newerThanExpected = hasNewerLocalVersion(game);
const transferStatus = gameTransferStatusPresentation(game.transfer_status);
const hasOutbound = game.active_outbound_transfers !== undefined &&
game.active_outbound_transfers > 0;
const statusMessage = transferStatus?.message ??
(hasOutbound
? `Sharing to ${game.active_outbound_transfers} peer${
game.active_outbound_transfers === 1 ? "" : "s"
}`
: (game.status_message ??
(newerThanExpected ? "Newer than expected" : "")));
const statusLevel = transferStatus?.level ??
(hasOutbound
? "info"
: (game.status_level ?? (newerThanExpected ? "warning" : undefined)));
return (
<button
type="button"
className="card"
onClick={() => onOpen(game)}
onKeyDown={onKey}
aria-label={game.name}
return (
<button
type="button"
className="card"
onClick={() => onOpen(game)}
onKeyDown={onKey}
aria-label={game.name}
>
<div className="card-cover-wrap" data-aspect={aspect}>
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
<StateChip game={game} />
{game.peer_count > 0 && (
<div
className="card-mp"
title={`${game.peer_count} peer${
game.peer_count === 1 ? "" : "s"
} have this`}
>
<Icon.users />
<span>{game.peer_count}</span>
</div>
)}
</div>
<div className="card-body">
<div className="card-title" title={game.name}>{game.name}</div>
<div className="card-meta">
{metaSeparator(formatBytes(game.size), game.genre || null)}
</div>
<div
className={`card-status${statusLevel ? ` is-${statusLevel}` : ""}`}
>
<div className="card-cover-wrap" data-aspect={aspect}>
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
<StateChip game={game} />
{game.peer_count > 0 && (
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}>
<Icon.users />
<span>{game.peer_count}</span>
</div>
)}
</div>
<div className="card-body">
<div className="card-title" title={game.name}>{game.name}</div>
<div className="card-meta">
{metaSeparator(formatBytes(game.size), game.genre || null)}
</div>
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
{statusMessage}
</div>
<ActionButton
game={game}
full
onClick={() => onPrimary(game)}
onCancelDownload={onCancelDownload}
/>
</div>
</button>
);
{statusMessage}
</div>
<ActionButton
game={game}
full
onClick={() => onPrimary(game)}
onCancelDownload={onCancelDownload}
/>
</div>
</button>
);
};
@@ -1,187 +1,213 @@
import { Modal } from '../Modal';
import { Icon } from '../Icon';
import { GameCover } from '../grid/GameCover';
import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
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 { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
import { Game, InstallStatus } from "../../lib/types";
import {
canStreamInstall,
gameStatusLabel,
gameTransferStatusPresentation,
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;
onStreamInstall: (game: Game) => void;
onUninstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void;
onCancelDownload: (game: Game) => void;
onStartServer: (game: Game) => void;
onViewFiles: (game: Game) => void;
game: Game;
thumbnailUrl: string | null;
supportsStreamedInstall: boolean;
onClose: () => void;
onPrimary: (game: Game) => void;
onStreamInstall: (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 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;
};
export const GameDetailModal = ({
game,
thumbnailUrl,
onClose,
onPrimary,
onStreamInstall,
onUninstall,
onRemoveDownload,
onCancelDownload,
onStartServer,
onViewFiles,
game,
thumbnailUrl,
supportsStreamedInstall,
onClose,
onPrimary,
onStreamInstall,
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 showStreamInstall = canStreamInstall(game);
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 />
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 showStreamInstall = canStreamInstall(game, supportsStreamedInstall);
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 transferStatus = gameTransferStatusPresentation(game.transfer_status);
const statusMessage = transferStatus?.message ?? outboundStatus ??
game.status_message ?? newerStatus;
const statusLevel = transferStatus?.level ??
(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">{gameStatusLabel(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}
/>
{showStreamInstall && (
<button
type="button"
className="ghost-btn"
title="Install without keeping archive files"
onClick={() => onStreamInstall(game)}
>
<Icon.install />
<span>Low disk install</span>
</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">{gameStatusLabel(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}
/>
{showStreamInstall && (
<button
type="button"
className="ghost-btn"
title="Install without keeping archive files"
onClick={() => onStreamInstall(game)}
>
<Icon.install />
<span>Low disk install</span>
</button>
)}
{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>
);
)}
{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>
);
};
@@ -11,6 +11,10 @@ import {
LANGUAGE_OPTIONS,
type UISettings,
} from '../../hooks/useSettings';
import {
type LocalNetworkSharingSnapshot,
displayedSharingTarget,
} from '../../lib/localNetworkSharing';
interface Props {
settings: UISettings;
@@ -18,6 +22,11 @@ interface Props {
hasGameDirectory: boolean;
onPickDirectory: () => void;
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
localNetworkSharing: LocalNetworkSharingSnapshot;
localNetworkSharingReady: boolean;
localNetworkSharingBusy: boolean;
localNetworkSharingError: string | null;
onLocalNetworkSharingChange: (enabled: boolean) => void;
onClose: () => void;
}
@@ -59,6 +68,28 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
</div>
);
interface SettingsSwitchProps {
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}
const SettingsSwitch = ({ checked, disabled, onChange }: SettingsSwitchProps) => (
<label className={`settings-switch ${checked ? 'is-checked' : ''}`}>
<input
type="checkbox"
role="switch"
aria-label="Local network sharing"
checked={checked}
disabled={disabled}
onChange={event => onChange(event.target.checked)}
/>
<span className="settings-switch-track" aria-hidden="true">
<span className="settings-switch-thumb" />
</span>
</label>
);
interface GameFolderFieldProps {
path: string;
isValid: boolean;
@@ -88,6 +119,11 @@ export const SettingsDialog = ({
hasGameDirectory,
onPickDirectory,
onChange,
localNetworkSharing,
localNetworkSharingReady,
localNetworkSharingBusy,
localNetworkSharingError,
onLocalNetworkSharingChange,
onClose,
}: Props) => (
<Modal onClose={onClose} className="settings-modal">
@@ -123,6 +159,25 @@ export const SettingsDialog = ({
</Row>
</section>
<section className="settings-section">
<div className="settings-section-title">Network</div>
<Row
label="Local network sharing"
hint="Allow nearby Lanspread devices to browse and request games from this library."
>
<SettingsSwitch
checked={displayedSharingTarget(localNetworkSharing)}
disabled={!localNetworkSharingReady || localNetworkSharingBusy}
onChange={onLocalNetworkSharingChange}
/>
</Row>
{localNetworkSharingError && (
<div className="settings-inline-error" role="status">
{localNetworkSharingError}
</div>
)}
</section>
<section className="settings-section">
<div className="settings-section-title">Appearance</div>
<Row label="Accent color" hint="Used for primary actions and highlights">
@@ -1,16 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { listen } from '@tauri-apps/api/event';
import {
CALL_TO_PLAY_CONNECTING_MESSAGE,
callToPlayEvent,
callToPlayPublishErrorMessage,
extendDeadline,
pruneCallToPlayEvents,
reduceCallToPlayEvents,
replaceCallToPlayView,
} from '../lib/callToPlay';
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
import { CallToPlayAsyncScope } from '../lib/callToPlayOwnership';
import { windowAsyncScope } from '../lib/asyncOwnership';
import {
CallToPlayAction,
CallToPlayLocalIntent,
CallToPlayReceipt,
CallToPlayView,
Nomination,
} from '../lib/types';
export interface CallToPlayActions {
createNomination: (
@@ -36,129 +43,177 @@ export interface UseCallToPlay {
error: string | null;
}
const mergeEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
incoming: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent> => {
const next = new Map(previous);
for (const event of incoming) next.set(event.id, event);
return next;
};
type PublishOutcome =
| { kind: 'connecting' }
| { kind: 'settled'; accepted: boolean }
| { kind: 'failed'; error: unknown };
export const useCallToPlay = (username: string): UseCallToPlay => {
export const useCallToPlay = (
username: string,
localNetworkSharingEnabled = true,
): UseCallToPlay => {
const actor = useMemo(() => {
const trimmed = username.trim();
return trimmed ? Array.from(trimmed).slice(0, 24).join('') : 'Commander';
}, [username]);
const [events, setEvents] = useState<ReadonlyMap<string, CallToPlayEvent>>(() => new Map());
const [view, setView] = useState<CallToPlayView>({ events: [] });
const [actorId, setActorId] = useState<string | null>(null);
const [now, setNow] = useState(Date.now());
const [transportReady, setTransportReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const asyncScopeRef = useRef<CallToPlayAsyncScope | null>(null);
const actorRef = useRef(actor);
actorRef.current = actor;
useEffect(() => {
const timer = window.setInterval(() => {
const current = Date.now();
setNow(current);
setEvents(previous => pruneCallToPlayEvents(previous, current));
setNow(Date.now());
}, 1_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let cancelled = false;
let unlisten: UnlistenFn | undefined;
let retry: number | undefined;
const scope = new CallToPlayAsyncScope(
(callback, delayMilliseconds) => {
const timer = window.setTimeout(() => void callback(), delayMilliseconds);
return () => window.clearTimeout(timer);
},
cleanupError => console.error('Failed to clean up Call to Play:', cleanupError),
retryError => console.error('Call to Play retry failed:', retryError),
);
asyncScopeRef.current = scope;
const syncDisplayName = async (): Promise<void> => {
try {
await scope.applyMutationIfActive(
() => invoke<boolean>('set_call_to_play_display_name', {
displayName: actorRef.current,
}),
() => {},
);
} catch (err) {
if (scope.isActive()) {
console.error('set_call_to_play_display_name failed:', err);
}
}
};
const requestSnapshot = async (): Promise<boolean> => {
let ready = false;
try {
const peerId = await invoke<string | null>('request_call_to_play_events');
if (cancelled) return false;
const ready = peerId !== null;
setActorId(peerId);
setTransportReady(ready);
if (ready) {
setError(current =>
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
);
}
if (ready && retry !== undefined) {
window.clearInterval(retry);
retry = undefined;
}
return ready;
const applied = await scope.applyIfActive(
() => invoke<string | null>('request_call_to_play_view'),
peerId => {
ready = peerId !== null;
setActorId(peerId);
setTransportReady(ready);
if (ready) {
setError(current =>
current === CALL_TO_PLAY_CONNECTING_MESSAGE ? null : current
);
scope.stopRetry();
void syncDisplayName();
}
},
);
return applied && ready;
} catch (err) {
if (!cancelled) {
setTransportReady(false);
console.error('request_call_to_play_events failed:', err);
}
if (!scope.isActive()) return false;
setTransportReady(false);
console.error('request_call_to_play_view failed:', err);
return false;
}
};
const register = async () => {
try {
unlisten = await listen<CallToPlayEvent[]>('call-to-play-events', event => {
setEvents(previous => mergeEvents(previous, event.payload));
});
if (cancelled) {
unlisten();
return;
}
const registered = await scope.registerListener(() =>
listen<CallToPlayView>('call-to-play-view', scope.guard(event => {
setView(previous => replaceCallToPlayView(previous, event.payload));
}))
);
if (!registered) return;
const ready = await requestSnapshot();
if (!cancelled && !ready) {
retry = window.setInterval(() => void requestSnapshot(), 2_000);
}
if (scope.isActive() && !ready) scope.startRetry(requestSnapshot, 2_000);
} catch (err) {
if (!scope.isActive()) return;
console.error('Failed to register Call to Play listener:', err);
if (!cancelled) {
setTransportReady(false);
setError('Call to Play networking is unavailable.');
}
setTransportReady(false);
setError('Call to Play networking is unavailable.');
}
};
void register();
void scope.applyIfActive(register, () => {});
return () => {
cancelled = true;
unlisten?.();
if (retry !== undefined) window.clearInterval(retry);
if (asyncScopeRef.current === scope) asyncScopeRef.current = null;
windowAsyncScope.adopt(scope.dispose());
};
}, []);
useEffect(() => {
const scope = asyncScopeRef.current;
if (scope === null || !scope.isActive()) return;
void scope.applyMutationIfActive(
() => invoke<boolean>('set_call_to_play_display_name', { displayName: actor }),
() => {},
).catch(err => {
if (scope.isActive()) console.error('set_call_to_play_display_name failed:', err);
});
}, [actor]);
const publish = useCallback(async (
callId: string,
callId: string | null,
action: CallToPlayAction,
): Promise<boolean> => {
if (actorId === null) {
setTransportReady(false);
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
return false;
}
const event = callToPlayEvent(callId, actorId, actor, action);
try {
const accepted = await invoke<boolean>('publish_call_to_play', { event });
if (!accepted) {
const scope = asyncScopeRef.current;
if (scope === null || !scope.isActive() || !localNetworkSharingEnabled) return false;
const publishOutcome = scope.guardLatestAction((outcome: PublishOutcome) => {
if (outcome.kind === 'connecting') {
setTransportReady(false);
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
return false;
} else if (outcome.kind === 'failed') {
setError(callToPlayPublishErrorMessage(outcome.error));
} else if (!outcome.accepted) {
setTransportReady(false);
setError(CALL_TO_PLAY_CONNECTING_MESSAGE);
} else {
setTransportReady(true);
setError(null);
}
setTransportReady(true);
setError(null);
return true;
} catch (err) {
console.error('publish_call_to_play failed:', err);
setError(callToPlayPublishErrorMessage(err));
});
if (actorId === null) {
publishOutcome({ kind: 'connecting' });
return false;
}
}, [actor, actorId]);
const intent: CallToPlayLocalIntent = { call_id: callId, action };
let acceptedResult = false;
try {
const completed = await scope.applyMutationIfActive(
() => invoke<CallToPlayReceipt>('publish_call_to_play', {
intent,
displayName: actor,
}),
_receipt => {
acceptedResult = true;
publishOutcome({ kind: 'settled', accepted: true });
},
);
return completed && acceptedResult;
} catch (err) {
if (!scope.isActive()) return false;
console.error('publish_call_to_play failed:', err);
publishOutcome({ kind: 'failed', error: err });
return false;
}
}, [actor, actorId, localNetworkSharingEnabled]);
const actions = useMemo<CallToPlayActions>(() => ({
createNomination: (gameId, maxPlayers, durationMinutes, scheduledFor) => {
const createdAt = Date.now();
const callId = globalThis.crypto.randomUUID();
const deadline = scheduledFor ?? createdAt + durationMinutes * 60_000;
void publish(callId, {
void publish(null, {
Create: {
game_id: gameId,
max_players: Math.max(2, Math.min(64, Math.round(maxPlayers))),
@@ -178,7 +233,6 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
if (!trimmed) return;
void publish(callId, {
SendMessage: {
message_id: globalThis.crypto.randomUUID(),
text: trimmed,
},
});
@@ -192,9 +246,15 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
}), [publish]);
const nominations = useMemo(
() => reduceCallToPlayEvents([...events.values()], now),
[events, now],
() => reduceCallToPlayEvents(view.events, now),
[view, now],
);
return { nominations, actions, actorId, transportReady, error };
return {
nominations,
actions,
actorId,
transportReady: transportReady && localNetworkSharingEnabled,
error,
};
};
@@ -1,153 +1,219 @@
import { useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { ask } from '@tauri-apps/plugin-dialog';
import { useCallback, useEffect, useRef } 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';
import { type UseGamesResult } from "./useGames";
import { type UISettings } from "./useSettings";
import { AsyncOwner, windowAsyncScope } from "../lib/asyncOwnership";
export interface GameActions {
play: (id: string) => Promise<void>;
startServer: (id: string) => Promise<void>;
install: (id: string) => Promise<void>;
streamInstall: (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>;
play: (id: string) => Promise<void>;
startServer: (id: string) => Promise<void>;
install: (id: string) => Promise<void>;
streamInstall: (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.
* / `uninstall_game` / `remove_downloaded_game` commands. Operation state is
* derived only from authoritative backend snapshots; cancellation waits for
* the backend to clear that snapshot.
*/
export const useGameActions = (
games: UseGamesResult,
settings: Pick<UISettings, 'language' | 'username'>,
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 ownerRef = useRef<AsyncOwner | null>(null);
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 streamInstall = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('stream_install_game', { id });
if (success) games.markChecking(id);
} catch (err) {
console.error('stream_install_game failed:', err);
}
}, [games]);
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,
streamInstall,
update,
uninstall,
removeDownload,
cancelDownload,
viewFiles,
useEffect(() => {
const owner = new AsyncOwner((error) => {
console.error("Failed to clean up a game action:", error);
});
ownerRef.current = owner;
return () => {
if (ownerRef.current === owner) ownerRef.current = null;
windowAsyncScope.adopt(owner.dispose());
};
}, []);
const applyOwned = useCallback(async <T>(
operation: () => Promise<T>,
apply: (value: T) => void = () => {},
): Promise<boolean> => {
const owner = ownerRef.current;
if (owner === null || !owner.isActive()) return false;
try {
return await owner.applyIfActive(operation, apply);
} catch (error) {
if (!owner.isActive()) return false;
throw error;
}
}, []);
const play = useCallback(async (id: string) => {
try {
await applyOwned(
() =>
invoke("run_game", {
id,
language: settings.language,
username: settings.username,
}),
);
} catch (err) {
console.error("run_game failed:", err);
}
}, [applyOwned, settings.language, settings.username]);
const startServer = useCallback(async (id: string) => {
try {
await applyOwned(
() =>
invoke("start_server", {
id,
language: settings.language,
username: settings.username,
}),
);
} catch (err) {
console.error("start_server failed:", err);
}
}, [applyOwned, settings.language, settings.username]);
const install = useCallback(async (id: string) => {
try {
await applyOwned(
() =>
invoke<boolean>("install_game", {
id,
language: settings.language,
username: settings.username,
}),
);
} catch (err) {
console.error("install_game failed:", err);
}
}, [applyOwned, settings.language, settings.username]);
const streamInstall = useCallback(async (id: string) => {
try {
await applyOwned(
() =>
invoke<boolean>("stream_install_game", {
id,
language: settings.language,
username: settings.username,
}),
);
} catch (err) {
console.error("stream_install_game failed:", err);
}
}, [applyOwned, 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
) {
let confirmed = false;
if (
!await applyOwned(
() =>
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" },
),
(answer) => {
confirmed = answer;
},
) || !confirmed
) {
return;
}
}
await applyOwned(
() =>
invoke<boolean>("update_game", {
id,
language: settings.language,
username: settings.username,
}),
);
} catch (err) {
console.error("update_game failed:", err);
}
}, [applyOwned, games, settings.language, settings.username]);
const uninstall = useCallback(async (id: string) => {
try {
await applyOwned(() => invoke("uninstall_game", { id }));
} catch (err) {
console.error("uninstall_game failed:", err);
}
}, [applyOwned]);
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
) {
let confirmed = false;
if (
!await applyOwned(
() =>
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" },
),
(answer) => {
confirmed = answer;
},
) || !confirmed
) {
return;
}
}
await applyOwned(() => invoke("remove_downloaded_game", { id }));
} catch (err) {
console.error("remove_downloaded_game failed:", err);
}
}, [applyOwned, games]);
const cancelDownload = useCallback(async (id: string) => {
try {
await applyOwned(() => invoke("cancel_download", { id }));
} catch (err) {
console.error("cancel_download failed:", err);
}
}, [applyOwned]);
const viewFiles = useCallback(async (id: string) => {
try {
await applyOwned(() => invoke("open_game_files", { id }));
} catch (err) {
console.error("open_game_files failed:", err);
}
}, [applyOwned]);
return {
play,
startServer,
install,
streamInstall,
update,
uninstall,
removeDownload,
cancelDownload,
viewFiles,
};
};
@@ -1,92 +1,143 @@
import { useCallback, useEffect, useState } from 'react';
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);
},
});
/**
* Owns the user's selected game directory. Hydrates from the persistent store
* on mount, writes back on every change, and pushes the value to the Tauri
* backend so it can scan/rescan.
* 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 = () => {
const [gameDir, setGameDir] = useState('');
const [gameDirExists, setGameDirExists] = useState(false);
export const useGameDirectory = (backendPolicyReady = true) => {
const [gameDir, setAcceptedGameDir] = useState('');
const [ready, setReady] = useState(false);
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;
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
const saved = await store.get<string>(GAME_DIR_KEY);
if (saved && !cancelled) setGameDir(saved);
} catch (err) {
console.error('Failed to load game directory:', err);
const acceptedPath = await requestGameDirectory(path);
acceptedGameDirRef.current = acceptedPath;
if (mountedRef.current && acceptingRef.current) {
setAcceptedGameDir(acceptedPath);
}
} catch (error) {
console.error(failureMessage, error);
}
};
void hydrate();
return () => {
cancelled = true;
};
});
updateQueueRef.current = update;
windowAsyncScope.adopt(update);
return update;
}, []);
useEffect(() => {
if (!gameDir.trim()) {
setGameDirExists(false);
if (!backendPolicyReady) {
setReady(false);
return;
}
let cancelled = false;
const sync = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
await store.set(GAME_DIR_KEY, gameDir);
} catch (err) {
console.error('Failed to persist game directory:', err);
}
setReady(false);
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]);
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 hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
const setGameDir = useCallback((requestedPath: string) => {
selectionVersionRef.current += 1;
void enqueueUpdate(
() => requestedPath,
'Failed to update game directory:',
);
}, [enqueueUpdate]);
const rescan = useCallback(() => {
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;
void enqueueUpdate(
() => acceptedGameDirRef.current,
'Failed to rescan game directory:',
);
}, [enqueueUpdate]);
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to rescan game directory:', err),
);
};
void sync();
}, [gameDir]);
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
return {
gameDir,
ready,
hasGameDirectory: gameDir !== '',
setGameDir,
rescan,
shutdown,
};
};
@@ -1,188 +1,271 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
DownloadProgressPayload,
Game,
GamesListPayload,
InstallStatus,
} from '../lib/types';
DownloadProgressPayload,
Game,
GamesListPayload,
GameTransferStatusSnapshot,
ProtocolMismatch,
} from "../lib/types";
import {
activeStatusById,
isInProgress,
mergeGameUpdate,
normalizeGamesListPayload,
} from '../lib/gameState';
activeStatusById,
applyDownloadProgress,
applyGameTransferStatusSnapshot,
gameTransferStatusFor,
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
mergeGameUpdate,
newestGameTransferStatusSnapshot,
} from "../lib/gameState";
import {
AsyncOwner,
type AsyncRegistration,
registerSequentially,
windowAsyncScope,
} from "../lib/asyncOwnership";
import {
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
newestProtocolMismatchSnapshot,
type ProtocolMismatchSnapshot,
} from "../lib/protocolMismatch";
interface PendingPatch {
install_status?: InstallStatus;
clearStatus?: boolean;
}
const applyPatch = (game: Game, patch: PendingPatch): Game => {
let next: Game = { ...game };
if (patch.install_status !== undefined) next.install_status = patch.install_status;
if (patch.clearStatus) {
next.status_message = undefined;
next.status_level = undefined;
}
return next;
};
/**
* Owns the games list and derives card status from backend snapshots. Returns
* a fire-and-forget `markChecking` helper so action calls can immediately show
* a "Checking peers…" state until the next backend snapshot arrives.
*/
/** Owns the games list and derives card status from backend snapshots. */
export interface UseGamesResult {
games: Game[];
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
totalPeerCount: number;
requestGames: () => Promise<void>;
markChecking: (id: string) => void;
games: Game[];
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
totalPeerCount: number;
protocolMismatch: ProtocolMismatch | null;
requestGames: () => Promise<void>;
}
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
const [games, setGames] = useState<Game[]>([]);
const [totalPeerCount, setTotalPeerCount] = useState(0);
const rescanRef = useRef(rescanGameDir);
rescanRef.current = rescanGameDir;
const [games, setGames] = useState<Game[]>([]);
const [totalPeerCount, setTotalPeerCount] = useState(0);
const [protocolMismatchSnapshot, setProtocolMismatchSnapshot] = useState(
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
);
const transferStatusRef = useRef<GameTransferStatusSnapshot>(
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
);
const rescanRef = useRef(rescanGameDir);
rescanRef.current = rescanGameDir;
const markChecking = useCallback((id: string) => {
setGames(prev => prev.map(item =>
item.id === id && !isInProgress(item.install_status)
? applyPatch(item, {
install_status: InstallStatus.CheckingPeers,
clearStatus: true,
})
: item
));
}, []);
const requestGames = useCallback(async () => {
try {
await invoke("request_games");
} catch (err) {
console.error("request_games failed:", err);
}
}, []);
const requestGames = useCallback(async () => {
try {
await invoke('request_games');
} catch (err) {
console.error('request_games failed:', err);
}
}, []);
useEffect(() => {
const owner = new AsyncOwner((error) => {
console.error("Failed to unregister game listener:", error);
});
useEffect(() => {
const unlisteners: UnlistenFn[] = [];
let cancelled = false;
const handleErrorEvent = (
id: string,
message: string,
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
) => {
setGames(prev => prev.map(item => item.id === id
? {
...item,
install_status: item.installed
? InstallStatus.Installed
: InstallStatus.NotInstalled,
status_message: message,
status_level: 'error',
download_progress: undefined,
}
: item));
if (triggerRescan) rescanRef.current();
};
const register = async () => {
try {
unlisteners.push(await listen('games-list-updated', (event) => {
const payload = normalizeGamesListPayload(
event.payload as GamesListPayload | Game[],
);
const activeStatuses = activeStatusById(payload.active_operations);
setGames(prev => {
const previousById = new Map(prev.map(item => [item.id, item]));
return payload.games.map(game => mergeGameUpdate(
game,
previousById.get(game.id),
activeStatuses.get(game.id),
));
});
}));
unlisteners.push(await listen('game-download-failed', (e) => {
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
triggerRescan: true,
});
}));
unlisteners.push(await listen('game-download-peers-gone', (e) => {
handleErrorEvent(e.payload as string, 'Failed: all peers gone.', {
triggerRescan: true,
});
}));
unlisteners.push(await listen('game-download-progress', (e) => {
const { id, ...download_progress } = e.payload as DownloadProgressPayload;
setGames(prev => prev.map(item => item.id === id
? {
...item,
download_progress,
}
: item));
}));
unlisteners.push(await listen('game-no-peers', (e) => {
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
}));
unlisteners.push(await listen('game-install-finished', () => {
rescanRef.current();
}));
unlisteners.push(await listen('game-install-failed', (e) => {
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
}));
unlisteners.push(await listen('game-uninstall-failed', (e) => {
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
}));
unlisteners.push(await listen('game-remove-download-finished', () => {
rescanRef.current();
}));
unlisteners.push(await listen('game-remove-download-failed', (e) => {
handleErrorEvent(e.payload as string, 'Remove failed. Please try again.', {
triggerRescan: true,
});
}));
unlisteners.push(await listen('peer-count-updated', (e) => {
setTotalPeerCount(e.payload as number);
}));
if (!cancelled) {
await invoke('request_games').catch(err =>
console.error('request_games failed:', err),
);
}
} catch (err) {
console.error('Failed to register game listeners:', err);
const handleErrorEvent = (
id: string,
message: string,
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
) => {
setGames((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
status_message: message,
status_level: "error",
}
};
void register();
return () => {
cancelled = true;
unlisteners.forEach(fn => fn());
};
}, []);
return {
games,
setGames,
totalPeerCount,
requestGames,
markChecking,
: item
)
);
if (triggerRescan) rescanRef.current();
};
const applyProtocolMismatchSnapshot = (
candidate: ProtocolMismatchSnapshot,
) => {
setProtocolMismatchSnapshot((current) =>
newestProtocolMismatchSnapshot(current, candidate)
);
};
const mismatchRegistration: AsyncRegistration = () =>
listen<ProtocolMismatchSnapshot>(
"protocol-mismatch-updated",
owner.guard((event) => applyProtocolMismatchSnapshot(event.payload)),
);
const applyTransferStatusSnapshot = (
candidate: GameTransferStatusSnapshot,
) => {
const current = transferStatusRef.current;
const newest = newestGameTransferStatusSnapshot(current, candidate);
if (newest === current) return;
transferStatusRef.current = newest;
setGames((previous) => applyGameTransferStatusSnapshot(previous, newest));
};
const transferStatusRegistration: AsyncRegistration = () =>
listen<GameTransferStatusSnapshot>(
"game-transfer-status-updated",
owner.guard((event) => applyTransferStatusSnapshot(event.payload)),
);
const registrations: AsyncRegistration[] = [
() =>
listen<GamesListPayload>(
"games-list-updated",
owner.guard((event) => {
const payload = event.payload;
const transferStatus = newestGameTransferStatusSnapshot(
transferStatusRef.current,
payload.transfer_status,
);
transferStatusRef.current = transferStatus;
const activeStatuses = activeStatusById(payload.active_operations);
setGames((prev) => {
const previousById = new Map(prev.map((item) => [item.id, item]));
return applyGameTransferStatusSnapshot(
payload.games.map((game) =>
mergeGameUpdate(
game,
previousById.get(game.id),
activeStatuses.get(game.id),
gameTransferStatusFor(transferStatus, game.id),
)
),
transferStatus,
);
});
}),
),
() =>
listen<string>(
"game-download-failed",
owner.guard((event) => {
handleErrorEvent(
event.payload,
"Download failed. Please try again.",
{
triggerRescan: true,
},
);
}),
),
() =>
listen<DownloadProgressPayload>(
"game-download-progress",
owner.guard((event) => {
const transferStatus = transferStatusRef.current;
setGames((prev) =>
applyDownloadProgress(prev, transferStatus, event.payload)
);
}),
),
() =>
listen(
"game-install-finished",
owner.guard(() => {
rescanRef.current();
}),
),
() =>
listen<string>(
"game-install-failed",
owner.guard((event) => {
handleErrorEvent(
event.payload,
"Install failed. Please try again.",
);
}),
),
() =>
listen<string>(
"game-uninstall-failed",
owner.guard((event) => {
handleErrorEvent(
event.payload,
"Uninstall failed. Please try again.",
);
}),
),
() =>
listen(
"game-remove-download-finished",
owner.guard(() => {
rescanRef.current();
}),
),
() =>
listen<string>(
"game-remove-download-failed",
owner.guard((event) => {
handleErrorEvent(
event.payload,
"Remove failed. Please try again.",
{
triggerRescan: true,
},
);
}),
),
() =>
listen<number>(
"peer-count-updated",
owner.guard((event) => {
setTotalPeerCount(event.payload);
}),
),
];
const register = async () => {
try {
if (
!await registerSequentially(owner, [
mismatchRegistration,
transferStatusRegistration,
...registrations,
])
) return;
} catch (err) {
console.error("Failed to register game listeners:", err);
return;
}
try {
await owner.applyIfActive(
() => invoke<ProtocolMismatchSnapshot>("get_protocol_mismatch"),
applyProtocolMismatchSnapshot,
);
} catch (err) {
if (owner.isActive()) {
console.error("get_protocol_mismatch failed:", err);
}
}
try {
await owner.applyIfActive(
() => invoke("request_games"),
() => {},
);
} catch (err) {
if (owner.isActive()) console.error("request_games failed:", err);
}
};
void register();
return () => {
windowAsyncScope.adopt(owner.dispose());
};
}, []);
return {
games,
setGames,
totalPeerCount,
protocolMismatch: protocolMismatchSnapshot.mismatch,
requestGames,
};
};
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { AsyncOwner, windowAsyncScope } from '../lib/asyncOwnership';
import {
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
type IdentityDiagnosticSnapshot,
newestIdentityDiagnosticSnapshot,
} from '../lib/identityDiagnostic';
export interface UseIdentityDiagnostic {
snapshot: IdentityDiagnosticSnapshot;
ready: boolean;
unavailable: boolean;
}
export const useIdentityDiagnostic = (): UseIdentityDiagnostic => {
const [snapshot, setSnapshot] = useState(INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT);
const [ready, setReady] = useState(false);
const [unavailable, setUnavailable] = useState(true);
useEffect(() => {
const owner = new AsyncOwner(error => {
console.error('Failed to clean up identity diagnostic state:', error);
});
const applySnapshot = (candidate: IdentityDiagnosticSnapshot) => {
setSnapshot(current => newestIdentityDiagnosticSnapshot(current, candidate));
setUnavailable(false);
};
const register = async () => {
try {
if (!await owner.register(() =>
listen<IdentityDiagnosticSnapshot>(
'identity-diagnostic-updated',
owner.guard(event => applySnapshot(event.payload)),
)
)) return;
await owner.applyIfActive(
() => invoke<IdentityDiagnosticSnapshot>('get_identity_diagnostic'),
applySnapshot,
);
} catch (error) {
if (owner.isActive()) {
console.error('Failed to initialize identity diagnostic state:', error);
}
} finally {
if (owner.isActive()) setReady(true);
}
};
windowAsyncScope.adopt(register());
return () => {
windowAsyncScope.adopt(owner.dispose());
};
}, []);
return { snapshot, ready, unavailable };
};
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { windowAsyncScope } from '../lib/asyncOwnership';
import {
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
LocalNetworkSharingAsyncScope,
type LocalNetworkSharingSnapshot,
newestLocalNetworkSharingSnapshot,
} from '../lib/localNetworkSharing';
const CHANGE_FAILED = 'Local network sharing could not be changed.';
interface MutationResult {
snapshot: LocalNetworkSharingSnapshot | null;
error: string | null;
}
export interface UseLocalNetworkSharing {
snapshot: LocalNetworkSharingSnapshot;
ready: boolean;
busy: boolean;
error: string | null;
setEnabled: (enabled: boolean) => void;
}
export const useLocalNetworkSharing = (): UseLocalNetworkSharing => {
const [snapshot, setSnapshot] = useState(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT);
const [ready, setReady] = useState(false);
const [pendingInvokes, setPendingInvokes] = useState(0);
const [error, setError] = useState<string | null>(null);
const scopeRef = useRef<LocalNetworkSharingAsyncScope | null>(null);
const applySnapshot = useCallback((candidate: LocalNetworkSharingSnapshot) => {
setSnapshot(current => newestLocalNetworkSharingSnapshot(current, candidate));
}, []);
useEffect(() => {
const scope = new LocalNetworkSharingAsyncScope(cleanupError => {
console.error('Failed to clean up Local network sharing state:', cleanupError);
});
scopeRef.current = scope;
const register = async () => {
try {
if (!await scope.registerListener(() =>
listen<LocalNetworkSharingSnapshot>(
'local-network-sharing-updated',
scope.guard(event => applySnapshot(event.payload)),
)
)) return;
await scope.applyIfActive(
() => invoke<LocalNetworkSharingSnapshot>('get_local_network_sharing'),
applySnapshot,
);
} catch (registrationError) {
if (scope.isActive()) {
console.error('Failed to initialize Local network sharing state:', registrationError);
}
} finally {
if (scope.isActive()) setReady(true);
}
};
const registration = register();
windowAsyncScope.adopt(registration);
return () => {
if (scopeRef.current === scope) scopeRef.current = null;
windowAsyncScope.adopt(scope.dispose());
};
}, [applySnapshot]);
const setEnabled = useCallback((enabled: boolean) => {
const scope = scopeRef.current;
if (scope === null || !scope.isActive()) return;
setPendingInvokes(count => count + 1);
const mutation = scope.applyMutationIfActive<MutationResult>(async () => {
try {
return {
snapshot: await invoke<LocalNetworkSharingSnapshot>(
'set_local_network_sharing',
{ enabled },
),
error: null,
};
} catch (mutationError) {
console.error('Failed to change Local network sharing:', mutationError);
try {
return {
snapshot: await invoke<LocalNetworkSharingSnapshot>(
'get_local_network_sharing',
),
error: CHANGE_FAILED,
};
} catch (queryError) {
console.error(
'Failed to resynchronize Local network sharing:',
queryError,
);
return { snapshot: null, error: CHANGE_FAILED };
}
}
}, result => {
if (result.snapshot !== null) applySnapshot(result.snapshot);
setError(result.error);
});
const settled = mutation.finally(scope.guard(() => {
setPendingInvokes(count => Math.max(0, count - 1));
}));
windowAsyncScope.adopt(settled);
}, [applySnapshot]);
return {
snapshot,
ready,
busy: pendingInvokes > 0 || snapshot.pendingTarget !== null,
error,
setEnabled,
};
};
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { load } from '@tauri-apps/plugin-store';
import { type GameFilter, type GameSort, type LauncherLanguage } from '../lib/types';
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
import {
createSerializedAsyncWriter,
mergeHydratedState,
windowAsyncScope,
} from '../lib/asyncOwnership';
import {
startAdmittedPersistenceEffect,
windowPersistenceScope,
} from '../lib/frontendPersistence';
export type Density = 'compact' | 'normal' | 'large';
export type CoverAspect = 'box' | 'square' | 'banner';
@@ -102,8 +111,21 @@ export interface UseSettings {
settings: UISettings;
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
ready: boolean;
shutdown: () => Promise<void>;
}
const settingsWriter = createSerializedAsyncWriter<Partial<UISettings>>(
async patch => {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
await store.set(UI_SETTINGS_KEY, {
...sanitize(saved ?? undefined),
...patch,
});
},
error => console.error('Failed to persist UI settings:', error),
);
/**
* Loads UI preferences from the Tauri persistent store once on mount and
* writes every change back through it. Components only see a synchronous
@@ -112,44 +134,69 @@ export interface UseSettings {
export const useSettings = (): UseSettings => {
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
const [ready, setReady] = useState(false);
const settingsRef = useRef(DEFAULT_SETTINGS);
const hydratedRef = useRef(false);
const pendingEditsRef = useRef<Partial<UISettings>>({});
const acceptingRef = useRef(false);
const hydrationRef = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
let cancelled = false;
const init = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
if (!cancelled) {
setSettings(sanitize(saved ?? undefined));
}
} catch (err) {
console.error('Failed to load UI settings:', err);
} finally {
if (!cancelled) setReady(true);
}
};
void init();
return () => {
cancelled = true;
};
const shutdown = useCallback(async (): Promise<void> => {
acceptingRef.current = false;
await hydrationRef.current;
await settingsWriter.closeAndWait();
}, []);
useEffect(() => startAdmittedPersistenceEffect(
windowPersistenceScope,
shutdown,
() => {
let cancelled = false;
acceptingRef.current = true;
const init = async () => {
try {
await settingsWriter.waitForIdle();
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
if (!cancelled && acceptingRef.current) {
const restored = mergeHydratedState(
sanitize(saved ?? undefined),
pendingEditsRef.current,
);
pendingEditsRef.current = {};
hydratedRef.current = true;
settingsRef.current = restored;
setSettings(restored);
}
} catch (err) {
console.error('Failed to load UI settings:', err);
} finally {
if (!cancelled && acceptingRef.current) setReady(true);
}
};
const hydration = init();
hydrationRef.current = hydration;
windowAsyncScope.adopt(hydration);
return () => {
cancelled = true;
acceptingRef.current = false;
};
},
), [shutdown]);
const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
setSettings(prev => {
const next = { ...prev, [key]: value };
void persist(next);
return next;
});
if (!acceptingRef.current) return;
const next = { ...settingsRef.current, [key]: value };
if (!hydratedRef.current) {
pendingEditsRef.current = {
...pendingEditsRef.current,
[key]: value,
};
}
settingsRef.current = next;
setSettings(next);
windowAsyncScope.adopt(settingsWriter.enqueue({ [key]: value }));
}, []);
return { settings, set, ready };
};
const persist = async (settings: UISettings): Promise<void> => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
await store.set(UI_SETTINGS_KEY, settings);
} catch (err) {
console.error('Failed to persist UI settings:', err);
}
return { settings, set, ready, shutdown };
};
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { AsyncOwner, windowAsyncScope } from '../lib/asyncOwnership';
interface CapabilityResult {
gameId: string;
supported: boolean;
}
/**
* Lazily resolves catalog-owned Stream Install support for the game whose
* detail modal is currently open. Replacing or closing the selection disposes
* and joins that generation, so a late result cannot affect another game.
*/
export const useStreamInstallCapability = (gameId: string | null): boolean => {
const [result, setResult] = useState<CapabilityResult | null>(null);
useEffect(() => {
if (gameId === null) return;
const owner = new AsyncOwner(error => {
console.error('Failed to clean up Stream Install capability lookup:', error);
});
const lookup = owner.applyIfActive(
async () => {
try {
return await invoke<boolean>('supports_streamed_install', { id: gameId });
} catch (error) {
console.error(`Failed to resolve Stream Install capability for ${gameId}:`, error);
return false;
}
},
supported => setResult({ gameId, supported }),
);
windowAsyncScope.adopt(lookup);
return () => {
windowAsyncScope.adopt(owner.dispose());
};
}, [gameId]);
return result?.gameId === gameId && result.supported;
};
@@ -1,28 +1,41 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
createThumbnailRequestKey,
startOwnedThumbnailGeneration,
ThumbnailRequestGeneration,
thumbnailIdsFromRequestKey,
} from '../lib/thumbnailRequests';
import { windowAsyncScope } from '../lib/asyncOwnership';
/**
* Lazy, per-id cache for cover thumbnails. Returns `null` until the value is
* known; returns the empty string when the backend has nothing for the id, so
* callers can fall back to the placeholder cover art.
* Per-id cache for cover thumbnails belonging to the supplied game ids.
* Returns `null` until the value is known; returns the empty string when the
* backend has nothing for the id, so callers can use the placeholder cover.
*/
export const useThumbnails = () => {
export const useThumbnails = (ids: readonly string[]) => {
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
const pending = useRef<Set<string>>(new Set());
const thumbnailsRef = useRef(thumbnails);
const requestKey = createThumbnailRequestKey(ids);
useEffect(() => {
const generation = new ThumbnailRequestGeneration(
id => invoke<string>('get_game_thumbnail', { gameId: id }),
(id, url) => {
const next = new Map(thumbnailsRef.current).set(id, url);
thumbnailsRef.current = next;
setThumbnails(next);
},
);
const missingIds = thumbnailIdsFromRequestKey(requestKey)
.filter(id => !thumbnailsRef.current.has(id));
return startOwnedThumbnailGeneration(windowAsyncScope, generation, missingIds);
}, [requestKey]);
const get = useCallback((id: string): string | null => {
if (thumbnails.has(id)) return thumbnails.get(id) ?? '';
if (pending.current.has(id)) return null;
pending.current.add(id);
invoke<string>('get_game_thumbnail', { gameId: id })
.then(url => {
pending.current.delete(id);
setThumbnails(prev => new Map(prev).set(id, url));
})
.catch(() => {
pending.current.delete(id);
setThumbnails(prev => new Map(prev).set(id, ''));
});
return null;
}, [thumbnails]);
@@ -0,0 +1,444 @@
export type AsyncCleanup = () => void | Promise<void>;
export type AsyncRegistration = () => Promise<AsyncCleanup>;
/**
* Observes promises whose lexical React cleanup cannot await. The Tauri event
* API removes its JavaScript callback synchronously; this scope owns the
* remaining backend acknowledgement while the webview realm is alive.
*/
export class AsyncAdoptionScope {
private readonly pending = new Set<Promise<unknown>>();
private readonly disposers = new Set<() => Promise<void>>();
private acceptingDisposers = true;
public constructor(
private readonly reportFailure: (error: unknown) => void = () => {},
) {}
public adopt<T>(promise: Promise<T>): void {
this.pending.add(promise);
void promise.then(
() => this.pending.delete(promise),
(error) => {
this.pending.delete(promise);
try {
this.reportFailure(error);
} catch {
// Reporting must not create a second unhandled rejection.
}
},
);
}
public registerDisposer(
dispose: () => Promise<void>,
): (() => void) | undefined {
if (!this.acceptingDisposers) return undefined;
this.disposers.add(dispose);
return () => this.disposers.delete(dispose);
}
/** Invalidates and drains all mounted scopes before native webview destruction. */
public async disposeOwned(): Promise<void> {
this.acceptingDisposers = false;
const disposers = [...this.disposers];
this.disposers.clear();
const pending: Promise<void>[] = [];
for (const dispose of disposers) {
try {
const disposal = dispose();
pending.push(disposal);
this.adopt(disposal);
} catch (error) {
try {
this.reportFailure(error);
} catch {
// Reporting must not interrupt the other disposers.
}
}
}
await Promise.allSettled(pending);
await this.drain();
}
public async drain(): Promise<void> {
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
}
export const windowAsyncScope = new AsyncAdoptionScope((error) => {
console.error("Detached frontend cleanup failed:", error);
});
export const mergeHydratedState = <T extends object>(
restored: T,
pendingEdits: Partial<T>,
): T => ({ ...restored, ...pendingEdits });
/**
* Owns asynchronous registrations and result publication for one mounted scope.
* Disposal invalidates the scope and invokes owned cleanup synchronously. Its
* returned promise drains asynchronous cleanup acknowledgements, including a
* listener that finishes registering after disposal.
*/
export class AsyncOwner {
private disposed = false;
private cleanups: AsyncCleanup[] = [];
private readonly pending = new Set<Promise<unknown>>();
private readonly pendingRegistrations = new Set<Promise<unknown>>();
private readonly pendingCleanups = new Set<Promise<unknown>>();
private latestOperation = 0;
private readonly releaseWindowOwnership: () => void;
public constructor(
private readonly reportCleanupError: (error: unknown) => void = () => {},
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
) {
const release = adoptionScope.registerDisposer(
() => this.disposeForWindowClose(),
);
if (release === undefined) {
this.disposed = true;
this.releaseWindowOwnership = () => {};
} else {
this.releaseWindowOwnership = release;
}
}
public isActive(): boolean {
return !this.disposed;
}
public guard<Args extends unknown[]>(
callback: (...args: Args) => void,
): (...args: Args) => void {
return (...args) => {
if (this.isActive()) callback(...args);
};
}
public register(registration: AsyncRegistration): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
return this.trackRegistration(this.registerInner(registration));
}
public own(cleanup: AsyncCleanup): boolean {
if (!this.isActive()) {
void this.release(cleanup);
return false;
}
this.cleanups.push(cleanup);
return true;
}
public release(cleanup: AsyncCleanup): Promise<void> {
return this.trackCleanup(this.runCleanup(cleanup));
}
private async registerInner(
registration: AsyncRegistration,
): Promise<boolean> {
if (!this.isActive()) return false;
let cleanup: AsyncCleanup;
try {
cleanup = await registration();
} catch (error) {
if (!this.isActive()) return false;
throw error;
}
if (!this.isActive()) {
await this.release(cleanup);
return false;
}
return this.own(cleanup);
}
public applyIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
return this.track(this.applyActive(operation, apply));
}
public applyLatestIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
const generation = ++this.latestOperation;
return this.track(this.applyActive(operation, (value) => {
if (generation === this.latestOperation) apply(value);
}, generation));
}
private async applyActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
generation?: number,
): Promise<boolean> {
if (!this.isActive()) return false;
let value: T;
try {
value = await operation();
} catch (error) {
if (
!this.isActive() ||
(generation !== undefined && generation !== this.latestOperation)
) {
return false;
}
throw error;
}
if (
!this.isActive() ||
(generation !== undefined && generation !== this.latestOperation)
) {
return false;
}
apply(value);
return true;
}
public dispose(): Promise<void> {
this.beginDispose();
return this.drain();
}
/** Drains every admitted operation before native webview destruction. */
public disposeForWindowClose(): Promise<void> {
this.beginDispose();
return this.drain();
}
private beginDispose(): void {
if (!this.isActive()) return;
this.disposed = true;
this.releaseWindowOwnership();
this.latestOperation += 1;
const cleanups = this.cleanups.reverse();
this.cleanups = [];
for (const cleanup of cleanups) void this.release(cleanup);
}
public async drain(): Promise<void> {
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
private track<T>(promise: Promise<T>): Promise<T> {
this.pending.add(promise);
void promise.then(
() => this.pending.delete(promise),
() => this.pending.delete(promise),
);
return promise;
}
private trackRegistration<T>(promise: Promise<T>): Promise<T> {
this.pendingRegistrations.add(promise);
void promise.then(
() => this.pendingRegistrations.delete(promise),
() => this.pendingRegistrations.delete(promise),
);
return this.track(promise);
}
private trackCleanup(promise: Promise<void>): Promise<void> {
this.pendingCleanups.add(promise);
void promise.then(
() => this.pendingCleanups.delete(promise),
() => this.pendingCleanups.delete(promise),
);
return this.track(promise);
}
private async runCleanup(cleanup: AsyncCleanup): Promise<void> {
try {
await cleanup();
} catch (error) {
try {
this.reportCleanupError(error);
} catch {
// Cleanup reporting must not interrupt the remaining cleanup.
}
}
}
}
export interface CompanionWindowCreationPort {
registerCreated: (handler: () => void) => Promise<AsyncCleanup>;
registerError: (handler: (payload: unknown) => void) => Promise<AsyncCleanup>;
destroy: () => Promise<void>;
}
export type CompanionWindowCreationResult =
| { kind: "created" }
| { kind: "error"; payload: unknown }
| { kind: "registration-error"; error: unknown };
/**
* Owns one native companion-window creation through exactly one created/error
* outcome. Parent disposal keeps the internal callbacks alive until that
* outcome, drains both unlisten acknowledgements, and destroys a window that
* finishes creating after close admission has ended.
*/
export const ownCompanionWindowCreation = async (
owner: AsyncOwner,
port: CompanionWindowCreationPort,
): Promise<CompanionWindowCreationResult> => {
let settle!: (result: CompanionWindowCreationResult) => void;
let settled = false;
const outcome = new Promise<CompanionWindowCreationResult>((resolve) => {
settle = (result) => {
if (settled) return;
settled = true;
resolve(result);
};
});
const register = (
operation: () => Promise<AsyncCleanup>,
): Promise<AsyncCleanup> => {
try {
return operation();
} catch (error) {
return Promise.reject(error);
}
};
const createdRegistration = register(() =>
port.registerCreated(() => settle({ kind: "created" }))
);
const errorRegistration = register(() =>
port.registerError((payload) => settle({ kind: "error", payload }))
);
void createdRegistration.catch((error) =>
settle({ kind: "registration-error", error })
);
void errorRegistration.catch((error) =>
settle({ kind: "registration-error", error })
);
let parentClosing = false;
let completion: Promise<CompanionWindowCreationResult> | undefined;
const complete = (): Promise<CompanionWindowCreationResult> => {
if (completion !== undefined) return completion;
completion = (async () => {
const result = await outcome;
const registrations = await Promise.allSettled([
createdRegistration,
errorRegistration,
]);
const cleanupResults = await Promise.allSettled(
registrations.flatMap((registration) =>
registration.status === "fulfilled"
? [Promise.resolve().then(registration.value)]
: []
),
);
const cleanupFailure = cleanupResults.find(
(cleanup) => cleanup.status === "rejected",
);
if (
result.kind === "registration-error" ||
(result.kind === "created" && parentClosing)
) {
await port.destroy();
}
if (cleanupFailure?.status === "rejected") throw cleanupFailure.reason;
return result;
})();
return completion;
};
if (
!owner.own(async () => {
parentClosing = true;
await complete();
})
) {
parentClosing = true;
}
return complete();
};
export const registerSequentially = async (
owner: AsyncOwner,
registrations: ReadonlyArray<AsyncRegistration>,
): Promise<boolean> => {
try {
for (const registration of registrations) {
if (!await owner.register(registration)) return false;
}
return owner.isActive();
} catch (error) {
await owner.dispose();
throw error;
}
};
export interface SerializedAsyncWriter<T> {
enqueue: (value: T) => Promise<void>;
waitForIdle: () => Promise<void>;
closeAndWait: () => Promise<void>;
}
/** Serializes writes and keeps a failed write from poisoning later work. */
export const createSerializedAsyncWriter = <T>(
write: (value: T) => Promise<void>,
reportFailure: (error: unknown) => void,
): SerializedAsyncWriter<T> => {
let tail = Promise.resolve();
let accepting = true;
const enqueue = (value: T): Promise<void> => {
if (!accepting) return Promise.resolve();
tail = tail.then(async () => {
try {
await write(value);
} catch (error) {
try {
reportFailure(error);
} catch {
// Reporting must not prevent a newer value from being written.
}
}
});
return tail;
};
const waitForIdle = async (): Promise<void> => {
let observed: Promise<void>;
do {
observed = tail;
await observed;
} while (observed !== tail);
};
return {
enqueue,
waitForIdle,
closeAndWait: () => {
accepting = false;
return waitForIdle();
},
};
};
@@ -1,6 +1,7 @@
import {
CallToPlayAction,
CallToPlayEvent,
CallToPlayView,
CallToPlayViewEvent,
CallToPlayParticipant,
Nomination,
} from './types';
@@ -13,17 +14,22 @@ export const CALL_TO_PLAY_CONNECTING_MESSAGE =
export const callToPlayPublishErrorMessage = (error: unknown): string => {
const detail = error instanceof Error ? error.message : String(error);
if (detail.includes('Call to Play event is obsolete')
|| detail.includes('Call to Play history is missing')
if (detail.includes('unknown or expired')
|| detail.includes('already terminal')
) {
return 'This Call to Play has expired or already finished.';
}
if (detail.includes('Call to Play event history is full')) {
if (detail.includes('local event history is full')) {
return 'Call to Play has reached its active update limit. Start or cancel an active call, then try again.';
}
return 'Could not send this Call to Play update.';
};
export const replaceCallToPlayView = (
_previous: CallToPlayView,
incoming: CallToPlayView,
): CallToPlayView => incoming;
export const extendDeadline = (
now: number,
currentDeadline: number,
@@ -44,7 +50,7 @@ interface MutableNomination extends Nomination {
messageIds: Set<string>;
}
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
const compareEvents = (a: CallToPlayViewEvent, b: CallToPlayViewEvent): number =>
a.at - b.at || a.id.localeCompare(b.id);
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
@@ -117,7 +123,7 @@ export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus
};
export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>,
input: ReadonlyArray<CallToPlayViewEvent>,
now: number,
): Nomination[] => {
const nominations = [...groupEvents(input).values()]
@@ -126,35 +132,11 @@ export const reduceCallToPlayEvents = (
return sortNominations(nominations);
};
export const pruneCallToPlayEvents = (
previous: ReadonlyMap<string, CallToPlayEvent>,
now: number,
): ReadonlyMap<string, CallToPlayEvent> => {
const retiredEventIds = new Set<string>();
for (const events of groupEvents([...previous.values()]).values()) {
if (deriveNomination(events, now) !== null) continue;
const hasCreate = events.some(event => createPayload(event.action) !== null);
const expiredTombstone = events.some(event =>
(event.action === 'Start' || event.action === 'Cancel')
&& now - event.at > TERMINAL_RETENTION_MS
);
if (hasCreate || expiredTombstone) {
for (const event of events) retiredEventIds.add(event.id);
}
}
if (retiredEventIds.size === 0) return previous;
const next = new Map(previous);
for (const eventId of retiredEventIds) next.delete(eventId);
return next;
};
const groupEvents = (
input: ReadonlyArray<CallToPlayEvent>,
): Map<string, CallToPlayEvent[]> => {
input: ReadonlyArray<CallToPlayViewEvent>,
): Map<string, CallToPlayViewEvent[]> => {
const unique = new Map(input.map(event => [event.id, event]));
const byCall = new Map<string, CallToPlayEvent[]>();
const byCall = new Map<string, CallToPlayViewEvent[]>();
for (const event of unique.values()) {
const events = byCall.get(event.call_id) ?? [];
events.push(event);
@@ -164,7 +146,7 @@ const groupEvents = (
};
const deriveNomination = (
events: CallToPlayEvent[],
events: CallToPlayViewEvent[],
now: number,
): Nomination | null => {
events.sort(compareEvents);
@@ -176,15 +158,15 @@ const deriveNomination = (
const nomination: MutableNomination = {
id: create.call_id,
gameId: payload.game_id,
creatorId: create.actor_id,
creator: create.actor_name,
creatorId: create.author_id,
creator: create.author_name,
maxPlayers: payload.max_players,
createdAt: create.at,
scheduledFor: payload.scheduled_for,
deadline: payload.deadline,
participants: {
[create.actor_id]: {
name: create.actor_name,
[create.author_id]: {
name: create.author_name,
status: payload.scheduled_for === null ? 'ready' : 'in',
joinedAt: create.at,
},
@@ -214,7 +196,7 @@ const deriveNomination = (
return result;
};
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
const applyEvent = (nomination: MutableNomination, event: CallToPlayViewEvent): void => {
if (isTerminal(nomination)) return;
const action = event.action;
@@ -225,9 +207,9 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const response = respondPayload(action);
if (response) {
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
const existing = nomination.participants[event.author_id];
nomination.participants[event.author_id] = {
name: event.author_name,
status: response.ready_at === null ? 'ready' : 'pending',
joinedAt: existing?.joinedAt ?? event.at,
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
@@ -236,12 +218,12 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
}
const message = messagePayload(action);
if (message && !nomination.messageIds.has(message.message_id)) {
nomination.messageIds.add(message.message_id);
if (message && !nomination.messageIds.has(event.id)) {
nomination.messageIds.add(event.id);
nomination.messages.push({
id: message.message_id,
fromId: event.actor_id,
from: event.actor_name,
id: event.id,
fromId: event.author_id,
from: event.author_name,
text: message.text,
at: event.at,
});
@@ -251,7 +233,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const extension = addTimePayload(action);
if (extension
&& event.actor_id === nomination.creatorId
&& event.author_id === nomination.creatorId
) {
nomination.deadline = extension.deadline;
nomination.state = 'open';
@@ -260,32 +242,32 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
const applyUnitAction = (
nomination: MutableNomination,
event: CallToPlayEvent,
event: CallToPlayViewEvent,
action: Extract<CallToPlayAction, string>,
): void => {
switch (action) {
case 'Rsvp': {
const existing = nomination.participants[event.actor_id];
nomination.participants[event.actor_id] = {
name: event.actor_name,
const existing = nomination.participants[event.author_id];
nomination.participants[event.author_id] = {
name: event.author_name,
status: 'in',
joinedAt: existing?.joinedAt ?? event.at,
};
break;
}
case 'Leave':
if (event.actor_id !== nomination.creatorId) {
delete nomination.participants[event.actor_id];
if (event.author_id !== nomination.creatorId) {
delete nomination.participants[event.author_id];
}
break;
case 'Cancel':
if (event.actor_id === nomination.creatorId) {
if (event.author_id === nomination.creatorId) {
nomination.state = 'cancelled';
nomination.terminalAt = event.at;
}
break;
case 'Start':
if (event.actor_id === nomination.creatorId) {
if (event.author_id === nomination.creatorId) {
nomination.state = 'running';
nomination.terminalAt = event.at;
}
@@ -293,21 +275,6 @@ const applyUnitAction = (
}
};
export const callToPlayEvent = (
callId: string,
actorId: string,
actorName: string,
action: CallToPlayAction,
at = Date.now(),
): CallToPlayEvent => ({
id: globalThis.crypto.randomUUID(),
call_id: callId,
actor_id: actorId,
actor_name: actorName,
at,
action,
});
export const formatClock = (timestamp: number): string => {
const date = new Date(timestamp);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
@@ -0,0 +1,223 @@
import {
AsyncOwner,
type AsyncCleanup,
type AsyncRegistration,
windowAsyncScope,
} from './asyncOwnership';
export type CallToPlayRetryCallback = () => Promise<void>;
export type CallToPlayRetryScheduler = (
callback: CallToPlayRetryCallback,
delayMilliseconds: number,
) => AsyncCleanup;
/**
* Owns the Call to Play listener, snapshots, actions, and retry timer as one
* lifecycle. A retry is scheduled only after the preceding attempt settles.
*/
export class CallToPlayAsyncScope {
private readonly owner: AsyncOwner;
private retryAttempt: (() => Promise<boolean>) | undefined;
private retryCleanup: AsyncCleanup | undefined;
private retryToken: object | undefined;
private retryRunning = false;
private retryEnabled = false;
private retryGeneration = 0;
private retryDelayMilliseconds = 0;
private actionGeneration = 0;
private mutationTail: Promise<void> = Promise.resolve();
private mutationAdmissionOpen = true;
private readonly releaseWindowOwnership: () => void;
public constructor(
private readonly scheduleRetry: CallToPlayRetryScheduler,
reportCleanupFailure: (error: unknown) => void = () => {},
private readonly reportRetryError: (error: unknown) => void = () => {},
) {
this.owner = new AsyncOwner(reportCleanupFailure);
this.releaseWindowOwnership = windowAsyncScope.registerDisposer(
() => this.disposeForWindowClose(),
) ?? (() => {});
}
public isActive(): boolean {
return this.owner.isActive();
}
public guard<Args extends unknown[]>(
callback: (...args: Args) => void,
): (...args: Args) => void {
return this.owner.guard(callback);
}
public registerListener(registration: AsyncRegistration): Promise<boolean> {
return this.owner.register(registration);
}
public applyIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
return this.owner.applyIfActive(operation, apply);
}
/**
* Admits a backend mutation in user order. Once admitted, a mutation runs
* even if the React scope is disposed while it is queued; disposal closes
* admission and drains the already-admitted sequence. Result publication
* remains lifecycle-guarded.
*/
public applyMutationIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive() || !this.mutationAdmissionOpen) {
return Promise.resolve(false);
}
const mutation = this.mutationTail.then(async () => {
const value = await operation();
if (!this.isActive()) return false;
apply(value);
return true;
});
this.mutationTail = mutation.then(
() => {},
() => {},
);
return mutation;
}
public guardLatestAction<Args extends unknown[]>(
publish: (...args: Args) => void,
): (...args: Args) => void {
const generation = ++this.actionGeneration;
return this.owner.guard((...args) => {
if (generation === this.actionGeneration) publish(...args);
});
}
public startRetry(
attempt: () => Promise<boolean>,
delayMilliseconds: number,
): void {
if (!this.isActive() || this.retryEnabled) return;
this.retryAttempt = attempt;
this.retryDelayMilliseconds = delayMilliseconds;
this.retryEnabled = true;
this.retryGeneration += 1;
this.scheduleNextRetry();
}
public stopRetry(): void {
this.retryEnabled = false;
this.retryGeneration += 1;
this.clearScheduledRetry();
}
public dispose(): Promise<void> {
this.releaseWindowOwnership();
this.stopRetry();
this.mutationAdmissionOpen = false;
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
}
private disposeForWindowClose(): Promise<void> {
this.releaseWindowOwnership();
this.stopRetry();
this.mutationAdmissionOpen = false;
return Promise.all([
this.mutationTail,
this.owner.disposeForWindowClose(),
]).then(() => {});
}
private scheduleNextRetry(): void {
if (
!this.isActive()
|| !this.retryEnabled
|| this.retryRunning
|| this.retryCleanup !== undefined
) {
return;
}
const generation = this.retryGeneration;
const token = {};
const cleanup = this.scheduleRetry(
async () => {
await this.owner.applyIfActive(
() => this.runRetry(generation, token),
() => {},
);
},
this.retryDelayMilliseconds,
);
if (
!this.isActive()
|| !this.retryEnabled
|| generation !== this.retryGeneration
) {
void this.owner.release(cleanup);
return;
}
this.retryToken = token;
this.retryCleanup = cleanup;
}
private async runRetry(generation: number, token: object): Promise<void> {
if (token !== this.retryToken) return;
this.retryToken = undefined;
this.retryCleanup = undefined;
if (
!this.isActive()
|| !this.retryEnabled
|| generation !== this.retryGeneration
|| this.retryRunning
) {
return;
}
const attempt = this.retryAttempt;
if (attempt === undefined) return;
this.retryRunning = true;
try {
const ready = await attempt();
if (
ready
&& this.isActive()
&& this.retryEnabled
&& generation === this.retryGeneration
) {
this.stopRetry();
}
} catch (error) {
if (
this.isActive()
&& this.retryEnabled
&& generation === this.retryGeneration
) {
try {
this.reportRetryError(error);
} catch {
// Retry error reporting must not detach the owned loop.
}
}
} finally {
this.retryRunning = false;
this.scheduleNextRetry();
}
}
private clearScheduledRetry(): void {
this.retryToken = undefined;
const cleanup = this.retryCleanup;
this.retryCleanup = undefined;
if (cleanup === undefined) return;
void this.owner.release(cleanup);
}
}
@@ -0,0 +1,127 @@
import { type AsyncCleanup } from './asyncOwnership';
export interface FrontendCloseRequest {
preventDefault: () => void;
}
export interface FrontendBootstrapOptions {
registerCloseRequested: (
handler: (event: FrontendCloseRequest) => void | Promise<void>,
) => Promise<AsyncCleanup>;
disposeOwners: () => Promise<void>;
drainPersistence: () => Promise<void>;
destroyWindow: () => Promise<void>;
render: () => void;
reportFailure: (message: string, error: unknown) => void;
}
const report = (
options: FrontendBootstrapOptions,
message: string,
error: unknown,
): void => {
try {
options.reportFailure(message, error);
} catch {
// Reporting must not turn a handled bootstrap failure into a rejection.
}
};
const runHandled = async (
options: FrontendBootstrapOptions,
message: string,
operation: () => void | Promise<void>,
): Promise<void> => {
try {
await operation();
} catch (error) {
report(options, message, error);
}
};
/**
* Installs the webview's close boundary before mounting any React hooks.
* Registration failure closes the empty realm instead of running without an
* ownership boundary.
*/
export const bootstrapFrontend = async (
options: FrontendBootstrapOptions,
): Promise<boolean> => {
let closing: Promise<void> | undefined;
let publishCloseCleanup!: (cleanup: AsyncCleanup) => void;
const closeCleanupReady = new Promise<AsyncCleanup>(resolve => {
publishCloseCleanup = resolve;
});
const finalizeWindow = (): Promise<void> => {
if (closing !== undefined) return closing;
// Calling every operation closes its admission or starts its cleanup
// synchronously, before any asynchronous drain is awaited.
const ownerDrain = runHandled(
options,
'Failed to dispose frontend window owners:',
options.disposeOwners,
);
const persistenceDrain = runHandled(
options,
'Failed to drain frontend persistence:',
options.drainPersistence,
);
const listenerDrain = runHandled(
options,
'Failed to unregister the window close listener:',
async () => {
const cleanup = await closeCleanupReady;
await cleanup();
},
);
closing = Promise.all([
ownerDrain,
persistenceDrain,
listenerDrain,
])
.then(() => runHandled(
options,
'Failed to destroy the frontend window:',
options.destroyWindow,
));
return closing;
};
try {
const cleanupCloseListener = await options.registerCloseRequested(event => {
event.preventDefault();
return finalizeWindow();
});
publishCloseCleanup(cleanupCloseListener);
} catch (error) {
publishCloseCleanup(() => {});
report(options, 'Failed to install the frontend close-drain boundary:', error);
// Nothing has rendered, but close both admissions in case a module-level
// owner was registered while loading the bundle, then explicitly await
// destruction of the empty webview.
await finalizeWindow();
return false;
}
if (closing !== undefined) {
// A close request may arrive after the native listener is callable but
// before its registration promise acknowledges ownership. The active
// finalizer now owns the late cleanup; never mount React into a realm
// that has already begun closing.
await closing;
return false;
}
try {
options.render();
} catch (error) {
report(options, 'Failed to render the frontend:', error);
await finalizeWindow();
return false;
}
return true;
};
@@ -0,0 +1,95 @@
export type PersistenceShutdown = () => Promise<void>;
export type PersistenceEffectStart = () => void | (() => void);
/**
* Owns persistence queues for one webview. Closing admission is synchronous
* and permanent; draining waits for every queue admitted before close without
* pulling unrelated network operations into window shutdown.
*/
export class PersistenceShutdownScope {
private accepting = true;
private readonly shutdownTasks = new Set<PersistenceShutdown>();
private readonly pending = new Set<Promise<void>>();
public constructor(
private readonly reportFailure: (error: unknown) => void = () => {},
) {}
public register(shutdown: PersistenceShutdown): (() => void) | undefined {
if (!this.accepting) return undefined;
this.shutdownTasks.add(shutdown);
return () => this.shutdownTasks.delete(shutdown);
}
public async closeAndDrain(): Promise<void> {
if (this.accepting) {
this.accepting = false;
const tasks = [...this.shutdownTasks];
this.shutdownTasks.clear();
for (const shutdown of tasks) this.start(shutdown);
}
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
private start(shutdown: PersistenceShutdown): void {
let pending: Promise<void>;
try {
pending = shutdown();
} catch (error) {
this.report(error);
return;
}
this.pending.add(pending);
void pending.then(
() => this.pending.delete(pending),
error => {
this.pending.delete(pending);
this.report(error);
},
);
}
private report(error: unknown): void {
try {
this.reportFailure(error);
} catch {
// Reporting cannot be allowed to escape the shutdown scope.
}
}
}
/**
* Atomically admits a passive effect before it starts persistence work. Once
* window shutdown closes the scope, later effects receive no admission and
* cannot reopen hydration or write queues.
*/
export const startAdmittedPersistenceEffect = (
scope: PersistenceShutdownScope,
shutdown: PersistenceShutdown,
start: PersistenceEffectStart,
): (() => void) => {
const unregister = scope.register(shutdown);
if (unregister === undefined) return () => {};
let cleanup: void | (() => void);
try {
cleanup = start();
} catch (error) {
unregister();
throw error;
}
return () => {
cleanup?.();
unregister();
};
};
export const windowPersistenceScope = new PersistenceShutdownScope(error => {
console.error('Failed to drain frontend persistence:', error);
});
@@ -0,0 +1,54 @@
export interface GameDirectoryUpdatePorts {
updateBackend: (requestedPath: string) => Promise<unknown>;
persist: (acceptedPath: string) => Promise<void>;
reportPersistenceError: (error: unknown) => void;
}
export interface GameDirectoryHydrationPorts {
loadSavedPath: () => Promise<string | null | undefined>;
acceptSavedPath: (savedPath: string) => Promise<void>;
reportLoadError: (error: unknown) => void;
}
/**
* Settles restoration before the caller marks game-directory state ready.
* Missing state and load/acceptance failures are completed outcomes: the UI
* may then show the chooser, but never while a saved path is still being
* accepted by the backend.
*/
export const hydrateGameDirectory = async (
ports: GameDirectoryHydrationPorts,
): Promise<void> => {
try {
const savedPath = await ports.loadSavedPath();
if (savedPath?.trim()) await ports.acceptSavedPath(savedPath);
} catch (error) {
ports.reportLoadError(error);
}
};
/**
* Asks the backend to accept a game directory and persists only the canonical
* path returned by that successful request.
*
* Backend acknowledgement is the commit point. Persistence is best-effort: a
* store failure must not make the UI retain a path that no longer matches the
* already-updated backend.
*/
export const acceptGameDirectory = async (
requestedPath: string,
ports: GameDirectoryUpdatePorts,
): Promise<string> => {
const acceptedPath = await ports.updateBackend(requestedPath);
if (typeof acceptedPath !== 'string' || !acceptedPath.trim()) {
throw new Error('update_game_directory returned an invalid accepted path');
}
try {
await ports.persist(acceptedPath);
} catch (error) {
ports.reportPersistenceError(error);
}
return acceptedPath;
};
@@ -1,178 +1,315 @@
import {
ActiveOperation,
ActiveOperationKind,
DerivedState,
Game,
GameFilter,
GameSort,
GamesListPayload,
InstallStatus,
} from './types';
ActiveOperation,
ActiveOperationKind,
DerivedState,
DownloadProgressPayload,
Game,
GameFilter,
GameSort,
GameTransferStatus,
GameTransferStatusSnapshot,
InstallStatus,
StatusLevel,
} from "./types";
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.CheckingPeers,
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
InstallStatus.Removing,
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
InstallStatus.Removing,
]);
export const isInProgress = (status: InstallStatus): boolean =>
IN_PROGRESS_INSTALL_STATUSES.has(status);
IN_PROGRESS_INSTALL_STATUSES.has(status);
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
switch (op) {
case ActiveOperationKind.Downloading:
return InstallStatus.Downloading;
case ActiveOperationKind.Installing:
case ActiveOperationKind.Updating:
return InstallStatus.Installing;
case ActiveOperationKind.Uninstalling:
return InstallStatus.Uninstalling;
case ActiveOperationKind.RemovingDownload:
return InstallStatus.Removing;
}
export const installStatusFromActiveOperation = (
op: ActiveOperationKind,
): InstallStatus => {
switch (op) {
case ActiveOperationKind.Downloading:
return InstallStatus.Downloading;
case ActiveOperationKind.Installing:
case ActiveOperationKind.Updating:
return InstallStatus.Installing;
case ActiveOperationKind.Uninstalling:
return InstallStatus.Uninstalling;
case ActiveOperationKind.RemovingDownload:
return InstallStatus.Removing;
}
};
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
export const activeStatusById = (
ops: ActiveOperation[] = [],
): Map<string, InstallStatus> =>
new Map(
ops.map((op) => [op.id, installStatusFromActiveOperation(op.operation)]),
);
export const normalizeGamesListPayload = (
payload: GamesListPayload | Game[],
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
export const INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT: GameTransferStatusSnapshot =
{
revision: 0,
statuses: {},
openAttempts: {},
};
/** Keeps listener events and GamesList snapshots monotonic across async races. */
export const newestGameTransferStatusSnapshot = (
current: GameTransferStatusSnapshot,
candidate: GameTransferStatusSnapshot,
): GameTransferStatusSnapshot =>
candidate.revision > current.revision ? candidate : current;
export interface GameTransferStatusPresentation {
message: string;
level: StatusLevel;
}
export const gameTransferStatusPresentation = (
status: GameTransferStatus | undefined,
): GameTransferStatusPresentation | undefined => {
switch (status) {
case GameTransferStatus.Verifying:
return {
message: "Verifying downloaded chunks",
level: "info",
};
case GameTransferStatus.Retrying:
return {
message: "A source sent invalid data; retrying another nearby peer",
level: "warning",
};
case GameTransferStatus.Exhausted:
return {
message: "No nearby peer could provide the verified catalog version",
level: "error",
};
case undefined:
return undefined;
}
};
/** Transient verification/retry activity replaces ordinary download copy. */
export const downloadProgressTransferLabel = (
game: Game,
): string | undefined => {
if (game.transfer_status === GameTransferStatus.Exhausted) return undefined;
return gameTransferStatusPresentation(game.transfer_status)?.message;
};
export const downloadProgressAriaLabel = (game: Game): string => {
const transferLabel = downloadProgressTransferLabel(game);
return transferLabel
? `${transferLabel}: ${game.name}`
: `Downloading ${game.name}`;
};
export const gameTransferStatusFor = (
snapshot: GameTransferStatusSnapshot,
gameId: string,
): GameTransferStatus | undefined =>
Object.prototype.hasOwnProperty.call(snapshot.statuses, gameId)
? snapshot.statuses[gameId]
: undefined;
export const gameTransferOpenAttemptFor = (
snapshot: GameTransferStatusSnapshot,
gameId: string,
): string | undefined =>
Object.prototype.hasOwnProperty.call(snapshot.openAttempts, gameId)
? snapshot.openAttempts[gameId]
: undefined;
export const applyDownloadProgress = (
games: Game[],
snapshot: GameTransferStatusSnapshot,
payload: DownloadProgressPayload,
): Game[] => {
const openAttempt = gameTransferOpenAttemptFor(snapshot, payload.id);
if (openAttempt === undefined || openAttempt !== payload.attemptId) {
return games;
}
const { id, ...downloadProgress } = payload;
return games.map((game) =>
game.id === id ? { ...game, download_progress: downloadProgress } : game
);
};
export const applyGameTransferStatusSnapshot = (
games: Game[],
snapshot: GameTransferStatusSnapshot,
): Game[] =>
games.map((game) => {
const openAttempt = gameTransferOpenAttemptFor(snapshot, game.id);
return {
...game,
transfer_status: gameTransferStatusFor(snapshot, game.id),
download_progress: openAttempt !== undefined &&
game.download_progress?.attemptId === openAttempt
? game.download_progress
: undefined,
};
});
/**
* Reconcile a freshly received backend snapshot. Core operation status is
* derived only from the backend active-operation snapshot plus installed state.
*/
export const mergeGameUpdate = (
incoming: Game,
previous?: Game,
activeStatus?: InstallStatus,
incoming: Game,
previous?: Game,
activeStatus?: InstallStatus,
transferStatus?: GameTransferStatus,
): Game => {
const installStatus = activeStatus
?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
const installStatus = activeStatus ??
(incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
const localStateChanged = previous !== undefined
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
const statusChanged = previous !== undefined
&& previous.install_status !== installStatus;
const clearStatus = localStateChanged
|| (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status)));
const localStateChanged = previous !== undefined &&
(previous.installed !== incoming.installed ||
previous.downloaded !== incoming.downloaded);
const statusChanged = previous !== undefined &&
previous.install_status !== installStatus;
const clearStatus = localStateChanged ||
(statusChanged &&
(activeStatus !== undefined || isInProgress(previous.install_status)));
return {
...incoming,
availability: incoming.availability,
install_status: installStatus,
status_message: clearStatus ? undefined : previous?.status_message,
status_level: clearStatus ? undefined : previous?.status_level,
download_progress: installStatus === InstallStatus.Downloading
? previous?.download_progress
: undefined,
peer_count: incoming.peer_count ?? 0,
};
return {
...incoming,
availability: incoming.availability,
install_status: installStatus,
status_message: clearStatus ? undefined : previous?.status_message,
status_level: clearStatus ? undefined : previous?.status_level,
transfer_status: transferStatus,
download_progress: installStatus === InstallStatus.Downloading
? previous?.download_progress
: undefined,
peer_count: incoming.peer_count ?? 0,
};
};
/** Visual card state — used for state chip color and action button styling. */
export const deriveState = (game: Game): DerivedState => {
if (game.install_status === InstallStatus.Downloading) return 'downloading';
if (isInProgress(game.install_status)) return 'busy';
if (game.installed) return 'installed';
if (game.downloaded) return 'local';
return 'none';
if (game.install_status === InstallStatus.Downloading) return "downloading";
if (isInProgress(game.install_status)) return "busy";
if (game.installed) return "installed";
if (game.downloaded) return "local";
return "none";
};
export const isInstalledNotShareable = (game: Game): boolean =>
game.installed && !game.downloaded;
game.installed && !game.downloaded;
export const stateChipLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Local';
case 'downloading': return 'Downloading';
case 'busy': return 'Working';
case 'none': return '';
}
const state = deriveState(game);
if (state === "installed" && isInstalledNotShareable(game)) {
return "Not shareable";
}
switch (state) {
case "installed":
return "Installed";
case "local":
return "Local";
case "downloading":
return "Downloading";
case "busy":
return "Working";
case "none":
return "";
}
};
export const gameStatusLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) {
return 'Installed, not shareable';
}
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
const state = deriveState(game);
if (state === "installed" && isInstalledNotShareable(game)) {
return "Installed, not shareable";
}
switch (state) {
case "installed":
return "Installed";
case "local":
return "Downloaded";
case "downloading":
return "Downloading";
case "busy":
return "Working…";
case "none":
return "Not downloaded";
}
};
export const isUnavailable = (game: Game): boolean =>
!game.installed
&& !game.downloaded
&& game.peer_count === 0
&& game.install_status === InstallStatus.NotInstalled;
!game.installed &&
!game.downloaded &&
game.peer_count === 0 &&
game.install_status === InstallStatus.NotInstalled;
const parseVersionStamp = (version: string | undefined): number | null => {
if (!version || !/^\d{8}$/.test(version)) return null;
const parsed = parseInt(version, 10);
return Number.isNaN(parsed) ? null : parsed;
if (!version || !/^\d{8}$/.test(version)) return null;
const parsed = parseInt(version, 10);
return Number.isNaN(parsed) ? null : parsed;
};
export const compareVersionStamps = (
left: string | undefined,
right: string | undefined,
left: string | undefined,
right: string | undefined,
): number | null => {
const parsedLeft = parseVersionStamp(left);
const parsedRight = parseVersionStamp(right);
if (parsedLeft === null || parsedRight === null) return null;
return parsedLeft - parsedRight;
const parsedLeft = parseVersionStamp(left);
const parsedRight = parseVersionStamp(right);
if (parsedLeft === null || parsedRight === null) return null;
return parsedLeft - parsedRight;
};
export const hasNewerLocalVersion = (game: Game): boolean =>
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
(compareVersionStamps(game.local_version, game.eti_game_version) ?? 0) > 0;
export const needsUpdate = (game: Game): boolean => {
if (!game.installed) return false;
if (game.peer_count <= 0) return false;
if (!game.local_version && game.eti_game_version) return true;
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
if (!game.installed) return false;
if (game.peer_count <= 0) return false;
if (!game.local_version && game.eti_game_version) return true;
return (compareVersionStamps(game.eti_game_version, game.local_version) ??
0) > 0;
};
export const canStreamInstall = (game: Game): boolean =>
!game.downloaded
&& !game.installed
&& game.peer_count > 0
&& !isInProgress(game.install_status);
export const canStreamInstall = (
game: Game,
catalogSupported: boolean,
): boolean =>
catalogSupported &&
!game.downloaded &&
!game.installed &&
game.peer_count > 0 &&
!isInProgress(game.install_status);
/** What pressing the card's main action button should do, given the state. */
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
export type PrimaryAction =
| "play"
| "install"
| "update"
| "download"
| "busy"
| "disabled";
export const primaryActionFor = (game: Game): PrimaryAction => {
if (isInProgress(game.install_status)) return 'busy';
if (isUnavailable(game)) return 'disabled';
if (!game.installed) return game.downloaded ? 'install' : 'download';
if (needsUpdate(game)) return 'update';
return 'play';
if (isInProgress(game.install_status)) return "busy";
if (isUnavailable(game)) return "disabled";
if (!game.installed) return game.downloaded ? "install" : "download";
if (needsUpdate(game)) return "update";
return "play";
};
export const formatBytesPerSecond = (bytesPerSecond: number): string => {
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = Math.max(0, bytesPerSecond);
let unitIndex = 0;
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = Math.max(0, bytesPerSecond);
let unitIndex = 0;
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex += 1;
}
while (value >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex += 1;
}
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
};
const MB = 1024 * 1024;
@@ -180,137 +317,145 @@ const GB = 1024 * 1024 * 1024;
const DECIMAL_MB = 1_000_000;
const stripTrailingDecimalZeros = (value: string): string =>
value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
value.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
export const downloadProgressPercent = (game: Game): number => {
const progress = game.download_progress;
if (!progress || progress.total_bytes <= 0) return 0;
const progress = game.download_progress;
if (!progress || progress.total_bytes <= 0) return 0;
return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100));
return Math.max(
0,
Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100),
);
};
export const formatDownloadSpeed = (bytesPerSecond: number): string => {
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`;
};
export const formatDownloadSpeedShort = (bytesPerSecond: number): string => {
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return `${Math.round(mb)} MB/s`;
const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB;
return `${Math.round(mb)} MB/s`;
};
export const formatDownloadBytes = (bytes: number): string => {
const safeBytes = Math.max(0, bytes);
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
const safeBytes = Math.max(0, bytes);
if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`;
const gb = safeBytes / GB;
return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`;
const gb = safeBytes / GB;
return `${
stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))
} GB`;
};
export const formatDownloadEta = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
if (seconds < 60) return `${Math.round(seconds)} s`;
if (!Number.isFinite(seconds) || seconds <= 0) return "—";
if (seconds < 60) return `${Math.round(seconds)} s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`;
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
};
export const inProgressLabel = (game: Game): string | undefined => {
switch (game.install_status) {
case InstallStatus.CheckingPeers:
return 'Checking peers…';
case InstallStatus.Downloading:
return game.download_progress
? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}`
: 'Downloading…';
case InstallStatus.Installing:
return 'Installing…';
case InstallStatus.Uninstalling:
return 'Uninstalling…';
case InstallStatus.Removing:
return 'Removing…';
default:
return undefined;
}
switch (game.install_status) {
case InstallStatus.Downloading:
return game.download_progress
? `Downloading… ${
formatBytesPerSecond(game.download_progress.bytes_per_second)
}`
: "Downloading…";
case InstallStatus.Installing:
return "Installing…";
case InstallStatus.Uninstalling:
return "Uninstalling…";
case InstallStatus.Removing:
return "Removing…";
default:
return undefined;
}
};
export const actionLabel = (game: Game): string => {
const busy = inProgressLabel(game);
if (busy) return busy;
if (isUnavailable(game)) return 'Unavailable';
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
if (needsUpdate(game)) return 'Update';
return 'Play';
const busy = inProgressLabel(game);
if (busy) return busy;
if (isUnavailable(game)) return "Unavailable";
if (!game.installed) return game.downloaded ? "Install" : "Download";
if (needsUpdate(game)) return "Update";
return "Play";
};
/** Counts shown on filter pills. */
export interface FilterCounts {
all: number;
local: number;
installed: number;
all: number;
local: number;
installed: number;
}
const isDownloading = (game: Game): boolean =>
game.install_status === InstallStatus.Downloading;
game.install_status === InstallStatus.Downloading;
const isNetworkGame = (game: Game): boolean =>
game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0;
game.installed || game.downloaded || isDownloading(game) ||
game.peer_count > 0 || game.transfer_status === GameTransferStatus.Exhausted;
export const countByFilter = (games: Game[]): FilterCounts => ({
all: games.filter(isNetworkGame).length,
local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length,
installed: games.filter(g => g.installed).length,
all: games.filter(isNetworkGame).length,
local:
games.filter((g) => g.installed || g.downloaded || isDownloading(g)).length,
installed: games.filter((g) => g.installed).length,
});
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
switch (filter) {
case 'local':
return game.installed || game.downloaded || isDownloading(game);
case 'installed':
return game.installed;
case 'all':
return isNetworkGame(game);
}
switch (filter) {
case "local":
return game.installed || game.downloaded || isDownloading(game);
case "installed":
return game.installed;
case "all":
return isNetworkGame(game);
}
};
const STATE_SORT_ORDER: Record<DerivedState, number> = {
installed: 0,
local: 1,
downloading: 2,
busy: 3,
none: 4,
installed: 0,
local: 1,
downloading: 2,
busy: 3,
none: 4,
};
const compareByState = (a: Game, b: Game): number => {
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
return diff !== 0 ? diff : a.name.localeCompare(b.name);
const diff = STATE_SORT_ORDER[deriveState(a)] -
STATE_SORT_ORDER[deriveState(b)];
return diff !== 0 ? diff : a.name.localeCompare(b.name);
};
export const applyFilterAndSort = (
games: Game[],
filter: GameFilter,
sort: GameSort,
query: string,
games: Game[],
filter: GameFilter,
sort: GameSort,
query: string,
): Game[] => {
let list = games.filter(g => matchesFilter(g, filter));
const q = query.trim().toLowerCase();
if (q) {
list = list.filter(g =>
g.name.toLowerCase().includes(q)
|| (g.genre?.toLowerCase().includes(q) ?? false)
|| (g.publisher?.toLowerCase().includes(q) ?? false),
);
}
switch (sort) {
case 'az':
return [...list].sort((a, b) => a.name.localeCompare(b.name));
case 'sizeDesc':
return [...list].sort((a, b) => b.size - a.size);
case 'sizeAsc':
return [...list].sort((a, b) => a.size - b.size);
case 'status':
return [...list].sort(compareByState);
}
let list = games.filter((g) => matchesFilter(g, filter));
const q = query.trim().toLowerCase();
if (q) {
list = list.filter((g) =>
g.name.toLowerCase().includes(q) ||
(g.genre?.toLowerCase().includes(q) ?? false) ||
(g.publisher?.toLowerCase().includes(q) ?? false)
);
}
switch (sort) {
case "az":
return [...list].sort((a, b) => a.name.localeCompare(b.name));
case "sizeDesc":
return [...list].sort((a, b) => b.size - a.size);
case "sizeAsc":
return [...list].sort((a, b) => a.size - b.size);
case "status":
return [...list].sort(compareByState);
}
};
@@ -0,0 +1,22 @@
export type IdentityDiagnostic = 'ephemeral';
export interface IdentityDiagnosticSnapshot {
revision: number;
diagnostic: IdentityDiagnostic | null;
}
export const INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT: IdentityDiagnosticSnapshot = {
revision: 0,
diagnostic: null,
};
export const EPHEMERAL_IDENTITY_NOTICE =
"This installation's network identity could not be saved and will change the next time Lanspread starts.";
export const IDENTITY_STATUS_UNAVAILABLE_NOTICE =
"This installation's network identity status could not be checked.";
export const newestIdentityDiagnosticSnapshot = (
current: IdentityDiagnosticSnapshot,
candidate: IdentityDiagnosticSnapshot,
): IdentityDiagnosticSnapshot => candidate.revision > current.revision ? candidate : current;
@@ -0,0 +1,151 @@
import {
AsyncOwner,
type AsyncAdoptionScope,
type AsyncRegistration,
windowAsyncScope,
} from './asyncOwnership';
export type LocalNetworkSharingPhase =
| 'waitingForGameDirectory'
| 'disabled'
| 'enabling'
| 'enabled'
| 'disabling';
export type SharingPersistenceProblem = 'load' | 'save';
export interface LocalNetworkSharingSnapshot {
revision: number;
enabled: boolean;
pendingTarget: boolean | null;
phase: LocalNetworkSharingPhase;
persistenceProblem: SharingPersistenceProblem | null;
}
export const INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT: LocalNetworkSharingSnapshot = {
revision: 0,
enabled: false,
pendingTarget: null,
phase: 'disabled',
persistenceProblem: 'load',
};
export const newestLocalNetworkSharingSnapshot = (
current: LocalNetworkSharingSnapshot,
candidate: LocalNetworkSharingSnapshot,
): LocalNetworkSharingSnapshot => candidate.revision > current.revision ? candidate : current;
export const displayedSharingTarget = (snapshot: LocalNetworkSharingSnapshot): boolean =>
snapshot.pendingTarget ?? snapshot.enabled;
export const isLocalNetworkSharingActive = (snapshot: LocalNetworkSharingSnapshot): boolean =>
snapshot.phase === 'enabled' && snapshot.pendingTarget !== false;
export const localNetworkSharingNotice = (
snapshot: LocalNetworkSharingSnapshot,
): string => {
if (snapshot.persistenceProblem === 'load') {
return 'Local network sharing is off because its setting could not be loaded.';
}
if (snapshot.persistenceProblem === 'save') {
if (!snapshot.enabled && snapshot.phase === 'disabled') {
return 'Local network sharing is off, but this setting could not be saved.';
}
return 'This Local network sharing setting could not be saved and may change after restart.';
}
if (snapshot.pendingTarget === false && snapshot.phase !== 'disabled') {
return 'Stopping Local network sharing…';
}
if (snapshot.pendingTarget === true && snapshot.phase !== 'enabled') {
return 'Starting Local network sharing…';
}
switch (snapshot.phase) {
case 'waitingForGameDirectory':
return 'Local network sharing will start after you choose a game folder.';
case 'disabled':
return 'Local network sharing is off. Nearby devices cannot browse or request games from this library.';
case 'enabling':
return 'Starting Local network sharing…';
case 'enabled':
return 'Local network sharing is on.';
case 'disabling':
return 'Stopping Local network sharing…';
}
};
/**
* Owns one listener/query lifecycle and serializes explicitly targeted sharing
* mutations. Disposal closes admission and drains already-admitted invokes;
* result publication remains guarded by the mounted owner.
*/
export class LocalNetworkSharingAsyncScope {
private readonly owner: AsyncOwner;
private mutationTail: Promise<void> = Promise.resolve();
private mutationAdmissionOpen = true;
private readonly releaseWindowOwnership: () => void;
public constructor(
reportCleanupFailure: (error: unknown) => void = () => {},
adoptionScope: AsyncAdoptionScope = windowAsyncScope,
) {
this.owner = new AsyncOwner(reportCleanupFailure, adoptionScope);
this.releaseWindowOwnership = adoptionScope.registerDisposer(
() => this.disposeForWindowClose(),
) ?? (() => {});
}
public isActive(): boolean {
return this.owner.isActive() && this.mutationAdmissionOpen;
}
public guard<Args extends unknown[]>(
callback: (...args: Args) => void,
): (...args: Args) => void {
return this.owner.guard(callback);
}
public registerListener(registration: AsyncRegistration): Promise<boolean> {
return this.owner.register(registration);
}
public applyIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
return this.owner.applyIfActive(operation, apply);
}
public applyMutationIfActive<T>(
operation: () => Promise<T>,
apply: (value: T) => void,
): Promise<boolean> {
if (!this.isActive()) return Promise.resolve(false);
const mutation = this.mutationTail.then(async () => {
const value = await operation();
if (!this.owner.isActive()) return false;
apply(value);
return true;
});
this.mutationTail = mutation.then(
() => {},
() => {},
);
return mutation;
}
public dispose(): Promise<void> {
this.releaseWindowOwnership();
this.mutationAdmissionOpen = false;
return Promise.all([this.mutationTail, this.owner.dispose()]).then(() => {});
}
private disposeForWindowClose(): Promise<void> {
this.releaseWindowOwnership();
this.mutationAdmissionOpen = false;
return Promise.all([
this.mutationTail,
this.owner.disposeForWindowClose(),
]).then(() => {});
}
}
@@ -0,0 +1,18 @@
import { type ProtocolMismatch } from "./types";
export interface ProtocolMismatchSnapshot {
revision: number;
mismatch: ProtocolMismatch | null;
}
export const INITIAL_PROTOCOL_MISMATCH_SNAPSHOT: ProtocolMismatchSnapshot = {
revision: 0,
mismatch: null,
};
/** Keeps listener events and bootstrap queries monotonic across async races. */
export const newestProtocolMismatchSnapshot = (
current: ProtocolMismatchSnapshot,
candidate: ProtocolMismatchSnapshot,
): ProtocolMismatchSnapshot =>
candidate.revision > current.revision ? candidate : current;
@@ -0,0 +1,78 @@
import { type AsyncAdoptionScope } from './asyncOwnership';
export type ThumbnailLoader = (id: string) => Promise<string>;
export type ThumbnailPublisher = (id: string, url: string) => void;
export const createThumbnailRequestKey = (ids: readonly string[]): string =>
JSON.stringify([...new Set(ids)].sort());
export const thumbnailIdsFromRequestKey = (key: string): string[] =>
JSON.parse(key) as string[];
/**
* Owns one effect generation of thumbnail requests. Loading can only begin via
* `start`, and disposing the generation prevents both later starts and late
* publications from requests that are already in flight.
*/
export class ThumbnailRequestGeneration {
private disposed = false;
private readonly pending = new Set<Promise<void>>();
constructor(
private readonly load: ThumbnailLoader,
private readonly publish: ThumbnailPublisher,
) {}
start(ids: readonly string[]): Promise<void> {
if (this.disposed) return Promise.resolve();
const loading = Promise.all(ids.map(id => this.loadOne(id))).then(() => {});
this.pending.add(loading);
void loading.then(
() => this.pending.delete(loading),
() => this.pending.delete(loading),
);
return loading;
}
dispose(): Promise<void> {
this.disposed = true;
return this.drain();
}
private async drain(): Promise<void> {
while (this.pending.size > 0) {
await Promise.allSettled([...this.pending]);
}
}
private async loadOne(id: string): Promise<void> {
let url: string;
try {
url = await this.load(id);
} catch {
url = '';
}
if (!this.disposed) this.publish(id, url);
}
}
/** Registers the generation with window-close admission before any load starts. */
export const startOwnedThumbnailGeneration = (
scope: AsyncAdoptionScope,
generation: ThumbnailRequestGeneration,
ids: readonly string[],
): (() => void) => {
const releaseWindowOwnership = scope.registerDisposer(() => generation.dispose());
if (releaseWindowOwnership === undefined) {
scope.adopt(generation.dispose());
return () => {};
}
scope.adopt(generation.start(ids));
return () => {
releaseWindowOwnership();
scope.adopt(generation.dispose());
};
};
+137 -91
View File
@@ -1,137 +1,183 @@
export enum InstallStatus {
NotInstalled = 'NotInstalled',
CheckingPeers = 'CheckingPeers',
Downloading = 'Downloading',
Installing = 'Installing',
Uninstalling = 'Uninstalling',
Removing = 'Removing',
Installed = 'Installed',
NotInstalled = "NotInstalled",
Downloading = "Downloading",
Installing = "Installing",
Uninstalling = "Uninstalling",
Removing = "Removing",
Installed = "Installed",
}
export enum GameAvailability {
Ready = 'Ready',
LocalOnly = 'LocalOnly',
Ready = "Ready",
LocalOnly = "LocalOnly",
}
export enum ActiveOperationKind {
Downloading = 'Downloading',
Installing = 'Installing',
Updating = 'Updating',
Uninstalling = 'Uninstalling',
RemovingDownload = 'RemovingDownload',
Downloading = "Downloading",
Installing = "Installing",
Updating = "Updating",
Uninstalling = "Uninstalling",
RemovingDownload = "RemovingDownload",
}
export type StatusLevel = 'info' | 'warning' | 'error';
export type StatusLevel = "info" | "warning" | "error";
export enum GameTransferStatus {
Verifying = "verifying",
Retrying = "retrying",
Exhausted = "exhausted",
}
export interface GameTransferStatusSnapshot {
revision: number;
statuses: Record<string, GameTransferStatus>;
openAttempts: Record<string, string>;
}
export interface DownloadProgress {
downloaded_bytes: number;
total_bytes: number;
bytes_per_second: number;
active_peer_count: number;
attemptId: string;
downloaded_bytes: number;
total_bytes: number;
bytes_per_second: number;
active_peer_count: number;
}
export interface DownloadProgressPayload extends DownloadProgress {
id: string;
id: string;
}
export interface Game {
id: string;
name: string;
description: string;
/** Bytes. */
size: number;
/** Raw bytes — unused in UI, kept for parity with backend payload. */
thumbnail?: Uint8Array | number[];
downloaded: boolean;
installed: boolean;
availability: GameAvailability;
install_status: InstallStatus;
eti_game_version?: string;
local_version?: string;
/** Optional richer metadata surfaced by the backend. */
release_year?: string;
publisher?: string;
max_players?: number;
version?: string;
genre?: string;
status_message?: string;
status_level?: StatusLevel;
download_progress?: DownloadProgress;
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
installed_peer_count?: number;
id: string;
name: string;
description: string;
/** Bytes. */
size: number;
/** Raw bytes — unused in UI, kept for parity with backend payload. */
thumbnail?: Uint8Array | number[];
downloaded: boolean;
installed: boolean;
availability: GameAvailability;
install_status: InstallStatus;
eti_game_version?: string;
local_version?: string;
/** Optional richer metadata surfaced by the backend. */
release_year?: string;
publisher?: string;
max_players?: number;
version?: string;
genre?: string;
status_message?: string;
status_level?: StatusLevel;
transfer_status?: GameTransferStatus;
download_progress?: DownloadProgress;
peer_count: number;
can_host_server?: boolean;
active_outbound_transfers?: number;
installed_peer_count?: number;
}
export interface ActiveOperation {
id: string;
operation: ActiveOperationKind;
id: string;
operation: ActiveOperationKind;
}
export interface GamesListPayload {
games: Game[];
active_operations?: ActiveOperation[];
games: Game[];
active_operations?: ActiveOperation[];
transfer_status: GameTransferStatusSnapshot;
}
export interface ProtocolMismatch {
observed: number | null;
expected: number;
}
/** Library filter chip — what subset of the catalog to show. */
export type GameFilter = 'all' | 'local' | 'installed';
export type GameFilter = "all" | "local" | "installed";
/** Library sort order. */
export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
export type GameSort = "az" | "sizeDesc" | "sizeAsc" | "status";
/** Visual state of a card. Derived from backend operation status and local flags. */
export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy';
export type DerivedState =
| "installed"
| "local"
| "downloading"
| "none"
| "busy";
/** Two-character language code passed through to game scripts. */
export type LauncherLanguage = 'en' | 'de';
export type LauncherLanguage = "en" | "de";
export type CallToPlayParticipantStatus = 'ready' | 'in' | 'pending';
export type CallToPlayParticipantStatus = "ready" | "in" | "pending";
export interface CallToPlayParticipant {
name: string;
status: CallToPlayParticipantStatus;
joinedAt: number;
readyAt?: number;
name: string;
status: CallToPlayParticipantStatus;
joinedAt: number;
readyAt?: number;
}
export interface CallToPlayMessage {
id: string;
fromId: string;
from: string;
text: string;
at: number;
id: string;
fromId: string;
from: string;
text: string;
at: number;
}
export interface Nomination {
id: string;
gameId: string;
creatorId: string;
creator: string;
maxPlayers: number;
createdAt: number;
scheduledFor: number | null;
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
id: string;
gameId: string;
creatorId: string;
creator: string;
maxPlayers: number;
createdAt: number;
scheduledFor: number | null;
deadline: number;
participants: Record<string, CallToPlayParticipant>;
messages: CallToPlayMessage[];
state: "open" | "done" | "running" | "cancelled";
terminalAt: number | null;
}
export type CallToPlayAction =
| { Create: { game_id: string; max_players: number; scheduled_for: number | null; deadline: number } }
| { Respond: { ready_at: number | null } }
| 'Rsvp'
| { SendMessage: { message_id: string; text: string } }
| 'Leave'
| 'Cancel'
| 'Start'
| { AddTime: { deadline: number } };
| {
Create: {
game_id: string;
max_players: number;
scheduled_for: number | null;
deadline: number;
};
}
| { Respond: { ready_at: number | null } }
| "Rsvp"
| { SendMessage: { text: string } }
| "Leave"
| "Cancel"
| "Start"
| { AddTime: { deadline: number } };
export interface CallToPlayEvent {
id: string;
call_id: string;
actor_id: string;
actor_name: string;
at: number;
action: CallToPlayAction;
export interface CallToPlayViewEvent {
id: string;
call_id: string;
author_id: string;
author_name: string;
at: number;
action: CallToPlayAction;
}
export interface CallToPlayView {
events: CallToPlayViewEvent[];
}
export interface CallToPlayLocalIntent {
call_id: string | null;
action: CallToPlayAction;
}
export interface CallToPlayReceipt {
call_id: string;
event_id: string;
revision: number;
}
+26 -3
View File
@@ -1,9 +1,32 @@
import ReactDOM from "react-dom/client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import App from "./App";
import { windowAsyncScope } from "./lib/asyncOwnership";
import { bootstrapFrontend } from "./lib/frontendBootstrap";
import { windowPersistenceScope } from "./lib/frontendPersistence";
import "./styles/tokens.css";
import "./styles/launcher.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<App />
);
const render = () => {
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<App />
);
};
const currentWindow = getCurrentWindow();
// This is the JavaScript realm's terminal task. bootstrapFrontend handles every
// failure path and does not mount the app unless close ownership is installed.
void bootstrapFrontend({
registerCloseRequested: handler => currentWindow.onCloseRequested(handler),
disposeOwners: () => windowAsyncScope.disposeOwned(),
drainPersistence: () => windowPersistenceScope.closeAndDrain(),
destroyWindow: () => currentWindow.destroy(),
render,
reportFailure: (message, error) => console.error(message, error),
}).catch(error => {
// The helper handles expected bootstrap and teardown failures. Keep this
// terminal observer for programming errors at the JavaScript realm root.
console.error('Unexpected frontend bootstrap failure:', error);
});
@@ -19,24 +19,24 @@
.bg-gradient {
background:
radial-gradient(
ellipse 80% 50% at 50% -10%,
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
transparent 60%
),
ellipse 80% 50% at 50% -10%,
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
transparent 60%
),
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
}
.bg-animated {
background:
radial-gradient(
ellipse 60% 40% at 20% 0%,
color-mix(in srgb, var(--accent) 24%, transparent) 0%,
transparent 55%
),
ellipse 60% 40% at 20% 0%,
color-mix(in srgb, var(--accent) 24%, transparent) 0%,
transparent 55%
),
radial-gradient(
ellipse 55% 40% at 85% 8%,
color-mix(in srgb, var(--accent) 16%, transparent) 0%,
transparent 55%
),
ellipse 55% 40% at 85% 8%,
color-mix(in srgb, var(--accent) 16%, transparent) 0%,
transparent 55%
),
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
background-size: 145% 130%, 140% 125%, 100% 100%;
animation: bgshift 22s ease-in-out infinite alternate;
@@ -474,6 +474,28 @@
padding: 4px 4px 16px;
gap: 16px;
}
.network-notice {
margin: 0 4px 16px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--bd-1));
border-radius: 8px;
background: color-mix(in srgb, var(--warn) 12%, var(--bg-2));
color: var(--t-1);
font-size: 12.5px;
font-weight: 600;
}
.network-notice-sharing.is-enabled {
border-color: color-mix(in srgb, var(--ok) 40%, var(--bd-1));
background: color-mix(in srgb, var(--ok) 9%, var(--bg-2));
}
.network-notice-sharing.is-disabled,
.network-notice.is-error {
border-color: color-mix(in srgb, var(--danger) 42%, var(--bd-1));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
}
.identity-notice {
border-color: color-mix(in srgb, var(--warn) 45%, var(--bd-1));
}
.results-count {
color: var(--t-2);
font-size: 12.5px;
@@ -582,15 +604,15 @@
.cover-grain {
background-image:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.018) 0 1px,
transparent 1px 3px
),
0deg,
rgba(255, 255, 255, 0.018) 0 1px,
transparent 1px 3px
),
repeating-linear-gradient(
90deg,
rgba(0, 0, 0, 0.04) 0 1px,
transparent 1px 3px
);
90deg,
rgba(0, 0, 0, 0.04) 0 1px,
transparent 1px 3px
);
mix-blend-mode: overlay;
opacity: 0.7;
}
@@ -982,6 +1004,18 @@
align-items: center;
color: var(--t-1);
}
.dl-md-status {
display: inline-flex;
align-items: center;
min-width: 0;
color: var(--t-1);
}
.dl-md-status > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dl-md .dl-pulse {
margin-right: 6px;
}
@@ -1066,6 +1100,9 @@
color: color-mix(in srgb, var(--accent) 80%, white);
}
.dl-lg-primary .dl-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dl-lg-secondary {
@@ -1510,6 +1547,59 @@
.settings-row-control {
flex-shrink: 0;
}
.settings-switch {
position: relative;
display: inline-flex;
cursor: pointer;
}
.settings-switch input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.settings-switch-track {
position: relative;
width: 40px;
height: 22px;
border: 1px solid var(--bd-3);
border-radius: 999px;
background: var(--bg-4);
transition: background 120ms ease, border-color 120ms ease;
}
.settings-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--t-2);
transition: transform 120ms ease, background 120ms ease;
}
.settings-switch.is-checked .settings-switch-track {
border-color: color-mix(in srgb, var(--accent) 75%, var(--bd-2));
background: color-mix(in srgb, var(--accent) 55%, var(--bg-3));
}
.settings-switch.is-checked .settings-switch-thumb {
transform: translateX(18px);
background: white;
}
.settings-switch input:focus-visible + .settings-switch-track {
outline: 2px solid color-mix(in srgb, var(--accent) 65%, transparent);
outline-offset: 2px;
}
.settings-switch:has(input:disabled) {
cursor: wait;
opacity: 0.55;
}
.settings-inline-error {
margin-top: -6px;
color: var(--danger);
font-size: 11.5px;
font-weight: 600;
}
.settings-foot {
display: flex;
align-items: center;
@@ -1791,52 +1881,67 @@
/* ─── Top-bar entry button ─── */
.ctp-btn {
position: relative;
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 14px;
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-2));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 8px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 700;
font: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background .15s, border-color .15s;
}
.ctp-btn svg { color: var(--accent); flex-shrink: 0; }
.ctp-btn svg {
color: var(--accent);
flex-shrink: 0;
}
.ctp-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, var(--bg-2));
border-color: color-mix(in srgb, var(--accent) 65%, var(--bd-2));
}
.ctp-badge {
display: inline-grid; place-items: center;
min-width: 18px; height: 18px;
display: inline-grid;
place-items: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 10.5px; font-weight: 800;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ─── Ticker strip ─── */
.ctp-ticker-stack {
display: flex; flex-direction: column; gap: 8px;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.ctp-ticker-stack .ctp-ticker { margin-bottom: 0; }
.ctp-ticker-stack .ctp-ticker {
margin-bottom: 0;
}
.ctp-ticker {
display: grid;
grid-template-columns:
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
10px /* LED */
98px /* status */
220px /* game */
130px /* by */
88px /* count */
170px /* time */
minmax(0, 1fr) /* chat — sole flexible track, absorbs bubbles variance */
max-content /* bubbles */
82px; /* cta */
align-items: center;
column-gap: 12px;
width: 100%;
@@ -1845,22 +1950,34 @@
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
border-radius: 10px;
color: var(--t-1);
font: inherit; font-size: 12.5px;
font: inherit;
font-size: 12.5px;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.ctp-ticker:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-2)); }
.ctp-ticker:hover {
background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
}
.ctp-ticker-dot {
width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
background: var(--accent);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
animation: ctp-tickerpulse 1.6s ease-out infinite;
}
@keyframes ctp-tickerpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
70% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
}
}
.ctp-ticker-label {
font-weight: 700;
@@ -1870,36 +1987,87 @@
letter-spacing: 0.06em;
flex-shrink: 0;
}
.ctp-ticker-game { font-weight: 700; color: var(--t-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-by { color: var(--t-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-sep { display: none; }
.ctp-ticker-ready, .ctp-ticker-time { color: var(--t-2); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctp-ticker-game {
font-weight: 700;
color: var(--t-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctp-ticker-by {
color: var(--t-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctp-ticker-sep {
display: none;
}
.ctp-ticker-ready,
.ctp-ticker-time {
color: var(--t-2);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctp-ticker-more {
color: var(--t-3);
font-size: 11.5px;
flex-shrink: 0;
}
.ctp-ticker-cta {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
font-weight: 700;
color: var(--accent);
}
.ctp-ticker-cta svg { transform: rotate(-90deg); }
.ctp-ticker-cta svg {
transform: rotate(-90deg);
}
/* ─── Overlay modal ─── */
.ctp-modal { width: min(720px, 100%); }
.ctp-modal {
width: min(720px, 100%);
}
.ctp-head-row {
display: flex; align-items: center; justify-content: space-between;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-right: 36px;
}
.ctp-head-new { height: 40px; padding: 0 18px; font-size: 13px; margin-top: 14px; width: 100%; }
.ctp-head { padding: 26px 28px 14px; border-bottom: 1px solid var(--bd-1); }
.ctp-head h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--t-1); }
.ctp-head-sub { margin: 6px 0 0; font-size: 12.5px; color: var(--t-3); max-width: 52ch; }
.ctp-head-new {
height: 40px;
padding: 0 18px;
font-size: 13px;
margin-top: 14px;
width: 100%;
}
.ctp-head {
padding: 26px 28px 14px;
border-bottom: 1px solid var(--bd-1);
}
.ctp-head h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--t-1);
}
.ctp-head-sub {
margin: 6px 0 0;
font-size: 12.5px;
color: var(--t-3);
max-width: 52ch;
}
.ctp-body {
padding: 18px 24px 24px;
display: flex; flex-direction: column; gap: 14px;
display: flex;
flex-direction: column;
gap: 14px;
max-height: 66vh;
overflow: auto;
}
@@ -1912,42 +2080,86 @@
/* ─── Nomination card ─── */
.ctp-card {
display: flex; flex-direction: column; gap: 12px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--bd-1);
border-radius: 12px;
}
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); }
.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); }
.ctp-card.is-terminal { opacity: 0.72; }
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
.ctp-card.is-done {
border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2));
}
.ctp-card.is-expired {
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
}
.ctp-card.is-terminal {
opacity: 0.72;
}
.ctp-card.is-running {
border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2));
}
.ctp-card.is-cancelled {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2));
}
.ctp-card.is-focused {
border-color: var(--accent);
animation: ctp-cardflash 1.4s ease-out 1;
}
@keyframes ctp-cardflash {
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
0% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
}
}
.ctp-card-top {
display: flex;
align-items: flex-start;
gap: 12px;
position: relative;
}
.ctp-card-top { display: flex; align-items: flex-start; gap: 12px; position: relative; }
.ctp-card-cover {
position: relative;
width: 52px; height: 52px;
width: 52px;
height: 52px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.ctp-card-cover-missing {
width: 100%; height: 100%;
display: grid; place-items: center;
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--t-3);
background: color-mix(in srgb, var(--bd-2) 55%, transparent);
border: 1px dashed var(--bd-2);
}
.ctp-card-cover-missing svg { width: 22px; height: 22px; }
.ctp-card-info { flex: 1; min-width: 0; }
.ctp-card-title { font-size: 14.5px; font-weight: 700; color: var(--t-1); }
.ctp-card-sub { margin-top: 3px; font-size: 11.5px; color: var(--t-3); }
.ctp-card-sub strong { color: var(--t-2); font-weight: 700; }
.ctp-card-cover-missing svg {
width: 22px;
height: 22px;
}
.ctp-card-info {
flex: 1;
min-width: 0;
}
.ctp-card-title {
font-size: 14.5px;
font-weight: 700;
color: var(--t-1);
}
.ctp-card-sub {
margin-top: 3px;
font-size: 11.5px;
color: var(--t-3);
}
.ctp-card-sub strong {
color: var(--t-2);
font-weight: 700;
}
.ctp-card-timer {
font-variant-numeric: tabular-nums;
font-size: 18px;
@@ -1956,25 +2168,45 @@
flex-shrink: 0;
padding-top: 2px;
}
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); }
.ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; }
.ctp-card.is-expired .ctp-card-timer { color: var(--danger); }
.ctp-card-timer[data-urgency="mid"] {
color: var(--warn);
}
.ctp-card-timer[data-urgency="high"] {
color: var(--danger);
}
.ctp-card.is-done .ctp-card-timer {
color: var(--ok);
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ctp-card.is-expired .ctp-card-timer {
color: var(--danger);
}
.ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-start;
margin-top: -2px;
padding: 6px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
font: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: color .15s;
}
.ctp-cancel-link:hover { color: #fca5a5; }
.ctp-cancel-link:hover {
color: #fca5a5;
}
.ctp-cancel-confirm {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
background: rgba(239,68,68,0.08);
@@ -1984,8 +2216,16 @@
color: #fca5a5;
font-weight: 600;
}
.ctp-cancel-confirm-btns { display: inline-flex; gap: 8px; flex-shrink: 0; }
.ctp-cancel-confirm-btns .ghost-btn { height: 32px; padding: 0 12px; font-size: 12px; }
.ctp-cancel-confirm-btns {
display: inline-flex;
gap: 8px;
flex-shrink: 0;
}
.ctp-cancel-confirm-btns .ghost-btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
}
.ctp-progress {
height: 5px;
@@ -1993,37 +2233,66 @@
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.ctp-progress-fill { height: 100%; transition: width 1s linear; }
.ctp-progress-fill {
height: 100%;
transition: width 1s linear;
}
.ctp-roster { display: flex; flex-direction: column; gap: 8px; }
.ctp-roster-count { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--t-3); }
.ctp-avatars { display: flex; flex-wrap: wrap; gap: 6px; }
.ctp-roster {
display: flex;
flex-direction: column;
gap: 8px;
}
.ctp-roster-count {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--t-3);
}
.ctp-avatars {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ctp-avatar {
position: relative;
display: inline-flex;
}
.ctp-avatar-dot {
width: 30px; height: 30px;
display: grid; place-items: center;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 999px;
color: white;
font-size: 10.5px; font-weight: 800;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.01em;
}
.ctp-avatar.is-pending .ctp-avatar-dot { opacity: 0.55; }
.ctp-avatar.is-pending .ctp-avatar-dot {
opacity: 0.55;
}
.ctp-avatar-check {
position: absolute; bottom: -2px; right: -2px;
width: 14px; height: 14px;
display: grid; place-items: center;
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 2px solid var(--bg-2);
}
.ctp-avatar-pending {
position: absolute; bottom: -6px; left: 50%;
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
font-size: 9px; font-weight: 700;
font-size: 9px;
font-weight: 700;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
@@ -2033,64 +2302,122 @@
font-variant-numeric: tabular-nums;
}
.ctp-avatar-empty {
width: 30px; height: 30px;
width: 30px;
height: 30px;
border-radius: 999px;
border: 1.5px dashed var(--bd-2);
}
.ctp-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding-top: 2px;
}
.ctp-actions .act-btn { height: 36px; padding: 0 16px; font-size: 12.5px; }
.ctp-actions .ghost-btn { height: 36px; padding: 0 14px; font-size: 12.5px; }
.ctp-note { font-size: 12.5px; color: var(--t-3); }
.ctp-note-launch { color: var(--ok); font-weight: 600; }
.ctp-me-status { font-size: 12.5px; font-weight: 600; color: var(--t-2); }
.ctp-buffer-group { display: inline-flex; gap: 6px; }
.ctp-actions .act-btn {
height: 36px;
padding: 0 16px;
font-size: 12.5px;
}
.ctp-actions .ghost-btn {
height: 36px;
padding: 0 14px;
font-size: 12.5px;
}
.ctp-note {
font-size: 12.5px;
color: var(--t-3);
}
.ctp-note-launch {
color: var(--ok);
font-weight: 600;
}
.ctp-me-status {
font-size: 12.5px;
font-weight: 600;
color: var(--t-2);
}
.ctp-buffer-group {
display: inline-flex;
gap: 6px;
}
.ctp-buffer-btn {
height: 36px; padding: 0 11px;
height: 36px;
padding: 0 11px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--bd-2);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px; font-weight: 600;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.ctp-buffer-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); }
.ctp-buffer-btn:hover {
background: rgba(255,255,255,0.08);
border-color: var(--bd-3);
}
/* ─── Create-nomination form ─── */
.ctp-create-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
background: transparent;
border: 1.5px dashed var(--bd-3);
border-radius: 12px;
color: var(--t-2);
font: inherit; font-size: 13px; font-weight: 700;
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.ctp-create-cta:hover { border-color: var(--accent); color: var(--t-1); background: rgba(255,255,255,0.02); }
.ctp-create-cta:hover {
border-color: var(--accent);
color: var(--t-1);
background: rgba(255,255,255,0.02);
}
.ctp-create {
display: flex; flex-direction: column; gap: 14px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--bd-2);
border-radius: 12px;
}
.ctp-create-row { display: flex; flex-direction: column; gap: 6px; position: relative; }
.ctp-create-row-inline { flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
.ctp-create-row {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
}
.ctp-create-row-inline {
flex-direction: row;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.ctp-create-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--t-3);
}
.ctp-create-search { width: 100%; }
.ctp-create-search {
width: 100%;
}
.ctp-create-matches {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
left: 0;
right: 0;
z-index: 20;
max-height: 220px;
overflow: auto;
@@ -2101,61 +2428,111 @@
box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5);
}
.ctp-create-matches button {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--t-1);
font: inherit; font-size: 12.5px; font-weight: 600;
font: inherit;
font-size: 12.5px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.ctp-create-matches button:hover { background: rgba(255,255,255,0.06); }
.ctp-create-match-meta { color: var(--t-3); font-size: 11px; font-weight: 500; }
.ctp-create-nomatch { padding: 10px; font-size: 12px; color: var(--t-3); }
.ctp-create-matches button:hover {
background: rgba(255,255,255,0.06);
}
.ctp-create-match-meta {
color: var(--t-3);
font-size: 11px;
font-weight: 500;
}
.ctp-create-nomatch {
padding: 10px;
font-size: 12px;
color: var(--t-3);
}
.ctp-create-num {
width: 70px; height: 34px;
width: 70px;
height: 34px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 13px; font-weight: 600;
font: inherit;
font-size: 13px;
font-weight: 600;
text-align: center;
}
.ctp-create-hint { font-size: 11.5px; color: var(--t-3); }
.ctp-duration-opts { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.ctp-create-hint {
font-size: 11.5px;
color: var(--t-3);
}
.ctp-duration-opts {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.ctp-duration-btn {
height: 32px; padding: 0 13px;
height: 32px;
padding: 0 13px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-2);
font: inherit; font-size: 12.5px; font-weight: 700;
font: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
.ctp-duration-btn:hover { color: var(--t-1); }
.ctp-duration-btn.is-active { color: white; }
.ctp-duration-btn:hover {
color: var(--t-1);
}
.ctp-duration-btn.is-active {
color: white;
}
.ctp-create-foot {
display: flex; justify-content: flex-end; gap: 10px;
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
.ctp-create-foot .act-btn,
.ctp-create-foot .ghost-btn { height: 40px; padding: 0 18px; }
.ctp-time-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.ctp-create-foot .ghost-btn {
height: 40px;
padding: 0 18px;
}
.ctp-time-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
}
/* ─── Time stepper ─── */
.ctp-timepick {
display: flex; align-items: center; gap: 6px;
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.ctp-timepick-field {
display: flex; flex-direction: column; align-items: center; gap: 3px;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.ctp-time-step {
width: 54px; height: 24px;
display: grid; place-items: center;
width: 54px;
height: 24px;
display: grid;
place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
@@ -2163,54 +2540,91 @@
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.ctp-time-step:hover { background: var(--bg-4); color: var(--t-1); border-color: var(--bd-3); }
.ctp-time-step:active { background: color-mix(in srgb, var(--accent) 30%, var(--bg-3)); }
.ctp-time-step:hover {
background: var(--bg-4);
color: var(--t-1);
border-color: var(--bd-3);
}
.ctp-time-step:active {
background: color-mix(in srgb, var(--accent) 30%, var(--bg-3));
}
.ctp-time-cell {
width: 54px; height: 44px;
display: grid; place-items: center;
width: 54px;
height: 44px;
display: grid;
place-items: center;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 9px;
font-size: 26px; font-weight: 700;
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
letter-spacing: 0.02em;
}
.ctp-time-editinput {
width: 132px; height: 44px;
width: 132px;
height: 44px;
padding: 0;
background: var(--bg-3);
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--bd-2));
border-radius: 9px;
font: inherit; font-size: 26px; font-weight: 700;
font: inherit;
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--t-1);
text-align: center;
letter-spacing: 0.06em;
}
.ctp-time-editinput:focus { outline: none; }
.ctp-time-editinput::placeholder { color: var(--t-4); }
.ctp-time-colon { font-size: 26px; font-weight: 700; color: var(--t-2); padding-bottom: 2px; }
.ctp-time-editinput:focus {
outline: none;
}
.ctp-time-editinput::placeholder {
color: var(--t-4);
}
.ctp-time-colon {
font-size: 26px;
font-weight: 700;
color: var(--t-2);
padding-bottom: 2px;
}
.ctp-time-type {
align-self: center;
margin-left: 8px;
height: 30px; padding: 0 12px;
height: 30px;
padding: 0 12px;
background: transparent;
border: 1px dashed var(--bd-3);
border-radius: 7px;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 600;
font: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.ctp-time-type:hover { color: var(--t-1); border-color: var(--accent); }
.ctp-day-row { gap: 5px; }
.ctp-time-type:hover {
color: var(--t-1);
border-color: var(--accent);
}
.ctp-day-row {
gap: 5px;
}
.ctp-day-label {
font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--t-3);
margin-right: 4px;
}
.ctp-day-btn { height: 26px; padding: 0 10px; font-size: 11px; border-radius: 6px; }
.ctp-day-btn {
height: 26px;
padding: 0 10px;
font-size: 11px;
border-radius: 6px;
}
/* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
@@ -2220,95 +2634,199 @@
background: var(--bg-2);
border-color: var(--bd-2);
}
.ctp-ticker[data-status="scheduled"]:hover { background: var(--bg-3); }
.ctp-ticker[data-status="scheduled"]:hover {
background: var(--bg-3);
}
.ctp-ticker[data-status="soon"] {
background: color-mix(in srgb, var(--warn) 10%, var(--bg-2));
border-color: color-mix(in srgb, var(--warn) 50%, var(--bd-2));
animation: ctp-soon-glow 2.4s ease-in-out infinite;
}
.ctp-ticker[data-status="soon"]:hover { background: color-mix(in srgb, var(--warn) 16%, var(--bg-2)); }
.ctp-ticker[data-status="soon"]:hover {
background: color-mix(in srgb, var(--warn) 16%, var(--bg-2));
}
@keyframes ctp-soon-glow {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent); }
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
50% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 28%, transparent);
}
}
.ctp-ticker[data-status="ready"] {
background: color-mix(in srgb, var(--ok) 11%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 55%, var(--bd-2));
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent);
}
.ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); }
.ctp-ticker[data-status="ready"]:hover {
background: color-mix(in srgb, var(--ok) 17%, var(--bg-2));
}
.ctp-ticker[data-status="expired"] {
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2));
}
.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); }
.ctp-ticker[data-status="expired"]:hover {
background: color-mix(in srgb, var(--danger) 13%, var(--bg-2));
}
.ctp-ticker[data-status="running"] {
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
}
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
.ctp-ticker[data-status="running"]:hover {
background: color-mix(in srgb, var(--ok) 9%, var(--bg-2));
}
.ctp-ticker[data-status="cancelled"] {
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
}
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); }
.ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; }
.ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); }
.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; }
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
.ctp-ticker[data-status="cancelled"]:hover {
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
}
.ctp-ticker-dot[data-status="scheduled"] {
background: var(--t-3);
animation: none;
box-shadow: none;
}
.ctp-ticker-dot[data-status="soon"] {
background: var(--warn);
animation: ctp-tickerpulse-warn 1.6s ease-out infinite;
}
.ctp-ticker-dot[data-status="ready"] {
background: var(--ok);
animation: none;
box-shadow: 0 0 6px var(--ok);
}
.ctp-ticker-dot[data-status="expired"] {
background: var(--danger);
animation: none;
box-shadow: none;
}
.ctp-ticker-dot[data-status="running"] {
background: var(--ok);
animation: none;
box-shadow: none;
opacity: 0.75;
}
.ctp-ticker-dot[data-status="cancelled"] {
background: var(--danger);
animation: none;
box-shadow: none;
opacity: 0.65;
}
@keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent);
}
70% {
box-shadow: 0 0 0 6px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
.ctp-ticker-label[data-status="scheduled"] {
color: var(--t-2);
}
.ctp-ticker-label[data-status="soon"] {
color: var(--warn);
}
.ctp-ticker-label[data-status="ready"] {
color: var(--ok);
}
.ctp-ticker-label[data-status="expired"] {
color: var(--danger);
}
.ctp-ticker-label[data-status="running"] {
color: color-mix(in srgb, var(--ok) 75%, var(--t-2));
}
.ctp-ticker-label[data-status="cancelled"] {
color: color-mix(in srgb, var(--danger) 70%, var(--t-2));
}
.ctp-ticker[data-status="soon"] .ctp-ticker-cta {
color: var(--warn);
}
.ctp-ticker[data-status="ready"] .ctp-ticker-cta {
color: var(--ok);
}
.ctp-ticker[data-status="expired"] .ctp-ticker-cta {
color: var(--danger);
}
.ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); }
.ctp-ticker-label[data-status="expired"] { color: var(--danger); }
.ctp-ticker-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
.ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); }
.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); }
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta {
color: var(--t-2);
}
/* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat {
min-width: 0;
display: inline-flex; align-items: center; gap: 5px;
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--t-3);
font-size: 12px;
}
.ctp-ticker-chat svg { flex-shrink: 0; opacity: 0.7; }
.ctp-ticker-chat b { flex-shrink: 0; font-weight: 700; }
.ctp-ticker-chat svg {
flex-shrink: 0;
opacity: 0.7;
}
.ctp-ticker-chat b {
flex-shrink: 0;
font-weight: 700;
}
.ctp-ticker-chat-text {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--t-2);
}
.ctp-card.is-checkin { border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2)); }
.ctp-card.is-checkin {
border-color: color-mix(in srgb, var(--accent) 70%, var(--bd-2));
}
.ctp-checkin-note {
display: flex; align-items: center; gap: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
border-radius: 8px;
font-size: 12.5px; font-weight: 600;
font-size: 12.5px;
font-weight: 600;
color: var(--t-1);
}
.ctp-checkin-note svg { color: var(--accent); flex-shrink: 0; }
.ctp-checkin-note svg {
color: var(--accent);
flex-shrink: 0;
}
.ctp-card-timer.is-sched {
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.ctp-card-clock {
font-size: 18px;
line-height: 1;
}
.ctp-card-until {
font-size: 10.5px;
font-weight: 600;
color: var(--t-3);
letter-spacing: 0.01em;
}
.ctp-avatar.is-in .ctp-avatar-dot {
opacity: 0.8;
}
.ctp-card-clock { font-size: 18px; line-height: 1; }
.ctp-card-until { font-size: 10.5px; font-weight: 600; color: var(--t-3); letter-spacing: 0.01em; }
.ctp-avatar.is-in .ctp-avatar-dot { opacity: 0.8; }
.ctp-avatar-in {
position: absolute; bottom: -6px; left: 50%;
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
font-size: 8.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
font-size: 8.5px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--t-2);
background: var(--bg-3);
border: 1px solid var(--bd-2);
@@ -2319,37 +2837,60 @@
/* ─── Ticker mini ready-bubbles ─── */
.ctp-ticker-bubbles {
display: inline-flex; align-items: center;
display: inline-flex;
align-items: center;
justify-self: end;
}
.ctp-mini {
position: relative;
width: 22px; height: 22px;
display: grid; place-items: center;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 999px;
margin-left: -6px;
border: 2px solid var(--bg-2);
color: white;
font-size: 8px; font-weight: 800; letter-spacing: 0.02em;
font-size: 8px;
font-weight: 800;
letter-spacing: 0.02em;
}
.ctp-mini:first-child {
margin-left: 0;
}
.ctp-mini[data-state="ready"] {
box-shadow: 0 0 0 1.5px var(--ok);
}
.ctp-mini[data-state="pending"] {
opacity: 0.75;
}
.ctp-mini[data-state="in"] {
opacity: 0.85;
}
.ctp-mini:first-child { margin-left: 0; }
.ctp-mini[data-state="ready"] { box-shadow: 0 0 0 1.5px var(--ok); }
.ctp-mini[data-state="pending"] { opacity: 0.75; }
.ctp-mini[data-state="in"] { opacity: 0.85; }
.ctp-mini-check {
position: absolute; bottom: -3px; right: -4px;
width: 11px; height: 11px;
display: grid; place-items: center;
position: absolute;
bottom: -3px;
right: -4px;
width: 11px;
height: 11px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--ok);
color: #06240f;
border: 1.5px solid var(--bg-2);
}
.ctp-mini-check svg { width: 7px; height: 7px; }
.ctp-mini-check svg {
width: 7px;
height: 7px;
}
.ctp-mini-tag {
position: absolute; bottom: -6px; right: -7px;
position: absolute;
bottom: -6px;
right: -7px;
font-style: normal;
font-size: 8px; font-weight: 700;
font-size: 8px;
font-weight: 700;
line-height: 11px;
padding: 0 3px;
border-radius: 999px;
@@ -2359,51 +2900,83 @@
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ctp-mini-more { background: var(--bg-4); color: var(--t-2); font-size: 8.5px; }
.ctp-mini-more {
background: var(--bg-4);
color: var(--t-2);
font-size: 8.5px;
}
/* ─── Per-call chat ─── */
.ctp-chat {
display: flex; flex-direction: column;
display: flex;
flex-direction: column;
border-top: 1px solid var(--bd-1);
margin-top: 2px;
padding-top: 8px;
}
.ctp-chat-toggle {
display: flex; align-items: center; gap: 8px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 2px;
background: transparent;
border: 0;
color: var(--t-3);
font: inherit; font-size: 11.5px; font-weight: 700;
font: inherit;
font-size: 11.5px;
font-weight: 700;
cursor: pointer;
transition: color .15s;
}
.ctp-chat-toggle:hover { color: var(--t-1); }
.ctp-chat-toggle svg { flex-shrink: 0; }
.ctp-chat-count { color: var(--t-4); font-weight: 600; }
.ctp-chat-toggle:hover {
color: var(--t-1);
}
.ctp-chat-toggle svg {
flex-shrink: 0;
}
.ctp-chat-count {
color: var(--t-4);
font-weight: 600;
}
.ctp-chat-unread {
min-width: 16px; height: 16px;
display: grid; place-items: center;
min-width: 16px;
height: 16px;
display: grid;
place-items: center;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: white;
font-size: 9.5px; font-weight: 800;
font-size: 9.5px;
font-weight: 800;
flex-shrink: 0;
}
.ctp-chat-preview {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--t-3);
text-align: left;
}
.ctp-chat-preview b { font-weight: 700; }
.ctp-chat-chevron { margin-left: auto; flex-shrink: 0; transition: transform .15s; }
.ctp-chat.is-open .ctp-chat-chevron { transform: rotate(180deg); }
.ctp-chat-preview b {
font-weight: 700;
}
.ctp-chat-chevron {
margin-left: auto;
flex-shrink: 0;
transition: transform .15s;
}
.ctp-chat.is-open .ctp-chat-chevron {
transform: rotate(180deg);
}
.ctp-chat-list {
display: flex; flex-direction: column; gap: 6px;
display: flex;
flex-direction: column;
gap: 6px;
max-height: 168px;
overflow-y: auto;
margin: 8px 0;
@@ -2412,26 +2985,57 @@
border: 1px solid var(--bd-1);
border-radius: 8px;
}
.ctp-chat-msg { font-size: 12px; line-height: 1.45; color: var(--t-2); overflow-wrap: anywhere; }
.ctp-chat-msg b { font-weight: 700; }
.ctp-chat-text { color: var(--t-1); }
.ctp-chat-time { margin-left: 6px; font-size: 10px; color: var(--t-4); font-variant-numeric: tabular-nums; }
.ctp-chat-empty { font-size: 11.5px; color: var(--t-4); text-align: center; padding: 6px 0; }
.ctp-chat-form { display: flex; gap: 6px; }
.ctp-chat-msg {
font-size: 12px;
line-height: 1.45;
color: var(--t-2);
overflow-wrap: anywhere;
}
.ctp-chat-msg b {
font-weight: 700;
}
.ctp-chat-text {
color: var(--t-1);
}
.ctp-chat-time {
margin-left: 6px;
font-size: 10px;
color: var(--t-4);
font-variant-numeric: tabular-nums;
}
.ctp-chat-empty {
font-size: 11.5px;
color: var(--t-4);
text-align: center;
padding: 6px 0;
}
.ctp-chat-form {
display: flex;
gap: 6px;
}
.ctp-chat-input {
flex: 1; height: 32px;
flex: 1;
height: 32px;
padding: 0 10px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 7px;
color: var(--t-1);
font: inherit; font-size: 12px;
font: inherit;
font-size: 12px;
}
.ctp-chat-input::placeholder {
color: var(--t-4);
}
.ctp-chat-input:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2));
}
.ctp-chat-input::placeholder { color: var(--t-4); }
.ctp-chat-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); }
.ctp-chat-send {
width: 32px; height: 32px;
display: grid; place-items: center;
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-3));
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2));
border-radius: 7px;
@@ -2440,25 +3044,34 @@
flex-shrink: 0;
transition: background .15s;
}
.ctp-chat-send:hover { background: color-mix(in srgb, var(--accent) 40%, var(--bg-3)); }
.ctp-chat-send:hover {
background: color-mix(in srgb, var(--accent) 40%, var(--bg-3));
}
.ctp-transport-note {
margin-top: 10px;
font-size: 11.5px;
color: var(--t-3);
}
.ctp-transport-note.is-error { color: var(--danger); }
.ctp-transport-note.is-error {
color: var(--danger);
}
@container launcher (max-width: 1280px) {
.ctp-ticker {
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content 70px;
grid-template-columns: 10px 86px minmax(120px, 1fr) 78px 130px max-content
70px;
}
.ctp-ticker-by,
.ctp-ticker-chat { display: none; }
.ctp-ticker-chat {
display: none;
}
}
@container launcher (max-width: 800px) {
.ctp-ticker {
grid-template-columns: 10px 78px minmax(100px, 1fr) 76px 64px;
}
.ctp-ticker-time,
.ctp-ticker-bubbles { display: none; }
.ctp-ticker-bubbles {
display: none;
}
}
@@ -1,264 +1,443 @@
import { useCallback, useMemo, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { TopBar } from '../components/topbar/TopBar';
import { KebabItem } from '../components/topbar/KebabMenu';
import { ResultsBar } from '../components/grid/ResultsBar';
import { GameGrid } from '../components/grid/GameGrid';
import { GameDetailModal } from '../components/modals/GameDetailModal';
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal';
import { SettingsDialog } from '../components/modals/SettingsDialog';
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
import { TopBar } from "../components/topbar/TopBar";
import { KebabItem } from "../components/topbar/KebabMenu";
import { ResultsBar } from "../components/grid/ResultsBar";
import { GameGrid } from "../components/grid/GameGrid";
import { GameDetailModal } from "../components/modals/GameDetailModal";
import { ConfirmRemoveDownloadModal } from "../components/modals/ConfirmRemoveDownloadModal";
import { SettingsDialog } from "../components/modals/SettingsDialog";
import { NoDirectoryState } from "../components/empty/NoDirectoryState";
import { EmptyResultsState } from "../components/empty/EmptyResultsState";
import { CallToPlayTicker } from "../components/calltoplay/CallToPlayTicker";
import { CallToPlayOverlay } from "../components/calltoplay/CallToPlayOverlay";
import { useGameDirectory } from '../hooks/useGameDirectory';
import { useGames } from '../hooks/useGames';
import { useGameActions } from '../hooks/useGameActions';
import { useThumbnails } from '../hooks/useThumbnails';
import { useSettings } from '../hooks/useSettings';
import { useCallToPlay } from '../hooks/useCallToPlay';
import { useGameDirectory } from "../hooks/useGameDirectory";
import { useGames } from "../hooks/useGames";
import { useGameActions } from "../hooks/useGameActions";
import { useThumbnails } from "../hooks/useThumbnails";
import { useSettings } from "../hooks/useSettings";
import { useCallToPlay } from "../hooks/useCallToPlay";
import { useStreamInstallCapability } from "../hooks/useStreamInstallCapability";
import { useLocalNetworkSharing } from "../hooks/useLocalNetworkSharing";
import { useIdentityDiagnostic } from "../hooks/useIdentityDiagnostic";
import { Game } from '../lib/types';
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
import { Game } from "../lib/types";
import {
AsyncOwner,
ownCompanionWindowCreation,
windowAsyncScope,
} from "../lib/asyncOwnership";
import {
isLocalNetworkSharingActive,
localNetworkSharingNotice,
} from "../lib/localNetworkSharing";
import {
EPHEMERAL_IDENTITY_NOTICE,
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
} from "../lib/identityDiagnostic";
import {
applyFilterAndSort,
countByFilter,
needsUpdate,
} from "../lib/gameState";
const openLogsWindow = async () => {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
try {
const existing = await WebviewWindow.getByLabel('unpack-logs');
if (existing) {
await existing.setFocus();
return;
}
const win = new WebviewWindow('unpack-logs', {
url: '/?view=unpack-logs',
title: 'Unpack Logs',
width: 900,
height: 700,
resizable: true,
});
await win.once<unknown>('tauri://error', (event) => {
console.error('Error opening unpack logs window:', event.payload);
});
} catch (err) {
console.error('Error opening unpack logs window:', err);
interface CompanionWindowOptions {
label: string;
url: string;
title: string;
width: number;
height: number;
}
const openCompanionWindow = async (
owner: AsyncOwner,
options: CompanionWindowOptions,
): Promise<void> => {
try {
let windowModule:
| typeof import("@tauri-apps/api/webviewWindow")
| undefined;
if (
!await owner.applyIfActive(
() => import("@tauri-apps/api/webviewWindow"),
(loaded) => {
windowModule = loaded;
},
) || windowModule === undefined
) {
return;
}
const { WebviewWindow } = windowModule;
const existingResult: Array<
Awaited<ReturnType<typeof WebviewWindow.getByLabel>>
> = [];
if (
!await owner.applyIfActive(
() => WebviewWindow.getByLabel(options.label),
(found) => {
existingResult.push(found);
},
)
) {
return;
}
const existingWindow = existingResult[0];
if (existingWindow) {
await owner.applyIfActive(() => existingWindow.setFocus(), () => {});
return;
}
if (!owner.isActive()) return;
const win = new WebviewWindow(options.label, {
url: options.url,
title: options.title,
width: options.width,
height: options.height,
resizable: true,
});
const result = await ownCompanionWindowCreation(owner, {
registerCreated: (handler) => win.once("tauri://created", handler),
registerError: (handler) =>
win.once<unknown>("tauri://error", (event) => handler(event.payload)),
destroy: () => win.destroy(),
});
if (!owner.isActive()) return;
if (result.kind === "error") {
console.error(`Error opening ${options.title}:`, result.payload);
} else if (result.kind === "registration-error") {
console.error(
`Error owning ${options.title} creation:`,
result.error,
);
}
} catch (err) {
if (owner.isActive()) console.error(`Error opening ${options.title}:`, err);
}
};
const openMainLogsWindow = async () => {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
try {
const existing = await WebviewWindow.getByLabel('main-logs');
if (existing) {
await existing.setFocus();
return;
}
const win = new WebviewWindow('main-logs', {
url: '/?view=main-logs',
title: 'Application Logs',
width: 980,
height: 720,
resizable: true,
});
await win.once<unknown>('tauri://error', (event) => {
console.error('Error opening application logs window:', event.payload);
});
} catch (err) {
console.error('Error opening application logs window:', err);
}
const UNPACK_LOG_WINDOW: CompanionWindowOptions = {
label: "unpack-logs",
url: "/?view=unpack-logs",
title: "Unpack Logs",
width: 900,
height: 700,
};
const MAIN_LOG_WINDOW: CompanionWindowOptions = {
label: "main-logs",
url: "/?view=main-logs",
title: "Application Logs",
width: 980,
height: 720,
};
export const MainWindow = () => {
const { settings, set: setSetting } = useSettings();
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails();
const callToPlay = useCallToPlay(settings.username);
const { settings, set: setSetting, ready: settingsReady } = useSettings();
const localNetworkSharing = useLocalNetworkSharing();
const identityDiagnostic = useIdentityDiagnostic();
const {
gameDir,
ready: gameDirectoryReady,
hasGameDirectory,
setGameDir,
rescan,
} = useGameDirectory(localNetworkSharing.ready);
const games = useGames(rescan);
const actions = useGameActions(games, settings);
const thumbnails = useThumbnails(games.games.map((game) => game.id));
const sharingActive = isLocalNetworkSharingActive(
localNetworkSharing.snapshot,
);
const callToPlay = useCallToPlay(settings.username, sharingActive);
const windowActionOwnerRef = useRef<AsyncOwner | null>(null);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
);
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
useEffect(() => {
const owner = new AsyncOwner((error) => {
console.error("Failed to clean up a main-window action:", error);
});
windowActionOwnerRef.current = owner;
return () => {
if (windowActionOwnerRef.current === owner) {
windowActionOwnerRef.current = null;
}
windowAsyncScope.adopt(owner.dispose());
};
}, []);
// Query is local UI state (no need to persist).
const [query, setQuery] = useState('');
const filteredGames = useMemo(
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[visibleGames, settings.filter, settings.sort, query],
);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const supportsStreamedInstall = useStreamInstallCapability(openGameId);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory],
);
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
const openGame = useMemo<Game | null>(
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null,
[openGameId, games.games],
);
const removeGame = useMemo<Game | null>(
() => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null,
[removeGameId, games.games],
);
// Query is local UI state (no need to persist).
const [query, setQuery] = useState("");
const filteredGames = useMemo(
() =>
applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
[visibleGames, settings.filter, settings.sort, query],
);
const pickDirectory = useCallback(async () => {
const picked = await open({ multiple: false, directory: true });
if (typeof picked === 'string' && picked) setGameDir(picked);
}, [setGameDir]);
const openGame = useMemo<Game | null>(
() =>
openGameId ? games.games.find((g) => g.id === openGameId) ?? null : null,
[openGameId, games.games],
);
const removeGame = useMemo<Game | null>(
() =>
removeGameId
? games.games.find((g) => g.id === removeGameId) ?? null
: null,
[removeGameId, games.games],
);
const handlePrimary = useCallback((game: Game) => {
if (game.installed) {
if (needsUpdate(game)) actions.update(game.id);
else actions.play(game.id);
} else {
actions.install(game.id);
const pickDirectory = useCallback(async () => {
const owner = windowActionOwnerRef.current;
if (owner === null || !owner.isActive()) return;
try {
await owner.applyIfActive(
() => open({ multiple: false, directory: true }),
(picked) => {
if (typeof picked === "string" && picked) setGameDir(picked);
},
);
} catch (error) {
if (owner.isActive()) {
console.error("Failed to choose a game directory:", error);
}
}
}, [setGameDir]);
const handlePrimary = useCallback((game: Game) => {
if (game.installed) {
if (needsUpdate(game)) actions.update(game.id);
else actions.play(game.id);
} else {
actions.install(game.id);
}
}, [actions]);
const handleUninstall = useCallback((game: Game) => {
actions.uninstall(game.id);
}, [actions]);
const handleRemoveDownload = useCallback((game: Game) => {
setRemoveGameId(game.id);
}, []);
const confirmRemoveDownload = useCallback((game: Game) => {
actions.removeDownload(game.id);
setRemoveGameId(null);
setOpenGameId((current) => current === game.id ? null : current);
}, [actions]);
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
{ kind: "item", label: "Settings", onClick: () => setSettingsOpen(true) },
{ kind: "item", label: "Refresh library", onClick: () => rescan() },
{ kind: "separator" },
{
kind: "item",
label: "Application logs",
onClick: () => {
const owner = windowActionOwnerRef.current;
if (owner) {
windowAsyncScope.adopt(openCompanionWindow(owner, MAIN_LOG_WINDOW));
}
}, [actions]);
},
},
{
kind: "item",
label: "Unpack logs",
onClick: () => {
const owner = windowActionOwnerRef.current;
if (owner) {
windowAsyncScope.adopt(openCompanionWindow(owner, UNPACK_LOG_WINDOW));
}
},
},
], [rescan]);
const handleUninstall = useCallback((game: Game) => {
actions.uninstall(game.id);
}, [actions]);
const rootStyle = { "--accent": settings.accent } as React.CSSProperties;
const className = [
"launcher",
`bg-${settings.bg}`,
`density-${settings.density}`,
].join(" ");
const handleRemoveDownload = useCallback((game: Game) => {
setRemoveGameId(game.id);
}, []);
if (
!settingsReady ||
!localNetworkSharing.ready ||
!identityDiagnostic.ready ||
!gameDirectoryReady
) {
return <div className={className} style={rootStyle} aria-busy="true" />;
}
const confirmRemoveDownload = useCallback((game: Game) => {
actions.removeDownload(game.id);
setRemoveGameId(null);
setOpenGameId(current => current === game.id ? null : current);
}, [actions]);
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
{ kind: 'separator' },
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
], [rescan]);
const rootStyle = { '--accent': settings.accent } as React.CSSProperties;
const className = [
'launcher',
`bg-${settings.bg}`,
`density-${settings.density}`,
].join(' ');
return (
<div className={className} style={rootStyle}>
<TopBar
accent={settings.accent}
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting('filter', v)}
counts={counts}
query={query}
setQuery={setQuery}
sort={settings.sort}
setSort={(v) => setSetting('sort', v)}
kebabItems={kebabItems}
nominations={callToPlay.nominations}
onOpenCallToPlay={() => {
setFocusedCallId(null);
setCallToPlayOpen(true);
}}
/>
<main className="grid-wrap">
<CallToPlayTicker
nominations={callToPlay.nominations}
games={games.games}
accent={settings.accent}
onOpen={(callId) => {
setFocusedCallId(callId);
setCallToPlayOpen(true);
}}
/>
{hasGameDirectory ? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0 ? (
visibleGames.length === 0 ? (
<EmptyResultsState
title="Scanning for games"
hint="Looking for game bundles in your selected directory…"
/>
) : (
<EmptyResultsState
title="Nothing matches"
hint="No games match the current filter or search query."
/>
)
) : (
<GameGrid
games={filteredGames}
aspect={settings.aspect}
getThumbnail={thumbnails.get}
onOpen={(g) => setOpenGameId(g.id)}
onPrimary={handlePrimary}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</>
) : (
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
)}
</main>
{openGame && (
<GameDetailModal
game={openGame}
thumbnailUrl={thumbnails.get(openGame.id)}
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onStreamInstall={(g) => actions.streamInstall(g.id)}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
onStartServer={(g) => actions.startServer(g.id)}
onViewFiles={(g) => actions.viewFiles(g.id)}
/>
)}
{removeGame && (
<ConfirmRemoveDownloadModal
game={removeGame}
onCancel={() => setRemoveGameId(null)}
onConfirm={confirmRemoveDownload}
/>
)}
{settingsOpen && (
<SettingsDialog
settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => void pickDirectory()}
onChange={setSetting}
onClose={() => setSettingsOpen(false)}
/>
)}
{callToPlayOpen && (
<CallToPlayOverlay
nominations={callToPlay.nominations}
games={games.games}
actorId={callToPlay.actorId}
actions={callToPlay.actions}
focusId={focusedCallId}
transportReady={callToPlay.transportReady}
error={callToPlay.error}
getThumbnail={thumbnails.get}
totalPeerCount={games.totalPeerCount}
onLaunch={handlePrimary}
onClose={() => {
setCallToPlayOpen(false);
setFocusedCallId(null);
}}
/>
)}
return (
<div className={className} style={rootStyle}>
<TopBar
accent={settings.accent}
peerCount={games.totalPeerCount}
filter={settings.filter}
setFilter={(v) => setSetting("filter", v)}
counts={counts}
query={query}
setQuery={setQuery}
sort={settings.sort}
setSort={(v) => setSetting("sort", v)}
kebabItems={kebabItems}
nominations={callToPlay.nominations}
onOpenCallToPlay={() => {
setFocusedCallId(null);
setCallToPlayOpen(true);
}}
/>
<main className="grid-wrap">
<div
className={`network-notice network-notice-sharing is-${localNetworkSharing.snapshot.phase}`}
role="status"
>
{localNetworkSharingNotice(localNetworkSharing.snapshot)}
</div>
);
{localNetworkSharing.error &&
localNetworkSharing.snapshot.persistenceProblem === null && (
<div className="network-notice is-error" role="status">
{localNetworkSharing.error}
</div>
)}
{identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
<div className="network-notice identity-notice" role="status">
{EPHEMERAL_IDENTITY_NOTICE}
</div>
)}
{identityDiagnostic.unavailable && (
<div className="network-notice identity-notice" role="status">
{IDENTITY_STATUS_UNAVAILABLE_NOTICE}
</div>
)}
{games.protocolMismatch && (
<div className="network-notice" role="status">
Nearby devices are running a different Lanspread version
</div>
)}
<CallToPlayTicker
nominations={callToPlay.nominations}
games={games.games}
accent={settings.accent}
onOpen={(callId) => {
setFocusedCallId(callId);
setCallToPlayOpen(true);
}}
/>
{hasGameDirectory
? (
<>
<ResultsBar shown={filteredGames.length} total={counts.all} />
{filteredGames.length === 0
? (
visibleGames.length === 0
? (
<EmptyResultsState
title="Scanning for games"
hint="Looking for game bundles in your selected directory…"
/>
)
: (
<EmptyResultsState
title="Nothing matches"
hint="No games match the current filter or search query."
/>
)
)
: (
<GameGrid
games={filteredGames}
aspect={settings.aspect}
getThumbnail={thumbnails.get}
onOpen={(g) => setOpenGameId(g.id)}
onPrimary={handlePrimary}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
/>
)}
</>
)
: (
<NoDirectoryState
onChooseDirectory={() => windowAsyncScope.adopt(pickDirectory())}
/>
)}
</main>
{openGame && (
<GameDetailModal
game={openGame}
thumbnailUrl={thumbnails.get(openGame.id)}
supportsStreamedInstall={supportsStreamedInstall}
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onStreamInstall={(g) => actions.streamInstall(g.id)}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
onCancelDownload={(g) => actions.cancelDownload(g.id)}
onStartServer={(g) => actions.startServer(g.id)}
onViewFiles={(g) => actions.viewFiles(g.id)}
/>
)}
{removeGame && (
<ConfirmRemoveDownloadModal
game={removeGame}
onCancel={() => setRemoveGameId(null)}
onConfirm={confirmRemoveDownload}
/>
)}
{settingsOpen && (
<SettingsDialog
settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => windowAsyncScope.adopt(pickDirectory())}
onChange={setSetting}
localNetworkSharing={localNetworkSharing.snapshot}
localNetworkSharingReady={localNetworkSharing.ready}
localNetworkSharingBusy={localNetworkSharing.busy}
localNetworkSharingError={localNetworkSharing.error}
onLocalNetworkSharingChange={localNetworkSharing.setEnabled}
onClose={() => setSettingsOpen(false)}
/>
)}
{callToPlayOpen && (
<CallToPlayOverlay
nominations={callToPlay.nominations}
games={games.games}
actorId={callToPlay.actorId}
actions={callToPlay.actions}
focusId={focusedCallId}
transportReady={callToPlay.transportReady && sharingActive}
error={sharingActive
? callToPlay.error
: "Local network sharing is off."}
getThumbnail={thumbnails.get}
totalPeerCount={games.totalPeerCount}
onLaunch={handlePrimary}
onClose={() => {
setCallToPlayOpen(false);
setFocusedCallId(null);
}}
/>
)}
</div>
);
};
@@ -0,0 +1,776 @@
import {
AsyncAdoptionScope,
type AsyncCleanup,
AsyncOwner,
createSerializedAsyncWriter,
mergeHydratedState,
ownCompanionWindowCreation,
registerSequentially,
} from "../src/lib/asyncOwnership.ts";
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test("unmount before listener resolution cleans the late listener and stops registration", async () => {
const owner = new AsyncOwner();
const first = deferred<AsyncCleanup>();
let firstRegistrations = 0;
let secondRegistrations = 0;
let firstCleanups = 0;
const registration = registerSequentially(owner, [
() => {
firstRegistrations += 1;
return first.promise;
},
() => {
secondRegistrations += 1;
return Promise.resolve(() => {});
},
]);
assertEquals(
firstRegistrations,
1,
"the first listener should start immediately",
);
const disposal = owner.dispose();
first.resolve(() => {
firstCleanups += 1;
});
assertEquals(
await registration,
false,
"disposed registration should report cancellation",
);
assertEquals(
firstCleanups,
1,
"the late listener should unlisten immediately",
);
assertEquals(
secondRegistrations,
0,
"no later listener should start after disposal",
);
await disposal;
});
Deno.test("partial listener registration is fully cleaned across an in-flight listener", async () => {
const owner = new AsyncOwner();
const second = deferred<AsyncCleanup>();
const secondStarted = deferred<void>();
let firstCleanups = 0;
let secondCleanups = 0;
let thirdRegistrations = 0;
const registration = registerSequentially(owner, [
() =>
Promise.resolve(() => {
firstCleanups += 1;
}),
() => {
secondStarted.resolve();
return second.promise;
},
() => {
thirdRegistrations += 1;
return Promise.resolve(() => {});
},
]);
await secondStarted.promise;
const disposal = owner.dispose();
assertEquals(
firstCleanups,
1,
"an owned listener should clean up during disposal",
);
second.resolve(() => {
secondCleanups += 1;
});
assertEquals(
await registration,
false,
"the in-flight registration should stop the sequence",
);
assertEquals(
secondCleanups,
1,
"the in-flight listener should clean up when it resolves",
);
assertEquals(thirdRegistrations, 0, "the third listener should never start");
await disposal;
await owner.dispose();
assertEquals(
firstCleanups,
1,
"repeated disposal must not duplicate owned cleanup",
);
assertEquals(
secondCleanups,
1,
"repeated disposal must not duplicate late cleanup",
);
});
Deno.test("listener registration failure cleans the partial scope and skips later listeners", async () => {
const owner = new AsyncOwner();
const failure = new Error("registration failed");
let firstCleanups = 0;
let thirdRegistrations = 0;
let reported: unknown;
try {
await registerSequentially(owner, [
() =>
Promise.resolve(() => {
firstCleanups += 1;
}),
() => Promise.reject(failure),
() => {
thirdRegistrations += 1;
return Promise.resolve(() => {});
},
]);
} catch (error) {
reported = error;
}
assertEquals(reported, failure, "the registration error should be preserved");
assertEquals(
firstCleanups,
1,
"earlier listeners should clean up on setup failure",
);
assertEquals(
thirdRegistrations,
0,
"later listeners should not start after setup failure",
);
assertEquals(
owner.isActive(),
false,
"a failed registration scope should stay disposed",
);
});
Deno.test("late refresh results and post-disposal refreshes cannot publish", async () => {
const owner = new AsyncOwner();
const refresh = deferred<string>();
let refreshStarts = 0;
const applied: string[] = [];
const pending = owner.applyIfActive(
() => {
refreshStarts += 1;
return refresh.promise;
},
(value) => applied.push(value),
);
const disposal = owner.dispose();
refresh.resolve("stale");
assertEquals(
await pending,
false,
"a late refresh should report cancellation",
);
assertArrayEquals(applied, [], "a late refresh must not publish state");
const afterDispose = await owner.applyIfActive(
() => {
refreshStarts += 1;
return Promise.resolve("newer");
},
(value) => applied.push(value),
);
assertEquals(
afterDispose,
false,
"a disposed owner should reject new refresh work",
);
assertEquals(refreshStarts, 1, "refresh work must not start after disposal");
assertArrayEquals(
applied,
[],
"post-disposal refreshes must not publish state",
);
await disposal;
});
Deno.test("disposal joins a late asynchronous listener cleanup", async () => {
const owner = new AsyncOwner();
const listener = deferred<AsyncCleanup>();
const cleanupDone = deferred<void>();
let cleanupStarts = 0;
let disposalSettled = false;
const registration = owner.register(() => listener.promise);
const disposal = owner.dispose().then(() => {
disposalSettled = true;
});
listener.resolve(async () => {
cleanupStarts += 1;
await cleanupDone.promise;
});
await Promise.resolve();
assertEquals(
cleanupStarts,
1,
"the late cleanup should start immediately on resolution",
);
assertEquals(
disposalSettled,
false,
"disposal must wait for the asynchronous cleanup",
);
cleanupDone.resolve();
assertEquals(
await registration,
false,
"the disposed registration should remain cancelled",
);
await disposal;
assertEquals(
disposalSettled,
true,
"disposal should settle after cleanup completion",
);
});
Deno.test("asynchronous cleanup rejection is reported and disposal still drains", async () => {
const cleanupDone = deferred<void>();
const failure = new Error("unlisten failed");
const reported: unknown[] = [];
const owner = new AsyncOwner((error) => reported.push(error));
await owner.register(() =>
Promise.resolve(async () => {
await cleanupDone.promise;
throw failure;
})
);
const disposal = owner.dispose();
cleanupDone.resolve();
await disposal;
assertEquals(
reported.length,
1,
"the cleanup failure should be reported once",
);
assertEquals(
reported[0],
failure,
"the original cleanup failure should be reported",
);
});
Deno.test("a root adoption scope drains work and observes rejection", async () => {
const first = deferred<void>();
const failure = new Error("adopted cleanup failed");
const reported: unknown[] = [];
const scope = new AsyncAdoptionScope((error) => reported.push(error));
let drained = false;
scope.adopt(first.promise);
scope.adopt(Promise.reject(failure));
const drain = scope.drain().then(() => {
drained = true;
});
await Promise.resolve();
assertEquals(drained, false, "the adoption scope should retain pending work");
assertEquals(
reported.length,
1,
"an adopted rejection should be observed once",
);
assertEquals(
reported[0],
failure,
"the adoption scope should report the original error",
);
first.resolve();
await drain;
assertEquals(
drained,
true,
"the adoption scope should drain after all work settles",
);
});
Deno.test("window close drains every admitted operation and rejects late owner registration", async () => {
const scope = new AsyncAdoptionScope();
const cleanupDone = deferred<void>();
const unrelatedInvoke = deferred<string>();
const owner = new AsyncOwner(() => {}, scope);
let cleanupStarts = 0;
await owner.register(() =>
Promise.resolve(async () => {
cleanupStarts += 1;
await cleanupDone.promise;
})
);
const unrelated = owner.applyIfActive(
() => unrelatedInvoke.promise,
() => {},
);
let closeDrained = false;
const close = scope.disposeOwned().then(() => {
closeDrained = true;
});
const lateOwner = new AsyncOwner(() => {}, scope);
assertEquals(
lateOwner.isActive(),
false,
"owner admission must close synchronously",
);
assertEquals(cleanupStarts, 1, "listener cleanup should start during close");
assertEquals(closeDrained, false, "window close must await listener cleanup");
cleanupDone.resolve();
await Promise.resolve();
assertEquals(
closeDrained,
false,
"an admitted invoke must retain the webview after listener cleanup",
);
unrelatedInvoke.resolve("settled during close");
assertEquals(
await unrelated,
false,
"unrelated late invoke publication stays suppressed",
);
await close;
assertEquals(
closeDrained,
true,
"window close must join every admitted operation",
);
});
Deno.test("closing during a dialog prevents its chained invoke from starting", async () => {
const scope = new AsyncAdoptionScope();
const owner = new AsyncOwner(() => {}, scope);
const dialog = deferred<boolean>();
let invokes = 0;
const action = (async () => {
let confirmed = false;
if (
!await owner.applyIfActive(
() => dialog.promise,
(answer) => {
confirmed = answer;
},
) || !confirmed
) {
return;
}
await owner.applyIfActive(
() => {
invokes += 1;
return Promise.resolve();
},
() => {},
);
})();
const close = scope.disposeOwned();
dialog.resolve(true);
await action;
await close;
assertEquals(
invokes,
0,
"a dialog result must not start work after close admission",
);
});
Deno.test("closing during a helper import prevents later helper side effects", async () => {
const scope = new AsyncAdoptionScope();
const owner = new AsyncOwner(() => {}, scope);
const imported = deferred<string>();
let helperStarts = 0;
const helper = (async () => {
let moduleName: string | undefined;
if (
!await owner.applyIfActive(
() => imported.promise,
(value) => {
moduleName = value;
},
) || moduleName === undefined
) {
return;
}
await owner.applyIfActive(
() => {
helperStarts += 1;
return Promise.resolve();
},
() => {},
);
})();
const close = scope.disposeOwned();
imported.resolve("window helper");
await helper;
await close;
assertEquals(
helperStarts,
0,
"a late import must not focus or create a window",
);
});
Deno.test("close after companion construction drains creation and both listeners before destroy", async () => {
const scope = new AsyncAdoptionScope();
const owner = new AsyncOwner(() => {}, scope);
const createdCleanupDone = deferred<void>();
const errorCleanupDone = deferred<void>();
const bothCleanupsStarted = deferred<void>();
const companionDestroyStarted = deferred<void>();
const companionDestroyDone = deferred<void>();
const order: string[] = [];
let createdHandler: (() => void) | undefined;
let errorHandler: ((payload: unknown) => void) | undefined;
let cleanupStarts = 0;
let latePublications = 0;
let rootDestroyed = false;
const noteCleanupStart = () => {
cleanupStarts += 1;
if (cleanupStarts === 2) bothCleanupsStarted.resolve();
};
const creation = ownCompanionWindowCreation(owner, {
registerCreated: (handler) => {
createdHandler = handler;
return Promise.resolve(async () => {
order.push("created-listener-cleanup");
noteCleanupStart();
await createdCleanupDone.promise;
});
},
registerError: (handler) => {
errorHandler = handler;
return Promise.resolve(async () => {
order.push("error-listener-cleanup");
noteCleanupStart();
await errorCleanupDone.promise;
});
},
destroy: async () => {
order.push("companion-destroy");
companionDestroyStarted.resolve();
await companionDestroyDone.promise;
},
}).then((result) => {
if (owner.isActive()) latePublications += 1;
return result;
});
scope.adopt(creation);
if (createdHandler === undefined || errorHandler === undefined) {
throw new Error(
"construction must synchronously begin both creation listeners",
);
}
const close = scope.disposeOwned().then(() => {
order.push("root-destroy");
rootDestroyed = true;
});
await Promise.resolve();
assertEquals(
rootDestroyed,
false,
"close must wait for the native creation outcome",
);
order.push("created-event");
createdHandler();
errorHandler(new Error("late losing outcome"));
await bothCleanupsStarted.promise;
assertEquals(
rootDestroyed,
false,
"root destruction must wait for both native unlisten acknowledgements",
);
createdCleanupDone.resolve();
errorCleanupDone.resolve();
await companionDestroyStarted.promise;
assertEquals(
rootDestroyed,
false,
"a companion created after close must be destroyed before its parent realm",
);
companionDestroyDone.resolve();
assertEquals(
(await creation).kind,
"created",
"the first native outcome must win exactly once",
);
await close;
assertEquals(
latePublications,
0,
"late creation must not publish into disposed React state",
);
assertArrayEquals(
order,
[
"created-event",
"created-listener-cleanup",
"error-listener-cleanup",
"companion-destroy",
"root-destroy",
],
"creation, listener cleanup, companion destruction, and root destruction stay ordered",
);
});
Deno.test("listener-first bootstrap keeps an update that arrives during the initial refresh", async () => {
const owner = new AsyncOwner();
const initial = deferred<string>();
const update = deferred<string>();
const published: string[] = [];
let emitUpdate: (() => Promise<boolean>) | undefined;
const order: string[] = [];
await owner.register(() => {
order.push("listener");
emitUpdate = () =>
owner.applyLatestIfActive(
() => update.promise,
(value) => published.push(value),
);
return Promise.resolve(() => {});
});
order.push("snapshot");
const initialRefresh = owner.applyLatestIfActive(
() => initial.promise,
(value) => published.push(value),
);
const eventRefresh = emitUpdate?.();
if (eventRefresh === undefined) throw new Error("listener was not installed");
initial.resolve("stale-initial");
assertEquals(
await initialRefresh,
false,
"the event should supersede the bootstrap snapshot",
);
update.resolve("event-update");
assertEquals(await eventRefresh, true, "the event refresh should publish");
assertArrayEquals(
order,
["listener", "snapshot"],
"the listener must precede the snapshot",
);
assertArrayEquals(
published,
["event-update"],
"the bootstrap interval must not lose updates",
);
await owner.dispose();
});
Deno.test("out-of-order refresh completion publishes only the latest request", async () => {
const owner = new AsyncOwner();
const older = deferred<string>();
const newer = deferred<string>();
const published: string[] = [];
const olderRefresh = owner.applyLatestIfActive(
() => older.promise,
(value) => published.push(value),
);
const newerRefresh = owner.applyLatestIfActive(
() => newer.promise,
(value) => published.push(value),
);
newer.resolve("newer");
assertEquals(await newerRefresh, true, "the newest refresh should publish");
older.resolve("older");
assertEquals(
await olderRefresh,
false,
"the older refresh should be suppressed",
);
assertArrayEquals(
published,
["newer"],
"older completion must not overwrite newer state",
);
await owner.dispose();
});
Deno.test("settings hydration keeps saved fields while applying newer user edits", () => {
const restored = {
accent: "saved-accent",
density: "saved-density",
username: "saved-user",
};
const pendingEdits = { accent: "user-edit" };
const merged = mergeHydratedState(restored, pendingEdits);
assertEquals(
merged.accent,
"user-edit",
"the newer edit should win its field",
);
assertEquals(
merged.density,
"saved-density",
"unrelated saved fields must survive",
);
assertEquals(
merged.username,
"saved-user",
"the full saved baseline must be retained",
);
});
Deno.test("serialized writes do not start a newer value before the older value settles", async () => {
const firstDone = deferred<void>();
const firstStarted = deferred<void>();
const secondDone = deferred<void>();
const secondStarted = deferred<void>();
const starts: number[] = [];
const writer = createSerializedAsyncWriter<number>((value) => {
starts.push(value);
if (value === 1) {
firstStarted.resolve();
return firstDone.promise;
}
secondStarted.resolve();
return secondDone.promise;
}, () => {});
const firstWrite = writer.enqueue(1);
const secondWrite = writer.enqueue(2);
await firstStarted.promise;
assertArrayEquals(
starts,
[1],
"only the oldest write should start initially",
);
firstDone.resolve();
await firstWrite;
await secondStarted.promise;
assertArrayEquals(
starts,
[1, 2],
"the newer write should start after the older write",
);
secondDone.resolve();
await secondWrite;
await writer.waitForIdle();
});
Deno.test("a failed serialized write is reported and does not block the newer value", async () => {
const firstDone = deferred<void>();
const firstStarted = deferred<void>();
const secondStarted = deferred<void>();
const failure = new Error("first write failed");
const errors: unknown[] = [];
const starts: number[] = [];
const writer = createSerializedAsyncWriter<number>(async (value) => {
starts.push(value);
if (value === 1) {
firstStarted.resolve();
await firstDone.promise;
throw failure;
}
secondStarted.resolve();
}, (error) => errors.push(error));
const firstWrite = writer.enqueue(1);
const secondWrite = writer.enqueue(2);
await firstStarted.promise;
assertArrayEquals(
starts,
[1],
"the failed write should still own the queue first",
);
firstDone.resolve();
await firstWrite;
await secondStarted.promise;
await secondWrite;
assertArrayEquals(
starts,
[1, 2],
"a newer write should run after the failure is handled",
);
assertEquals(errors.length, 1, "the failed write should be reported once");
assertEquals(
errors[0],
failure,
"the original write failure should be reported",
);
});
Deno.test("closing a writer stops admission and drains the pending write", async () => {
const pending = deferred<void>();
const started = deferred<void>();
const writes: number[] = [];
const writer = createSerializedAsyncWriter<number>(async (value) => {
writes.push(value);
started.resolve();
await pending.promise;
}, () => {});
void writer.enqueue(1);
await started.promise;
let drained = false;
const drain = writer.closeAndWait().then(() => {
drained = true;
});
await writer.enqueue(2);
assertEquals(drained, false, "close must wait for the admitted write");
assertArrayEquals(writes, [1], "close must reject newer writes");
pending.resolve();
await drain;
assertEquals(drained, true, "close should settle after the pending write");
});
@@ -9,13 +9,13 @@ import {
phaseOf,
bumpTime,
normalizeTimeInput,
pruneCallToPlayEvents,
readyCountOf,
reduceCallToPlayEvents,
replaceCallToPlayView,
sortNominations,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
import { type CallToPlayAction, type CallToPlayViewEvent } from '../src/lib/types.ts';
const NOW = 1_000_000;
@@ -33,11 +33,11 @@ const event = (
action: CallToPlayAction,
at = NOW,
actorName = actorId,
): CallToPlayEvent => ({
): CallToPlayViewEvent => ({
id,
call_id: 'call-1',
actor_id: actorId,
actor_name: actorName,
author_id: actorId,
author_name: actorName,
at,
action,
});
@@ -45,7 +45,7 @@ const event = (
const create = (
scheduledFor: number | null = null,
deadline = NOW + 30 * 60_000,
): CallToPlayEvent => event('create', 'Alice', {
): CallToPlayViewEvent => event('create', 'Alice', {
Create: {
game_id: 'game-1',
max_players: 3,
@@ -187,17 +187,17 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
'peer startup message',
);
assertEquals(
callToPlayPublishErrorMessage('Call to Play event is obsolete'),
callToPlayPublishErrorMessage('Call-to-Play call x is unknown or expired'),
'This Call to Play has expired or already finished.',
'obsolete call message',
);
assertEquals(
callToPlayPublishErrorMessage('Call to Play history is missing'),
callToPlayPublishErrorMessage('Call-to-Play call x is already terminal'),
'This Call to Play has expired or already finished.',
'missing expired history message',
);
assertEquals(
callToPlayPublishErrorMessage(new Error('Call to Play event history is full')),
callToPlayPublishErrorMessage(new Error('Call-to-Play local event history is full')),
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
'active history limit message',
);
@@ -210,15 +210,12 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
const message = event('message-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Ready?' },
SendMessage: { text: 'Ready?' },
}, NOW + 2);
const duplicateMessage = event('other-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'duplicate' },
}, NOW + 3);
const events = [message, create(), message, duplicateMessage];
const events = [message, create(), message];
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
assertEquals(nomination.messages.length, 1, 'unique message id');
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages');
assertEquals(nomination.messages[0].text, 'Ready?', 'message retained');
});
Deno.test('actions timestamped before creation cannot mutate a call', () => {
@@ -237,11 +234,11 @@ Deno.test('terminal calls retain complete read-only history for fifteen minutes'
create(),
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('message', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Launching' },
SendMessage: { text: 'Launching' },
}, NOW + 2),
event('start', 'Alice', 'Start', NOW + 3),
event('late-message', 'Bob', {
SendMessage: { message_id: 'message-2', text: 'Too late' },
SendMessage: { text: 'Too late' },
}, NOW + 4),
];
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
@@ -273,34 +270,13 @@ Deno.test('terminal calls sort last and do not contribute to the badge', () => {
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
});
Deno.test('retired calls are pruned from the frontend raw event map', () => {
const terminalEvents = [
create(),
event('start', 'Alice', 'Start', NOW + 1),
];
const map = new Map(terminalEvents.map(item => [item.id, item]));
Deno.test('incoming Call to Play views replace removed author slices wholesale', () => {
const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] };
const incoming = { events: [create()] };
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
2,
'visible terminal history stays cached',
);
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
0,
'retired terminal history is pruned',
);
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
assertEquals(
pruneCallToPlayEvents(
tombstoneMap,
NOW + 1 + TERMINAL_RETENTION_MS + 1,
).size,
0,
'backend tombstone is pruned too',
);
const replaced = replaceCallToPlayView(previous, incoming);
assertEquals(replaced.events.length, 1, 'departed author slice is removed');
assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative');
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
@@ -0,0 +1,325 @@
import {
CallToPlayAsyncScope,
type CallToPlayRetryCallback,
type CallToPlayRetryScheduler,
} from '../src/lib/callToPlayOwnership.ts';
import { type AsyncCleanup } from '../src/lib/asyncOwnership.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
interface ScheduledRetry {
callback: CallToPlayRetryCallback;
cancelled: boolean;
}
class ManualRetryScheduler {
private readonly scheduled: ScheduledRetry[] = [];
public readonly schedule: CallToPlayRetryScheduler = callback => {
const retry = { callback, cancelled: false };
this.scheduled.push(retry);
return () => {
retry.cancelled = true;
};
};
public pendingCount(): number {
return this.scheduled.filter(retry => !retry.cancelled).length;
}
public fireNext(): Promise<void> {
const index = this.scheduled.findIndex(retry => !retry.cancelled);
if (index < 0) throw new Error('no retry is scheduled');
const [retry] = this.scheduled.splice(index, 1);
return retry.callback();
}
}
const noRetry: CallToPlayRetryScheduler = () => () => {};
Deno.test('queued listener callbacks are ignored and a late listener unlistens after disposal', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const listener = deferred<AsyncCleanup>();
const cleanupDone = deferred<void>();
const published: string[] = [];
let callback: ((value: string) => void) | undefined;
let unlistens = 0;
const registration = scope.registerListener(() => {
callback = scope.guard(value => published.push(value));
return listener.promise;
});
const disposal = scope.dispose();
callback?.('queued-after-dispose');
listener.resolve(async () => {
unlistens += 1;
await cleanupDone.promise;
});
await Promise.resolve();
assertEquals(unlistens, 1, 'the listener should unlisten as soon as registration resolves');
cleanupDone.resolve();
assertEquals(await registration, false, 'the late registration should report cancellation');
await disposal;
assertArrayEquals(published, [], 'a queued listener must not publish after disposal');
});
Deno.test('retry attempts never overlap and stop after readiness', async () => {
const scheduler = new ManualRetryScheduler();
const scope = new CallToPlayAsyncScope(scheduler.schedule);
const first = deferred<boolean>();
const second = deferred<boolean>();
const firstStarted = deferred<void>();
const secondStarted = deferred<void>();
let attempts = 0;
scope.startRetry(() => {
attempts += 1;
if (attempts === 1) {
firstStarted.resolve();
return first.promise;
}
secondStarted.resolve();
return second.promise;
}, 2_000);
assertEquals(scheduler.pendingCount(), 1, 'starting retry should schedule one attempt');
const firstRun = scheduler.fireNext();
await firstStarted.promise;
assertEquals(attempts, 1, 'the first retry should start');
assertEquals(scheduler.pendingCount(), 0, 'no retry should queue while one is in flight');
scope.startRetry(() => Promise.resolve(false), 2_000);
assertEquals(scheduler.pendingCount(), 0, 'starting again must not overlap the in-flight retry');
first.resolve(false);
await firstRun;
assertEquals(scheduler.pendingCount(), 1, 'a failed attempt should schedule its successor');
const secondRun = scheduler.fireNext();
await secondStarted.promise;
assertEquals(attempts, 2, 'the successor should start only after the first settled');
assertEquals(scheduler.pendingCount(), 0, 'the second in-flight retry must be exclusive');
second.resolve(true);
await secondRun;
assertEquals(scheduler.pendingCount(), 0, 'readiness should stop the retry loop');
await scope.dispose();
});
Deno.test('disposal waits for an in-flight retry attempt', async () => {
const scheduler = new ManualRetryScheduler();
const scope = new CallToPlayAsyncScope(scheduler.schedule);
const attempt = deferred<boolean>();
const started = deferred<void>();
let disposalSettled = false;
scope.startRetry(() => {
started.resolve();
return attempt.promise;
}, 2_000);
const retry = scheduler.fireNext();
await started.promise;
const disposal = scope.dispose().then(() => {
disposalSettled = true;
});
await Promise.resolve();
assertEquals(disposalSettled, false, 'disposal must join the running retry');
attempt.resolve(false);
await retry;
await disposal;
assertEquals(disposalSettled, true, 'disposal should settle after the retry exits');
assertEquals(scheduler.pendingCount(), 0, 'a disposed retry must not schedule a successor');
});
Deno.test('late snapshots and actions cannot publish after their scope is disposed', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const snapshot = deferred<string>();
const action = deferred<boolean>();
const publications: string[] = [];
let snapshotStarts = 0;
let actionStarts = 0;
const pendingSnapshot = scope.applyIfActive(
() => {
snapshotStarts += 1;
return snapshot.promise;
},
value => publications.push(`snapshot:${value}`),
);
const pendingAction = scope.applyIfActive(
() => {
actionStarts += 1;
return action.promise;
},
value => publications.push(`action:${value}`),
);
const disposal = scope.dispose();
snapshot.resolve('stale');
action.resolve(true);
assertEquals(await pendingSnapshot, false, 'the late snapshot should report cancellation');
assertEquals(await pendingAction, false, 'the late action should report cancellation');
await disposal;
assertArrayEquals(publications, [], 'late work must not publish into a disposed hook');
const postDisposeAction = await scope.applyIfActive(
() => {
actionStarts += 1;
return Promise.resolve(true);
},
value => publications.push(`post-dispose:${value}`),
);
assertEquals(postDisposeAction, false, 'an action must not start after disposal');
assertEquals(snapshotStarts, 1, 'the snapshot should start exactly once');
assertEquals(actionStarts, 1, 'only the pre-disposal action should start');
assertArrayEquals(publications, [], 'post-disposal actions must not publish');
});
Deno.test('an older action completion cannot overwrite a newer action status', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const older = deferred<boolean>();
const newer = deferred<boolean>();
const statuses: string[] = [];
const publishOlder = scope.guardLatestAction((accepted: boolean) => {
statuses.push(`older:${accepted}`);
});
const olderAction = scope.applyIfActive(
() => older.promise,
accepted => publishOlder(accepted),
);
const publishNewer = scope.guardLatestAction((accepted: boolean) => {
statuses.push(`newer:${accepted}`);
});
const newerAction = scope.applyIfActive(
() => newer.promise,
accepted => publishNewer(accepted),
);
newer.resolve(true);
assertEquals(await newerAction, true, 'the newer action should complete normally');
older.resolve(false);
assertEquals(await olderAction, true, 'the older action result should still complete');
assertArrayEquals(statuses, ['newer:true'], 'only the newest action may publish status');
await scope.dispose();
});
Deno.test('backend mutations start in user order even when the first invoke is delayed', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const first = deferred<string>();
const second = deferred<string>();
const started: string[] = [];
const applied: string[] = [];
const firstMutation = scope.applyMutationIfActive(
() => {
started.push('display-name:older');
return first.promise;
},
value => applied.push(value),
);
const secondMutation = scope.applyMutationIfActive(
() => {
started.push('action:newer');
return second.promise;
},
value => applied.push(value),
);
await Promise.resolve();
assertArrayEquals(started, ['display-name:older'], 'the newer mutation must remain queued');
first.resolve('older-applied');
assertEquals(await firstMutation, true, 'the first mutation should publish while active');
await Promise.resolve();
assertArrayEquals(
started,
['display-name:older', 'action:newer'],
'the second backend invoke must start after the first settles',
);
second.resolve('newer-applied');
assertEquals(await secondMutation, true, 'the second mutation should publish while active');
assertArrayEquals(
applied,
['older-applied', 'newer-applied'],
'mutation results should publish in backend order',
);
await scope.dispose();
});
Deno.test('disposal drains admitted mutations and rejects later admission', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const first = deferred<void>();
const second = deferred<void>();
const started: string[] = [];
const publications: string[] = [];
const firstMutation = scope.applyMutationIfActive(
() => {
started.push('first');
return first.promise;
},
() => publications.push('first'),
);
const secondMutation = scope.applyMutationIfActive(
() => {
started.push('second');
return second.promise;
},
() => publications.push('second'),
);
await Promise.resolve();
let disposed = false;
const disposal = scope.dispose().then(() => {
disposed = true;
});
const rejected = await scope.applyMutationIfActive(
() => {
started.push('rejected');
return Promise.resolve();
},
() => publications.push('rejected'),
);
assertEquals(rejected, false, 'a mutation submitted after disposal must be rejected');
assertEquals(disposed, false, 'disposal must wait for admitted mutations');
first.resolve();
assertEquals(await firstMutation, false, 'disposed scopes suppress the first result');
await Promise.resolve();
assertArrayEquals(started, ['first', 'second'], 'the admitted successor must still run');
assertEquals(disposed, false, 'disposal must also wait for the admitted successor');
second.resolve();
assertEquals(await secondMutation, false, 'disposed scopes suppress the second result');
await disposal;
assertEquals(disposed, true, 'disposal should settle after the admitted queue drains');
assertArrayEquals(started, ['first', 'second'], 'no post-disposal mutation may start');
assertArrayEquals(publications, [], 'disposed mutation results must not publish');
});
@@ -0,0 +1,257 @@
import {
bootstrapFrontend,
type FrontendCloseRequest,
} from '../src/lib/frontendBootstrap.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('close-listener registration failure destroys the empty window without mounting hooks', async () => {
const registrationFailure = new Error('registration failed');
const destructionStarted = deferred<void>();
const destruction = deferred<void>();
const order: string[] = [];
const reported: unknown[] = [];
let renders = 0;
let settled = false;
const bootstrap = bootstrapFrontend({
registerCloseRequested: () => Promise.reject(registrationFailure),
disposeOwners: () => {
order.push('dispose owners');
return Promise.resolve();
},
drainPersistence: () => {
order.push('drain persistence');
return Promise.resolve();
},
destroyWindow: () => {
order.push('destroy window');
destructionStarted.resolve();
return destruction.promise;
},
render: () => {
renders += 1;
},
reportFailure: (_message, error) => reported.push(error),
}).then(result => {
settled = true;
return result;
});
await destructionStarted.promise;
assertEquals(renders, 0, 'React and its hooks must remain unmounted');
assertEquals(settled, false, 'bootstrap must explicitly await window destruction');
assertEquals(
JSON.stringify(order),
JSON.stringify(['dispose owners', 'drain persistence', 'destroy window']),
'admissions must close and drain before destroying the empty window',
);
destruction.resolve();
assertEquals(await bootstrap, false, 'registration failure must report no render');
assertEquals(renders, 0, 'hooks must never mount after failure cleanup');
assertEquals(reported.length, 1, 'the registration failure must be handled once');
assertEquals(reported[0], registrationFailure, 'the original failure must be reported');
});
Deno.test('bootstrap handles failure of the fail-closed destroy action', async () => {
const registrationFailure = new Error('registration failed');
const destructionFailure = new Error('destroy failed');
const reported: unknown[] = [];
let renders = 0;
const rendered = await bootstrapFrontend({
registerCloseRequested: () => Promise.reject(registrationFailure),
disposeOwners: () => Promise.resolve(),
drainPersistence: () => Promise.resolve(),
destroyWindow: () => Promise.reject(destructionFailure),
render: () => {
renders += 1;
},
reportFailure: (_message, error) => reported.push(error),
});
assertEquals(rendered, false, 'a failed registration must never render');
assertEquals(renders, 0, 'destroy failure must not fall back to mounting hooks');
assertEquals(reported.length, 2, 'both terminal failures must be handled');
assertEquals(reported[0], registrationFailure, 'registration failure must be preserved');
assertEquals(reported[1], destructionFailure, 'destroy failure must be observed');
});
Deno.test('successful bootstrap mounts only after close registration resolves', async () => {
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
const registrationAcknowledged = deferred<() => void>();
let renders = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
registered.resolve(handler);
return registrationAcknowledged.promise;
},
disposeOwners: () => Promise.resolve(),
drainPersistence: () => Promise.resolve(),
destroyWindow: () => Promise.resolve(),
render: () => {
renders += 1;
},
reportFailure: () => {},
});
await registered.promise;
assertEquals(renders, 0, 'render must wait for registration acknowledgement');
registrationAcknowledged.resolve(() => {});
assertEquals(await bootstrap, true, 'successful registration should render the app');
assertEquals(renders, 1, 'the app should mount once');
});
Deno.test('close before registration acknowledgement owns late cleanup and suppresses render', async () => {
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
const registrationAcknowledged = deferred<() => void>();
const cleanupDone = deferred<void>();
const order: string[] = [];
let renders = 0;
let prevented = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
registered.resolve(handler);
return registrationAcknowledged.promise;
},
disposeOwners: () => {
order.push('dispose owners');
return Promise.resolve();
},
drainPersistence: () => {
order.push('drain persistence');
return Promise.resolve();
},
destroyWindow: () => {
order.push('destroy window');
return Promise.resolve();
},
render: () => {
renders += 1;
},
reportFailure: () => {},
});
const close = (await registered.promise)({
preventDefault: () => {
prevented += 1;
},
});
assertEquals(prevented, 1, 'the pre-acknowledgement close must be intercepted');
assertEquals(renders, 0, 'React must remain unmounted while registration is unresolved');
registrationAcknowledged.resolve(async () => {
order.push('unlisten');
await cleanupDone.promise;
});
await Promise.resolve();
assertEquals(renders, 0, 'registration acknowledgement must not render after close began');
assertEquals(
order.includes('destroy window'),
false,
'the late listener cleanup must drain before destruction',
);
cleanupDone.resolve();
await close;
assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app');
assertEquals(renders, 0, 'a closing realm must never mount React');
assertEquals(
JSON.stringify(order),
JSON.stringify(['dispose owners', 'drain persistence', 'unlisten', 'destroy window']),
'late listener ownership must join the same close finalizer',
);
});
Deno.test('render failure drains and unregisters before handled window destruction', async () => {
const renderFailure = new Error('render failed');
const unlistenFailure = new Error('unlisten failed');
const destroyFailure = new Error('destroy failed');
const ownerDrained = deferred<void>();
const persistenceDrained = deferred<void>();
const finalizerStarted = deferred<void>();
const order: string[] = [];
const reported: unknown[] = [];
let cleanups = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: () => {
order.push('register');
return Promise.resolve(async () => {
order.push('unlisten');
cleanups += 1;
finalizerStarted.resolve();
throw unlistenFailure;
});
},
disposeOwners: async () => {
order.push('dispose owners');
await ownerDrained.promise;
},
drainPersistence: async () => {
order.push('drain persistence');
await persistenceDrained.promise;
},
destroyWindow: () => {
order.push('destroy window');
return Promise.reject(destroyFailure);
},
render: () => {
order.push('render');
throw renderFailure;
},
reportFailure: (_message, error) => reported.push(error),
});
await finalizerStarted.promise;
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once');
assertEquals(
JSON.stringify(order),
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence', 'unlisten']),
'render failure must synchronously start every frontend finalizer',
);
ownerDrained.resolve();
await Promise.resolve();
assertEquals(
order.includes('destroy window'),
false,
'window destruction must wait for persistence as well as owner cleanup',
);
persistenceDrained.resolve();
assertEquals(await bootstrap, false, 'a render failure must be fully handled');
assertEquals(
JSON.stringify(order),
JSON.stringify([
'register',
'render',
'dispose owners',
'drain persistence',
'unlisten',
'destroy window',
]),
'destruction must be the terminal finalizer',
);
assertEquals(reported.length, 3, 'render, cleanup, and destroy failures must be observed');
assertEquals(reported[0], renderFailure, 'the render failure must be reported first');
assertEquals(reported[1], unlistenFailure, 'listener cleanup rejection must be reported');
assertEquals(reported[2], destroyFailure, 'window destroy rejection must be reported');
});
@@ -0,0 +1,103 @@
import {
PersistenceShutdownScope,
startAdmittedPersistenceEffect,
} from '../src/lib/frontendPersistence.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('window close drains admitted persistence and rejects later registration', async () => {
const first = deferred<void>();
const starts: string[] = [];
const scope = new PersistenceShutdownScope();
scope.register(() => {
starts.push('first');
return first.promise;
});
let drained = false;
const drain = scope.closeAndDrain().then(() => {
drained = true;
});
const lateRegistration = scope.register(() => {
starts.push('late');
return Promise.resolve();
});
assertEquals(lateRegistration, undefined, 'closed persistence admission must stay closed');
assertEquals(JSON.stringify(starts), JSON.stringify(['first']), 'late queues must not start');
assertEquals(drained, false, 'close must remain pending while persistence is active');
first.resolve();
await drain;
assertEquals(drained, true, 'close should settle after every admitted queue');
});
Deno.test('settings hydration cannot start after close wins before its passive effect', async () => {
const scope = new PersistenceShutdownScope();
await scope.closeAndDrain();
let storeLoads = 0;
let publications = 0;
const cleanup = startAdmittedPersistenceEffect(
scope,
() => Promise.resolve(),
() => {
storeLoads += 1;
publications += 1;
},
);
cleanup();
assertEquals(storeLoads, 0, 'closed admission must prevent the settings store load');
assertEquals(publications, 0, 'closed admission must prevent settings publication');
});
Deno.test('game-directory hydration cannot load or invoke after pre-effect close', async () => {
const scope = new PersistenceShutdownScope();
await scope.closeAndDrain();
let storeLoads = 0;
let backendInvokes = 0;
const cleanup = startAdmittedPersistenceEffect(
scope,
() => Promise.resolve(),
() => {
storeLoads += 1;
backendInvokes += 1;
},
);
cleanup();
assertEquals(storeLoads, 0, 'closed admission must prevent the directory store load');
assertEquals(backendInvokes, 0, 'closed admission must prevent the directory invoke');
});
Deno.test('persistence shutdown rejection is observed without blocking other queues', async () => {
const failure = new Error('persistence drain failed');
const reported: unknown[] = [];
const scope = new PersistenceShutdownScope(error => reported.push(error));
let secondFinished = false;
scope.register(() => Promise.reject(failure));
scope.register(async () => {
secondFinished = true;
});
await scope.closeAndDrain();
assertEquals(reported.length, 1, 'the failed shutdown should be reported once');
assertEquals(reported[0], failure, 'the original shutdown failure should be reported');
assertEquals(secondFinished, true, 'one failure must not skip another persistence queue');
});
@@ -0,0 +1,157 @@
import {
acceptGameDirectory,
hydrateGameDirectory,
} from '../src/lib/gameDirectory.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('game directory is neither persisted nor used before backend acknowledgement', async () => {
const acknowledgement = deferred<unknown>();
const persisted: string[] = [];
let displayedPath = '/old/path';
const update = acceptGameDirectory('/picked/../path', {
updateBackend: requestedPath => {
assertEquals(
requestedPath,
'/picked/../path',
'backend should receive the requested path',
);
return acknowledgement.promise;
},
persist: acceptedPath => {
persisted.push(acceptedPath);
return Promise.resolve();
},
reportPersistenceError: () => {},
});
assertEquals(displayedPath, '/old/path', 'pending update should retain the old UI path');
assertEquals(persisted.length, 0, 'pending update should not write the requested path');
acknowledgement.resolve('/canonical/path');
displayedPath = await update;
assertEquals(displayedPath, '/canonical/path', 'UI should use the acknowledged path');
assertEquals(persisted.length, 1, 'acknowledged path should be persisted once');
assertEquals(persisted[0], '/canonical/path', 'store should receive the canonical path');
});
Deno.test('backend rejection leaves the old game directory untouched', async () => {
const rejected = new Error('directory rejected');
const persisted: string[] = [];
let displayedPath = '/old/path';
try {
displayedPath = await acceptGameDirectory('/rejected/path', {
updateBackend: () => Promise.reject(rejected),
persist: acceptedPath => {
persisted.push(acceptedPath);
return Promise.resolve();
},
reportPersistenceError: () => {},
});
throw new Error('expected backend rejection');
} catch (error) {
assertEquals(error, rejected, 'backend error should be preserved');
}
assertEquals(displayedPath, '/old/path', 'rejection should retain the old UI path');
assertEquals(persisted.length, 0, 'rejection should not change persistent state');
});
Deno.test('invalid backend acknowledgement is not persisted', async () => {
let persisted = false;
let rejected = false;
try {
await acceptGameDirectory('/picked/path', {
updateBackend: () => Promise.resolve(' '),
persist: () => {
persisted = true;
return Promise.resolve();
},
reportPersistenceError: () => {},
});
} catch {
rejected = true;
}
assertEquals(rejected, true, 'empty acknowledgement should be rejected');
assertEquals(persisted, false, 'invalid acknowledgement should not be persisted');
});
Deno.test('persistence failure does not undo an acknowledged backend update', async () => {
const persistenceError = new Error('store unavailable');
let reportedError: unknown;
const acceptedPath = await acceptGameDirectory('/picked/path', {
updateBackend: () => Promise.resolve('/canonical/path'),
persist: () => Promise.reject(persistenceError),
reportPersistenceError: error => {
reportedError = error;
},
});
assertEquals(acceptedPath, '/canonical/path', 'backend acknowledgement should remain accepted');
assertEquals(reportedError, persistenceError, 'persistence failure should be reported');
});
Deno.test('saved game-directory hydration settles only after backend acceptance', async () => {
const acceptance = deferred<void>();
let settled = false;
const hydration = hydrateGameDirectory({
loadSavedPath: () => Promise.resolve('/saved/path'),
acceptSavedPath: path => {
assertEquals(path, '/saved/path', 'saved path should be restored');
return acceptance.promise;
},
reportLoadError: () => {},
}).then(() => {
settled = true;
});
await Promise.resolve();
assertEquals(settled, false, 'hydration must wait for backend acceptance');
acceptance.resolve();
await hydration;
assertEquals(settled, true, 'hydration should settle after acceptance');
});
Deno.test('missing or failed game-directory state still completes hydration', async () => {
let accepts = 0;
await hydrateGameDirectory({
loadSavedPath: () => Promise.resolve(undefined),
acceptSavedPath: () => {
accepts += 1;
return Promise.resolve();
},
reportLoadError: () => {},
});
assertEquals(accepts, 0, 'missing state should not invoke the backend');
const failure = new Error('store unavailable');
let reported: unknown;
await hydrateGameDirectory({
loadSavedPath: () => Promise.reject(failure),
acceptSavedPath: () => Promise.resolve(),
reportLoadError: error => {
reported = error;
},
});
assertEquals(reported, failure, 'load failure should be reported before ready');
});
@@ -1,286 +1,418 @@
import {
actionLabel,
activeStatusById,
applyFilterAndSort,
canStreamInstall,
countByFilter,
deriveState,
downloadProgressPercent,
formatDownloadBytes,
formatBytesPerSecond,
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate,
stateChipLabel,
} from '../src/lib/gameState.ts';
actionLabel,
activeStatusById,
applyFilterAndSort,
applyGameTransferStatusSnapshot,
canStreamInstall,
countByFilter,
deriveState,
downloadProgressPercent,
formatBytesPerSecond,
formatDownloadBytes,
formatDownloadEta,
formatDownloadSpeed,
formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate,
stateChipLabel,
} from "../src/lib/gameState.ts";
import {
ActiveOperationKind,
GameAvailability,
InstallStatus,
type Game,
} from '../src/lib/types.ts';
ActiveOperationKind,
type Game,
GameAvailability,
GameTransferStatus,
InstallStatus,
} from "../src/lib/types.ts";
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const game = (overrides: Partial<Game> = {}): Game => ({
id: 'game',
name: 'Game',
description: '',
size: 0,
downloaded: false,
installed: false,
availability: GameAvailability.LocalOnly,
install_status: InstallStatus.NotInstalled,
id: "game",
name: "Game",
description: "",
size: 0,
downloaded: false,
installed: false,
availability: GameAvailability.LocalOnly,
install_status: InstallStatus.NotInstalled,
peer_count: 1,
...overrides,
});
Deno.test("snapshot keeps installing visible until installed state settles", () => {
const fromDownloading = game({
install_status: InstallStatus.Downloading,
});
const installing = mergeGameUpdate(
game({ downloaded: true }),
fromDownloading,
InstallStatus.Installing,
);
const installedWhileActive = mergeGameUpdate(
game({ downloaded: true, installed: true }),
installing,
InstallStatus.Installing,
);
const settled = mergeGameUpdate(
game({ downloaded: true, installed: true }),
installedWhileActive,
);
assertEquals(
installing.install_status,
InstallStatus.Installing,
"active install snapshot should render Installing",
);
assertEquals(
installedWhileActive.install_status,
InstallStatus.Installing,
"installed local state should not override an active install snapshot",
);
assertEquals(
settled.install_status,
InstallStatus.Installed,
"cleared active snapshot with installed local state should render Installed",
);
});
Deno.test("active operation snapshot is the source of busy status", () => {
const statuses = activeStatusById([
{ id: "game", operation: ActiveOperationKind.Downloading },
{ id: "other", operation: ActiveOperationKind.Updating },
]);
assertEquals(
statuses.get("game"),
InstallStatus.Downloading,
"download operation should render Downloading",
);
assertEquals(
statuses.get("other"),
InstallStatus.Installing,
"update operation should render Installing",
);
});
Deno.test("download progress is preserved only while actively downloading", () => {
const downloading = game({
install_status: InstallStatus.Downloading,
download_progress: {
attemptId: "1",
downloaded_bytes: 50,
total_bytes: 100,
bytes_per_second: 12_500_000,
active_peer_count: 2,
},
});
const stillDownloading = mergeGameUpdate(
game(),
downloading,
InstallStatus.Downloading,
);
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading);
assertEquals(
stillDownloading.download_progress?.downloaded_bytes,
50,
"active download snapshot should keep progress",
);
assertEquals(
stillDownloading.download_progress?.active_peer_count,
2,
"active download snapshot should keep live peer count",
);
assertEquals(
settled.download_progress,
undefined,
"settled snapshot should clear progress",
);
});
Deno.test("downloading action label includes current speed", () => {
const downloading = game({
install_status: InstallStatus.Downloading,
download_progress: {
attemptId: "1",
downloaded_bytes: 50,
total_bytes: 100,
bytes_per_second: 12_500_000,
active_peer_count: 2,
},
});
assertEquals(
formatBytesPerSecond(12_500_000),
"12.5 MB/s",
"speed formatter should use compact decimal units",
);
assertEquals(
actionLabel(downloading),
"Downloading… 12.5 MB/s",
"download label should include speed",
);
});
Deno.test("downloading state is distinct and stays on the local filter", () => {
const downloading = game({
id: "downloading",
name: "Downloading",
install_status: InstallStatus.Downloading,
});
const local = game({
id: "local",
name: "Local",
downloaded: true,
});
const remote = game({
id: "remote",
name: "Remote",
peer_count: 1,
...overrides,
});
assertEquals(
deriveState(downloading),
"downloading",
"download operation should render the dedicated downloading state",
);
assertEquals(
countByFilter([downloading, local, remote]).local,
2,
"local filter count should include in-flight downloads",
);
assertEquals(
applyFilterAndSort([downloading, local, remote], "local", "status", "")
.length,
2,
"local filter should include in-flight downloads",
);
});
Deno.test('snapshot keeps installing visible until installed state settles', () => {
const fromDownloading = game({
install_status: InstallStatus.Downloading,
});
const installing = mergeGameUpdate(
game({ downloaded: true }),
fromDownloading,
InstallStatus.Installing,
);
const installedWhileActive = mergeGameUpdate(
game({ downloaded: true, installed: true }),
installing,
InstallStatus.Installing,
);
const settled = mergeGameUpdate(
game({ downloaded: true, installed: true }),
installedWhileActive,
);
Deno.test("sticky transfer exhaustion keeps a departed remote game visible until cleared", () => {
const remoteOnly = game({
id: "remote-only",
name: "Remote only",
peer_count: 1,
});
assertEquals(
applyFilterAndSort([remoteOnly], "all", "az", "").length,
1,
"a remote-only game should initially be visible",
);
assertEquals(
installing.install_status,
InstallStatus.Installing,
'active install snapshot should render Installing',
);
assertEquals(
installedWhileActive.install_status,
InstallStatus.Installing,
'installed local state should not override an active install snapshot',
);
assertEquals(
settled.install_status,
InstallStatus.Installed,
'cleared active snapshot with installed local state should render Installed',
);
const peerDeparted = mergeGameUpdate(
game({ id: "remote-only", name: "Remote only", peer_count: 0 }),
remoteOnly,
);
assertEquals(
peerDeparted.install_status,
InstallStatus.NotInstalled,
"an empty active-operation snapshot should leave the game idle",
);
assertEquals(
peerDeparted.peer_count,
0,
"the sole remote source should be absent from the next catalog snapshot",
);
assertEquals(
applyFilterAndSort([peerDeparted], "all", "az", "").length,
0,
"an idle remote-only game should be invisible before exhaustion arrives",
);
const [exhausted] = applyGameTransferStatusSnapshot([peerDeparted], {
revision: 1,
statuses: { "remote-only": GameTransferStatus.Exhausted },
openAttempts: {},
});
assertEquals(
exhausted.transfer_status,
GameTransferStatus.Exhausted,
"the sticky snapshot should preserve the exact terminal status",
);
assertEquals(
applyFilterAndSort([exhausted], "all", "az", "").length,
1,
"terminal exhaustion should keep the departed remote game visible",
);
assertEquals(
countByFilter([exhausted]).all,
1,
"the All-filter count should match terminal exhaustion visibility",
);
const [cleared] = applyGameTransferStatusSnapshot([exhausted], {
revision: 2,
statuses: {},
openAttempts: {},
});
assertEquals(
cleared.transfer_status,
undefined,
"a full replacement that omits the game should clear exhaustion",
);
assertEquals(
applyFilterAndSort([cleared], "all", "az", "").length,
0,
"clearing exhaustion should remove the otherwise-invisible game",
);
});
Deno.test('active operation snapshot is the source of busy status', () => {
const statuses = activeStatusById([
{ id: 'game', operation: ActiveOperationKind.Downloading },
{ id: 'other', operation: ActiveOperationKind.Updating },
]);
Deno.test("transient transfer activity does not resurrect an invisible game", () => {
const departed = game({ peer_count: 0 });
for (
const transferStatus of [
GameTransferStatus.Verifying,
GameTransferStatus.Retrying,
]
) {
assertEquals(
statuses.get('game'),
InstallStatus.Downloading,
'download operation should render Downloading',
);
assertEquals(
statuses.get('other'),
InstallStatus.Installing,
'update operation should render Installing',
applyFilterAndSort(
[{ ...departed, transfer_status: transferStatus }],
"all",
"az",
"",
).length,
0,
`${transferStatus} should not make an otherwise-invisible game visible`,
);
}
});
Deno.test('download progress is preserved only while actively downloading', () => {
const downloading = game({
install_status: InstallStatus.Downloading,
download_progress: {
downloaded_bytes: 50,
total_bytes: 100,
bytes_per_second: 12_500_000,
active_peer_count: 2,
},
});
Deno.test("download progress formatting matches the progress-bar layouts", () => {
const downloading = game({
install_status: InstallStatus.Downloading,
download_progress: {
attemptId: "1",
downloaded_bytes: 12 * 1024 * 1024 * 1024,
total_bytes: 35 * 1024 * 1024 * 1024,
bytes_per_second: 49_400_000,
active_peer_count: 3,
},
});
const stillDownloading = mergeGameUpdate(
game(),
downloading,
InstallStatus.Downloading,
);
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading);
assertEquals(
stillDownloading.download_progress?.downloaded_bytes,
50,
'active download snapshot should keep progress',
);
assertEquals(
stillDownloading.download_progress?.active_peer_count,
2,
'active download snapshot should keep live peer count',
);
assertEquals(
settled.download_progress,
undefined,
'settled snapshot should clear progress',
);
assertEquals(
Math.round(downloadProgressPercent(downloading)),
34,
"progress percent should come from backend byte counters",
);
assertEquals(
formatDownloadSpeed(49_400_000),
"49.4 MB/s",
"large bar speed format",
);
assertEquals(
formatDownloadSpeedShort(49_400_000),
"49 MB/s",
"card speed format",
);
assertEquals(
formatDownloadBytes(12 * 1024 * 1024 * 1024),
"12 GB",
"downloaded byte format should avoid noisy trailing decimals",
);
assertEquals(
formatDownloadEta(485),
"8 min",
"eta format should stay compact",
);
});
Deno.test('downloading action label includes current speed', () => {
const downloading = game({
install_status: InstallStatus.Downloading,
download_progress: {
downloaded_bytes: 50,
total_bytes: 100,
bytes_per_second: 12_500_000,
active_peer_count: 2,
},
});
assertEquals(
formatBytesPerSecond(12_500_000),
'12.5 MB/s',
'speed formatter should use compact decimal units',
);
assertEquals(
actionLabel(downloading),
'Downloading… 12.5 MB/s',
'download label should include speed',
);
});
Deno.test('downloading state is distinct and stays on the local filter', () => {
const downloading = game({
id: 'downloading',
name: 'Downloading',
install_status: InstallStatus.Downloading,
});
const local = game({
id: 'local',
name: 'Local',
downloaded: true,
});
const remote = game({
id: 'remote',
name: 'Remote',
peer_count: 1,
});
assertEquals(
deriveState(downloading),
'downloading',
'download operation should render the dedicated downloading state',
);
assertEquals(
countByFilter([downloading, local, remote]).local,
2,
'local filter count should include in-flight downloads',
);
assertEquals(
applyFilterAndSort([downloading, local, remote], 'local', 'status', '').length,
2,
'local filter should include in-flight downloads',
);
});
Deno.test('download progress formatting matches the progress-bar layouts', () => {
const downloading = game({
install_status: InstallStatus.Downloading,
download_progress: {
downloaded_bytes: 12 * 1024 * 1024 * 1024,
total_bytes: 35 * 1024 * 1024 * 1024,
bytes_per_second: 49_400_000,
active_peer_count: 3,
},
});
assertEquals(
Math.round(downloadProgressPercent(downloading)),
34,
'progress percent should come from backend byte counters',
);
assertEquals(formatDownloadSpeed(49_400_000), '49.4 MB/s', 'large bar speed format');
assertEquals(formatDownloadSpeedShort(49_400_000), '49 MB/s', 'card speed format');
assertEquals(
formatDownloadBytes(12 * 1024 * 1024 * 1024),
'12 GB',
'downloaded byte format should avoid noisy trailing decimals',
);
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
});
Deno.test('stream install is available only for idle remote games', () => {
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
true,
'remote-only idle games should allow streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
false,
'downloaded games should install from local archives',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
false,
'installed games should not expose streamed install',
);
assertEquals(
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
false,
'games without peers should not expose streamed install',
);
assertEquals(
canStreamInstall(game({
downloaded: false,
installed: false,
peer_count: 1,
install_status: InstallStatus.CheckingPeers,
})),
false,
'busy games should not expose streamed install',
);
});
Deno.test('streamed local installs are labeled installed but not shareable', () => {
const streamed = game({
Deno.test("stream install is available only for idle remote games", () => {
assertEquals(
canStreamInstall(
game({ downloaded: false, installed: false, peer_count: 1 }),
true,
),
true,
"catalog-supported remote-only idle games should allow streamed install",
);
assertEquals(
canStreamInstall(
game({ downloaded: false, installed: false, peer_count: 1 }),
false,
),
false,
"catalog-unsupported games should not expose streamed install",
);
assertEquals(
canStreamInstall(
game({ downloaded: true, installed: false, peer_count: 1 }),
true,
),
false,
"downloaded games should install from local archives",
);
assertEquals(
canStreamInstall(
game({ downloaded: false, installed: true, peer_count: 1 }),
true,
),
false,
"installed games should not expose streamed install",
);
assertEquals(
canStreamInstall(
game({ downloaded: false, installed: false, peer_count: 0 }),
true,
),
false,
"games without peers should not expose streamed install",
);
assertEquals(
canStreamInstall(
game({
downloaded: false,
installed: true,
install_status: InstallStatus.Installed,
});
const downloadedInstall = game({
downloaded: true,
installed: true,
install_status: InstallStatus.Installed,
});
installed: false,
peer_count: 1,
install_status: InstallStatus.Installing,
}),
true,
),
false,
"busy games should not expose streamed install",
);
});
assertEquals(
deriveState(streamed),
'installed',
'streamed local installs should keep installed visual state',
);
assertEquals(
stateChipLabel(streamed),
'Not shareable',
'card chip should make the non-shareable state visible',
);
assertEquals(
gameStatusLabel(streamed),
'Installed, not shareable',
'detail status should spell out installed plus non-shareable',
);
assertEquals(
stateChipLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed chip label',
);
assertEquals(
gameStatusLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed detail label',
);
Deno.test("streamed local installs are labeled installed but not shareable", () => {
const streamed = game({
downloaded: false,
installed: true,
install_status: InstallStatus.Installed,
});
const downloadedInstall = game({
downloaded: true,
installed: true,
install_status: InstallStatus.Installed,
});
assertEquals(
deriveState(streamed),
"installed",
"streamed local installs should keep installed visual state",
);
assertEquals(
stateChipLabel(streamed),
"Not shareable",
"card chip should make the non-shareable state visible",
);
assertEquals(
gameStatusLabel(streamed),
"Installed, not shareable",
"detail status should spell out installed plus non-shareable",
);
assertEquals(
stateChipLabel(downloadedInstall),
"Installed",
"normal downloaded installs should keep the installed chip label",
);
assertEquals(
gameStatusLabel(downloadedInstall),
"Installed",
"normal downloaded installs should keep the installed detail label",
);
});
@@ -0,0 +1,68 @@
import {
EPHEMERAL_IDENTITY_NOTICE,
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
type IdentityDiagnosticSnapshot,
newestIdentityDiagnosticSnapshot,
} from '../src/lib/identityDiagnostic.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
};
Deno.test('newer identity event beats delayed query and a later clear wins', () => {
const delayedQuery: IdentityDiagnosticSnapshot = { revision: 1, diagnostic: null };
const ephemeral: IdentityDiagnosticSnapshot = {
revision: 2,
diagnostic: 'ephemeral',
};
const afterEvent = newestIdentityDiagnosticSnapshot(
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
ephemeral,
);
assertEquals(
newestIdentityDiagnosticSnapshot(afterEvent, delayedQuery),
ephemeral,
'delayed query must lose',
);
const cleared: IdentityDiagnosticSnapshot = { revision: 3, diagnostic: null };
assertEquals(
newestIdentityDiagnosticSnapshot(ephemeral, cleared),
cleared,
'higher-revision clear must win',
);
assertEquals(
newestIdentityDiagnosticSnapshot(cleared, ephemeral),
cleared,
'stale event must not resurrect diagnostic',
);
});
Deno.test('ephemeral identity copy is exact and contains no implementation detail', () => {
assertEquals(
EPHEMERAL_IDENTITY_NOTICE,
"This installation's network identity could not be saved and will change the next time Lanspread starts.",
'identity diagnostic copy',
);
for (const forbidden of ['path', 'key', 'permission', '.json', 'error']) {
if (EPHEMERAL_IDENTITY_NOTICE.toLowerCase().includes(forbidden)) {
throw new Error(`identity copy leaked forbidden detail: ${forbidden}`);
}
}
});
Deno.test('identity initialization failure has a distinct redacted warning', () => {
assertEquals(
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
"This installation's network identity status could not be checked.",
'unavailable diagnostic copy',
);
if (IDENTITY_STATUS_UNAVAILABLE_NOTICE.includes('could not be saved')) {
throw new Error('unavailable status must not claim confirmed ephemeral identity');
}
});
@@ -0,0 +1,201 @@
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
import {
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
LocalNetworkSharingAsyncScope,
type LocalNetworkSharingSnapshot,
displayedSharingTarget,
isLocalNetworkSharingActive,
localNetworkSharingNotice,
newestLocalNetworkSharingSnapshot,
} from '../src/lib/localNetworkSharing.ts';
const assert = (condition: boolean, message: string) => {
if (!condition) throw new Error(message);
};
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
};
const snapshot = (
revision: number,
overrides: Partial<LocalNetworkSharingSnapshot> = {},
): LocalNetworkSharingSnapshot => ({
...INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
revision,
persistenceProblem: null,
...overrides,
});
Deno.test('sharing bootstrap remains visibly fail-closed if listener and query fail', () => {
assert(!displayedSharingTarget(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT), 'fallback switch');
assert(
!isLocalNetworkSharingActive(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
'fallback network admission',
);
assertEquals(
localNetworkSharingNotice(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
'Local network sharing is off because its setting could not be loaded.',
'fallback copy',
);
});
Deno.test('delayed sharing query cannot overwrite a newer listener event', () => {
const delayedQuery = snapshot(1, { enabled: false, phase: 'disabled' });
const event = snapshot(2, { enabled: true, phase: 'enabled' });
const afterEvent = newestLocalNetworkSharingSnapshot(
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
event,
);
assertEquals(
newestLocalNetworkSharingSnapshot(afterEvent, delayedQuery),
event,
'newer listener event must win',
);
assertEquals(
newestLocalNetworkSharingSnapshot(event, { ...event, enabled: false }),
event,
'equal revisions must not rewrite state',
);
});
Deno.test('pending policy target is distinct from effective network activity', () => {
const waiting = snapshot(1, {
enabled: true,
phase: 'waitingForGameDirectory',
});
assert(displayedSharingTarget(waiting), 'waiting policy should remain checked');
assert(!isLocalNetworkSharingActive(waiting), 'waiting policy must not claim active sharing');
const disabling = snapshot(2, {
enabled: true,
pendingTarget: false,
phase: 'enabled',
});
assert(!displayedSharingTarget(disabling), 'pending explicit off target should drive switch');
assert(
!isLocalNetworkSharingActive(disabling),
'an admitted off target must close network actions before the core event arrives',
);
});
Deno.test('sharing notices use exact redacted state copy', () => {
assertEquals(
localNetworkSharingNotice(snapshot(1, { enabled: false, phase: 'disabled' })),
'Local network sharing is off. Nearby devices cannot browse or request games from this library.',
'disabled copy',
);
assertEquals(
localNetworkSharingNotice(snapshot(2, { phase: 'waitingForGameDirectory' })),
'Local network sharing will start after you choose a game folder.',
'waiting copy',
);
assertEquals(
localNetworkSharingNotice(snapshot(3, {
enabled: true,
pendingTarget: false,
phase: 'enabled',
})),
'Stopping Local network sharing…',
'pending-off copy before core Disabling event',
);
assertEquals(
localNetworkSharingNotice(snapshot(4, {
enabled: false,
phase: 'disabled',
persistenceProblem: 'load',
})),
'Local network sharing is off because its setting could not be loaded.',
'load failure copy',
);
assertEquals(
localNetworkSharingNotice(snapshot(5, {
enabled: false,
phase: 'disabled',
persistenceProblem: 'save',
})),
'Local network sharing is off, but this setting could not be saved.',
'save failure copy',
);
});
Deno.test('sharing mutations execute in admission order', async () => {
const adoption = new AsyncAdoptionScope();
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
const order: string[] = [];
let releaseFirst = () => {};
const firstGate = new Promise<void>(resolve => {
releaseFirst = resolve;
});
const first = scope.applyMutationIfActive(async () => {
order.push('first-start');
await firstGate;
order.push('first-finish');
return 'first';
}, value => order.push(`${value}-apply`));
const second = scope.applyMutationIfActive(async () => {
order.push('second-start');
return 'second';
}, value => order.push(`${value}-apply`));
await Promise.resolve();
assertEquals(order, ['first-start'], 'second mutation must remain queued');
releaseFirst();
await Promise.all([first, second]);
assertEquals(
order,
['first-start', 'first-finish', 'first-apply', 'second-start', 'second-apply'],
'mutation order',
);
await scope.dispose();
});
Deno.test('failed mutation does not poison its admitted successor', async () => {
const adoption = new AsyncAdoptionScope();
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
const order: string[] = [];
const first = scope.applyMutationIfActive(async () => {
order.push('first');
throw new Error('injected');
}, () => order.push('unexpected-apply'));
const second = scope.applyMutationIfActive(async () => {
order.push('second');
return true;
}, () => order.push('second-apply'));
await first.catch(() => false);
assert(await second, 'successor should still publish');
assertEquals(order, ['first', 'second', 'second-apply'], 'successor ordering');
await scope.dispose();
});
Deno.test('sharing scope drains admitted mutation and rejects late work on dispose', async () => {
const adoption = new AsyncAdoptionScope();
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
let release = () => {};
const gate = new Promise<void>(resolve => {
release = resolve;
});
let published = false;
const admitted = scope.applyMutationIfActive(async () => {
await gate;
return true;
}, () => {
published = true;
});
const disposal = scope.dispose();
assert(
!await scope.applyMutationIfActive(async () => true, () => {}),
'dispose must close mutation admission',
);
release();
await Promise.all([admitted, disposal]);
assert(!published, 'disposed scope must suppress late React publication');
});
@@ -0,0 +1,45 @@
import {
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
newestProtocolMismatchSnapshot,
type ProtocolMismatchSnapshot,
} from "../src/lib/protocolMismatch.ts";
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
};
Deno.test("delayed bootstrap snapshot cannot overwrite a newer mismatch event", () => {
const delayedQuery = INITIAL_PROTOCOL_MISMATCH_SNAPSHOT;
const event: ProtocolMismatchSnapshot = {
revision: 1,
mismatch: { observed: 7, expected: 8 },
};
const afterEvent = newestProtocolMismatchSnapshot(
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
event,
);
assertEquals(
newestProtocolMismatchSnapshot(afterEvent, delayedQuery),
event,
"delayed query must lose to event",
);
});
Deno.test("new runtime generation clears an older mismatch", () => {
const mismatch: ProtocolMismatchSnapshot = {
revision: 4,
mismatch: { observed: null, expected: 8 },
};
const cleared: ProtocolMismatchSnapshot = { revision: 5, mismatch: null };
assertEquals(
newestProtocolMismatchSnapshot(mismatch, cleared),
cleared,
"new generation clear must win",
);
});
@@ -0,0 +1,130 @@
import {
createThumbnailRequestKey,
startOwnedThumbnailGeneration,
ThumbnailRequestGeneration,
thumbnailIdsFromRequestKey,
} from '../src/lib/thumbnailRequests.ts';
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('thumbnail request keys are stable and deduplicate ids', () => {
const first = createThumbnailRequestKey(['bravo', 'alpha', 'bravo']);
const second = createThumbnailRequestKey(['alpha', 'bravo']);
assertEquals(first, second, 'equivalent id sets should have one effect key');
assertEquals(
JSON.stringify(thumbnailIdsFromRequestKey(first)),
JSON.stringify(['alpha', 'bravo']),
'the effect should receive each id once in stable order',
);
});
Deno.test('constructing a thumbnail generation does not start a request', async () => {
let starts = 0;
const generation = new ThumbnailRequestGeneration(
() => {
starts += 1;
return Promise.resolve('url');
},
() => {},
);
assertEquals(starts, 0, 'request work must wait for the owning effect to start it');
await generation.dispose();
await generation.start(['game']);
assertEquals(starts, 0, 'a disposed generation must not start later work');
});
Deno.test('a thumbnail generation cannot start after window admission closes', async () => {
const scope = new AsyncAdoptionScope();
await scope.disposeOwned();
let starts = 0;
const generation = new ThumbnailRequestGeneration(
() => {
starts += 1;
return Promise.resolve('url');
},
() => {},
);
const cleanup = startOwnedThumbnailGeneration(scope, generation, ['game']);
cleanup();
await scope.drain();
assertEquals(starts, 0, 'closed window admission must prevent the first invoke');
});
Deno.test('an unmounted thumbnail generation suppresses its in-flight result', async () => {
const result = deferred<string>();
const published: string[] = [];
const generation = new ThumbnailRequestGeneration(
() => result.promise,
(id, url) => published.push(`${id}:${url}`),
);
const loading = generation.start(['game']);
const disposal = generation.dispose();
result.resolve('thumbnail-url');
await loading;
await disposal;
assertEquals(published.length, 0, 'an in-flight result must not publish after unmount');
});
Deno.test('a replaced thumbnail generation cannot overwrite the current generation', async () => {
const abandonedResult = deferred<string>();
const published: string[] = [];
const abandoned = new ThumbnailRequestGeneration(
() => abandonedResult.promise,
(_id, url) => published.push(url),
);
const abandonedLoading = abandoned.start(['game']);
const abandonedDisposal = abandoned.dispose();
const current = new ThumbnailRequestGeneration(
() => Promise.resolve('current-url'),
(_id, url) => published.push(url),
);
await current.start(['game']);
abandonedResult.resolve('stale-url');
await abandonedLoading;
await abandonedDisposal;
assertEquals(
JSON.stringify(published),
JSON.stringify(['current-url']),
'only the current effect generation may publish',
);
});
Deno.test('an active failed thumbnail request publishes the empty fallback', async () => {
const failure = new Error('thumbnail unavailable');
const published: string[] = [];
const generation = new ThumbnailRequestGeneration(
() => Promise.reject(failure),
(id, url) => published.push(`${id}:${url}`),
);
await generation.start(['game']);
assertEquals(
JSON.stringify(published),
JSON.stringify(['game:']),
'active failures should retain the placeholder-cover behavior',
);
});
@@ -0,0 +1,266 @@
import {
applyDownloadProgress,
applyGameTransferStatusSnapshot,
downloadProgressAriaLabel,
downloadProgressTransferLabel,
gameTransferStatusPresentation,
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
newestGameTransferStatusSnapshot,
} from "../src/lib/gameState.ts";
import {
type DownloadProgressPayload,
type Game,
GameAvailability,
GameTransferStatus,
type GameTransferStatusSnapshot,
InstallStatus,
} from "../src/lib/types.ts";
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${message}: expected ${JSON.stringify(expected)}, got ${
JSON.stringify(actual)
}`,
);
}
};
const game = (id: string): Game => ({
id,
name: id,
description: "",
size: 0,
downloaded: false,
installed: false,
availability: GameAvailability.LocalOnly,
install_status: InstallStatus.Downloading,
peer_count: 1,
download_progress: {
attemptId: `${id}-attempt`,
downloaded_bytes: 10,
total_bytes: 100,
bytes_per_second: 5,
active_peer_count: 1,
},
});
const snapshot = (
revision: number,
statuses: GameTransferStatusSnapshot["statuses"],
openAttempts: GameTransferStatusSnapshot["openAttempts"] = {},
): GameTransferStatusSnapshot => ({ revision, statuses, openAttempts });
const progress = (
id: string,
attemptId: string,
downloadedBytes: number,
): DownloadProgressPayload => ({
id,
attemptId,
downloaded_bytes: downloadedBytes,
total_bytes: 100,
bytes_per_second: 5,
active_peer_count: 1,
});
Deno.test("newer transfer event beats stale and equal GamesList snapshots", () => {
const event = snapshot(2, { alpha: GameTransferStatus.Retrying });
assertEquals(
newestGameTransferStatusSnapshot(event, snapshot(1, {})),
event,
"a delayed older GamesList snapshot must not replace the listener event",
);
assertEquals(
newestGameTransferStatusSnapshot(
event,
snapshot(2, { alpha: GameTransferStatus.Exhausted }),
),
event,
"an equal revision replay must not change the accepted full snapshot",
);
});
Deno.test("higher revision full replacement clears omitted game statuses", () => {
const games = applyGameTransferStatusSnapshot(
[game("alpha"), game("bravo")],
snapshot(4, {
alpha: GameTransferStatus.Verifying,
bravo: GameTransferStatus.Exhausted,
}, { alpha: "alpha-attempt", bravo: "bravo-attempt" }),
);
const replacement = applyGameTransferStatusSnapshot(
games,
snapshot(
5,
{ bravo: GameTransferStatus.Retrying },
{ alpha: "alpha-attempt", bravo: "bravo-attempt" },
),
);
assertEquals(
replacement.map((item) => item.transfer_status),
[undefined, GameTransferStatus.Retrying],
"omission must mean None while the listed game retains its exact status",
);
assertEquals(
replacement.map((item) => item.download_progress?.downloaded_bytes),
[10, 10],
"typed status replacement must not mutate direct byte progress",
);
});
Deno.test("progress is fenced by the newest exact open attempt", () => {
const attemptA = "9007199254740992";
const attemptB = "9007199254740993";
const withoutProgress = [{ ...game("alpha"), download_progress: undefined }];
const openA = snapshot(1, {}, { alpha: attemptA });
const openB = snapshot(2, {}, { alpha: attemptB });
const terminalB = snapshot(3, {}, {});
const progressA = applyDownloadProgress(
withoutProgress,
openA,
progress("alpha", attemptA, 10),
);
assertEquals(
progressA[0].download_progress?.attemptId,
attemptA,
"A progress should be accepted while A is the open attempt",
);
const beganB = applyGameTransferStatusSnapshot(progressA, openB);
assertEquals(
beganB[0].download_progress,
undefined,
"a newer B Begin snapshot must clear already-rendered A progress",
);
const delayedA = applyDownloadProgress(
beganB,
openB,
progress("alpha", attemptA, 20),
);
assertEquals(
delayedA[0].download_progress,
undefined,
"delayed A progress must be rejected after B becomes authoritative",
);
const progressB = applyDownloadProgress(
delayedA,
openB,
progress("alpha", attemptB, 30),
);
assertEquals(
progressB[0].download_progress?.downloaded_bytes,
30,
"progress for the exact current B attempt must be accepted",
);
const finishedB = applyGameTransferStatusSnapshot(progressB, terminalB);
assertEquals(
finishedB[0].download_progress,
undefined,
"terminal replacement must clear B progress with the open attempt",
);
const lateB = applyDownloadProgress(
finishedB,
terminalB,
progress("alpha", attemptB, 40),
);
assertEquals(
lateB[0].download_progress,
undefined,
"late B progress must remain rejected after terminal settlement",
);
});
Deno.test("transfer status lookup uses only own game-id properties", () => {
const [projected] = applyGameTransferStatusSnapshot(
[game("toString")],
snapshot(3, {}),
);
assertEquals(
projected.transfer_status,
undefined,
"prototype properties must not become catalog game statuses",
);
assertEquals(
newestGameTransferStatusSnapshot(
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
snapshot(1, {}),
).revision,
1,
"the backend revision-one bootstrap must replace the frontend initial state",
);
});
Deno.test("transfer notices use the exact copy and severity contract", () => {
assertEquals(
gameTransferStatusPresentation(GameTransferStatus.Verifying),
{ message: "Verifying downloaded chunks", level: "info" },
"verification copy",
);
assertEquals(
gameTransferStatusPresentation(GameTransferStatus.Retrying),
{
message: "A source sent invalid data; retrying another nearby peer",
level: "warning",
},
"retry copy",
);
assertEquals(
gameTransferStatusPresentation(GameTransferStatus.Exhausted),
{
message: "No nearby peer could provide the verified catalog version",
level: "error",
},
"exhaustion copy",
);
assertEquals(
gameTransferStatusPresentation(undefined),
undefined,
"None must remain invisible",
);
});
Deno.test("download progress promotes transient verification and retry copy", () => {
const verifying = {
...game("Alpha"),
transfer_status: GameTransferStatus.Verifying,
};
const retrying = {
...game("Bravo"),
transfer_status: GameTransferStatus.Retrying,
};
const exhausted = {
...game("Charlie"),
transfer_status: GameTransferStatus.Exhausted,
};
assertEquals(
downloadProgressTransferLabel(verifying),
"Verifying downloaded chunks",
"verification should replace the primary progress copy",
);
assertEquals(
downloadProgressAriaLabel(verifying),
"Verifying downloaded chunks: Alpha",
"verification should replace the progressbar accessible name",
);
assertEquals(
downloadProgressTransferLabel(retrying),
"A source sent invalid data; retrying another nearby peer",
"retry should replace the primary progress copy",
);
assertEquals(
downloadProgressAriaLabel(retrying),
"A source sent invalid data; retrying another nearby peer: Bravo",
"retry should replace the progressbar accessible name",
);
assertEquals(
downloadProgressTransferLabel(exhausted),
undefined,
"terminal exhaustion should remain on the card/modal status surface",
);
});